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:
133
src/features/report/ActivitiesTable.tsx
Normal file
133
src/features/report/ActivitiesTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/features/report/CostByResourceChart.tsx
Normal file
28
src/features/report/CostByResourceChart.tsx
Normal 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 />
|
||||
}
|
||||
18
src/features/report/CostCompositionChart.tsx
Normal file
18
src/features/report/CostCompositionChart.tsx
Normal 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 />
|
||||
}
|
||||
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'
|
||||
35
src/features/report/HeatmapLegend.tsx
Normal file
35
src/features/report/HeatmapLegend.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
src/features/report/HeatmapModeToggle.tsx
Normal file
49
src/features/report/HeatmapModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
src/features/report/KpiBar.tsx
Normal file
72
src/features/report/KpiBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/features/report/MethodologyFooter.tsx
Normal file
36
src/features/report/MethodologyFooter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
src/features/report/ReportHeader.tsx
Normal file
67
src/features/report/ReportHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
24
src/features/report/TopActivitiesChart.tsx
Normal file
24
src/features/report/TopActivitiesChart.tsx
Normal 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 />
|
||||
}
|
||||
36
src/features/report/WarningsBanner.tsx
Normal file
36
src/features/report/WarningsBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
src/features/report/useReportData.ts
Normal file
54
src/features/report/useReportData.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user