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,82 @@
import { useEffect, useRef } from 'react'
import BpmnViewer from 'bpmn-js/lib/Viewer'
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
const ACTIVITY_TYPES = new Set([
'bpmn:Task', 'bpmn:UserTask', 'bpmn:ServiceTask', 'bpmn:ScriptTask',
'bpmn:ManualTask', 'bpmn:BusinessRuleTask', 'bpmn:SubProcess',
])
const GATEWAY_TYPES = new Set([
'bpmn:ExclusiveGateway', 'bpmn:ParallelGateway',
'bpmn:InclusiveGateway', 'bpmn:EventBasedGateway',
])
function classifyElement(type: string): BpmnElementCategory {
if (ACTIVITY_TYPES.has(type)) return 'activity'
if (GATEWAY_TYPES.has(type)) return 'gateway'
return 'other'
}
interface BpmnCanvasProps {
xml: string
onElementClick?: (elementId: string, elementName: string, category: BpmnElementCategory) => void
selectedElementId?: string | null
className?: string
}
export function BpmnCanvas({ xml, onElementClick, selectedElementId, className }: BpmnCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<InstanceType<typeof BpmnViewer> | null>(null)
useEffect(() => {
if (!containerRef.current) return
const viewer = new BpmnViewer({ container: containerRef.current })
viewerRef.current = viewer
return () => {
viewer.destroy()
viewerRef.current = null
}
}, [])
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !xml) return
viewer.importXML(xml).then(({ warnings }) => {
if (warnings.length) console.warn('bpmn-js warnings:', warnings)
const canvas = viewer.get('canvas') as any
canvas.zoom('fit-viewport', 'auto')
}).catch((err: Error) => console.error('Error cargando BPMN:', err))
}, [xml])
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !onElementClick) return
const eventBus = viewer.get('eventBus') as any
const handler = (event: any) => {
const el = event.element
if (!el) return
const category = classifyElement(el.type)
if (category === 'other') return
onElementClick(el.id, el.businessObject?.name ?? el.id, category)
}
eventBus.on('element.click', handler)
return () => eventBus.off('element.click', handler)
}, [onElementClick])
useEffect(() => {
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 (selectedElementId) {
try { canvas.addMarker(selectedElementId, 'bpmn-selected') } catch { /* ignorar */ }
}
}, [selectedElementId])
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />
}