633 lines
29 KiB
TypeScript
633 lines
29 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useParams, useNavigate } from '@tanstack/react-router'
|
|
import { AlertTriangle, Home, Info } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { AppFooter } from '@/components/AppFooter'
|
|
import { calculateRoi } from '@/domain/roi'
|
|
import { useReportData } from './useReportData'
|
|
import { ReportHeader } from './ReportHeader'
|
|
import { KpiBar } from './KpiBar'
|
|
import { RoiKpiBar } from './RoiKpiBar'
|
|
import { HeatmapCanvas, type HeatmapCanvasHandle } from './HeatmapCanvas'
|
|
import { HeatmapLegend } from './HeatmapLegend'
|
|
import { HeatmapModeToggle } from './HeatmapModeToggle'
|
|
import { WarningsBanner } from './WarningsBanner'
|
|
import { TopActivitiesChart } from './TopActivitiesChart'
|
|
import { CostCompositionChart } from './CostCompositionChart'
|
|
import { CostByResourceChart } from './CostByResourceChart'
|
|
import { ActivitiesTable } from './ActivitiesTable'
|
|
import { ComparisonTable } from './ComparisonTable'
|
|
import { CumulativeSavingsChart } from './CumulativeSavingsChart'
|
|
import { SensitivityPanel } from './SensitivityPanel'
|
|
import { TopSavingsChart } from './TopSavingsChart'
|
|
import { TransparencySection } from './TransparencySection'
|
|
import { AutomationScenarioNote } from './AutomationScenarioNote'
|
|
import { MethodologyFooter } from './MethodologyFooter'
|
|
import { useHeatmapMode } from '@/lib/hooks/useHeatmapMode'
|
|
import { useSimulationStore } from '@/store/simulation-store'
|
|
import { AppHeader } from '@/components/AppHeader'
|
|
|
|
export function ReportPage() {
|
|
const { processId } = useParams({ from: '/report/$processId' })
|
|
const navigate = useNavigate()
|
|
const { process, simulation, activities, resources, isLoading, error } = useReportData(processId)
|
|
|
|
const { toast } = useToast()
|
|
const [heatmapMode, setHeatmapMode] = useHeatmapMode()
|
|
const { lastPersistedExecutedAt, loadLatestForProcess } = useSimulationStore()
|
|
|
|
// Cargar executedAt persistido si el usuario llega directo al reporte (sin pasar por workspace)
|
|
// Intencional: dep [process?.id] — solo re-ejecutar cuando cambia el proceso, no en cada
|
|
// re-render por cambios de nombre/cliente/etc. del objeto process.
|
|
useEffect(() => {
|
|
if (process) loadLatestForProcess(process.id)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [process?.id, loadLatestForProcess])
|
|
|
|
// Set de bpmnElementIds marcados como automatizables — debe estar antes de los early returns
|
|
const automatableIds = useMemo(
|
|
() => new Set(activities.filter((a) => a.automatable).map((a) => a.bpmnElementId)),
|
|
[activities]
|
|
)
|
|
|
|
// ROI — calculado solo cuando hay ambos resultados
|
|
const roi = useMemo(() => {
|
|
const result = simulation?.result
|
|
const resultAutomated = simulation?.resultAutomated
|
|
if (!result || !resultAutomated || !process) return null
|
|
return calculateRoi({
|
|
costPerExecutionActual: result.totalCost,
|
|
costPerExecutionAutomated: resultAutomated.totalCost,
|
|
annualFrequency: process.annualFrequency,
|
|
analysisHorizonYears: process.analysisHorizonYears,
|
|
automationInvestment: process.automationInvestment,
|
|
})
|
|
}, [simulation, process])
|
|
|
|
// Sliders del panel de sensibilidad — efímero, no persiste (Sprint 6 Etapa 2)
|
|
const [volumeAdjust, setVolumeAdjust] = useState(0) // entero: -50 a +100
|
|
const [investmentAdjust, setInvestmentAdjust] = useState(0) // entero: -50 a +100
|
|
|
|
// ROI ajustado por sliders de sensibilidad — recalcula solo los agregados anuales,
|
|
// sin tocar costPerExecutionActual ni costPerExecutionAutomated (son estables, no se re-simula).
|
|
const adjustedRoi = useMemo(() => {
|
|
if (!roi || !simulation?.result || !simulation?.resultAutomated || !process) return null
|
|
if (volumeAdjust === 0 && investmentAdjust === 0) return roi // sin cambios → mismo objeto
|
|
return calculateRoi({
|
|
costPerExecutionActual: simulation.result.totalCost,
|
|
costPerExecutionAutomated: simulation.resultAutomated.totalCost,
|
|
annualFrequency: process.annualFrequency * (1 + volumeAdjust / 100),
|
|
analysisHorizonYears: process.analysisHorizonYears,
|
|
automationInvestment: process.automationInvestment * (1 + investmentAdjust / 100),
|
|
})
|
|
}, [roi, simulation, process, volumeAdjust, investmentAdjust])
|
|
|
|
// Breakdown del escenario actual, indexado por activityId, para comparación lado a lado en tab Automatizado
|
|
const actualBreakdownById = useMemo(() => {
|
|
if (!simulation?.result) return undefined
|
|
return Object.fromEntries(
|
|
simulation.result.perActivity.map((a) => [a.activityId, a.resourceCostBreakdown])
|
|
)
|
|
}, [simulation])
|
|
|
|
// Estado y refs para el tab "Actual"
|
|
const [selectedIdActual, setSelectedIdActual] = useState<string | null>(null)
|
|
const heatmapRef = useRef<HeatmapCanvasHandle>(null)
|
|
const heatmapSectionRef = useRef<HTMLDivElement>(null)
|
|
const tableSectionRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Estado y refs para el tab "Automatizado"
|
|
const [selectedIdAuto, setSelectedIdAuto] = useState<string | null>(null)
|
|
const heatmapRefAuto = useRef<HeatmapCanvasHandle>(null)
|
|
const heatmapSectionRefAuto = useRef<HTMLDivElement>(null)
|
|
const tableSectionRefAuto = useRef<HTMLDivElement>(null)
|
|
|
|
const [isExportingPdf, setIsExportingPdf] = useState(false)
|
|
const [isExportingCsv, setIsExportingCsv] = useState(false)
|
|
const [activeTab, setActiveTab] = useState<'actual' | 'automatizado' | 'roi'>('actual')
|
|
|
|
// Tab "Actual" — cross-linking heatmap ↔ tabla
|
|
const handleHeatmapClickActual = useCallback((bpmnElementId: string) => {
|
|
setSelectedIdActual(bpmnElementId)
|
|
setTimeout(() => {
|
|
tableSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}, 50)
|
|
}, [])
|
|
|
|
const handleTableRowClickActual = useCallback((bpmnElementId: string) => {
|
|
setSelectedIdActual(bpmnElementId)
|
|
heatmapRef.current?.highlightElement(bpmnElementId)
|
|
setTimeout(() => {
|
|
heatmapSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}, 50)
|
|
}, [])
|
|
|
|
// Tab "Automatizado" — cross-linking heatmap ↔ tabla
|
|
const handleHeatmapClickAuto = useCallback((bpmnElementId: string) => {
|
|
setSelectedIdAuto(bpmnElementId)
|
|
setTimeout(() => {
|
|
tableSectionRefAuto.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}, 50)
|
|
}, [])
|
|
|
|
const handleTableRowClickAuto = useCallback((bpmnElementId: string) => {
|
|
setSelectedIdAuto(bpmnElementId)
|
|
heatmapRefAuto.current?.highlightElement(bpmnElementId)
|
|
setTimeout(() => {
|
|
heatmapSectionRefAuto.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}, 50)
|
|
}, [])
|
|
|
|
const handleExportPdf = useCallback(async () => {
|
|
if (!process || !simulation || isExportingPdf) return
|
|
setIsExportingPdf(true)
|
|
try {
|
|
let heatmapImageData: string | null = null
|
|
if (activeTab === 'actual') {
|
|
heatmapImageData = await heatmapRef.current?.captureImage() ?? null
|
|
} else if (activeTab === 'automatizado') {
|
|
heatmapImageData = await heatmapRefAuto.current?.captureImage() ?? null
|
|
}
|
|
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
|
await exportToPdf({ process, simulation, resources, heatmapImageData, tab: activeTab, activities })
|
|
toast({ title: 'PDF generado', description: 'El reporte se descargó correctamente.' })
|
|
} catch (err) {
|
|
console.error('PDF export error:', err)
|
|
toast({ title: 'Error al generar el PDF', description: String(err), variant: 'destructive' })
|
|
} finally {
|
|
setIsExportingPdf(false)
|
|
}
|
|
}, [process, simulation, resources, isExportingPdf, toast, activeTab, activities])
|
|
|
|
const handleExportCsv = useCallback(async () => {
|
|
if (!process || !simulation || isExportingCsv) return
|
|
setIsExportingCsv(true)
|
|
try {
|
|
const { exportToCsv } = await import('@/lib/export/csv-export')
|
|
exportToCsv({ process, simulation, resources, tab: activeTab, activities })
|
|
toast({ title: 'CSV generado', description: 'Los datos se descargaron correctamente.' })
|
|
} catch (err) {
|
|
console.error('CSV export error:', err)
|
|
toast({ title: 'Error al exportar CSV', description: String(err), variant: 'destructive' })
|
|
} finally {
|
|
setIsExportingCsv(false)
|
|
}
|
|
}, [process, simulation, resources, isExportingCsv, toast, activeTab, activities])
|
|
|
|
// ── Loading ───────────────────────────────────────────────────────────────
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-50">
|
|
<div className="max-w-screen-xl mx-auto px-6 py-10 animate-pulse">
|
|
<div className="flex items-start justify-between mb-8">
|
|
<div className="space-y-2">
|
|
<div className="h-3 w-20 bg-slate-200 rounded" />
|
|
<div className="h-8 w-80 bg-slate-200 rounded" />
|
|
<div className="h-3 w-48 bg-slate-200 rounded" />
|
|
</div>
|
|
<div className="flex gap-2 pt-9">
|
|
<div className="h-9 w-32 bg-slate-200 rounded-md" />
|
|
<div className="h-9 w-32 bg-slate-200 rounded-md" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<div key={i} className="bg-white rounded-xl border border-slate-200 p-6 flex gap-4">
|
|
<div className="h-11 w-11 bg-slate-100 rounded-lg shrink-0" />
|
|
<div className="flex-1 space-y-2 pt-1">
|
|
<div className="h-2.5 bg-slate-100 rounded w-3/4" />
|
|
<div className="h-6 bg-slate-200 rounded w-full" />
|
|
<div className="h-2 bg-slate-100 rounded w-1/2" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
|
<div className="h-5 w-48 bg-slate-200 rounded mb-4" />
|
|
<div className="h-72 bg-slate-100 rounded-lg" />
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
|
<div className="h-4 w-40 bg-slate-200 rounded mb-4" />
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<div key={i} className="h-10 bg-slate-50 border-b border-slate-100 flex items-center gap-4 px-2">
|
|
<div className="h-3 bg-slate-200 rounded flex-1" />
|
|
<div className="h-3 bg-slate-200 rounded w-20" />
|
|
<div className="h-3 bg-slate-200 rounded w-16" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Error ─────────────────────────────────────────────────────────────────
|
|
if (error || !process || !simulation) {
|
|
return (
|
|
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-slate-50 px-6">
|
|
<AlertTriangle className="h-10 w-10 text-amber-400" />
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium text-slate-700 mb-1">No se pudo cargar el reporte</p>
|
|
<p className="text-xs text-slate-400 max-w-sm">
|
|
{error ?? 'Simulá el proceso desde el workspace para generar el reporte.'}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/' })}>
|
|
<Home className="h-4 w-4 mr-1.5" />
|
|
Inicio
|
|
</Button>
|
|
<Button size="sm" onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}>
|
|
Volver a configurar y simular
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const result = simulation.result
|
|
const resultAutomated = simulation.resultAutomated ?? null
|
|
const hasAutomatedResult = resultAutomated !== null
|
|
|
|
// Tabs de escenarios se habilitan solo cuando hay resultado automatizado Y hay actividades
|
|
// marcadas como automatizables. Sin automatizables, los tabs muestran datos idénticos al actual.
|
|
const hasAutomatedScenario = hasAutomatedResult && automatableIds.size > 0
|
|
|
|
const isStale = Boolean(process && process.updatedAt > (lastPersistedExecutedAt ?? 0))
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<div className="min-h-screen bg-slate-50">
|
|
<AppHeader />
|
|
{/* Banner stale — cambios sin simular */}
|
|
{isStale && (
|
|
<div className="flex items-center justify-between px-6 py-2.5 text-[12px] font-medium"
|
|
style={{ background: '#fffbeb', borderBottom: '1px solid #f59e0b', color: '#92400e' }}>
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
|
Los resultados pueden estar desactualizados — hay cambios sin simular.
|
|
</div>
|
|
<button
|
|
onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}
|
|
className="underline hover:no-underline shrink-0 ml-4"
|
|
>
|
|
Ir al workspace →
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="max-w-screen-xl mx-auto px-6 py-10">
|
|
|
|
{/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */}
|
|
<ReportHeader
|
|
process={process}
|
|
simulatedAt={simulation.executedAt}
|
|
onBack={() => navigate({ to: '/workspace/$processId', params: { processId } })}
|
|
onExportPdf={handleExportPdf}
|
|
onExportCsv={handleExportCsv}
|
|
isExportingPdf={isExportingPdf}
|
|
isExportingCsv={isExportingCsv}
|
|
/>
|
|
|
|
{/* ── Tabs ─────────────────────────────────────────────────────── */}
|
|
<Tabs defaultValue="actual" className="w-full" onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
|
|
<TabsList className="mb-6">
|
|
<TabsTrigger value="actual">Actual</TabsTrigger>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="contents">
|
|
<TabsTrigger value="automatizado" disabled={!hasAutomatedScenario}>
|
|
Automatizado
|
|
</TabsTrigger>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{!hasAutomatedScenario && (
|
|
<TooltipContent side="bottom" className="text-xs max-w-56">
|
|
{!hasAutomatedResult
|
|
? 'Re-simulá el proceso para generar el escenario automatizado'
|
|
: 'Marcá al menos una actividad como automatizable en el workspace para ver este escenario'}
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="contents">
|
|
<TabsTrigger value="roi" disabled={!hasAutomatedScenario}>
|
|
Comparación + ROI
|
|
</TabsTrigger>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{!hasAutomatedScenario && (
|
|
<TooltipContent side="bottom" className="text-xs max-w-56">
|
|
{!hasAutomatedResult
|
|
? 'Re-simulá el proceso para generar el análisis de ROI'
|
|
: 'Marcá al menos una actividad como automatizable en el workspace para calcular el ROI'}
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
</TabsList>
|
|
|
|
{/* ──────────────── TAB: ACTUAL ──────────────────────────────── */}
|
|
<TabsContent value="actual">
|
|
<KpiBar result={result} currency={process.currency} />
|
|
|
|
<div ref={heatmapSectionRef} className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-slate-800">Mapa de calor de costos</h2>
|
|
<p className="text-xs text-slate-400 mt-0.5">
|
|
Hacé click en una actividad para ver su detalle en la tabla
|
|
</p>
|
|
</div>
|
|
<HeatmapModeToggle mode={heatmapMode} onChange={setHeatmapMode} />
|
|
</div>
|
|
|
|
<HeatmapCanvas
|
|
ref={heatmapRef}
|
|
xml={process.bpmnXml}
|
|
perActivity={result.perActivity}
|
|
mode={heatmapMode}
|
|
currency={process.currency}
|
|
selectedId={selectedIdActual}
|
|
onActivityClick={handleHeatmapClickActual}
|
|
minHeight={520}
|
|
/>
|
|
|
|
<HeatmapLegend
|
|
perActivity={result.perActivity}
|
|
mode={heatmapMode}
|
|
currency={process.currency}
|
|
/>
|
|
</div>
|
|
|
|
<WarningsBanner warnings={result.warnings} />
|
|
|
|
<div className="mb-6">
|
|
<h2 className="text-base font-semibold text-slate-800 mb-4">Análisis de composición</h2>
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
|
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
|
Top actividades por costo
|
|
</h3>
|
|
<TopActivitiesChart
|
|
perActivity={result.perActivity}
|
|
mode={heatmapMode}
|
|
currency={process.currency}
|
|
/>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
|
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
|
Composición directo / overhead
|
|
</h3>
|
|
<CostCompositionChart result={result} currency={process.currency} />
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
|
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
|
Costo por tipo de recurso
|
|
</h3>
|
|
<CostByResourceChart
|
|
perActivity={result.perActivity}
|
|
resources={resources}
|
|
currency={process.currency}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref={tableSectionRef} className="mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-base font-semibold text-slate-800">
|
|
Detalle por actividad
|
|
<span className="ml-2 text-sm font-normal text-slate-400">
|
|
({result.perActivity.length} actividades)
|
|
</span>
|
|
</h2>
|
|
</div>
|
|
<ActivitiesTable
|
|
perActivity={result.perActivity}
|
|
mode={heatmapMode}
|
|
currency={process.currency}
|
|
highlightedId={selectedIdActual}
|
|
onRowClick={handleTableRowClickActual}
|
|
/>
|
|
</div>
|
|
|
|
<Separator className="my-4" />
|
|
<MethodologyFooter process={process} />
|
|
<AppFooter />
|
|
</TabsContent>
|
|
|
|
{/* ──────────────── TAB: AUTOMATIZADO ───────────────────────── */}
|
|
<TabsContent value="automatizado">
|
|
{!hasAutomatedResult ? (
|
|
<NeedsResimulate processId={processId} />
|
|
) : (
|
|
<>
|
|
<KpiBar result={resultAutomated!} currency={process.currency} />
|
|
|
|
<div ref={heatmapSectionRefAuto} className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-slate-800">Mapa de calor — Escenario Automatizado</h2>
|
|
<p className="text-xs text-slate-400 mt-0.5">
|
|
Hacé click en una actividad para ver su detalle en la tabla
|
|
</p>
|
|
</div>
|
|
<HeatmapModeToggle mode={heatmapMode} onChange={setHeatmapMode} />
|
|
</div>
|
|
|
|
<HeatmapCanvas
|
|
ref={heatmapRefAuto}
|
|
xml={process.bpmnXml}
|
|
perActivity={resultAutomated!.perActivity}
|
|
mode={heatmapMode}
|
|
currency={process.currency}
|
|
selectedId={selectedIdAuto}
|
|
onActivityClick={handleHeatmapClickAuto}
|
|
minHeight={520}
|
|
/>
|
|
|
|
<HeatmapLegend
|
|
perActivity={resultAutomated!.perActivity}
|
|
mode={heatmapMode}
|
|
currency={process.currency}
|
|
/>
|
|
|
|
<AutomationScenarioNote />
|
|
</div>
|
|
|
|
<WarningsBanner warnings={resultAutomated!.warnings} />
|
|
|
|
<div className="mb-6">
|
|
<h2 className="text-base font-semibold text-slate-800 mb-4">Análisis de composición</h2>
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
|
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
|
Top actividades por costo
|
|
</h3>
|
|
<TopActivitiesChart
|
|
perActivity={resultAutomated!.perActivity}
|
|
mode={heatmapMode}
|
|
currency={process.currency}
|
|
/>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
|
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
|
Composición directo / overhead
|
|
</h3>
|
|
<CostCompositionChart result={resultAutomated!} currency={process.currency} />
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
|
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
|
Costo por tipo de recurso
|
|
</h3>
|
|
<CostByResourceChart
|
|
perActivity={resultAutomated!.perActivity}
|
|
resources={resources}
|
|
currency={process.currency}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref={tableSectionRefAuto} className="mb-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-base font-semibold text-slate-800">
|
|
Detalle por actividad — Automatizado
|
|
<span className="ml-2 text-sm font-normal text-slate-400">
|
|
({resultAutomated!.perActivity.length} actividades)
|
|
</span>
|
|
</h2>
|
|
</div>
|
|
<ActivitiesTable
|
|
perActivity={resultAutomated!.perActivity}
|
|
pairedBreakdown={actualBreakdownById}
|
|
mode={heatmapMode}
|
|
currency={process.currency}
|
|
highlightedId={selectedIdAuto}
|
|
onRowClick={handleTableRowClickAuto}
|
|
/>
|
|
</div>
|
|
|
|
<Separator className="my-4" />
|
|
<MethodologyFooter process={process} />
|
|
<AppFooter />
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* ──────────────── TAB: COMPARACIÓN + ROI ──────────────────── */}
|
|
<TabsContent value="roi">
|
|
{!hasAutomatedResult || !roi ? (
|
|
<NeedsResimulate processId={processId} />
|
|
) : (
|
|
<>
|
|
{/* KPIs de ROI — actualizados por sliders de sensibilidad */}
|
|
<RoiKpiBar
|
|
roi={adjustedRoi ?? roi}
|
|
currency={process.currency}
|
|
horizonYears={process.analysisHorizonYears}
|
|
/>
|
|
|
|
{/* Panel de sensibilidad — colapsado por defecto */}
|
|
<SensitivityPanel
|
|
process={process}
|
|
volumeAdjust={volumeAdjust}
|
|
investmentAdjust={investmentAdjust}
|
|
onVolumeChange={setVolumeAdjust}
|
|
onInvestmentChange={setInvestmentAdjust}
|
|
onReset={() => { setVolumeAdjust(0); setInvestmentAdjust(0) }}
|
|
/>
|
|
|
|
{/* Tabla de comparación actividad por actividad — per-ejecución, no varía con los sliders */}
|
|
<div className="mb-6">
|
|
<h2 className="text-base font-semibold text-slate-800 mb-4">
|
|
Comparación por actividad
|
|
</h2>
|
|
<ComparisonTable
|
|
perActivityActual={result.perActivity}
|
|
perActivityAutomated={resultAutomated!.perActivity}
|
|
automatableIds={automatableIds}
|
|
currency={process.currency}
|
|
/>
|
|
</div>
|
|
|
|
{/* Gráficos: ahorro acumulado + top ahorros */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
|
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
|
Ahorro acumulado neto ({process.analysisHorizonYears} años)
|
|
</h3>
|
|
<CumulativeSavingsChart
|
|
roi={adjustedRoi ?? roi}
|
|
roiBase={volumeAdjust !== 0 || investmentAdjust !== 0 ? roi : undefined}
|
|
horizonYears={process.analysisHorizonYears}
|
|
currency={process.currency}
|
|
investment={process.automationInvestment * (1 + investmentAdjust / 100)}
|
|
/>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
|
<h3 className="text-xs font-semibold text-slate-600 uppercase tracking-wide mb-4">
|
|
Top actividades por ahorro
|
|
</h3>
|
|
<TopSavingsChart
|
|
perActivityActual={result.perActivity}
|
|
perActivityAutomated={resultAutomated!.perActivity}
|
|
currency={process.currency}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Transparencia metodológica (condicional) */}
|
|
<div className="mb-6">
|
|
<TransparencySection
|
|
activities={activities}
|
|
investment={process.automationInvestment}
|
|
roi={roi ?? undefined}
|
|
/>
|
|
</div>
|
|
|
|
<Separator className="my-4" />
|
|
<MethodologyFooter process={process} />
|
|
<AppFooter />
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
</div>
|
|
</div>
|
|
</TooltipProvider>
|
|
)
|
|
}
|
|
|
|
// ── Componente auxiliar: aviso cuando falta re-simular ───────────────────────
|
|
|
|
function NeedsResimulate({ processId }: { processId: string }) {
|
|
const navigate = useNavigate()
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-4 py-20 text-center">
|
|
<div className="w-12 h-12 bg-amber-50 rounded-full flex items-center justify-center">
|
|
<Info className="h-6 w-6 text-amber-500" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-700 mb-1">
|
|
Esta simulación no incluye el escenario automatizado
|
|
</p>
|
|
<p className="text-xs text-slate-400 max-w-sm">
|
|
Volvé al workspace y presioná "Simular" para generar ambos escenarios.
|
|
</p>
|
|
</div>
|
|
<Button size="sm" onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}>
|
|
Ir al workspace
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|