Antes de la última revisión de etapa 4. Sprint 1. Previo al ajuste de look n feel de app, reportes, nombre y colores.

This commit is contained in:
2026-05-15 22:14:53 -03:00
parent b6f31f4371
commit 8b71c6ee13
54 changed files with 6497 additions and 261 deletions

View File

@@ -1,14 +1,17 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router'
import { AlertTriangle, Home } from 'lucide-react'
import { AlertTriangle, Home, Info } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { TooltipProvider } from '@/components/ui/tooltip'
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'
@@ -17,48 +20,102 @@ 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 { TopSavingsChart } from './TopSavingsChart'
import { TransparencySection } from './TransparencySection'
import { AutomationScenarioNote } from './AutomationScenarioNote'
import { MethodologyFooter } from './MethodologyFooter'
import type { HeatmapMode } from '@/lib/colors'
export function ReportPage() {
const { processId } = useParams({ from: '/report/$processId' })
const navigate = useNavigate()
const { process, simulation, resources, isLoading, error } = useReportData(processId)
const { process, simulation, activities, resources, isLoading, error } = useReportData(processId)
const { toast } = useToast()
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('relative')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isExportingPdf, setIsExportingPdf] = useState(false)
const [isExportingCsv, setIsExportingCsv] = useState(false)
// 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])
// 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)
// Click en heatmap → resaltar fila en tabla + scroll a tabla
const handleHeatmapClick = useCallback((bpmnElementId: string) => {
setSelectedId(bpmnElementId)
// 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)
}, [])
// Click en fila de tabla → resaltar en canvas + scroll a heatmap
const handleTableRowClick = useCallback((bpmnElementId: string) => {
setSelectedId(bpmnElementId)
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 {
const heatmapImageData = await heatmapRef.current?.captureImage() ?? null
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 })
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)
@@ -66,14 +123,14 @@ export function ReportPage() {
} finally {
setIsExportingPdf(false)
}
}, [process, simulation, resources, isExportingPdf, toast])
}, [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 })
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)
@@ -81,14 +138,13 @@ export function ReportPage() {
} finally {
setIsExportingCsv(false)
}
}, [process, simulation, resources, isExportingCsv, toast])
}, [process, simulation, resources, isExportingCsv, toast, activeTab, activities])
// ── Loading — skeleton en lugar de pantalla en blanco ────────────────────
// ── 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">
{/* Header skeleton */}
<div className="flex items-start justify-between mb-8">
<div className="space-y-2">
<div className="h-3 w-20 bg-slate-200 rounded" />
@@ -100,7 +156,6 @@ export function ReportPage() {
<div className="h-9 w-32 bg-slate-200 rounded-md" />
</div>
</div>
{/* KPI skeleton */}
<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">
@@ -113,12 +168,10 @@ export function ReportPage() {
</div>
))}
</div>
{/* Canvas skeleton */}
<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>
{/* Table skeleton */}
<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) => (
@@ -159,14 +212,19 @@ export function ReportPage() {
}
const result = simulation.result
const perActivity = result.perActivity
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
return (
<TooltipProvider>
<div className="min-h-screen bg-slate-50">
<div className="max-w-screen-xl mx-auto px-6 py-10">
{/* ── 1. Header ─────────────────────────────────────────────── */}
{/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */}
<ReportHeader
process={process}
simulatedAt={simulation.executedAt}
@@ -177,102 +235,328 @@ export function ReportPage() {
isExportingCsv={isExportingCsv}
/>
{/* ── 2. KPI Bar ────────────────────────────────────────────── */}
<KpiBar result={result} currency={process.currency} />
{/* ── Tabs ─────────────────────────────────────────────────────── */}
<Tabs defaultValue="actual" className="w-full" onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
<TabsList className="mb-6">
<TabsTrigger value="actual">Actual</TabsTrigger>
{/* ── 3. Mapa de Calor ──────────────────────────────────────── */}
<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>
<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>
<HeatmapCanvas
ref={heatmapRef}
xml={process.bpmnXml}
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
selectedId={selectedId}
onActivityClick={handleHeatmapClick}
minHeight={520}
/>
<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>
<HeatmapLegend
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
/>
</div>
{/* ──────────────── TAB: ACTUAL ──────────────────────────────── */}
<TabsContent value="actual">
<KpiBar result={result} currency={process.currency} />
{/* ── 4. Warnings ───────────────────────────────────────────── */}
<WarningsBanner warnings={result.warnings} />
<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>
{/* ── 5. Análisis — 3 gráficos ──────────────────────────────── */}
<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={perActivity}
<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>
<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} />
<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 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={perActivity}
resources={resources}
<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>
</div>
</div>
{/* ── 6. Tabla detalle ──────────────────────────────────────── */}
<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">({perActivity.length} actividades)</span>
</h2>
</div>
<ActivitiesTable
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
highlightedId={selectedId}
onRowClick={handleTableRowClick}
/>
</div>
<Separator className="my-4" />
<MethodologyFooter process={process} />
<AppFooter />
</TabsContent>
<Separator className="my-4" />
{/* ──────────────── TAB: AUTOMATIZADO ───────────────────────── */}
<TabsContent value="automatizado">
{!hasAutomatedResult ? (
<NeedsResimulate processId={processId} />
) : (
<>
<KpiBar result={resultAutomated!} currency={process.currency} />
{/* ── 7. Footer metodológico ────────────────────────────────── */}
<MethodologyFooter process={process} />
<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}
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 */}
<RoiKpiBar
roi={roi}
currency={process.currency}
horizonYears={process.analysisHorizonYears}
/>
{/* Tabla de comparación actividad por actividad */}
<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={roi}
horizonYears={process.analysisHorizonYears}
currency={process.currency}
investment={process.automationInvestment}
/>
</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>
<AppFooter />
</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á &quot;Simular&quot; para generar ambos escenarios.
</p>
</div>
<Button size="sm" onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}>
Ir al workspace
</Button>
</div>
)
}