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:
277
src/features/import/ImportPage.tsx
Normal file
277
src/features/import/ImportPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
src/features/import/SampleProcessLoader.tsx
Normal file
119
src/features/import/SampleProcessLoader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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 }
|
||||
}
|
||||
56
src/features/simulation/useSimulate.ts
Normal file
56
src/features/simulation/useSimulate.ts
Normal 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 }
|
||||
}
|
||||
241
src/features/workspace/ActivityPanel.tsx
Normal file
241
src/features/workspace/ActivityPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
src/features/workspace/BpmnCanvas.tsx
Normal file
82
src/features/workspace/BpmnCanvas.tsx
Normal 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%' }} />
|
||||
}
|
||||
277
src/features/workspace/GatewayPanel.tsx
Normal file
277
src/features/workspace/GatewayPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
src/features/workspace/GlobalSettingsPanel.tsx
Normal file
140
src/features/workspace/GlobalSettingsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
201
src/features/workspace/ResourcesPanel.tsx
Normal file
201
src/features/workspace/ResourcesPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
src/features/workspace/WorkspacePage.tsx
Normal file
293
src/features/workspace/WorkspacePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user