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:
279
src/features/report/HeatmapCanvas.tsx
Normal file
279
src/features/report/HeatmapCanvas.tsx
Normal 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'
|
||||
Reference in New Issue
Block a user