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,133 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { activityColor } from '@/lib/colors'
import { formatCurrency, formatPercent, formatMinutes } from '@/lib/format'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
type SortKey = 'activityName' | 'expectedDirectCost' | 'expectedIndirectCost' | 'expectedTotalCost' | 'percentOfTotal' | 'executionTimeMinutes' | 'executionProbability'
interface ActivitiesTableProps {
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
highlightedId: string | null
onRowClick: (bpmnElementId: string) => void
}
function SortIcon({ column, sortKey, dir }: { column: SortKey; sortKey: SortKey; dir: 'asc' | 'desc' }) {
if (column !== sortKey) return <ArrowUpDown className="h-3 w-3 ml-1 opacity-40" />
return dir === 'asc'
? <ArrowUp className="h-3 w-3 ml-1 text-primary" />
: <ArrowDown className="h-3 w-3 ml-1 text-primary" />
}
export function ActivitiesTable({ perActivity, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) {
const [sortKey, setSortKey] = useState<SortKey>('expectedTotalCost')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const rowRefs = useRef(new Map<string, HTMLTableRowElement>())
function handleSort(key: SortKey) {
if (key === sortKey) {
setSortDir((d) => d === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('desc')
}
}
const sorted = useMemo(() => {
return [...perActivity].sort((a, b) => {
const av = a[sortKey]
const bv = b[sortKey]
const mult = sortDir === 'asc' ? 1 : -1
if (typeof av === 'string') return (av as string).localeCompare(bv as string) * mult
return ((av as number) - (bv as number)) * mult
})
}, [perActivity, sortKey, sortDir])
// Scroll a la fila resaltada
useEffect(() => {
if (!highlightedId) return
const row = rowRefs.current.get(highlightedId)
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [highlightedId])
function Th({ label, column }: { label: string; column: SortKey }) {
return (
<TableHead
className="cursor-pointer select-none hover:text-foreground whitespace-nowrap"
onClick={() => handleSort(column)}
>
<span className="inline-flex items-center text-xs">
{label}
<SortIcon column={column} sortKey={sortKey} dir={sortDir} />
</span>
</TableHead>
)
}
return (
<div className="rounded-xl border border-slate-200 overflow-hidden">
<Table>
<TableHeader className="bg-slate-50">
<tr>
<TableHead className="w-8 text-xs" />
<Th label="Actividad" column="activityName" />
<Th label="Costo directo" column="expectedDirectCost" />
<Th label="Overhead" column="expectedIndirectCost" />
<Th label="Costo total" column="expectedTotalCost" />
<Th label="% total" column="percentOfTotal" />
<Th label="Tiempo" column="executionTimeMinutes" />
<Th label="Prob. ejec." column="executionProbability" />
</tr>
</TableHeader>
<TableBody>
{sorted.map((act) => {
const color = activityColor(act, perActivity, mode)
const isHighlighted = act.bpmnElementId === highlightedId
return (
<TableRow
key={act.activityId}
ref={(el) => { if (el) rowRefs.current.set(act.bpmnElementId, el) }}
onClick={() => onRowClick(act.bpmnElementId)}
className={`cursor-pointer ${isHighlighted ? 'bg-primary/5 ring-1 ring-inset ring-primary/20' : ''}`}
data-state={isHighlighted ? 'selected' : undefined}
>
<TableCell className="py-2">
<div
className="w-3 h-3 rounded-sm flex-shrink-0"
style={{ backgroundColor: color }}
/>
</TableCell>
<TableCell className="text-xs font-medium text-slate-800 max-w-48 truncate">
{act.activityName}
</TableCell>
<TableCell className="text-xs font-mono text-slate-600">
{formatCurrency(act.expectedDirectCost, currency)}
</TableCell>
<TableCell className="text-xs font-mono text-slate-500">
{formatCurrency(act.expectedIndirectCost, currency)}
</TableCell>
<TableCell className="text-xs font-mono font-semibold text-slate-800">
{formatCurrency(act.expectedTotalCost, currency)}
</TableCell>
<TableCell className="text-xs font-mono text-slate-600">
{formatPercent(act.percentOfTotal)}
</TableCell>
<TableCell className="text-xs font-mono text-slate-500">
{act.executionTimeMinutes > 0 ? formatMinutes(act.executionTimeMinutes) : '—'}
</TableCell>
<TableCell className="text-xs font-mono text-slate-500">
{formatPercent(act.executionProbability * 100)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { buildCostByResourceOption } from '@/lib/chart-options'
import type { ActivitySimResult, Resource } from '@/domain/types'
interface CostByResourceChartProps {
perActivity: ActivitySimResult[]
resources: Resource[]
currency: string
}
export function CostByResourceChart({ perActivity, resources, currency }: CostByResourceChartProps) {
const option = useMemo(
() => buildCostByResourceOption(perActivity, resources, currency),
[perActivity, resources, currency]
)
if (!option) {
return (
<div className="flex flex-col items-center justify-center h-64 text-center px-4">
<p className="text-xs text-slate-400 mb-1">Sin recursos asignados</p>
<p className="text-xs text-slate-300">Asigná recursos a las actividades en el workspace para ver este gráfico</p>
</div>
)
}
return <ReactECharts option={option} style={{ height: 300 }} notMerge />
}

View File

@@ -0,0 +1,18 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { buildCostCompositionOption } from '@/lib/chart-options'
import type { SimulationResult } from '@/domain/types'
interface CostCompositionChartProps {
result: SimulationResult
currency: string
}
export function CostCompositionChart({ result, currency }: CostCompositionChartProps) {
const option = useMemo(
() => buildCostCompositionOption(result, currency),
[result, currency]
)
return <ReactECharts option={option} style={{ height: 300 }} notMerge />
}

View File

@@ -0,0 +1,279 @@
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import BpmnViewer from 'bpmn-js/lib/Viewer'
import { activityColor } from '@/lib/colors'
import { formatCurrency, formatPercent, formatMinutes } from '@/lib/format'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
interface TooltipData {
visible: boolean
x: number
y: number
activity: ActivitySimResult | null
}
interface HeatmapCanvasProps {
xml: string
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
selectedId: string | null
onActivityClick: (bpmnElementId: string) => void
minHeight?: number
}
export interface HeatmapCanvasHandle {
highlightElement: (bpmnElementId: string | null) => void
captureImage: () => Promise<string | null>
}
function applyHeatmapToViewer(
viewer: any,
perActivity: ActivitySimResult[],
mode: HeatmapMode
) {
const elementRegistry = viewer.get('elementRegistry') as any
if (!elementRegistry) return
for (const act of perActivity) {
const gfx: SVGElement | null = elementRegistry.getGraphics(act.bpmnElementId)
if (!gfx) continue
// activityColor devuelve NEUTRAL_HEATMAP_COLOR cuando no hay varianza significativa,
// evitando una falsa diferenciación en procesos con costos uniformes.
const color = activityColor(act, perActivity, mode)
const shape = gfx.querySelector('.djs-visual rect') as SVGElement | null
if (shape) {
// Usar style.fill (inline CSS) en lugar de setAttribute: el CSS inline tiene
// mayor especificidad que los estilos de bpmn-js, garantizando que html2canvas
// capture el color real (getAttribute devuelve el atributo, pero el estilo
// computado — que usa html2canvas — podría estar sobreescrito por CSS de bpmn-js).
;(shape as any).style.fill = color
;(shape as any).style.fillOpacity = '0.82'
;(shape as any).style.transition = 'fill 0.35s ease, fill-opacity 0.35s ease'
}
}
}
export const HeatmapCanvas = forwardRef<HeatmapCanvasHandle, HeatmapCanvasProps>(
({ xml, perActivity, mode, currency, selectedId, onActivityClick, minHeight = 500 }, ref) => {
const containerRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<any>(null)
const [isImported, setIsImported] = useState(false)
// Ref que espeja isImported — permite que captureImage() lea el valor live
// desde dentro de una closure asíncrona (las closures capturan state por valor).
const isImportedRef = useRef(false)
const [tooltip, setTooltip] = useState<TooltipData>({ visible: false, x: 0, y: 0, activity: null })
const activityMap = useRef(new Map<string, ActivitySimResult>())
useEffect(() => {
activityMap.current = new Map(perActivity.map((a) => [a.bpmnElementId, a]))
}, [perActivity])
// Exponer métodos para resaltar y capturar imagen (para el PDF)
useImperativeHandle(ref, () => ({
highlightElement: (bpmnElementId: string | null) => {
const viewer = viewerRef.current
if (!viewer) return
const canvas = viewer.get('canvas') as any
const reg = viewer.get('elementRegistry') as any
reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected'))
if (bpmnElementId) {
try { canvas.addMarker(bpmnElementId, 'bpmn-selected') } catch { /* ignorar */ }
}
},
captureImage: async () => {
if (!containerRef.current) return null
// Esperar a que bpmn-js termine de importar y renderizar (máx. 5 s).
// isImportedRef.current se actualiza sincrónicamente en el .then() de importXML,
// por lo que es visible aquí aunque esta closure se haya creado antes.
if (!isImportedRef.current) {
const TIMEOUT_MS = 5_000
const POLL_MS = 100
let elapsed = 0
while (!isImportedRef.current && elapsed < TIMEOUT_MS) {
await new Promise<void>((resolve) => setTimeout(resolve, POLL_MS))
elapsed += POLL_MS
}
if (!isImportedRef.current) {
throw new Error(
'El diagrama aún no terminó de cargar. Esperá unos segundos y reintentá.'
)
}
}
// Esperar a que React corra el useEffect de applyHeatmapToViewer Y que la
// transición CSS de los fills termine (fill 0.35s ease → necesitamos > 350ms).
await new Promise<void>((resolve) => setTimeout(resolve, 500))
const { default: html2canvas } = await import('html2canvas')
try {
const canvas = await html2canvas(containerRef.current, {
scale: 1.5,
useCORS: true,
allowTaint: true,
backgroundColor: '#f8fafc',
logging: false,
})
return canvas.toDataURL('image/jpeg', 0.82)
} catch (err) {
console.warn('html2canvas error:', err)
return null
}
},
}))
// Crear viewer
useEffect(() => {
if (!containerRef.current) return
const viewer = new BpmnViewer({ container: containerRef.current })
viewerRef.current = viewer
return () => {
viewer.destroy()
viewerRef.current = null
isImportedRef.current = false
setIsImported(false)
}
}, [])
// Importar XML.
// bpmn-js 18 puede rechazar la promesa de importXML (error interno de canvas)
// pero igualmente renderiza el diagrama parcialmente y dispara 'import.done'
// en el eventBus antes de rechazar. Escuchamos 'import.done' para setear
// isImported de forma confiable sin depender de que la promesa resuelva.
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !xml) return
isImportedRef.current = false
setIsImported(false)
const eventBus = viewer.get('eventBus') as any
const onImportDone = () => {
eventBus.off('import.done', onImportDone)
const canvas = viewer.get('canvas') as any
try { canvas.zoom('fit-viewport', 'auto') } catch { /* canvas puede no estar listo */ }
isImportedRef.current = true
setIsImported(true)
}
// Usamos .on + .off manual en lugar de .once para compatibilidad con mocks de test.
eventBus.on('import.done', onImportDone)
viewer.importXML(xml).catch((err: unknown) => {
// Puede rechazar aunque el diagrama ya se haya renderizado (error de root layer en bpmn-js 18).
// 'import.done' ya habrá disparado antes de este catch, por lo que isImported ya es true.
console.warn('HeatmapCanvas importXML error (diagrama puede estar parcialmente renderizado):', err)
})
return () => {
eventBus.off('import.done', onImportDone)
}
}, [xml])
// Aplicar heatmap cuando el XML está listo o el modo cambia
useEffect(() => {
const viewer = viewerRef.current
if (!isImported || !viewer || perActivity.length === 0) return
applyHeatmapToViewer(viewer, perActivity, mode)
}, [isImported, perActivity, mode])
// Resaltar elemento seleccionado externamente
useEffect(() => {
const viewer = viewerRef.current
if (!isImported || !viewer) return
const canvas = viewer.get('canvas') as any
const reg = viewer.get('elementRegistry') as any
reg.forEach((el: any) => canvas.removeMarker(el.id, 'bpmn-selected'))
if (selectedId) {
try { canvas.addMarker(selectedId, 'bpmn-selected') } catch { /* ignorar */ }
}
}, [isImported, selectedId])
// Event listeners de bpmn-js para click y hover
useEffect(() => {
const viewer = viewerRef.current
if (!isImported || !viewer) return
const eventBus = viewer.get('eventBus') as any
const TASK_TYPES = new Set([
'bpmn:Task', 'bpmn:UserTask', 'bpmn:ServiceTask', 'bpmn:ScriptTask',
'bpmn:ManualTask', 'bpmn:BusinessRuleTask', 'bpmn:SubProcess',
])
const onHover = (event: any) => {
const el = event.element
if (!TASK_TYPES.has(el?.type)) { setTooltip((t) => ({ ...t, visible: false })); return }
const act = activityMap.current.get(el.id)
if (!act) return
setTooltip((t) => ({ ...t, visible: true, activity: act }))
}
const onOut = () => setTooltip((t) => ({ ...t, visible: false }))
const onClick = (event: any) => {
const el = event.element
if (!el || !TASK_TYPES.has(el.type)) return
onActivityClick(el.id)
}
eventBus.on('element.hover', onHover)
eventBus.on('element.out', onOut)
eventBus.on('element.click', onClick)
return () => {
eventBus.off('element.hover', onHover)
eventBus.off('element.out', onOut)
eventBus.off('element.click', onClick)
}
}, [isImported, onActivityClick])
const onMouseMove = useCallback((e: React.MouseEvent) => {
setTooltip((t) => ({ ...t, x: e.clientX, y: e.clientY }))
}, [])
return (
<div className="relative" style={{ minHeight }}>
{/* Canvas bpmn-js */}
<div
ref={containerRef}
onMouseMove={onMouseMove}
className="w-full bpmn-container"
style={{ minHeight, height: '100%' }}
/>
{/* Tooltip custom */}
{tooltip.visible && tooltip.activity && (
<div
className="fixed z-50 pointer-events-none bg-white border border-slate-200 shadow-lg rounded-lg p-3 w-56"
style={{ left: tooltip.x + 14, top: tooltip.y - 70 }}
>
<p className="text-xs font-semibold text-slate-800 mb-2 leading-tight">
{tooltip.activity.activityName}
</p>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-slate-500">Costo esperado</span>
<span className="font-mono font-medium">{formatCurrency(tooltip.activity.expectedTotalCost, currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">% del total</span>
<span className="font-mono">{formatPercent(tooltip.activity.percentOfTotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Prob. ejecución</span>
<span className="font-mono">{formatPercent(tooltip.activity.executionProbability * 100)}</span>
</div>
{tooltip.activity.executionTimeMinutes > 0 && (
<div className="flex justify-between">
<span className="text-slate-500">Tiempo</span>
<span className="font-mono">{formatMinutes(tooltip.activity.executionTimeMinutes)}</span>
</div>
)}
</div>
</div>
)}
</div>
)
}
)
HeatmapCanvas.displayName = 'HeatmapCanvas'

View File

@@ -0,0 +1,35 @@
import { formatCurrency, formatPercent } from '@/lib/format'
import { heatmapLegendBounds } from '@/lib/colors'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
interface HeatmapLegendProps {
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
}
export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProps) {
const bounds = heatmapLegendBounds(perActivity, mode)
function formatBound(value: number): string {
if (bounds.unit === 'percent') return formatPercent(value, value % 1 === 0 ? 0 : 1)
return formatCurrency(value, currency)
}
return (
<div className="flex items-center gap-3 mt-3">
<span className="text-xs text-slate-500 shrink-0">Bajo costo</span>
<div className="flex-1 h-3 rounded-full" style={{
background: 'linear-gradient(to right, #10b981, #f59e0b, #ef4444)'
}} />
<span className="text-xs text-slate-500 shrink-0">Alto costo</span>
<div className="h-4 border-l border-slate-200 mx-1" />
<div className="flex items-center gap-4 text-xs text-slate-400 font-mono">
<span>{formatBound(bounds.min)}</span>
<span>{formatBound(bounds.mid)}</span>
<span>{formatBound(bounds.max)}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { HeatmapMode } from '@/lib/colors'
interface HeatmapModeToggleProps {
mode: HeatmapMode
onChange: (mode: HeatmapMode) => void
}
const MODE_LABELS: Record<HeatmapMode, { label: string; tip: string }> = {
relative: {
label: 'Costo relativo (%)',
tip: 'Colorea cada actividad en función de su % del costo total. Útil para ver proporciones.',
},
absolute: {
label: 'Costo absoluto',
tip: 'Colorea según el costo esperado en la moneda del proceso. Útil para comparar magnitudes reales.',
},
}
export function HeatmapModeToggle({ mode, onChange }: HeatmapModeToggleProps) {
return (
<div className="inline-flex rounded-lg border border-slate-200 bg-slate-50 p-0.5 text-xs">
{(['relative', 'absolute'] as HeatmapMode[]).map((m) => {
const { label, tip } = MODE_LABELS[m]
return (
<Tooltip key={m}>
<TooltipTrigger asChild>
<button
onClick={() => onChange(m)}
className={cn(
'px-3 py-1.5 rounded-md font-medium transition-all',
mode === m
? 'bg-white text-slate-800 shadow-sm'
: 'text-slate-500 hover:text-slate-700'
)}
>
{label}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-52 text-xs">
{tip}
</TooltipContent>
</Tooltip>
)
})}
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { DollarSign, TrendingUp, Clock, LayoutList } from 'lucide-react'
import { formatCurrency, formatPercent, formatMinutes } from '@/lib/format'
import type { SimulationResult } from '@/domain/types'
interface KpiBarProps {
result: SimulationResult
currency: string
}
interface KpiCardProps {
label: string
value: string
sub?: string
icon: React.ReactNode
iconColor: string
}
function KpiCard({ label, value, sub, icon, iconColor }: KpiCardProps) {
return (
<div data-testid="kpi-card" className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 flex items-center gap-4">
<div className={`shrink-0 p-3 rounded-lg ${iconColor}`}>
{icon}
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-1">{label}</p>
<p className="text-2xl font-bold text-slate-900 font-mono leading-tight truncate">{value}</p>
{sub && <p className="text-xs text-slate-400 mt-0.5">{sub}</p>}
</div>
</div>
)
}
export function KpiBar({ result, currency }: KpiBarProps) {
const directPct = result.totalCost > 0
? formatPercent(result.totalDirectCost / result.totalCost * 100)
: '—'
const indirectPct = result.totalCost > 0
? formatPercent(result.totalIndirectCost / result.totalCost * 100)
: '—'
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<KpiCard
label="Costo total esperado"
value={formatCurrency(result.totalCost, currency)}
icon={<DollarSign className="h-5 w-5 text-primary" />}
iconColor="bg-primary/10"
/>
<KpiCard
label="Directo / Indirecto"
value={formatCurrency(result.totalDirectCost, currency)}
sub={`${directPct} directo · ${formatCurrency(result.totalIndirectCost, currency)} (${indirectPct}) overhead`}
icon={<TrendingUp className="h-5 w-5 text-emerald-600" />}
iconColor="bg-emerald-50"
/>
<KpiCard
label="Tiempo total esperado"
value={formatMinutes(result.totalTimeMinutes)}
sub={`${result.totalTimeMinutes.toFixed(0)} minutos ponderados por probabilidad`}
icon={<Clock className="h-5 w-5 text-amber-600" />}
iconColor="bg-amber-50"
/>
<KpiCard
label="Actividades costeadas"
value={String(result.perActivity.length)}
sub={result.perActivity.length === 1 ? 'actividad en el proceso' : 'actividades en el proceso'}
icon={<LayoutList className="h-5 w-5 text-slate-500" />}
iconColor="bg-slate-100"
/>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import type { Process } from '@/domain/types'
import { formatPercent } from '@/lib/format'
interface MethodologyFooterProps {
process: Process
}
export function MethodologyFooter({ process }: MethodologyFooterProps) {
return (
<footer className="mt-12 pt-6 border-t border-slate-200">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">Nota metodológica</p>
<div className="text-xs text-slate-400 leading-relaxed space-y-2 max-w-4xl">
<p>
<strong className="text-slate-500">Costo total</strong> = Σ(actividades) costo_directo_esperado + costo_indirecto_esperado.
</p>
<p>
<strong className="text-slate-500">Costo directo por actividad</strong> = (costo_fijo_por_ejecución + Σ(recurso × costo_por_hora × tiempo_de_ejecución × porcentaje_utilización / 60)) × probabilidad_de_ejecución.
</p>
<p>
<strong className="text-slate-500">Costo indirecto</strong> = costo_directo_total × overhead_global ({formatPercent(process.overheadPercentage * 100, 0)}).
Representa costos estructurales no imputables directamente a actividades individuales (alquiler, administración, infraestructura).
</p>
<p>
<strong className="text-slate-500">Probabilidad de ejecución</strong>: calculada mediante propagación hacia adelante desde el evento de inicio,
considerando las probabilidades configuradas en cada gateway de decisión.
Las actividades en el camino principal tienen probabilidad 1 (100%).
Las actividades en ramas condicionales tienen la probabilidad acumulada del camino que las incluye.
</p>
<p className="text-slate-300 pt-1">
Modelo determinístico agregado Noche Cero MVP. Los tiempos y costos son estimaciones.
Para análisis de sensibilidad y distribuciones probabilísticas, ver V1.0.
</p>
</div>
</footer>
)
}

View File

@@ -0,0 +1,67 @@
import { ArrowLeft, Download, FileText, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatSimulationTimestamp } from '@/lib/format'
import type { Process } from '@/domain/types'
interface ReportHeaderProps {
process: Process
simulatedAt: number
onBack: () => void
onExportPdf: () => void
onExportCsv: () => void
isExportingPdf?: boolean
isExportingCsv?: boolean
}
export function ReportHeader({
process, simulatedAt, onBack,
onExportPdf, onExportCsv,
isExportingPdf = false, isExportingCsv = false,
}: ReportHeaderProps) {
const { date, time } = formatSimulationTimestamp(simulatedAt)
return (
<div className="flex items-start justify-between gap-6 mb-8">
<div className="min-w-0">
<button
onClick={onBack}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-600 transition-colors mb-3"
>
<ArrowLeft className="h-3.5 w-3.5" />
Volver a editar
</button>
<h1 className="text-3xl font-bold text-slate-900 leading-tight">{process.name}</h1>
<p className="text-sm text-slate-400 mt-1.5">
{process.clientName && <><span className="text-slate-600">{process.clientName}</span> · </>}
Simulado el {date} a las {time} · Moneda: <span className="font-medium">{process.currency}</span>
</p>
</div>
<div className="flex items-center gap-2 shrink-0 pt-9">
<Button
variant="outline"
size="sm"
onClick={onExportCsv}
disabled={isExportingCsv || isExportingPdf}
className="gap-1.5 min-w-[120px]"
>
{isExportingCsv
? <><Loader2 className="h-4 w-4 animate-spin" />Generando CSV</>
: <><FileText className="h-4 w-4" />Exportar CSV</>
}
</Button>
<Button
size="sm"
onClick={onExportPdf}
disabled={isExportingPdf || isExportingCsv}
className="gap-1.5 min-w-[120px]"
>
{isExportingPdf
? <><Loader2 className="h-4 w-4 animate-spin" />Generando PDF</>
: <><Download className="h-4 w-4" />Exportar PDF</>
}
</Button>
</div>
</div>
)
}

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>
)
}

View File

@@ -0,0 +1,24 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { buildTopActivitiesOption } from '@/lib/chart-options'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
interface TopActivitiesChartProps {
perActivity: ActivitySimResult[]
mode: HeatmapMode
currency: string
}
export function TopActivitiesChart({ perActivity, mode, currency }: TopActivitiesChartProps) {
const option = useMemo(
() => buildTopActivitiesOption(perActivity, mode, currency),
[perActivity, mode, currency]
)
if (perActivity.length === 0) {
return <div className="flex items-center justify-center h-64 text-xs text-slate-400">Sin datos de actividades</div>
}
return <ReactECharts option={option} style={{ height: 320 }} notMerge />
}

View File

@@ -0,0 +1,36 @@
import { AlertTriangle } from 'lucide-react'
interface WarningsBannerProps {
warnings: string[]
}
export function WarningsBanner({ warnings }: WarningsBannerProps) {
if (warnings.length === 0) return null
const hasLoop = warnings.some((w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo'))
return (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-semibold text-amber-800 mb-2">
{warnings.length === 1 ? 'Advertencia del motor de simulación' : `${warnings.length} advertencias del motor de simulación`}
</p>
<ul className="space-y-1">
{warnings.map((w, i) => (
<li key={i} className="text-sm text-amber-700 leading-relaxed"> {w}</li>
))}
</ul>
{hasLoop && (
<p className="text-xs text-amber-600 mt-2 border-t border-amber-200 pt-2">
<strong>Impacto en los resultados:</strong> Los procesos con ciclos se calculan asumiendo una sola ejecución del loop.
Los costos reales pueden ser mayores si el loop se repite múltiples veces en cada instancia del proceso.
Para modelado probabilístico de loops, se implementará distribuciones en V1.0.
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import { processRepo, activityRepo, resourceRepo, simulationRepo } from '@/persistence/repositories'
import type { Process, Activity, Resource, Simulation } from '@/domain/types'
interface ReportData {
process: Process | null
simulation: Simulation | null
activities: Activity[]
resources: Resource[]
isLoading: boolean
error: string | null
}
export function useReportData(processId: string): ReportData {
const [process, setProcess] = useState<Process | null>(null)
const [simulation, setSimulation] = useState<Simulation | null>(null)
const [activities, setActivities] = useState<Activity[]>([])
const [resources, setResources] = useState<Resource[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
setIsLoading(true)
setError(null)
try {
const [proc, sim, acts, res] = await Promise.all([
processRepo.getById(processId),
simulationRepo.getLatestByProcess(processId),
activityRepo.getByProcess(processId),
resourceRepo.getByProcess(processId),
])
if (cancelled) return
if (!proc) throw new Error('Proceso no encontrado en la base de datos local')
if (!sim) throw new Error('Sin simulación disponible. Volvé al workspace y ejecutá "Simular" primero.')
setProcess(proc)
setSimulation(sim)
setActivities(acts)
setResources(res)
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Error al cargar el reporte')
} finally {
if (!cancelled) setIsLoading(false)
}
}
load()
return () => { cancelled = true }
}, [processId])
return { process, simulation, activities, resources, isLoading, error }
}