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,277 @@
import { useNavigate } from '@tanstack/react-router'
import { useCallback, useState } from 'react'
import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid'
import { Card, CardContent } from '@/components/ui/card'
import { parseBpmnXml, extractActivityElements, extractGatewayElements, BpmnParseError } from '@/domain/bpmn-parser'
import { processRepo, activityRepo, gatewayRepo } from '@/persistence/repositories'
import type { Process, Activity, GatewayConfig } from '@/domain/types'
import { bpmnTypeToGatewayType } from '@/domain/types'
import { useToast } from '@/components/ui/use-toast'
import { AppFooter } from '@/components/AppFooter'
const SAMPLE_PROCESSES = [
{
id: 'simple-linear',
fileName: 'simple-linear.bpmn',
name: 'Proceso lineal simple',
tags: ['5 tareas', 'Sin gateways'],
},
{
id: 'medium-with-gateways',
fileName: 'medium-with-gateways.bpmn',
name: 'Aprobación de crédito',
tags: ['XOR', 'Paralelo', '11 tareas'],
},
{
id: 'complex-with-loop',
fileName: 'complex-with-loop.bpmn',
name: 'Control de calidad con revisión',
tags: ['Loop', 'XOR', '9 tareas'],
},
]
export function ImportPage() {
const navigate = useNavigate()
const { toast } = useToast()
const [isDragging, setIsDragging] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null)
const processFile = useCallback(async (file: File) => {
if (!file.name.endsWith('.bpmn') && !file.name.endsWith('.xml')) {
toast({
title: 'Formato no válido',
description: 'El archivo debe tener extensión .bpmn o .xml',
variant: 'destructive',
})
return
}
setIsProcessing(true)
try {
const xml = await file.text()
const graph = parseBpmnXml(xml)
const processId = uuidv4()
const now = Date.now()
const process: Process = {
id: processId,
name: file.name.replace(/\.(bpmn|xml)$/, ''),
clientName: '',
bpmnXml: xml,
currency: 'USD',
overheadPercentage: 0.2,
createdAt: now,
updatedAt: now,
}
const activityElements = extractActivityElements(graph)
const activities: Activity[] = activityElements.map((el) => ({
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
name: el.name,
type: el.type,
directCostFixed: 0,
executionTimeMinutes: 0,
assignedResources: [],
}))
const gatewayElements = extractGatewayElements(graph)
const gateways: GatewayConfig[] = gatewayElements.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
const n = el.outgoing.length
return {
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
gatewayType: gwType,
branches: el.outgoing.map((flowId) => ({
flowId,
targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)),
})),
}
})
await Promise.all([
processRepo.save(process),
activityRepo.saveMany(activities),
gatewayRepo.saveMany(gateways),
])
toast({
title: 'Proceso importado',
description: `${activityElements.length} ${activityElements.length === 1 ? 'actividad' : 'actividades'} detectadas. Configurá los costos para simular.`,
})
navigate({ to: '/workspace/$processId', params: { processId } })
} catch (err) {
setIsProcessing(false)
if (err instanceof BpmnParseError) {
toast({
title: 'BPMN 2.0 no válido',
description: 'El archivo no pudo ser interpretado como un diagrama BPMN 2.0 estándar.',
variant: 'destructive',
})
} else {
toast({
title: 'Error al procesar el archivo',
description: 'Ocurrió un error inesperado. Verificá que el archivo no esté dañado.',
variant: 'destructive',
})
console.error(err)
}
}
}, [navigate, toast])
const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) processFile(file)
}, [processFile])
const onFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) processFile(file)
}, [processFile])
async function handleSampleLoad(sample: typeof SAMPLE_PROCESSES[0]) {
setLoadingSampleId(sample.id)
try {
const response = await fetch(`/sample-processes/${sample.fileName}`)
if (!response.ok) throw new Error('No se pudo cargar el proceso de ejemplo')
const text = await response.text()
const file = new File([text], sample.fileName, { type: 'text/xml' })
await processFile(file)
} catch (err) {
toast({
title: 'Error al cargar el ejemplo',
description: 'No se pudo descargar el proceso. Intentá de nuevo.',
variant: 'destructive',
})
console.error(err)
} finally {
setLoadingSampleId(null)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col items-center justify-center p-6">
{/* Header */}
<div className="text-center mb-10">
<div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full">
<FlaskConical className="h-3.5 w-3.5" />
Process Cost Platform · MVP Fase 0
</div>
<h1 className="text-4xl font-bold text-slate-900 mb-3">
Cuantificá el costo operativo<br />de tus procesos
</h1>
<p className="text-lg text-slate-500 max-w-xl">
Importá un archivo BPMN 2.0 y obtené un reporte visual de costos
listo para presentar a tu cliente en minutos.
</p>
</div>
{/* Drop zone */}
<Card className="w-full max-w-lg shadow-lg">
<CardContent className="p-0">
<label
htmlFor="bpmn-file-input"
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
className={`
flex flex-col items-center justify-center gap-4 p-12 rounded-xl cursor-pointer
border-2 border-dashed transition-all duration-200
${isDragging
? 'border-primary bg-primary/5 scale-[1.01]'
: 'border-slate-200 hover:border-primary/50 hover:bg-slate-50'
}
`}
>
{isProcessing ? (
<>
<Loader2 className="h-12 w-12 text-primary animate-spin" />
<p className="text-slate-600 font-medium">Procesando el diagrama</p>
</>
) : (
<>
<div className={`p-4 rounded-full transition-colors ${isDragging ? 'bg-primary/10' : 'bg-slate-100'}`}>
<UploadCloud className={`h-10 w-10 transition-colors ${isDragging ? 'text-primary' : 'text-slate-400'}`} />
</div>
<div className="text-center">
<p className="text-slate-700 font-semibold text-lg">
Arrastrá tu archivo .bpmn aquí
</p>
<p className="text-slate-400 text-sm mt-1">
o hacé click para seleccionarlo desde tu computadora
</p>
</div>
<div className="flex items-center gap-2 text-xs text-slate-400">
<FileText className="h-3.5 w-3.5" />
BPMN 2.0 XML estándar (.bpmn, .xml)
</div>
</>
)}
<input
id="bpmn-file-input"
type="file"
accept=".bpmn,.xml"
className="hidden"
onChange={onFileInput}
disabled={isProcessing}
/>
</label>
</CardContent>
</Card>
{/* Procesos de ejemplo — visibles directamente */}
<div className="mt-8 w-full max-w-lg">
<p className="text-xs text-slate-400 uppercase tracking-wide font-medium text-center mb-3">
O cargá un proceso de ejemplo con un click
</p>
<div className="grid grid-cols-3 gap-3">
{SAMPLE_PROCESSES.map((sample) => {
const isLoading = loadingSampleId === sample.id
return (
<button
key={sample.id}
onClick={() => handleSampleLoad(sample)}
disabled={isProcessing || loadingSampleId !== null}
className="flex flex-col items-start gap-2 p-3 rounded-xl border border-slate-200 bg-white hover:border-primary/40 hover:shadow-sm transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center justify-between w-full">
<div className="p-1.5 bg-primary/10 rounded-lg">
{isLoading
? <Loader2 className="h-3.5 w-3.5 text-primary animate-spin" />
: <FileText className="h-3.5 w-3.5 text-primary" />
}
</div>
</div>
<p className="text-xs font-semibold text-slate-700 leading-tight">{sample.name}</p>
<div className="flex flex-wrap gap-1">
{sample.tags.map((tag) => (
<span key={tag} className="text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded-full">
{tag}
</span>
))}
</div>
</button>
)
})}
</div>
</div>
<p className="mt-8 text-xs text-slate-300">
100% en tu navegador · Sin backend · Sin registro · Datos guardados localmente
</p>
<AppFooter />
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useState } from 'react'
import { ArrowLeft, FileText, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
interface SampleProcess {
id: string
fileName: string
name: string
description: string
tags: string[]
}
const SAMPLE_PROCESSES: SampleProcess[] = [
{
id: 'simple-linear',
fileName: 'simple-linear.bpmn',
name: 'Proceso Lineal Simple',
description: '5 tareas en secuencia sin gateways. Ideal para validar el flujo básico de importación y costeo.',
tags: ['Lineal', '5 tareas', 'Sin gateways'],
},
{
id: 'medium-with-gateways',
fileName: 'medium-with-gateways.bpmn',
name: 'Aprobación de Crédito',
description: '12 tareas con un gateway exclusivo (XOR) y un gateway paralelo (AND). Caso de negocio real.',
tags: ['XOR', 'Paralelo', '12 tareas'],
},
{
id: 'complex-with-loop',
fileName: 'complex-with-loop.bpmn',
name: 'Control de Calidad con Revisión',
description: 'Proceso con un loop de correcciones que vuelve a la inspección. Valida la detección de ciclos.',
tags: ['Loop', 'XOR', '9 tareas'],
},
]
interface SampleProcessLoaderProps {
onBack: () => void
onLoad: (file: File) => void
}
export function SampleProcessLoader({ onBack, onLoad }: SampleProcessLoaderProps) {
const [loadingId, setLoadingId] = useState<string | null>(null)
async function handleSelect(sample: SampleProcess) {
setLoadingId(sample.id)
try {
const response = await fetch(`/sample-processes/${sample.fileName}`)
if (!response.ok) throw new Error('No se pudo cargar el archivo de ejemplo')
const text = await response.text()
const file = new File([text], sample.fileName, { type: 'text/xml' })
onLoad(file)
} catch (err) {
console.error('Error cargando proceso de ejemplo:', err)
setLoadingId(null)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col items-center justify-center p-6">
<div className="w-full max-w-2xl">
<Button variant="ghost" size="sm" onClick={onBack} className="mb-6 text-slate-500">
<ArrowLeft className="h-4 w-4 mr-1.5" />
Volver
</Button>
<div className="mb-8">
<h2 className="text-2xl font-bold text-slate-900 mb-1">Procesos de ejemplo</h2>
<p className="text-slate-500">Seleccioná un proceso para comenzar la demo sin necesitar un archivo propio.</p>
</div>
<div className="flex flex-col gap-4">
{SAMPLE_PROCESSES.map((sample) => (
<Card
key={sample.id}
className="cursor-pointer hover:shadow-md transition-all border-slate-200 hover:border-primary/30"
onClick={() => handleSelect(sample)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<FileText className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{sample.name}</CardTitle>
<CardDescription className="text-xs mt-0.5">{sample.fileName}</CardDescription>
</div>
</div>
{loadingId === sample.id ? (
<Loader2 className="h-5 w-5 text-primary animate-spin mt-1" />
) : (
<Button size="sm" variant="outline" className="shrink-0">
Cargar
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-slate-600 mb-3">{sample.description}</p>
<div className="flex gap-2 flex-wrap">
{sample.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600"
>
{tag}
</span>
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
)
}

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

View File

@@ -0,0 +1,56 @@
import { useCallback } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { useProcessStore } from '@/store/process-store'
import { useSimulationStore } from '@/store/simulation-store'
import { runSimulation } from '@/domain/simulation'
import { simulationRepo } from '@/persistence/repositories'
import type { SimulationInput } from '@/domain/types'
export function useSimulate() {
const { currentProcess, activities, gateways, resources } = useProcessStore()
const { setResult, setRunning, setError } = useSimulationStore()
const simulate = useCallback(async () => {
if (!currentProcess) {
setError('No hay proceso cargado')
return null
}
setRunning(true)
setError(null)
try {
const input: SimulationInput = {
processXml: currentProcess.bpmnXml,
activities: new Map(activities.map((a) => [a.bpmnElementId, a])),
gateways: new Map(gateways.map((g) => [g.bpmnElementId, g])),
resources: new Map(resources.map((r) => [r.id, r])),
globalSettings: {
currency: currentProcess.currency,
overheadPercentage: currentProcess.overheadPercentage,
},
}
const result = runSimulation(input)
const simulation = {
id: uuidv4(),
processId: currentProcess.id,
executedAt: Date.now(),
result,
}
await simulationRepo.save(simulation)
setResult(result)
setRunning(false)
return result
} catch (err) {
const message = err instanceof Error ? err.message : 'Error en la simulación'
setError(message)
setRunning(false)
return null
}
}, [currentProcess, activities, gateways, resources, setResult, setRunning, setError])
return { simulate }
}

View File

@@ -0,0 +1,241 @@
import { useEffect, useState } from 'react'
import { PlusCircle, Trash2, Info } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useProcessStore } from '@/store/process-store'
import { formatCurrency } from '@/lib/format'
import type { Activity } from '@/domain/types'
interface ActivityPanelProps {
selectedElementId: string | null
}
export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
const { activities, resources, updateActivity } = useProcessStore()
const [localActivity, setLocalActivity] = useState<Activity | null>(null)
const [isDirty, setIsDirty] = useState(false)
const activity = activities.find((a) => a.bpmnElementId === selectedElementId) ?? null
// Sincronizar con el store cuando cambia la selección
useEffect(() => {
setLocalActivity(activity ? { ...activity, assignedResources: [...activity.assignedResources] } : null)
setIsDirty(false)
}, [selectedElementId, activities])
function handleChange<K extends keyof Activity>(key: K, value: Activity[K]) {
if (!localActivity) return
setLocalActivity((prev) => prev ? { ...prev, [key]: value } : null)
setIsDirty(true)
}
async function handleSave() {
if (!localActivity) return
await updateActivity(localActivity)
setIsDirty(false)
}
function addResource(resourceId: string) {
if (!localActivity) return
const already = localActivity.assignedResources.some((r) => r.resourceId === resourceId)
if (already) return
handleChange('assignedResources', [
...localActivity.assignedResources,
{ resourceId, utilizationPercent: 1.0 },
])
}
function removeResource(resourceId: string) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.filter((r) => r.resourceId !== resourceId)
)
}
function setUtilization(resourceId: string, value: number) {
if (!localActivity) return
handleChange(
'assignedResources',
localActivity.assignedResources.map((r) =>
r.resourceId === resourceId ? { ...r, utilizationPercent: value } : r
)
)
}
if (!selectedElementId) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-12">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5" />
</svg>
</div>
<p className="text-sm font-medium text-slate-600 mb-1">Seleccioná una actividad</p>
<p className="text-xs text-slate-400">Hacé click sobre una tarea en el diagrama para editar sus atributos de costo</p>
</div>
)
}
if (!localActivity) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-xs text-slate-400">Esta actividad no tiene configuración todavía</p>
</div>
)
}
const availableResources = resources.filter(
(r) => !localActivity.assignedResources.some((ar) => ar.resourceId === r.id)
)
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
{/* Nombre (read-only, viene del BPMN) */}
<div>
<Label className="text-xs text-slate-500 mb-1 block">Actividad</Label>
<p className="font-semibold text-slate-800 text-sm leading-tight">{localActivity.name || localActivity.bpmnElementId}</p>
<p className="text-xs text-slate-400 mt-0.5 font-mono">{localActivity.bpmnElementId}</p>
</div>
{/* Costo directo fijo */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="direct-cost" className="text-xs font-medium">Costo directo fijo</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-48 text-xs">
Costo fijo por cada ejecución de esta actividad (materiales, licencias, etc.) independiente del tiempo
</TooltipContent>
</Tooltip>
</div>
<Input
id="direct-cost"
type="number"
min={0}
step={10}
value={localActivity.directCostFixed}
onChange={(e) => handleChange('directCostFixed', Math.max(0, parseFloat(e.target.value) || 0))}
className="font-mono h-8 text-sm"
placeholder="0"
/>
</div>
{/* Tiempo de ejecución */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="exec-time" className="text-xs font-medium">Tiempo de ejecución (min)</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-48 text-xs">
Tiempo promedio estimado para completar esta actividad
</TooltipContent>
</Tooltip>
</div>
<Input
id="exec-time"
type="number"
min={0}
step={5}
value={localActivity.executionTimeMinutes}
onChange={(e) => handleChange('executionTimeMinutes', Math.max(0, parseFloat(e.target.value) || 0))}
className="font-mono h-8 text-sm"
placeholder="0"
/>
</div>
{/* Recursos asignados */}
<div className="space-y-2">
<Label className="text-xs font-medium block">Recursos asignados</Label>
{localActivity.assignedResources.length === 0 ? (
<p className="text-xs text-slate-400 py-2">Sin recursos asignados</p>
) : (
<div className="space-y-3">
{localActivity.assignedResources.map((assignment) => {
const resource = resources.find((r) => r.id === assignment.resourceId)
if (!resource) return null
const hourlyShare = (resource.costPerHour * assignment.utilizationPercent).toFixed(2)
return (
<div key={assignment.resourceId} className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-700">{resource.name}</p>
<p className="text-xs text-slate-400">{formatCurrency(resource.costPerHour)}/h · {formatCurrency(parseFloat(hourlyShare))}/h efectivo</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-slate-400 hover:text-destructive"
onClick={() => removeResource(assignment.resourceId)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-xs text-slate-500">Utilización</span>
<span className="text-xs font-mono font-medium">{Math.round(assignment.utilizationPercent * 100)}%</span>
</div>
<Slider
value={[assignment.utilizationPercent * 100]}
min={0}
max={100}
step={5}
onValueChange={([v]) => setUtilization(assignment.resourceId, v / 100)}
/>
</div>
</div>
)
})}
</div>
)}
{/* Agregar recurso */}
{availableResources.length > 0 && (
<Select onValueChange={addResource}>
<SelectTrigger className="h-8 text-xs">
<div className="flex items-center gap-1.5 text-slate-500">
<PlusCircle className="h-3.5 w-3.5" />
<SelectValue placeholder="Agregar recurso…" />
</div>
</SelectTrigger>
<SelectContent>
{availableResources.map((r) => (
<SelectItem key={r.id} value={r.id} className="text-xs">
{r.name} {formatCurrency(r.costPerHour)}/h
</SelectItem>
))}
</SelectContent>
</Select>
)}
{resources.length === 0 && (
<p className="text-xs text-slate-400">Creá recursos en la tab "Recursos" para asignarlos</p>
)}
</div>
{/* Guardar */}
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar cambios' : 'Sin cambios'}
</Button>
</div>
</ScrollArea>
)
}

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%' }} />
}

View File

@@ -0,0 +1,277 @@
import { useEffect, useMemo, useState } from 'react'
import { Info, AlertTriangle, RotateCcw, Check, GitMerge } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useProcessStore } from '@/store/process-store'
import { parseBpmnXml } from '@/domain/bpmn-parser'
import { formatPercent } from '@/lib/format'
import type { GatewayConfig } from '@/domain/types'
interface GatewayPanelProps {
selectedElementId: string | null
}
// Tolerancia para validar que la suma de probs XOR = 1.0
const XOR_SUM_TOLERANCE = 0.005
function sumBranches(gw: GatewayConfig): number {
return gw.branches.reduce((s, b) => s + b.probability, 0)
}
export function isXorSumValid(gw: GatewayConfig): boolean {
if (gw.gatewayType !== 'exclusive') return true
return Math.abs(sumBranches(gw) - 1.0) <= XOR_SUM_TOLERANCE
}
export function GatewayPanel({ selectedElementId }: GatewayPanelProps) {
const { gateways, currentProcess, updateGateway, activities } = useProcessStore()
const [localGw, setLocalGw] = useState<GatewayConfig | null>(null)
const [isDirty, setIsDirty] = useState(false)
// Mapa bpmnElementId → nombre legible (parseamos el XML una sola vez)
const elementNames = useMemo(() => {
if (!currentProcess) return new Map<string, string>()
try {
const graph = parseBpmnXml(currentProcess.bpmnXml)
return new Map(Array.from(graph.nodes.values()).map((n) => [n.id, n.name || n.id]))
} catch {
return new Map<string, string>()
}
}, [currentProcess?.bpmnXml])
const gateway = gateways.find((g) => g.bpmnElementId === selectedElementId) ?? null
useEffect(() => {
setLocalGw(gateway ? structuredClone(gateway) : null)
setIsDirty(false)
}, [selectedElementId, gateways])
function targetLabel(targetId: string): string {
// Primero buscar en actividades (tienen nombre más descriptivo)
const act = activities.find((a) => a.bpmnElementId === targetId)
if (act?.name) return act.name
// Luego en el mapa del grafo
const fromGraph = elementNames.get(targetId)
if (fromGraph) return fromGraph
return targetId
}
function setProbability(branchIndex: number, value: number) {
if (!localGw) return
const updated = structuredClone(localGw)
updated.branches[branchIndex].probability = value
setLocalGw(updated)
setIsDirty(true)
}
function distributeUniformly() {
if (!localGw) return
const n = localGw.branches.length
const equalProb = parseFloat((1 / n).toFixed(4))
const updated = structuredClone(localGw)
updated.branches = updated.branches.map((b, i) => ({
...b,
probability: i === n - 1 ? parseFloat((1 - equalProb * (n - 1)).toFixed(4)) : equalProb,
}))
setLocalGw(updated)
setIsDirty(true)
}
async function handleSave() {
if (!localGw) return
await updateGateway(localGw)
setIsDirty(false)
}
// ── Empty state ──────────────────────────────────────────────────────────
if (!selectedElementId) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-12">
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mb-3">
<GitMerge className="h-6 w-6 text-slate-400" />
</div>
<p className="text-sm font-medium text-slate-600 mb-1">Seleccioná un gateway</p>
<p className="text-xs text-slate-400">Hacé click sobre un gateway en el diagrama para configurar sus probabilidades</p>
</div>
)
}
if (!localGw) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-xs text-slate-400">Gateway sin configuración</p>
</div>
)
}
// ── AND Paralelo: informativo, sin sliders ────────────────────────────────
if (localGw.gatewayType === 'parallel') {
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Gateway Paralelo (AND)</span>
</div>
<p className="text-xs font-medium text-slate-800 font-mono">{localGw.bpmnElementId}</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-500 shrink-0 mt-0.5" />
<div>
<p className="text-xs font-semibold text-blue-800">Ejecución paralela</p>
<p className="text-xs text-blue-700 mt-0.5">
En un gateway AND todas las salidas se ejecutan simultáneamente.
No hay probabilidades configurables la probabilidad de cada rama
es igual a la probabilidad de llegada al gateway.
</p>
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium">Flujos de salida</Label>
{localGw.branches.map((branch) => (
<div key={branch.flowId} className="flex items-center justify-between p-2.5 rounded-lg bg-slate-50 border border-slate-100">
<div className="min-w-0">
<p className="text-xs font-medium text-slate-700 truncate">{targetLabel(branch.targetElementId)}</p>
<p className="text-xs text-slate-400 font-mono">{branch.flowId}</p>
</div>
<span className="text-xs font-mono font-semibold text-blue-600 shrink-0 ml-2">100%</span>
</div>
))}
</div>
</div>
</ScrollArea>
)
}
// ── XOR Exclusivo / OR Inclusivo (con aviso) ──────────────────────────────
const isInclusive = localGw.gatewayType === 'inclusive'
const currentSum = sumBranches(localGw)
const xorValid = isInclusive || Math.abs(currentSum - 1.0) <= XOR_SUM_TOLERANCE
const sumColor = xorValid ? 'text-green-600' : 'text-destructive'
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Header */}
<div>
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide block mb-1">
{isInclusive ? 'Gateway Inclusivo (OR)' : 'Gateway Exclusivo (XOR)'}
</span>
<p className="text-xs font-medium text-slate-800 font-mono">{localGw.bpmnElementId}</p>
</div>
{/* Aviso para OR inclusivo */}
{isInclusive && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">
<span className="font-semibold">Gateway Inclusivo (OR)</span> En el MVP se simula como
XOR: solo una rama se toma por instancia. Las probabilidades deben sumar 1.
El soporte OR nativo llega en V1.0.
</p>
</div>
)}
{/* Suma actual + botón distribuir */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500">Suma de probabilidades:</span>
<span className={`text-xs font-mono font-bold ${sumColor}`}>
{formatPercent(currentSum * 100, 1)}
</span>
{!xorValid && (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className="h-3.5 w-3.5 text-destructive cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-44 text-xs">
La suma debe ser exactamente 100% para poder simular
</TooltipContent>
</Tooltip>
)}
{xorValid && (
<Tooltip>
<TooltipTrigger asChild>
<Check className="h-3.5 w-3.5 text-green-500 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
Suma 100% listo para simular
</TooltipContent>
</Tooltip>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1 text-slate-500"
onClick={distributeUniformly}
>
<RotateCcw className="h-3 w-3" />
Distribuir uniformemente
</Button>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-52 text-xs">
Reparte el 100% en partes iguales entre todas las ramas del gateway
</TooltipContent>
</Tooltip>
</div>
{/* Sliders por rama */}
<div className="space-y-4">
{localGw.branches.map((branch, idx) => (
<div key={branch.flowId} className="space-y-1.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-medium text-slate-700 truncate leading-tight">
{targetLabel(branch.targetElementId)}
</p>
<p className="text-[10px] text-slate-400 font-mono truncate">{branch.flowId}</p>
</div>
<span className="text-xs font-mono font-semibold text-slate-700 shrink-0 w-11 text-right tabular-nums">
{formatPercent(branch.probability * 100, 1)}
</span>
</div>
<Slider
value={[Math.round(branch.probability * 1000) / 10]}
min={0}
max={100}
step={1}
onValueChange={([v]) => setProbability(idx, parseFloat((v / 100).toFixed(4)))}
/>
</div>
))}
</div>
{/* Error de suma — banner prominente */}
{!xorValid && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-3 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<p className="text-xs text-destructive">
La suma es <strong>{formatPercent(currentSum * 100, 1)}</strong> debe ser 100%.
Ajustá los sliders o usá "Distribuir" para repartir uniformemente.
El botón <strong>Simular</strong> quedará deshabilitado hasta corregirlo.
</p>
</div>
)}
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar probabilidades' : 'Sin cambios'}
</Button>
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,140 @@
import { useState, useEffect } from 'react'
import { Info } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useProcessStore } from '@/store/process-store'
import { formatPercent } from '@/lib/format'
const CURRENCIES = [
{ code: 'USD', label: 'USD — Dólar estadounidense' },
{ code: 'PYG', label: 'PYG — Guaraní paraguayo' },
{ code: 'BRL', label: 'BRL — Real brasileño' },
{ code: 'ARS', label: 'ARS — Peso argentino' },
{ code: 'EUR', label: 'EUR — Euro' },
{ code: 'COP', label: 'COP — Peso colombiano' },
{ code: 'MXN', label: 'MXN — Peso mexicano' },
{ code: 'CLP', label: 'CLP — Peso chileno' },
]
export function GlobalSettingsPanel() {
const { currentProcess, updateProcess } = useProcessStore()
const [localName, setLocalName] = useState('')
const [localClient, setLocalClient] = useState('')
const [localCurrency, setLocalCurrency] = useState('USD')
const [localOverhead, setLocalOverhead] = useState(20)
const [isDirty, setIsDirty] = useState(false)
useEffect(() => {
if (!currentProcess) return
setLocalName(currentProcess.name)
setLocalClient(currentProcess.clientName)
setLocalCurrency(currentProcess.currency)
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
setIsDirty(false)
}, [currentProcess])
function markDirty() { setIsDirty(true) }
async function handleSave() {
await updateProcess({
name: localName.trim() || 'Proceso sin nombre',
clientName: localClient.trim(),
currency: localCurrency,
overheadPercentage: localOverhead / 100,
})
setIsDirty(false)
}
if (!currentProcess) return null
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
{/* Nombre del proceso */}
<div className="space-y-1.5">
<Label htmlFor="proc-name" className="text-xs font-medium">Nombre del proceso</Label>
<Input
id="proc-name"
value={localName}
onChange={(e) => { setLocalName(e.target.value); markDirty() }}
placeholder="ej. Proceso de ventas"
className="h-8 text-sm"
/>
</div>
{/* Cliente */}
<div className="space-y-1.5">
<Label htmlFor="client-name" className="text-xs font-medium">Cliente</Label>
<Input
id="client-name"
value={localClient}
onChange={(e) => { setLocalClient(e.target.value); markDirty() }}
placeholder="ej. Empresa ABC"
className="h-8 text-sm"
/>
</div>
{/* Moneda */}
<div className="space-y-1.5">
<Label className="text-xs font-medium">Moneda</Label>
<Select value={localCurrency} onValueChange={(v) => { setLocalCurrency(v); markDirty() }}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code} className="text-xs">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Overhead */}
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium">Overhead (costos indirectos)</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-52 text-xs">
Porcentaje adicional sobre los costos directos que representa los costos indirectos: alquiler, administración, infraestructura general.
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-3">
<Slider
value={[localOverhead]}
min={0}
max={100}
step={1}
onValueChange={([v]) => { setLocalOverhead(v); markDirty() }}
className="flex-1"
/>
<span className="text-sm font-mono font-semibold text-slate-700 w-12 text-right">
{formatPercent(localOverhead, 0)}
</span>
</div>
<p className="text-xs text-slate-400">
Típicamente entre 15% y 40% para empresas de servicios
</p>
</div>
{/* Guardar */}
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty}
className="w-full"
>
{isDirty ? 'Guardar configuración' : 'Sin cambios'}
</Button>
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,201 @@
import { useState } from 'react'
import { PlusCircle, Pencil, Trash2, Check, X } from 'lucide-react'
import { v4 as uuidv4 } from 'uuid'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useProcessStore } from '@/store/process-store'
import { formatCurrency } from '@/lib/format'
import type { Resource, ResourceType } from '@/domain/types'
const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
role: 'Rol',
person: 'Persona',
system: 'Sistema',
equipment: 'Equipo',
supply: 'Insumo',
}
const RESOURCE_TYPE_COLORS: Record<ResourceType, string> = {
role: 'bg-blue-100 text-blue-700',
person: 'bg-green-100 text-green-700',
system: 'bg-purple-100 text-purple-700',
equipment: 'bg-amber-100 text-amber-700',
supply: 'bg-slate-100 text-slate-700',
}
interface ResourceFormState {
name: string
type: ResourceType
costPerHour: string
}
const EMPTY_FORM: ResourceFormState = { name: '', type: 'role', costPerHour: '' }
export function ResourcesPanel() {
const { currentProcess, resources, addResource, updateResource, deleteResource } = useProcessStore()
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<ResourceFormState>(EMPTY_FORM)
function startAdd() {
setForm(EMPTY_FORM)
setEditingId(null)
setShowForm(true)
}
function startEdit(resource: Resource) {
setForm({ name: resource.name, type: resource.type, costPerHour: resource.costPerHour.toString() })
setEditingId(resource.id)
setShowForm(true)
}
function cancelForm() {
setShowForm(false)
setEditingId(null)
setForm(EMPTY_FORM)
}
async function handleSubmit() {
if (!form.name.trim() || !currentProcess) return
const costPerHour = parseFloat(form.costPerHour) || 0
if (editingId) {
const existing = resources.find((r) => r.id === editingId)
if (!existing) return
await updateResource({ ...existing, name: form.name.trim(), type: form.type, costPerHour })
} else {
const newResource: Resource = {
id: uuidv4(),
processId: currentProcess.id,
name: form.name.trim(),
type: form.type,
costPerHour,
}
await addResource(newResource)
}
cancelForm()
}
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-slate-700">Recursos del proceso</p>
<p className="text-xs text-slate-400">{resources.length} definido{resources.length !== 1 ? 's' : ''}</p>
</div>
{!showForm && (
<Button size="sm" variant="outline" onClick={startAdd} className="h-7 text-xs gap-1">
<PlusCircle className="h-3.5 w-3.5" />
Nuevo
</Button>
)}
</div>
{/* Formulario */}
{showForm && (
<div className="border border-primary/20 bg-primary/5 rounded-lg p-3 space-y-3">
<p className="text-xs font-semibold text-slate-700">{editingId ? 'Editar recurso' : 'Nuevo recurso'}</p>
<div className="space-y-1.5">
<Label className="text-xs">Nombre</Label>
<Input
autoFocus
placeholder="ej. Analista Senior"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="h-8 text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Tipo</Label>
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v as ResourceType }))}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(RESOURCE_TYPE_LABELS) as [ResourceType, string][]).map(([value, label]) => (
<SelectItem key={value} value={value} className="text-xs">{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Costo por hora</Label>
<Input
type="number"
min={0}
step={5}
placeholder="0.00"
value={form.costPerHour}
onChange={(e) => setForm((f) => ({ ...f, costPerHour: e.target.value }))}
className="h-8 text-xs font-mono"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleSubmit} disabled={!form.name.trim()} className="flex-1 h-7 text-xs gap-1">
<Check className="h-3.5 w-3.5" />
{editingId ? 'Actualizar' : 'Crear'}
</Button>
<Button size="sm" variant="ghost" onClick={cancelForm} className="h-7 text-xs">
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
{/* Lista */}
{resources.length === 0 && !showForm ? (
<div className="text-center py-8">
<p className="text-xs text-slate-400 mb-2">Sin recursos definidos</p>
<p className="text-xs text-slate-300">Los recursos (roles, sistemas, equipos) se asignan a actividades para calcular su costo por hora</p>
</div>
) : (
<div className="space-y-2">
{resources.map((resource) => (
<div
key={resource.id}
className="flex items-center justify-between p-3 rounded-lg border border-slate-100 hover:border-slate-200 bg-white group"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<p className="text-xs font-medium text-slate-800 truncate">{resource.name}</p>
<span className={`shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full ${RESOURCE_TYPE_COLORS[resource.type]}`}>
{RESOURCE_TYPE_LABELS[resource.type]}
</span>
</div>
<p className="text-xs text-slate-500 font-mono">{formatCurrency(resource.costPerHour)}/hora</p>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-slate-400 hover:text-slate-600"
onClick={() => startEdit(resource)}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-slate-400 hover:text-destructive"
onClick={() => deleteResource(resource.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea>
)
}

View File

@@ -0,0 +1,293 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router'
import {
Loader2, BarChart3, Play, AlertTriangle,
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip'
import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { BpmnCanvas, type BpmnElementCategory } from './BpmnCanvas'
import { ActivityPanel } from './ActivityPanel'
import { GatewayPanel, isXorSumValid } from './GatewayPanel'
import { ResourcesPanel } from './ResourcesPanel'
import { GlobalSettingsPanel } from './GlobalSettingsPanel'
import { useProcessStore } from '@/store/process-store'
import { useSimulationStore } from '@/store/simulation-store'
import { useSimulate } from '../simulation/useSimulate'
type SelectedCategory = 'activity' | 'gateway' | null
export function WorkspacePage() {
const { processId } = useParams({ from: '/workspace/$processId' })
const navigate = useNavigate()
const { currentProcess, isLoading, error, loadProcess, gateways } = useProcessStore()
const { isRunning, result, error: simError, selectActivity } = useSimulationStore()
const { simulate } = useSimulate()
const { toast } = useToast()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [selectedElementId, setSelectedElementId] = useState<string | null>(null)
const [selectedElementName, setSelectedElementName] = useState<string>('')
const [selectedCategory, setSelectedCategory] = useState<SelectedCategory>(null)
const [activeTab, setActiveTab] = useState('elemento')
// Usamos un ref para saber si loadProcess ya terminó al menos una vez.
// Esto evita que el redirect se dispare en el render inicial donde
// isLoading=false y currentProcess=null (antes del primer efecto).
const hasLoadedOnce = useRef(false)
useEffect(() => { loadProcess(processId) }, [processId, loadProcess])
useEffect(() => {
if (isLoading) { hasLoadedOnce.current = true; return }
if (!hasLoadedOnce.current) return // carga no iniciada aún
if ((error || !currentProcess) && processId) {
toast({
title: 'Proceso no encontrado',
description: 'El proceso que buscás no existe o fue eliminado.',
variant: 'destructive',
})
navigate({ to: '/' })
}
}, [isLoading, error, currentProcess, processId, toast, navigate])
// Validación XOR: todos los gateways exclusivos deben sumar 1.0
const xorValidation = useMemo(() => {
const invalidIds = gateways
.filter((gw) => gw.gatewayType === 'exclusive' && !isXorSumValid(gw))
.map((gw) => gw.bpmnElementId)
return { valid: invalidIds.length === 0, invalidIds }
}, [gateways])
const canSimulate = !isRunning && xorValidation.valid
const handleElementClick = useCallback((
elementId: string,
elementName: string,
category: BpmnElementCategory
) => {
if (category === 'other') return
setSelectedElementId(elementId)
setSelectedElementName(elementName)
setSelectedCategory(category)
selectActivity(category === 'activity' ? elementId : null)
setActiveTab('elemento')
if (!sidebarOpen) setSidebarOpen(true)
}, [selectActivity, sidebarOpen])
function clearSelection() {
setSelectedElementId(null)
setSelectedElementName('')
setSelectedCategory(null)
selectActivity(null)
}
async function handleSimulate() {
if (!canSimulate) return
const simResult = await simulate()
if (simResult) navigate({ to: '/report/$processId', params: { processId } })
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (error || !currentProcess) {
return null // el useEffect ya inició el redirect con toast
}
// Label del tab "elemento" según lo seleccionado
const elementTabLabel = selectedCategory === 'gateway' ? 'Gateway' : 'Actividad'
const elementTabIcon = selectedCategory === 'gateway'
? <GitMerge className="h-3 w-3 mr-1" />
: <MousePointer2 className="h-3 w-3 mr-1" />
return (
<TooltipProvider>
<div className="h-screen flex flex-col bg-slate-100 overflow-hidden">
{/* ── Topbar ─────────────────────────────────────────────────── */}
<header className="h-12 bg-white border-b border-slate-200 flex items-center px-3 gap-3 shrink-0 z-10">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navigate({ to: '/' })}>
<Home className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Inicio</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
{currentProcess.clientName && (
<p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
)}
</div>
{/* Errores de simulación */}
{simError && (
<div className="flex items-center gap-1.5 text-destructive text-xs bg-destructive/10 px-2 py-1 rounded">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{simError}
</div>
)}
{/* Advertencia de XOR inválido */}
{!xorValidation.valid && (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 text-amber-700 text-xs bg-amber-50 border border-amber-200 px-2 py-1 rounded cursor-help">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{xorValidation.invalidIds.length} gateway{xorValidation.invalidIds.length > 1 ? 's' : ''} con probabilidades inválidas
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-60 text-xs">
<p className="font-semibold mb-1">Gateways XOR que no suman 100%:</p>
{xorValidation.invalidIds.map((id) => (
<p key={id} className="font-mono"> {id}</p>
))}
<p className="mt-1 text-slate-300">Ajustá las probabilidades para habilitar la simulación</p>
</TooltipContent>
</Tooltip>
)}
{/* Ver reporte si ya hay simulación */}
{result && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs gap-1.5 text-primary border-primary/30"
onClick={() => navigate({ to: '/report/$processId', params: { processId } })}
>
<BarChart3 className="h-3.5 w-3.5" />
Ver reporte
</Button>
)}
{/* Simular */}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
size="sm"
className="h-8 text-xs gap-1.5"
onClick={handleSimulate}
disabled={!canSimulate}
>
{isRunning
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Simulando</>
: <><Play className="h-3.5 w-3.5" />Simular</>
}
</Button>
</span>
</TooltipTrigger>
{!xorValidation.valid && (
<TooltipContent side="bottom" className="text-xs">
Corregí las probabilidades de los gateways XOR primero
</TooltipContent>
)}
</Tooltip>
<Separator orientation="vertical" className="h-5" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setSidebarOpen((v) => !v)}
>
{sidebarOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{sidebarOpen ? 'Cerrar panel' : 'Abrir panel'}</TooltipContent>
</Tooltip>
</header>
{/* ── Main ───────────────────────────────────────────────────── */}
<div className="flex-1 flex overflow-hidden relative">
{/* Canvas BPMN */}
<div className="flex-1 relative overflow-hidden bpmn-container">
<BpmnCanvas
xml={currentProcess.bpmnXml}
onElementClick={handleElementClick}
selectedElementId={selectedElementId}
className="absolute inset-0"
/>
{/* Chip de elemento seleccionado */}
{selectedElementId && (
<div className="absolute top-3 left-3 bg-white/90 backdrop-blur-sm border border-slate-200 rounded-lg px-3 py-1.5 shadow-sm flex items-center gap-2 max-w-xs">
<div className={`w-2 h-2 rounded-full shrink-0 ${selectedCategory === 'gateway' ? 'bg-amber-500' : 'bg-primary'}`} />
<span className="text-xs font-medium text-slate-700 truncate">{selectedElementName || selectedElementId}</span>
<button onClick={clearSelection} className="ml-1 text-slate-400 hover:text-slate-600 shrink-0">×</button>
</div>
)}
{/* Hint inicial */}
{!selectedElementId && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white/80 backdrop-blur-sm border border-slate-200 rounded-full px-4 py-2 shadow-sm">
<p className="text-xs text-slate-500">Hacé click en una tarea o gateway para configurarlo</p>
</div>
)}
</div>
{/* Panel lateral */}
<div className={`
shrink-0 bg-white border-l border-slate-200 flex flex-col overflow-hidden
transition-all duration-300 ease-in-out
${sidebarOpen ? 'w-72' : 'w-0'}
`}>
{sidebarOpen && (
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
<TabsList className="shrink-0 mx-3 mt-3 grid grid-cols-3 w-auto">
<TabsTrigger value="elemento" className="text-xs flex items-center">
{elementTabIcon}{elementTabLabel}
</TabsTrigger>
<TabsTrigger value="recursos" className="text-xs">Recursos</TabsTrigger>
<TabsTrigger value="global" className="text-xs">Global</TabsTrigger>
</TabsList>
<TabsContent value="elemento" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
{selectedCategory === 'gateway'
? <GatewayPanel selectedElementId={selectedElementId} />
: <ActivityPanel selectedElementId={selectedElementId} />
}
</TabsContent>
<TabsContent value="recursos" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
<ResourcesPanel />
</TabsContent>
<TabsContent value="global" className="flex-1 overflow-hidden mt-0 data-[state=active]:flex data-[state=active]:flex-col">
<GlobalSettingsPanel />
</TabsContent>
</Tabs>
)}
</div>
{/* Toggle flotante cuando sidebar cerrado */}
{!sidebarOpen && (
<button
onClick={() => setSidebarOpen(true)}
className="absolute right-0 top-1/2 -translate-y-1/2 bg-white border border-slate-200 border-r-0 rounded-l-lg p-1.5 shadow-md hover:bg-slate-50 z-20"
>
<PanelRightOpen className="h-4 w-4 text-slate-500" />
</button>
)}
</div>
</div>
</TooltipProvider>
)
}