Files
inq-roi-simulador-web/src/features/report/HeatmapCanvas.tsx
Marcos Benítez bed161c92a 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>
2026-05-14 01:58:40 -03:00

280 lines
11 KiB
TypeScript

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'