feat: MVP Fase 0 — Process Cost Platform v0.1.0

Plataforma completa de análisis de costos operativos basada en BPMN 2.0.

Funcionalidades incluidas:
- Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo
- Visualización read-only del diagrama (bpmn-js)
- Configuración de actividades (costo, tiempo, recursos asignados)
- CRUD de recursos (rol, persona, sistema, equipo, insumo)
- Configuración global (moneda, overhead %, nombre, cliente)
- Motor de simulación determinístico agregado con propagación de gateways XOR/AND
- Reporte visual con mapa de calor en dos modos (relativo/absoluto)
- Gráficos: KPIs, top actividades, composición, costo por recurso
- Export PDF (jsPDF + html2canvas) y CSV (papaparse)
- Persistencia en IndexedDB (Dexie)
- 268 tests unitarios/integración + 6 E2E con Playwright
- Deploy: https://process-cost-platform.pages.dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 01:58:40 -03:00
commit bed161c92a
97 changed files with 19638 additions and 0 deletions

View File

@@ -0,0 +1,278 @@
import { useCallback, useRef, useState } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router'
import { AlertTriangle, Home } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useToast } from '@/components/ui/use-toast'
import { AppFooter } from '@/components/AppFooter'
import { useReportData } from './useReportData'
import { ReportHeader } from './ReportHeader'
import { KpiBar } from './KpiBar'
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 { 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 { 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)
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)
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)
heatmapRef.current?.highlightElement(bpmnElementId)
setTimeout(() => {
heatmapSectionRef.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
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process, simulation, resources, heatmapImageData })
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])
const handleExportCsv = useCallback(async () => {
if (!process || !simulation || isExportingCsv) return
setIsExportingCsv(true)
try {
const { exportToCsv } = await import('@/lib/export/csv-export')
exportToCsv({ process, simulation, resources })
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])
// ── Loading — skeleton en lugar de pantalla en blanco ────────────────────
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" />
<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>
{/* 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">
<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>
{/* 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) => (
<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 perActivity = result.perActivity
return (
<TooltipProvider>
<div className="min-h-screen bg-slate-50">
<div className="max-w-screen-xl mx-auto px-6 py-10">
{/* ── 1. Header ─────────────────────────────────────────────── */}
<ReportHeader
process={process}
simulatedAt={simulation.executedAt}
onBack={() => navigate({ to: '/workspace/$processId', params: { processId } })}
onExportPdf={handleExportPdf}
onExportCsv={handleExportCsv}
isExportingPdf={isExportingPdf}
isExportingCsv={isExportingCsv}
/>
{/* ── 2. KPI Bar ────────────────────────────────────────────── */}
<KpiBar result={result} currency={process.currency} />
{/* ── 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>
<HeatmapCanvas
ref={heatmapRef}
xml={process.bpmnXml}
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
selectedId={selectedId}
onActivityClick={handleHeatmapClick}
minHeight={520}
/>
<HeatmapLegend
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
/>
</div>
{/* ── 4. Warnings ───────────────────────────────────────────── */}
<WarningsBanner warnings={result.warnings} />
{/* ── 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}
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={perActivity}
resources={resources}
currency={process.currency}
/>
</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" />
{/* ── 7. Footer metodológico ────────────────────────────────── */}
<MethodologyFooter process={process} />
<AppFooter />
</div>
</div>
</TooltipProvider>
)
}