Antes de la última revisión de etapa 4. Sprint 1. Previo al ajuste de look n feel de app, reportes, nombre y colores.

This commit is contained in:
2026-05-15 22:14:53 -03:00
parent b6f31f4371
commit 8b71c6ee13
54 changed files with 6497 additions and 261 deletions

View File

@@ -64,6 +64,9 @@ export function ImportPage() {
bpmnXml: xml,
currency: 'USD',
overheadPercentage: 0.2,
annualFrequency: 1000,
analysisHorizonYears: 3,
automationInvestment: 0,
createdAt: now,
updatedAt: now,
}
@@ -78,6 +81,9 @@ export function ImportPage() {
directCostFixed: 0,
executionTimeMinutes: 0,
assignedResources: [],
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
}))
const gatewayElements = extractGatewayElements(graph)

View File

@@ -0,0 +1,12 @@
// Leyenda para el tab "Automatizado" del reporte.
// Explica por qué ciertas actividades mantienen su costo original.
// Se coloca al pie del heatmap en el tab "Automatizado" — wired en Etapa 3.
export function AutomationScenarioNote() {
return (
<p className="text-xs text-slate-500 text-center mt-2 px-4">
💡 Las actividades no marcadas como automatizables mantienen su costo original en este
escenario. Identificalas por el ícono ámbar de automatización en el workspace.
</p>
)
}

View File

@@ -0,0 +1,130 @@
import { useMemo, useState } from 'react'
import { Bot, ArrowUpDown } from 'lucide-react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { formatCurrency, formatPercent } from '@/lib/format'
import type { ActivitySimResult } from '@/domain/types'
interface ComparisonRow {
activityId: string
activityName: string
actualCost: number
automatedCost: number
savings: number
savingsPercent: number
automatable: boolean
}
interface ComparisonTableProps {
perActivityActual: ActivitySimResult[]
perActivityAutomated: ActivitySimResult[]
automatableIds: Set<string> // bpmnElementIds marcados como automatizables
currency: string
}
export function ComparisonTable({
perActivityActual,
perActivityAutomated,
automatableIds,
currency,
}: ComparisonTableProps) {
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const automatedByActivityId = useMemo(() => {
const m = new Map<string, ActivitySimResult>()
for (const a of perActivityAutomated) m.set(a.activityId, a)
return m
}, [perActivityAutomated])
const rows: ComparisonRow[] = useMemo(() => {
return perActivityActual
.map((act) => {
const automated = automatedByActivityId.get(act.activityId)
const automatedCost = automated?.expectedTotalCost ?? act.expectedTotalCost
const savings = act.expectedTotalCost - automatedCost
const savingsPercent =
act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
return {
activityId: act.activityId,
activityName: act.activityName,
actualCost: act.expectedTotalCost,
automatedCost,
savings,
savingsPercent,
automatable: automatableIds.has(act.bpmnElementId),
}
})
.sort((a, b) => (b.savings - a.savings) * (sortDir === 'desc' ? 1 : -1))
}, [perActivityActual, automatedByActivityId, automatableIds, sortDir])
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-24 text-xs text-slate-400 rounded-xl border border-slate-200">
Sin actividades para comparar
</div>
)
}
return (
<div className="rounded-xl border border-slate-200 overflow-hidden">
<Table>
<TableHeader className="bg-slate-50">
<tr>
<TableHead className="w-8 text-xs" />
<TableHead className="text-xs">Actividad</TableHead>
<TableHead className="text-xs text-right">Costo actual</TableHead>
<TableHead className="text-xs text-right">Costo automatizado</TableHead>
<TableHead
className="text-xs text-right cursor-pointer select-none hover:text-foreground"
onClick={() => setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'))}
>
<span className="inline-flex items-center justify-end gap-1">
Ahorro
<ArrowUpDown className="h-3 w-3 opacity-40" />
</span>
</TableHead>
<TableHead className="text-xs text-right">% ahorro</TableHead>
</tr>
</TableHeader>
<TableBody>
{rows.map((row) => {
const savingsColor =
row.savings > 0
? 'text-emerald-700'
: row.savings < 0
? 'text-red-600'
: 'text-slate-400'
return (
<TableRow key={row.activityId}>
<TableCell className="py-2">
{row.automatable && (
<Bot
className="h-3.5 w-3.5 text-amber-500"
aria-label="Marcada como automatizable"
/>
)}
</TableCell>
<TableCell className="text-xs font-medium text-slate-800 max-w-48 truncate">
{row.activityName}
</TableCell>
<TableCell className="text-xs font-mono text-slate-600 text-right">
{formatCurrency(row.actualCost, currency)}
</TableCell>
<TableCell className="text-xs font-mono text-slate-600 text-right">
{formatCurrency(row.automatedCost, currency)}
</TableCell>
<TableCell className={`text-xs font-mono font-semibold text-right ${savingsColor}`}>
{row.savings > 0 ? '+' : ''}
{formatCurrency(row.savings, currency)}
</TableCell>
<TableCell className={`text-xs font-mono text-right ${savingsColor}`}>
{row.savings > 0 ? '+' : ''}
{formatPercent(row.savingsPercent)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { formatCurrency } from '@/lib/format'
import type { RoiResult } from '@/domain/roi'
interface CumulativeSavingsChartProps {
roi: RoiResult
horizonYears: number
currency: string
investment: number
}
export function CumulativeSavingsChart({
roi,
horizonYears,
currency,
investment,
}: CumulativeSavingsChartProps) {
const option = useMemo(() => {
const years = Array.from({ length: horizonYears + 1 }, (_, i) => i)
const values = years.map((y) => roi.annualSavings * y - investment)
const markLineData: unknown[] = []
if (isFinite(roi.paybackYears) && roi.paybackYears > 0 && roi.paybackYears <= horizonYears) {
markLineData.push({
xAxis: roi.paybackYears,
name: 'Payback',
label: {
formatter: `Payback\n${roi.paybackYears.toFixed(1)} años`,
position: 'insideEndTop',
fontSize: 10,
color: '#10b981',
},
lineStyle: { color: '#10b981', type: 'dashed', width: 2 },
})
}
// Línea de referencia en y=0 para identificar el break-even visualmente
markLineData.push({
yAxis: 0,
name: 'Break-even',
label: { formatter: '', position: 'insideStartTop' },
lineStyle: { color: '#94a3b8', type: 'solid', width: 1 },
})
return {
tooltip: {
trigger: 'axis',
formatter: (params: unknown[]) => {
const p = params[0] as { dataIndex: number; value: [number, number] }
const year = p.value[0]
const val = p.value[1]
return `<div style="font-size:12px;line-height:1.6">
<div style="font-weight:600;margin-bottom:4px">Año ${year}</div>
<div>Acumulado neto: <strong style="color:${val >= 0 ? '#10b981' : '#ef4444'}">${formatCurrency(val, currency)}</strong></div>
</div>`
},
},
grid: { left: 16, right: 24, top: 16, bottom: 8, containLabel: true },
xAxis: {
type: 'value',
name: 'Año',
nameLocation: 'end',
nameTextStyle: { fontSize: 10, color: '#94a3b8' },
min: 0,
max: horizonYears,
interval: 1,
axisLabel: { fontSize: 11, formatter: (v: number) => `Año ${v}` },
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
yAxis: {
type: 'value',
axisLabel: {
fontSize: 11,
formatter: (v: number) => {
if (v === 0) return '0'
const abs = Math.abs(v)
const sign = v < 0 ? '-' : ''
if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`
if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(0)}k`
return String(v)
},
},
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
series: [
{
type: 'line',
data: years.map((y, i) => [y, values[i]]),
smooth: false,
lineStyle: { color: '#3b82f6', width: 2.5 },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(59,130,246,0.18)' },
{ offset: 1, color: 'rgba(59,130,246,0.02)' },
],
},
},
itemStyle: { color: '#3b82f6' },
symbolSize: 6,
markLine: {
silent: true,
symbol: ['none', 'none'],
data: markLineData,
},
},
],
}
}, [roi, horizonYears, currency, investment])
return <ReactECharts option={option} style={{ height: 280 }} notMerge />
}

View File

@@ -1,14 +1,17 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router'
import { AlertTriangle, Home } from 'lucide-react'
import { AlertTriangle, Home, Info } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { useToast } from '@/components/ui/use-toast'
import { AppFooter } from '@/components/AppFooter'
import { calculateRoi } from '@/domain/roi'
import { useReportData } from './useReportData'
import { ReportHeader } from './ReportHeader'
import { KpiBar } from './KpiBar'
import { RoiKpiBar } from './RoiKpiBar'
import { HeatmapCanvas, type HeatmapCanvasHandle } from './HeatmapCanvas'
import { HeatmapLegend } from './HeatmapLegend'
import { HeatmapModeToggle } from './HeatmapModeToggle'
@@ -17,48 +20,102 @@ import { TopActivitiesChart } from './TopActivitiesChart'
import { CostCompositionChart } from './CostCompositionChart'
import { CostByResourceChart } from './CostByResourceChart'
import { ActivitiesTable } from './ActivitiesTable'
import { ComparisonTable } from './ComparisonTable'
import { CumulativeSavingsChart } from './CumulativeSavingsChart'
import { TopSavingsChart } from './TopSavingsChart'
import { TransparencySection } from './TransparencySection'
import { AutomationScenarioNote } from './AutomationScenarioNote'
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 { process, simulation, activities, 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)
// Set de bpmnElementIds marcados como automatizables — debe estar antes de los early returns
const automatableIds = useMemo(
() => new Set(activities.filter((a) => a.automatable).map((a) => a.bpmnElementId)),
[activities]
)
// ROI — calculado solo cuando hay ambos resultados
const roi = useMemo(() => {
const result = simulation?.result
const resultAutomated = simulation?.resultAutomated
if (!result || !resultAutomated || !process) return null
return calculateRoi({
costPerExecutionActual: result.totalCost,
costPerExecutionAutomated: resultAutomated.totalCost,
annualFrequency: process.annualFrequency,
analysisHorizonYears: process.analysisHorizonYears,
automationInvestment: process.automationInvestment,
})
}, [simulation, process])
// Estado y refs para el tab "Actual"
const [selectedIdActual, setSelectedIdActual] = useState<string | null>(null)
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)
// Estado y refs para el tab "Automatizado"
const [selectedIdAuto, setSelectedIdAuto] = useState<string | null>(null)
const heatmapRefAuto = useRef<HeatmapCanvasHandle>(null)
const heatmapSectionRefAuto = useRef<HTMLDivElement>(null)
const tableSectionRefAuto = useRef<HTMLDivElement>(null)
const [isExportingPdf, setIsExportingPdf] = useState(false)
const [isExportingCsv, setIsExportingCsv] = useState(false)
const [activeTab, setActiveTab] = useState<'actual' | 'automatizado' | 'roi'>('actual')
// Tab "Actual" — cross-linking heatmap ↔ tabla
const handleHeatmapClickActual = useCallback((bpmnElementId: string) => {
setSelectedIdActual(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)
const handleTableRowClickActual = useCallback((bpmnElementId: string) => {
setSelectedIdActual(bpmnElementId)
heatmapRef.current?.highlightElement(bpmnElementId)
setTimeout(() => {
heatmapSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 50)
}, [])
// Tab "Automatizado" — cross-linking heatmap ↔ tabla
const handleHeatmapClickAuto = useCallback((bpmnElementId: string) => {
setSelectedIdAuto(bpmnElementId)
setTimeout(() => {
tableSectionRefAuto.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, 50)
}, [])
const handleTableRowClickAuto = useCallback((bpmnElementId: string) => {
setSelectedIdAuto(bpmnElementId)
heatmapRefAuto.current?.highlightElement(bpmnElementId)
setTimeout(() => {
heatmapSectionRefAuto.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
let heatmapImageData: string | null = null
if (activeTab === 'actual') {
heatmapImageData = await heatmapRef.current?.captureImage() ?? null
} else if (activeTab === 'automatizado') {
heatmapImageData = await heatmapRefAuto.current?.captureImage() ?? null
}
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process, simulation, resources, heatmapImageData })
await exportToPdf({ process, simulation, resources, heatmapImageData, tab: activeTab, activities })
toast({ title: 'PDF generado', description: 'El reporte se descargó correctamente.' })
} catch (err) {
console.error('PDF export error:', err)
@@ -66,14 +123,14 @@ export function ReportPage() {
} finally {
setIsExportingPdf(false)
}
}, [process, simulation, resources, isExportingPdf, toast])
}, [process, simulation, resources, isExportingPdf, toast, activeTab, activities])
const handleExportCsv = useCallback(async () => {
if (!process || !simulation || isExportingCsv) return
setIsExportingCsv(true)
try {
const { exportToCsv } = await import('@/lib/export/csv-export')
exportToCsv({ process, simulation, resources })
exportToCsv({ process, simulation, resources, tab: activeTab, activities })
toast({ title: 'CSV generado', description: 'Los datos se descargaron correctamente.' })
} catch (err) {
console.error('CSV export error:', err)
@@ -81,14 +138,13 @@ export function ReportPage() {
} finally {
setIsExportingCsv(false)
}
}, [process, simulation, resources, isExportingCsv, toast])
}, [process, simulation, resources, isExportingCsv, toast, activeTab, activities])
// ── Loading — skeleton en lugar de pantalla en blanco ────────────────────
// ── Loading ───────────────────────────────────────────────────────────────
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" />
@@ -100,7 +156,6 @@ export function ReportPage() {
<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">
@@ -113,12 +168,10 @@ export function ReportPage() {
</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) => (
@@ -159,14 +212,19 @@ export function ReportPage() {
}
const result = simulation.result
const perActivity = result.perActivity
const resultAutomated = simulation.resultAutomated ?? null
const hasAutomatedResult = resultAutomated !== null
// Tabs de escenarios se habilitan solo cuando hay resultado automatizado Y hay actividades
// marcadas como automatizables. Sin automatizables, los tabs muestran datos idénticos al actual.
const hasAutomatedScenario = hasAutomatedResult && automatableIds.size > 0
return (
<TooltipProvider>
<div className="min-h-screen bg-slate-50">
<div className="max-w-screen-xl mx-auto px-6 py-10">
{/* ── 1. Header ─────────────────────────────────────────────── */}
{/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */}
<ReportHeader
process={process}
simulatedAt={simulation.executedAt}
@@ -177,102 +235,328 @@ export function ReportPage() {
isExportingCsv={isExportingCsv}
/>
{/* ── 2. KPI Bar ────────────────────────────────────────────── */}
<KpiBar result={result} currency={process.currency} />
{/* ── Tabs ─────────────────────────────────────────────────────── */}
<Tabs defaultValue="actual" className="w-full" onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
<TabsList className="mb-6">
<TabsTrigger value="actual">Actual</TabsTrigger>
{/* ── 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>
<Tooltip>
<TooltipTrigger asChild>
<span className="contents">
<TabsTrigger value="automatizado" disabled={!hasAutomatedScenario}>
Automatizado
</TabsTrigger>
</span>
</TooltipTrigger>
{!hasAutomatedScenario && (
<TooltipContent side="bottom" className="text-xs max-w-56">
{!hasAutomatedResult
? 'Re-simulá el proceso para generar el escenario automatizado'
: 'Marcá al menos una actividad como automatizable en el workspace para ver este escenario'}
</TooltipContent>
)}
</Tooltip>
<HeatmapCanvas
ref={heatmapRef}
xml={process.bpmnXml}
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
selectedId={selectedId}
onActivityClick={handleHeatmapClick}
minHeight={520}
/>
<Tooltip>
<TooltipTrigger asChild>
<span className="contents">
<TabsTrigger value="roi" disabled={!hasAutomatedScenario}>
Comparación + ROI
</TabsTrigger>
</span>
</TooltipTrigger>
{!hasAutomatedScenario && (
<TooltipContent side="bottom" className="text-xs max-w-56">
{!hasAutomatedResult
? 'Re-simulá el proceso para generar el análisis de ROI'
: 'Marcá al menos una actividad como automatizable en el workspace para calcular el ROI'}
</TooltipContent>
)}
</Tooltip>
</TabsList>
<HeatmapLegend
perActivity={perActivity}
mode={heatmapMode}
currency={process.currency}
/>
</div>
{/* ──────────────── TAB: ACTUAL ──────────────────────────────── */}
<TabsContent value="actual">
<KpiBar result={result} currency={process.currency} />
{/* ── 4. Warnings ───────────────────────────────────────────── */}
<WarningsBanner warnings={result.warnings} />
<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>
{/* ── 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}
<HeatmapCanvas
ref={heatmapRef}
xml={process.bpmnXml}
perActivity={result.perActivity}
mode={heatmapMode}
currency={process.currency}
selectedId={selectedIdActual}
onActivityClick={handleHeatmapClickActual}
minHeight={520}
/>
<HeatmapLegend
perActivity={result.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} />
<WarningsBanner warnings={result.warnings} />
<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={result.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={result.perActivity}
resources={resources}
currency={process.currency}
/>
</div>
</div>
</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}
<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">
({result.perActivity.length} actividades)
</span>
</h2>
</div>
<ActivitiesTable
perActivity={result.perActivity}
mode={heatmapMode}
currency={process.currency}
highlightedId={selectedIdActual}
onRowClick={handleTableRowClickActual}
/>
</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" />
<MethodologyFooter process={process} />
<AppFooter />
</TabsContent>
<Separator className="my-4" />
{/* ──────────────── TAB: AUTOMATIZADO ───────────────────────── */}
<TabsContent value="automatizado">
{!hasAutomatedResult ? (
<NeedsResimulate processId={processId} />
) : (
<>
<KpiBar result={resultAutomated!} currency={process.currency} />
{/* ── 7. Footer metodológico ────────────────────────────────── */}
<MethodologyFooter process={process} />
<div ref={heatmapSectionRefAuto} 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 Escenario Automatizado</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={heatmapRefAuto}
xml={process.bpmnXml}
perActivity={resultAutomated!.perActivity}
mode={heatmapMode}
currency={process.currency}
selectedId={selectedIdAuto}
onActivityClick={handleHeatmapClickAuto}
minHeight={520}
/>
<HeatmapLegend
perActivity={resultAutomated!.perActivity}
mode={heatmapMode}
currency={process.currency}
/>
<AutomationScenarioNote />
</div>
<WarningsBanner warnings={resultAutomated!.warnings} />
<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={resultAutomated!.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={resultAutomated!} 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={resultAutomated!.perActivity}
resources={resources}
currency={process.currency}
/>
</div>
</div>
</div>
<div ref={tableSectionRefAuto} className="mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-slate-800">
Detalle por actividad Automatizado
<span className="ml-2 text-sm font-normal text-slate-400">
({resultAutomated!.perActivity.length} actividades)
</span>
</h2>
</div>
<ActivitiesTable
perActivity={resultAutomated!.perActivity}
mode={heatmapMode}
currency={process.currency}
highlightedId={selectedIdAuto}
onRowClick={handleTableRowClickAuto}
/>
</div>
<Separator className="my-4" />
<MethodologyFooter process={process} />
<AppFooter />
</>
)}
</TabsContent>
{/* ──────────────── TAB: COMPARACIÓN + ROI ──────────────────── */}
<TabsContent value="roi">
{!hasAutomatedResult || !roi ? (
<NeedsResimulate processId={processId} />
) : (
<>
{/* KPIs de ROI */}
<RoiKpiBar
roi={roi}
currency={process.currency}
horizonYears={process.analysisHorizonYears}
/>
{/* Tabla de comparación actividad por actividad */}
<div className="mb-6">
<h2 className="text-base font-semibold text-slate-800 mb-4">
Comparación por actividad
</h2>
<ComparisonTable
perActivityActual={result.perActivity}
perActivityAutomated={resultAutomated!.perActivity}
automatableIds={automatableIds}
currency={process.currency}
/>
</div>
{/* Gráficos: ahorro acumulado + top ahorros */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<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">
Ahorro acumulado neto ({process.analysisHorizonYears} años)
</h3>
<CumulativeSavingsChart
roi={roi}
horizonYears={process.analysisHorizonYears}
currency={process.currency}
investment={process.automationInvestment}
/>
</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">
Top actividades por ahorro
</h3>
<TopSavingsChart
perActivityActual={result.perActivity}
perActivityAutomated={resultAutomated!.perActivity}
currency={process.currency}
/>
</div>
</div>
{/* Transparencia metodológica (condicional) */}
<div className="mb-6">
<TransparencySection
activities={activities}
investment={process.automationInvestment}
roi={roi ?? undefined}
/>
</div>
<Separator className="my-4" />
<MethodologyFooter process={process} />
<AppFooter />
</>
)}
</TabsContent>
</Tabs>
<AppFooter />
</div>
</div>
</TooltipProvider>
)
}
// ── Componente auxiliar: aviso cuando falta re-simular ───────────────────────
function NeedsResimulate({ processId }: { processId: string }) {
const navigate = useNavigate()
return (
<div className="flex flex-col items-center justify-center gap-4 py-20 text-center">
<div className="w-12 h-12 bg-amber-50 rounded-full flex items-center justify-center">
<Info className="h-6 w-6 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium text-slate-700 mb-1">
Esta simulación no incluye el escenario automatizado
</p>
<p className="text-xs text-slate-400 max-w-sm">
Volvé al workspace y presioná &quot;Simular&quot; para generar ambos escenarios.
</p>
</div>
<Button size="sm" onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}>
Ir al workspace
</Button>
</div>
)
}

View File

@@ -0,0 +1,110 @@
import { TrendingDown, Calendar, BarChart3, DollarSign, Percent } from 'lucide-react'
import { formatCurrency, formatPercent } from '@/lib/format'
import type { RoiResult } from '@/domain/roi'
interface RoiKpiBarProps {
roi: RoiResult
currency: string
horizonYears: number
}
function RoiKpiCard({
label, value, sub, icon, iconColor, valueColor,
}: {
label: string
value: string
sub?: string
icon: React.ReactNode
iconColor: string
valueColor?: string
}) {
return (
<div data-testid="roi-kpi-card" className="bg-white rounded-xl border border-slate-200 shadow-sm p-5 flex items-start gap-4">
<div className={`shrink-0 p-3 rounded-lg ${iconColor}`}>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-1">{label}</p>
<p className={`text-2xl font-bold font-mono leading-tight ${valueColor ?? 'text-slate-900'}`}>{value}</p>
{sub && <p className="text-xs text-slate-400 mt-0.5 leading-snug">{sub}</p>}
</div>
</div>
)
}
export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
const paybackStr = !isFinite(roi.paybackMonths)
? 'No se recupera'
: roi.paybackMonths === 0
? 'Inmediato'
: roi.paybackMonths < 24
? `${roi.paybackMonths.toFixed(1)} meses`
: `${roi.paybackYears.toFixed(1)} años`
const roiStr = !isFinite(roi.roiAnnualPercent)
? '∞%'
: formatPercent(roi.roiAnnualPercent)
const savingsPosColor = roi.savingsPerExecution > 0
? 'text-emerald-700'
: roi.savingsPerExecution < 0
? 'text-red-600'
: 'text-slate-900'
const annualPosColor = roi.annualSavings > 0
? 'text-emerald-700'
: roi.annualSavings < 0
? 'text-red-600'
: 'text-slate-900'
const cumulativePosColor = roi.cumulativeSavingsHorizon > 0
? 'text-emerald-700'
: roi.cumulativeSavingsHorizon < 0
? 'text-red-600'
: 'text-slate-900'
return (
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<RoiKpiCard
label="Ahorro por ejecución"
value={formatCurrency(roi.savingsPerExecution, currency)}
sub={`${formatPercent(roi.savingsPerExecutionPercent)} vs. escenario actual`}
icon={<TrendingDown className="h-5 w-5 text-emerald-600" />}
iconColor="bg-emerald-50"
valueColor={savingsPosColor}
/>
<RoiKpiCard
label="Ahorro anual"
value={formatCurrency(roi.annualSavings, currency)}
sub="sobre la frecuencia configurada"
icon={<DollarSign className="h-5 w-5 text-emerald-600" />}
iconColor="bg-emerald-50"
valueColor={annualPosColor}
/>
<RoiKpiCard
label="Payback"
value={paybackStr}
sub={roi.breaksEvenInHorizon ? '✓ Dentro del horizonte' : 'Fuera del horizonte de análisis'}
icon={<Calendar className="h-5 w-5 text-primary" />}
iconColor="bg-primary/10"
valueColor={roi.breaksEvenInHorizon ? 'text-emerald-700' : 'text-slate-900'}
/>
<RoiKpiCard
label={`Acumulado (${horizonYears} años)`}
value={formatCurrency(roi.cumulativeSavingsHorizon, currency)}
sub="ahorro neto de inversión inicial"
icon={<BarChart3 className="h-5 w-5 text-violet-600" />}
iconColor="bg-violet-50"
valueColor={cumulativePosColor}
/>
<RoiKpiCard
label="ROI anual"
value={roiStr}
sub="retorno anual sobre la inversión"
icon={<Percent className="h-5 w-5 text-amber-600" />}
iconColor="bg-amber-50"
valueColor={roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'}
/>
</div>
)
}

View File

@@ -0,0 +1,95 @@
import { useMemo } from 'react'
import ReactECharts from 'echarts-for-react'
import { formatCurrency } from '@/lib/format'
import type { ActivitySimResult } from '@/domain/types'
interface TopSavingsChartProps {
perActivityActual: ActivitySimResult[]
perActivityAutomated: ActivitySimResult[]
currency: string
}
function truncate(s: string, max: number) {
return s.length > max ? s.slice(0, max - 1) + '…' : s
}
export function TopSavingsChart({
perActivityActual,
perActivityAutomated,
currency,
}: TopSavingsChartProps) {
const option = useMemo(() => {
const automatedMap = new Map(perActivityAutomated.map((a) => [a.activityId, a]))
const top5 = perActivityActual
.map((act) => {
const auto = automatedMap.get(act.activityId)
const savings = act.expectedTotalCost - (auto?.expectedTotalCost ?? act.expectedTotalCost)
return { name: act.activityName, value: savings }
})
.filter((s) => s.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, 5)
if (top5.length === 0) return null
const reversed = [...top5].reverse()
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: unknown[]) => {
const p = params[0] as { name: string; value: number }
return `<div style="font-size:12px"><b>${p.name}</b><br/>Ahorro: <strong>${formatCurrency(p.value, currency)}</strong></div>`
},
},
grid: { left: 12, right: 32, top: 8, bottom: 8, containLabel: true },
xAxis: {
type: 'value',
axisLabel: {
fontSize: 10,
formatter: (v: number) => {
if (v === 0) return '0'
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
if (v >= 1_000) return `${(v / 1_000).toFixed(0)}k`
return String(v)
},
},
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
yAxis: {
type: 'category',
data: reversed.map((s) => truncate(s.name, 22)),
axisLabel: { fontSize: 10, color: '#64748b' },
axisTick: { show: false },
axisLine: { show: false },
},
series: [
{
type: 'bar',
data: reversed.map((s) => s.value),
barMaxWidth: 32,
itemStyle: { color: '#10b981', borderRadius: [0, 4, 4, 0] },
label: {
show: true,
position: 'right',
fontSize: 10,
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
color: '#10b981',
},
},
],
}
}, [perActivityActual, perActivityAutomated, currency])
if (!option) {
return (
<div className="flex items-center justify-center h-48 text-xs text-slate-400">
Sin actividades con ahorro positivo
</div>
)
}
return <ReactECharts option={option} style={{ height: Math.max(180, option.yAxis.data.length * 48) }} notMerge />
}

View File

@@ -0,0 +1,91 @@
import { AlertTriangle } from 'lucide-react'
import { formatPercent } from '@/lib/format'
import type { Activity } from '@/domain/types'
import type { RoiResult } from '@/domain/roi'
// Umbral de ROI a partir del cual se dispara la advertencia de verosimilitud.
// Un ROI > 1000% anual es matemáticamente posible pero comercialmente inverosímil:
// indica casi siempre un input incorrecto (frecuencia sobreestimada, costo auto subestimado
// o inversión subestimada). Documentado en METHODOLOGY_AUTOMATED_COST.md.
const ROI_HIGH_THRESHOLD = 1000
interface TransparencySectionProps {
activities: Activity[]
investment: number
roi?: RoiResult
}
export function TransparencySection({ activities, investment, roi }: TransparencySectionProps) {
const zeroCostAutomatable = activities.filter(
(a) => a.automatable && a.automatedCostFixed === 0 && a.automatedTimeMinutes === 0
)
const investmentIsZero = investment === 0
// Caso 4: ROI > 1000% finito (Infinity no aplica — ya lo cubre el caso de investment=0)
const roiIsUnusuallyHigh =
roi !== undefined &&
Number.isFinite(roi.roiAnnualPercent) &&
roi.roiAnnualPercent > ROI_HIGH_THRESHOLD
if (zeroCostAutomatable.length === 0 && !investmentIsZero && !roiIsUnusuallyHigh) return null
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 space-y-3">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 shrink-0" />
<h3 className="text-sm font-semibold text-amber-800">Transparencia metodológica</h3>
</div>
{zeroCostAutomatable.length > 0 && (
<div className="text-xs text-amber-700 leading-relaxed space-y-1.5">
<p className="font-medium">
{zeroCostAutomatable.length === 1
? '1 actividad automatizable sin costo configurado:'
: `${zeroCostAutomatable.length} actividades automatizables sin costo configurado:`}
</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
{zeroCostAutomatable.map((a) => (
<li key={a.id} className="font-mono">
{a.name || a.bpmnElementId}
</li>
))}
</ul>
<p className="text-amber-600">
Estas actividades se incluyen en el escenario automatizado con costo cero, lo que puede
inflar artificialmente el ahorro calculado. Configurá sus costos en el workspace para
un análisis realista.
</p>
</div>
)}
{investmentIsZero && (
<p className="text-xs text-amber-700 leading-relaxed">
<span className="font-medium">Inversión en automatización no configurada.</span>{' '}
El payback aparece como inmediato y el ROI como infinito porque no hay inversión inicial.
Configurá la inversión total en la tab &quot;Global&quot; del workspace para un análisis
financiero completo.
</p>
)}
{roiIsUnusuallyHigh && roi && (
<div className="text-xs text-amber-700 leading-relaxed space-y-1.5">
<p>
<span className="font-medium">
ROI inusualmente alto detectado: {formatPercent(roi.roiAnnualPercent)}.
</span>{' '}
Un ROI superior al {formatPercent(ROI_HIGH_THRESHOLD)} anual suele indicar uno de estos casos:
</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>Frecuencia anual del proceso sobreestimada</li>
<li>Costo automatizado por ejecución subestimado</li>
<li>Inversión en automatización subestimada</li>
</ul>
<p className="text-amber-600">
Revisá estos inputs antes de presentar el reporte al cliente.
Ver <span className="font-mono">METHODOLOGY_AUTOMATED_COST.md</span> para guía de estimación.
</p>
</div>
)}
</div>
)
}

View File

@@ -1,14 +1,12 @@
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 { persistResults, setRunning, setError } = useSimulationStore()
const simulate = useCallback(async () => {
if (!currentProcess) {
@@ -20,7 +18,7 @@ export function useSimulate() {
setError(null)
try {
const input: SimulationInput = {
const baseInput: Omit<SimulationInput, 'scenario'> = {
processXml: currentProcess.bpmnXml,
activities: new Map(activities.map((a) => [a.bpmnElementId, a])),
gateways: new Map(gateways.map((g) => [g.bpmnElementId, g])),
@@ -31,26 +29,20 @@ export function useSimulate() {
},
}
const result = runSimulation(input)
// Las dos simulaciones se computan y persisten SIEMPRE en conjunto (regla dura).
const resultActual = runSimulation({ ...baseInput, scenario: 'actual' })
const resultAutomated = runSimulation({ ...baseInput, scenario: 'automated' })
const simulation = {
id: uuidv4(),
processId: currentProcess.id,
executedAt: Date.now(),
result,
}
await simulationRepo.save(simulation)
setResult(result)
await persistResults(currentProcess.id, resultActual, resultAutomated)
setRunning(false)
return result
return { resultActual, resultAutomated }
} 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])
}, [currentProcess, activities, gateways, resources, persistResults, setRunning, setError])
return { simulate }
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { PlusCircle, Trash2, Info } from 'lucide-react'
import { PlusCircle, Trash2, Info, AlertTriangle } from 'lucide-react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
@@ -7,6 +7,8 @@ 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 { Switch } from '@/components/ui/switch'
import { Separator } from '@/components/ui/separator'
import { useProcessStore } from '@/store/process-store'
import { formatCurrency } from '@/lib/format'
import type { Activity } from '@/domain/types'
@@ -226,6 +228,101 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
)}
</div>
{/* ── Sección: Automatización ──────────────────────────────────── */}
<div>
<Separator className="mb-4" />
<div className="space-y-3">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Automatización</p>
{/* Toggle */}
<div className="flex items-center justify-between">
<Label htmlFor="automatable-toggle" className="text-xs font-medium cursor-pointer">
¿Es automatizable?
</Label>
<Switch
id="automatable-toggle"
checked={localActivity.automatable}
onCheckedChange={(v) => handleChange('automatable', v)}
aria-label="Marcar actividad como automatizable"
/>
</div>
{/* Campos — aparecen con transición suave cuando toggle=ON */}
<div className={`space-y-3 overflow-hidden transition-all duration-200 ${localActivity.automatable ? 'max-h-80 opacity-100' : 'max-h-0 opacity-0'}`}>
{/* Costo automatizado */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="automated-cost" className="text-xs font-medium">
Costo automatizado
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-56 text-xs">
Costo recurrente por ejecución automatizada. No incluye la inversión inicial del proyecto.
Ver METHODOLOGY_AUTOMATED_COST.md para la guía de cálculo.
</TooltipContent>
</Tooltip>
</div>
<Input
id="automated-cost"
type="number"
min={0}
step={1}
value={localActivity.automatedCostFixed === 0 ? '' : localActivity.automatedCostFixed}
onChange={(e) => handleChange('automatedCostFixed', Math.max(0, parseFloat(e.target.value) || 0))}
className="font-mono h-8 text-sm"
placeholder="0"
/>
</div>
{/* Tiempo automatizado */}
<div className="space-y-1.5">
<Label htmlFor="automated-time" className="text-xs font-medium">
Tiempo automatizado (min)
</Label>
<Input
id="automated-time"
type="number"
min={0}
step={1}
value={localActivity.automatedTimeMinutes === 0 ? '' : localActivity.automatedTimeMinutes}
onChange={(e) => handleChange('automatedTimeMinutes', Math.max(0, parseFloat(e.target.value) || 0))}
className="font-mono h-8 text-sm"
placeholder="0"
/>
</div>
{/* Helper ámbar — siempre en el DOM para que la transición funcione
al tipear un valor (no desaparece instantáneo sino con fade).
La condición controla opacity + max-h, no mount/unmount. */}
{(() => {
const showHelper = localActivity.automatable
&& localActivity.automatedCostFixed === 0
&& localActivity.automatedTimeMinutes === 0
return (
<div
aria-hidden={!showHelper}
className={`overflow-hidden transition-all duration-150 ease-out ${
showHelper ? 'opacity-100 max-h-20' : 'opacity-0 max-h-0'
}`}
>
<div className="flex items-start gap-2 rounded-lg bg-amber-50 border border-amber-200 px-3 py-2">
<AlertTriangle className="h-3.5 w-3.5 text-amber-600 shrink-0 mt-0.5" />
<p className="text-xs text-amber-700 leading-snug">
Esta actividad aparecerá en &quot;Transparencia metodológica&quot; del reporte hasta que cargues valores.
</p>
</div>
</div>
)
})()}
</div>
</div>
</div>
{/* Guardar */}
<Button
size="sm"

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useCallback } from 'react'
import BpmnViewer from 'bpmn-js/lib/Viewer'
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
@@ -19,37 +19,117 @@ function classifyElement(type: string): BpmnElementCategory {
return 'other'
}
// SVG inline del ícono Bot de Lucide (12×12 px, color ámbar #f59e0b).
// Copiado de lucide.dev/icons/bot — robot con antena, cuerpo rectangular y ojos.
const BOT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8V4H8"/>
<rect width="16" height="12" x="4" y="8" rx="2"/>
<path d="M2 14h2"/>
<path d="M20 14h2"/>
<path d="M15 13v2"/>
<path d="M9 13v2"/>
</svg>`
// HTML del badge de automatización.
// Fondo #fffbeb (amber-50): identidad cromática con el ícono Bot ámbar,
// contrasta con el fondo blanco de los nodos BPMN.
function makeBadgeHtml(): string {
return `<div title="Actividad marcada como automatizable" style="
width:20px;height:20px;
border-radius:50%;
background:#fffbeb;
border:1px solid #e2e8f0;
box-shadow:0 1px 3px rgba(0,0,0,0.12);
display:flex;align-items:center;justify-content:center;
cursor:default;
pointer-events:none;
">${BOT_SVG}</div>`
}
// Identificador del tipo de overlay para poder remover solo los nuestros.
const OVERLAY_TYPE = 'automation-indicator'
interface BpmnCanvasProps {
xml: string
onElementClick?: (elementId: string, elementName: string, category: BpmnElementCategory) => void
selectedElementId?: string | null
automatableElementIds?: string[] // IDs de actividades marcadas como automatizables
className?: string
}
export function BpmnCanvas({ xml, onElementClick, selectedElementId, className }: BpmnCanvasProps) {
export function BpmnCanvas({
xml,
onElementClick,
selectedElementId,
automatableElementIds = [],
className,
}: BpmnCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<InstanceType<typeof BpmnViewer> | null>(null)
const isImportedRef = useRef(false)
// ─── Inicializar viewer ────────────────────────────────────────────────────
useEffect(() => {
if (!containerRef.current) return
const viewer = new BpmnViewer({ container: containerRef.current })
viewerRef.current = viewer
return () => {
isImportedRef.current = false
viewer.destroy()
viewerRef.current = null
}
}, [])
// ─── Sincronizar overlays de automatización ───────────────────────────────
const syncAutomationOverlays = useCallback(() => {
const viewer = viewerRef.current
if (!viewer || !isImportedRef.current) return
const overlays = viewer.get('overlays') as any
const reg = viewer.get('elementRegistry') as any
// Remover overlays anteriores del tipo propio antes de re-agregar
overlays.remove({ type: OVERLAY_TYPE })
for (const elementId of automatableElementIds) {
try {
// Leer el ancho real del elemento para posicionar el badge en la
// esquina superior derecha con overlap: left = width - 12 pone el
// borde derecho del badge 8px fuera del borde derecho del nodo.
// (right: -8 en la API tiene semántica distinta: left = -right + width
// con -8 daría left = 8 + width, enteramente fuera del nodo.)
const el = reg?.get(elementId)
const width = el?.width ?? 100
overlays.add(elementId, OVERLAY_TYPE, {
position: { top: -8, left: width - 12 },
html: makeBadgeHtml(),
})
} catch {
// El elemento puede no existir en el diagrama actual — ignorar
}
}
}, [automatableElementIds])
// ─── Importar XML ──────────────────────────────────────────────────────────
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !xml) return
isImportedRef.current = false
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')
isImportedRef.current = true
syncAutomationOverlays()
}).catch((err: Error) => console.error('Error cargando BPMN:', err))
}, [xml])
}, [xml, syncAutomationOverlays])
// ─── Re-sincronizar overlays cuando cambia la lista de automatizables ─────
useEffect(() => {
syncAutomationOverlays()
}, [syncAutomationOverlays])
// ─── Click handler ─────────────────────────────────────────────────────────
useEffect(() => {
const viewer = viewerRef.current
if (!viewer || !onElementClick) return
@@ -67,6 +147,7 @@ export function BpmnCanvas({ xml, onElementClick, selectedElementId, className }
return () => eventBus.off('element.click', handler)
}, [onElementClick])
// ─── Highlight del elemento seleccionado ──────────────────────────────────
useEffect(() => {
const viewer = viewerRef.current
if (!viewer) return

View File

@@ -6,6 +6,7 @@ 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 { Separator } from '@/components/ui/separator'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useProcessStore } from '@/store/process-store'
import { formatPercent } from '@/lib/format'
@@ -27,6 +28,9 @@ export function GlobalSettingsPanel() {
const [localClient, setLocalClient] = useState('')
const [localCurrency, setLocalCurrency] = useState('USD')
const [localOverhead, setLocalOverhead] = useState(20)
const [localFrequency, setLocalFrequency] = useState(1000)
const [localHorizon, setLocalHorizon] = useState(3)
const [localInvestment, setLocalInvestment] = useState(0)
const [isDirty, setIsDirty] = useState(false)
useEffect(() => {
@@ -35,18 +39,26 @@ export function GlobalSettingsPanel() {
setLocalClient(currentProcess.clientName)
setLocalCurrency(currentProcess.currency)
setLocalOverhead(Math.round(currentProcess.overheadPercentage * 100))
setLocalFrequency(currentProcess.annualFrequency)
setLocalHorizon(currentProcess.analysisHorizonYears)
setLocalInvestment(currentProcess.automationInvestment)
setIsDirty(false)
}, [currentProcess])
function markDirty() { setIsDirty(true) }
async function handleSave() {
const clampedHorizon = Math.min(10, Math.max(1, localHorizon))
await updateProcess({
name: localName.trim() || 'Proceso sin nombre',
clientName: localClient.trim(),
currency: localCurrency,
overheadPercentage: localOverhead / 100,
annualFrequency: Math.max(1, localFrequency),
analysisHorizonYears: clampedHorizon,
automationInvestment: Math.max(0, localInvestment),
})
setLocalHorizon(clampedHorizon)
setIsDirty(false)
}
@@ -125,6 +137,99 @@ export function GlobalSettingsPanel() {
</p>
</div>
{/* ── Sección: Análisis de Impacto ──────────────────────────────── */}
<div>
<Separator className="mb-4" />
<div className="space-y-4">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Análisis de Impacto</p>
{/* Frecuencia anual */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="annual-frequency" className="text-xs font-medium">
Frecuencia anual
</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">
Cantidad de veces que se ejecuta el proceso completo por año. Se usa para anualizar los costos y calcular el ahorro.
</TooltipContent>
</Tooltip>
</div>
<Input
id="annual-frequency"
type="number"
min={1}
max={10_000_000}
step={1}
value={localFrequency === 0 ? '' : localFrequency}
onChange={(e) => { setLocalFrequency(Math.max(1, parseInt(e.target.value) || 1)); markDirty() }}
className="font-mono h-8 text-sm"
placeholder="1000"
/>
<p className="text-[10px] text-slate-400">
Ejemplos: 12 (mensual) · 52 (semanal) · 250 (diario laboral) · 8760 (cada hora)
</p>
</div>
{/* Horizonte de análisis */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Label className="text-xs font-medium">Horizonte de análisis</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">
Años hasta los que se proyecta el ahorro acumulado. Determina el eje X del gráfico de ROI.
</TooltipContent>
</Tooltip>
</div>
<span className="text-sm font-mono font-semibold text-slate-700">
{localHorizon} {localHorizon === 1 ? 'año' : 'años'}
</span>
</div>
<Slider
value={[localHorizon]}
min={1}
max={10}
step={1}
onValueChange={([v]) => { setLocalHorizon(v); markDirty() }}
/>
</div>
{/* Inversión en automatización */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="automation-investment" className="text-xs font-medium">
Inversión en automatización
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3 w-3 text-slate-400 cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-56 text-xs">
Costo único total del proyecto: desarrollo, licencias, implementación, capacitación. No incluir costos recurrentes.
</TooltipContent>
</Tooltip>
</div>
<Input
id="automation-investment"
type="number"
min={0}
step={100}
value={localInvestment === 0 ? '' : localInvestment}
onChange={(e) => { setLocalInvestment(Math.max(0, parseFloat(e.target.value) || 0)); markDirty() }}
className="font-mono h-8 text-sm"
placeholder="0"
/>
</div>
</div>
</div>
{/* Guardar */}
<Button
size="sm"

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router'
import {
Loader2, BarChart3, Play, AlertTriangle,
@@ -23,8 +23,8 @@ 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 { currentProcess, activities, isLoading, error, loadProcess, gateways } = useProcessStore()
const { isRunning, resultActual, error: simError, selectActivity } = useSimulationStore()
const { simulate } = useSimulate()
const { toast } = useToast()
@@ -64,6 +64,14 @@ export function WorkspacePage() {
const canSimulate = !isRunning && xorValidation.valid
// IDs de actividades marcadas como automatizables — alimenta los overlays del canvas.
// useDeferredValue evita re-renders en cascada mientras el usuario edita el panel.
const automatableElementIds = useMemo(
() => activities.filter((a) => a.automatable).map((a) => a.bpmnElementId),
[activities]
)
const deferredAutomatableIds = useDeferredValue(automatableElementIds)
const handleElementClick = useCallback((
elementId: string,
elementName: string,
@@ -91,6 +99,8 @@ export function WorkspacePage() {
if (simResult) navigate({ to: '/report/$processId', params: { processId } })
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -161,7 +171,7 @@ export function WorkspacePage() {
)}
{/* Ver reporte si ya hay simulación */}
{result && (
{resultActual && (
<Button
variant="outline"
size="sm"
@@ -223,6 +233,7 @@ export function WorkspacePage() {
xml={currentProcess.bpmnXml}
onElementClick={handleElementClick}
selectedElementId={selectedElementId}
automatableElementIds={deferredAutomatableIds}
className="absolute inset-0"
/>