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:
278
src/features/report/ReportPage.tsx
Normal file
278
src/features/report/ReportPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user