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:
36
src/components/ui/switch.tsx
Normal file
36
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SwitchProps {
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
export function Switch({ checked, onCheckedChange, id, disabled, 'aria-label': ariaLabel }: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-primary' : 'bg-slate-200'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform duration-200',
|
||||
checked ? 'translate-x-4' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
79
src/domain/roi.ts
Normal file
79
src/domain/roi.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Cálculos de ROI de automatización — funciones puras, sin dependencias de UI ni persistencia.
|
||||
// Fórmulas nominales simples (ADR-014): sin VPN, TIR ni tasa de descuento.
|
||||
// Los edge cases (inversión cero, ahorro negativo, división por cero) están explicitados.
|
||||
|
||||
export interface RoiInput {
|
||||
costPerExecutionActual: number // costo total de una ejecución en escenario actual
|
||||
costPerExecutionAutomated: number // costo total de una ejecución en escenario automatizado
|
||||
annualFrequency: number // ejecuciones por año
|
||||
analysisHorizonYears: number // horizonte de análisis en años
|
||||
automationInvestment: number // inversión total única del proyecto de automatización
|
||||
}
|
||||
|
||||
export interface RoiResult {
|
||||
savingsPerExecution: number // ahorro por ejecución individual (actual - automatizado)
|
||||
savingsPerExecutionPercent: number // ahorro % sobre el costo actual (0 si actual = 0)
|
||||
annualSavings: number // ahorro anual = ahorro/ejecución × frecuencia
|
||||
paybackMonths: number // meses para recuperar inversión (Infinity si ahorro <= 0)
|
||||
paybackYears: number // años para recuperar inversión
|
||||
cumulativeSavingsHorizon: number // ahorro acumulado neto al final del horizonte
|
||||
breaksEvenInHorizon: boolean // ¿el payback ocurre dentro del horizonte?
|
||||
roiAnnualPercent: number // ROI anualizado % = (ahorroAnual / inversión) × 100
|
||||
// Infinity si inversión = 0 y ahorro > 0
|
||||
}
|
||||
|
||||
export function calculateRoi(input: RoiInput): RoiResult {
|
||||
const {
|
||||
costPerExecutionActual,
|
||||
costPerExecutionAutomated,
|
||||
annualFrequency,
|
||||
analysisHorizonYears,
|
||||
automationInvestment,
|
||||
} = input
|
||||
|
||||
const savingsPerExecution = costPerExecutionActual - costPerExecutionAutomated
|
||||
|
||||
// Evitar división por cero cuando el costo actual es 0
|
||||
const savingsPerExecutionPercent =
|
||||
costPerExecutionActual > 0
|
||||
? (savingsPerExecution / costPerExecutionActual) * 100
|
||||
: 0
|
||||
|
||||
const annualSavings = savingsPerExecution * annualFrequency
|
||||
|
||||
// Payback: si no hay ahorro (o es negativo), nunca se recupera la inversión
|
||||
let paybackMonths: number
|
||||
if (annualSavings <= 0) {
|
||||
paybackMonths = Infinity
|
||||
} else if (automationInvestment === 0) {
|
||||
// Inversión cero con ahorro positivo: recuperación instantánea
|
||||
paybackMonths = 0
|
||||
} else {
|
||||
paybackMonths = (automationInvestment / annualSavings) * 12
|
||||
}
|
||||
|
||||
const paybackYears = paybackMonths / 12 // Infinity / 12 = Infinity, 0 / 12 = 0
|
||||
|
||||
const cumulativeSavingsHorizon = annualSavings * analysisHorizonYears - automationInvestment
|
||||
|
||||
const breaksEvenInHorizon = isFinite(paybackYears) && paybackYears <= analysisHorizonYears
|
||||
|
||||
// ROI anualizado: Infinity si inversión = 0 con ahorro > 0; 0 si inversión = 0 y ahorro = 0
|
||||
let roiAnnualPercent: number
|
||||
if (automationInvestment === 0) {
|
||||
roiAnnualPercent = annualSavings > 0 ? Infinity : 0
|
||||
} else {
|
||||
roiAnnualPercent = (annualSavings / automationInvestment) * 100
|
||||
}
|
||||
|
||||
return {
|
||||
savingsPerExecution,
|
||||
savingsPerExecutionPercent,
|
||||
annualSavings,
|
||||
paybackMonths,
|
||||
paybackYears,
|
||||
cumulativeSavingsHorizon,
|
||||
breaksEvenInHorizon,
|
||||
roiAnnualPercent,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export const ActivitySchema = z.object({
|
||||
directCostFixed: z.number().min(0),
|
||||
executionTimeMinutes: z.number().min(0),
|
||||
assignedResources: z.array(ActivityResourceAssignmentSchema),
|
||||
automatable: z.boolean().default(false),
|
||||
automatedCostFixed: z.number().min(0).default(0),
|
||||
automatedTimeMinutes: z.number().min(0).default(0),
|
||||
})
|
||||
|
||||
export const GatewayBranchSchema = z.object({
|
||||
@@ -45,6 +48,9 @@ export const ProcessSchema = z.object({
|
||||
bpmnXml: z.string().min(1),
|
||||
currency: z.string().length(3, 'Código ISO de 3 letras'),
|
||||
overheadPercentage: z.number().min(0).max(1),
|
||||
annualFrequency: z.number().min(1).default(1000),
|
||||
analysisHorizonYears: z.number().min(1).max(20).default(3),
|
||||
automationInvestment: z.number().min(0).default(0),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
})
|
||||
|
||||
@@ -181,6 +181,9 @@ export function runSimulation(input: SimulationInput): SimulationResult {
|
||||
const perActivity: ActivitySimResult[] = []
|
||||
const resourceTotals = new Map<string, { cost: number; minutes: number; name: string }>()
|
||||
|
||||
// Seleccionar campos del escenario: para actividades automatable, usar costos automatizados
|
||||
const scenario = input.scenario ?? 'actual'
|
||||
|
||||
for (const activity of activities.values()) {
|
||||
const execProb = execProbs.get(activity.bpmnElementId) ?? 0
|
||||
const node = graph.nodes.get(activity.bpmnElementId)
|
||||
@@ -192,13 +195,19 @@ export function runSimulation(input: SimulationInput): SimulationResult {
|
||||
continue
|
||||
}
|
||||
|
||||
// En escenario automatizado, usar campos automatizados solo para actividades marcadas;
|
||||
// el resto mantiene sus costos originales (regla de negocio central del Sprint 1).
|
||||
const useAutomated = scenario === 'automated' && activity.automatable
|
||||
const directCostFixed = useAutomated ? activity.automatedCostFixed : activity.directCostFixed
|
||||
const executionTimeMinutes = useAutomated ? activity.automatedTimeMinutes : activity.executionTimeMinutes
|
||||
|
||||
const resourceBreakdown: ActivitySimResult['resourceCostBreakdown'] = []
|
||||
let resourceCostDirect = 0
|
||||
|
||||
for (const assignment of activity.assignedResources) {
|
||||
const resource = resources.get(assignment.resourceId)
|
||||
if (!resource) continue
|
||||
const hoursUsed = (activity.executionTimeMinutes / 60) * assignment.utilizationPercent
|
||||
const hoursUsed = (executionTimeMinutes / 60) * assignment.utilizationPercent
|
||||
const cost = resource.costPerHour * hoursUsed * execProb
|
||||
resourceBreakdown.push({ resourceId: resource.id, resourceName: resource.name, cost })
|
||||
resourceCostDirect += cost
|
||||
@@ -211,7 +220,7 @@ export function runSimulation(input: SimulationInput): SimulationResult {
|
||||
})
|
||||
}
|
||||
|
||||
const fixedCostExpected = activity.directCostFixed * execProb
|
||||
const fixedCostExpected = directCostFixed * execProb
|
||||
const totalExpectedDirectCost = fixedCostExpected + resourceCostDirect
|
||||
|
||||
perActivity.push({
|
||||
@@ -225,7 +234,7 @@ export function runSimulation(input: SimulationInput): SimulationResult {
|
||||
expectedExecutions: execProb,
|
||||
executionProbability: execProb,
|
||||
resourceCostBreakdown: resourceBreakdown,
|
||||
executionTimeMinutes: activity.executionTimeMinutes,
|
||||
executionTimeMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface Process {
|
||||
bpmnXml: string
|
||||
currency: string
|
||||
overheadPercentage: number
|
||||
annualFrequency: number // 🆕 Sprint 1 — execuciones por año, default 1000
|
||||
analysisHorizonYears: number // 🆕 Sprint 1 — horizonte de análisis en años, default 3
|
||||
automationInvestment: number // 🆕 Sprint 1 — inversión total del proyecto, default 0
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
@@ -26,6 +29,9 @@ export interface Activity {
|
||||
directCostFixed: number
|
||||
executionTimeMinutes: number
|
||||
assignedResources: ActivityResourceAssignment[]
|
||||
automatable: boolean // 🆕 Sprint 1 — si la actividad es candidata a automatización
|
||||
automatedCostFixed: number // 🆕 Sprint 1 — costo recurrente por ejecución automatizada
|
||||
automatedTimeMinutes: number // 🆕 Sprint 1 — tiempo de la actividad en escenario automatizado
|
||||
}
|
||||
|
||||
export interface ActivityResourceAssignment {
|
||||
@@ -63,11 +69,16 @@ export interface Simulation {
|
||||
id: UUID
|
||||
processId: UUID
|
||||
executedAt: number
|
||||
result: SimulationResult
|
||||
result: SimulationResult // escenario actual
|
||||
resultAutomated?: SimulationResult // 🆕 Sprint 1 — escenario automatizado (opcional por retrocompatibilidad)
|
||||
}
|
||||
|
||||
// ─── Motor de simulación: Input/Output ───────────────────────────────────────
|
||||
|
||||
// Escenario de simulación: actual (campos directCostFixed/executionTimeMinutes)
|
||||
// o automatizado (automatedCostFixed/automatedTimeMinutes para actividades marcadas).
|
||||
export type Scenario = 'actual' | 'automated'
|
||||
|
||||
export interface SimulationInput {
|
||||
processXml: string
|
||||
activities: Map<BpmnElementId, Activity>
|
||||
@@ -77,6 +88,7 @@ export interface SimulationInput {
|
||||
currency: string
|
||||
overheadPercentage: number
|
||||
}
|
||||
scenario?: Scenario // default 'actual' — opcional para retrocompatibilidad con tests existentes
|
||||
}
|
||||
|
||||
export interface ActivitySimResult {
|
||||
|
||||
@@ -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)
|
||||
|
||||
12
src/features/report/AutomationScenarioNote.tsx
Normal file
12
src/features/report/AutomationScenarioNote.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
src/features/report/ComparisonTable.tsx
Normal file
130
src/features/report/ComparisonTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
src/features/report/CumulativeSavingsChart.tsx
Normal file
115
src/features/report/CumulativeSavingsChart.tsx
Normal 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 />
|
||||
}
|
||||
@@ -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á "Simular" para generar ambos escenarios.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}>
|
||||
Ir al workspace
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
110
src/features/report/RoiKpiBar.tsx
Normal file
110
src/features/report/RoiKpiBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
src/features/report/TopSavingsChart.tsx
Normal file
95
src/features/report/TopSavingsChart.tsx
Normal 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 />
|
||||
}
|
||||
91
src/features/report/TransparencySection.tsx
Normal file
91
src/features/report/TransparencySection.tsx
Normal 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 "Global" 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 "Transparencia metodológica" del reporte hasta que cargues valores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guardar */}
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import Papa from 'papaparse'
|
||||
import { formatSimulationTimestamp } from '@/lib/format'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import { buildFileName } from './slug'
|
||||
import type { Process, Simulation, Resource, ActivitySimResult } from '@/domain/types'
|
||||
import type { Process, Simulation, Resource, Activity, ActivitySimResult } from '@/domain/types'
|
||||
|
||||
export type ReportTab = 'actual' | 'automatizado' | 'roi'
|
||||
|
||||
// Monedas que usan coma como separador decimal (necesitan punto y coma en el CSV)
|
||||
const COMMA_DECIMAL_CURRENCIES = new Set(['PYG', 'BRL', 'EUR', 'ARS', 'COP', 'MXN', 'CLP'])
|
||||
@@ -18,19 +21,11 @@ function formatDecimalForCsv(value: number, currency: string): string {
|
||||
return formatted
|
||||
}
|
||||
|
||||
function buildResourcesCell(
|
||||
act: ActivitySimResult,
|
||||
resources: Resource[]
|
||||
): string {
|
||||
function buildResourcesCell(act: ActivitySimResult, resources: Resource[]): string {
|
||||
if (act.resourceCostBreakdown.length === 0) return ''
|
||||
const resourceById = new Map(resources.map((r) => [r.id, r]))
|
||||
return act.resourceCostBreakdown
|
||||
.map((rb) => {
|
||||
const res = resourceById.get(rb.resourceId)
|
||||
if (!res) return rb.resourceName
|
||||
// Calcular % de utilización desde el costo relativo
|
||||
return res.name
|
||||
})
|
||||
.map((rb) => resourceById.get(rb.resourceId)?.name ?? rb.resourceName)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
@@ -38,42 +33,43 @@ export interface CsvExportParams {
|
||||
process: Process
|
||||
simulation: Simulation
|
||||
resources: Resource[]
|
||||
tab?: ReportTab // default: 'actual'
|
||||
activities?: Activity[] // requerido para tab='roi'
|
||||
}
|
||||
|
||||
export function generateCsvContent(params: CsvExportParams): string {
|
||||
const { process, simulation, resources } = params
|
||||
const { process, simulation, resources, tab = 'actual', activities = [] } = params
|
||||
const { currency } = process
|
||||
const result = simulation.result
|
||||
|
||||
if (tab === 'roi') {
|
||||
return generateRoiCsvContent(process, simulation, resources, activities, currency)
|
||||
}
|
||||
|
||||
const result = tab === 'automatizado'
|
||||
? (simulation.resultAutomated ?? simulation.result)
|
||||
: simulation.result
|
||||
|
||||
const delimiter = getDelimiter(currency)
|
||||
const { date } = formatSimulationTimestamp(simulation.executedAt)
|
||||
|
||||
// ── Filas de metadata como comentarios ──────────────────────────────────
|
||||
const meta = [
|
||||
`# Proceso: ${process.name}`,
|
||||
`# Cliente: ${process.clientName || 'N/A'}`,
|
||||
`# Fecha simulación: ${date}`,
|
||||
`# Moneda: ${currency}`,
|
||||
`# Escenario: ${tab === 'automatizado' ? 'Automatizado' : 'Actual'}`,
|
||||
`# Costo total (${currency}): ${formatDecimalForCsv(result.totalCost, currency)}`,
|
||||
`# Costo directo: ${formatDecimalForCsv(result.totalDirectCost, currency)}`,
|
||||
`# Costo indirecto (overhead ${(process.overheadPercentage * 100).toFixed(0)}%): ${formatDecimalForCsv(result.totalIndirectCost, currency)}`,
|
||||
'',
|
||||
].join('\r\n')
|
||||
|
||||
// ── Headers ──────────────────────────────────────────────────────────────
|
||||
const headers = [
|
||||
'ID BPMN',
|
||||
'Actividad',
|
||||
'Tipo',
|
||||
`Costo directo (${currency})`,
|
||||
`Costo indirecto (${currency})`,
|
||||
`Costo total (${currency})`,
|
||||
'% del total',
|
||||
'Tiempo (min)',
|
||||
'Ejecuciones esperadas',
|
||||
'Recursos asignados',
|
||||
'ID BPMN', 'Actividad', 'Tipo',
|
||||
`Costo directo (${currency})`, `Costo indirecto (${currency})`, `Costo total (${currency})`,
|
||||
'% del total', 'Tiempo (min)', 'Ejecuciones esperadas', 'Recursos asignados',
|
||||
]
|
||||
|
||||
// ── Filas ─────────────────────────────────────────────────────────────────
|
||||
const rows = result.perActivity.map((act) => [
|
||||
act.bpmnElementId,
|
||||
act.activityName,
|
||||
@@ -87,22 +83,135 @@ export function generateCsvContent(params: CsvExportParams): string {
|
||||
buildResourcesCell(act, resources),
|
||||
])
|
||||
|
||||
const csvBody = Papa.unparse(
|
||||
{ fields: headers, data: rows },
|
||||
{ delimiter, newline: '\r\n', quotes: true }
|
||||
)
|
||||
const csvBody = Papa.unparse({ fields: headers, data: rows }, { delimiter, newline: '\r\n', quotes: true })
|
||||
return '' + meta + csvBody
|
||||
}
|
||||
|
||||
// BOM UTF-8 + metadata + datos
|
||||
// ─── CSV de ROI: 16 columnas + metadata extendida ────────────────────────────
|
||||
|
||||
function generateRoiCsvContent(
|
||||
process: Process,
|
||||
simulation: Simulation,
|
||||
resources: Resource[],
|
||||
activities: Activity[],
|
||||
currency: string
|
||||
): string {
|
||||
const resultActual = simulation.result
|
||||
const resultAutomated = simulation.resultAutomated ?? simulation.result
|
||||
const delimiter = getDelimiter(currency)
|
||||
const { date } = formatSimulationTimestamp(simulation.executedAt)
|
||||
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: resultActual.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
annualFrequency: process.annualFrequency,
|
||||
analysisHorizonYears: process.analysisHorizonYears,
|
||||
automationInvestment: process.automationInvestment,
|
||||
})
|
||||
|
||||
const paybackStr = !Number.isFinite(roi.paybackMonths)
|
||||
? 'N/A'
|
||||
: roi.paybackMonths === 0
|
||||
? '0'
|
||||
: formatDecimalForCsv(roi.paybackMonths, currency)
|
||||
|
||||
const roiStr = !Number.isFinite(roi.roiAnnualPercent)
|
||||
? 'Infinito'
|
||||
: formatDecimalForCsv(roi.roiAnnualPercent, currency)
|
||||
|
||||
const meta = [
|
||||
`# Proceso: ${process.name}`,
|
||||
`# Cliente: ${process.clientName || 'N/A'}`,
|
||||
`# Fecha simulación: ${date}`,
|
||||
`# Moneda: ${currency}`,
|
||||
`# Costo total actual (${currency}): ${formatDecimalForCsv(resultActual.totalCost, currency)}`,
|
||||
`# Costo total automatizado (${currency}): ${formatDecimalForCsv(resultAutomated.totalCost, currency)}`,
|
||||
`# Ahorro por ejecución (${currency}): ${formatDecimalForCsv(roi.savingsPerExecution, currency)}`,
|
||||
`# Ahorro anual (${currency}): ${formatDecimalForCsv(roi.annualSavings, currency)}`,
|
||||
`# Payback (meses): ${paybackStr}`,
|
||||
`# Ahorro acumulado a ${process.analysisHorizonYears} años (${currency}): ${formatDecimalForCsv(roi.cumulativeSavingsHorizon, currency)}`,
|
||||
`# ROI anualizado (%): ${roiStr}`,
|
||||
`# Frecuencia anual: ${process.annualFrequency}`,
|
||||
`# Horizonte de análisis: ${process.analysisHorizonYears} años`,
|
||||
`# Inversión en automatización (${currency}): ${formatDecimalForCsv(process.automationInvestment, currency)}`,
|
||||
'',
|
||||
].join('\r\n')
|
||||
|
||||
// Mapas para lookup eficiente
|
||||
const autoByBpmnId = new Map(resultAutomated.perActivity.map((a) => [a.bpmnElementId, a]))
|
||||
const actByBpmnId = new Map(activities.map((a) => [a.bpmnElementId, a]))
|
||||
|
||||
const headers = [
|
||||
'ID BPMN',
|
||||
'Actividad',
|
||||
'Tipo',
|
||||
'Automatizable',
|
||||
'Probabilidad (%)',
|
||||
`Costo actual directo (${currency})`,
|
||||
`Costo actual indirecto (${currency})`,
|
||||
`Costo actual total (${currency})`,
|
||||
'Tiempo actual (min)',
|
||||
`Costo automatizado directo (${currency})`,
|
||||
`Costo automatizado indirecto (${currency})`,
|
||||
`Costo automatizado total (${currency})`,
|
||||
'Tiempo automatizado (min)',
|
||||
`Ahorro (${currency})`,
|
||||
'Ahorro (%)',
|
||||
'Recursos asignados',
|
||||
]
|
||||
|
||||
const rows = [...resultActual.perActivity]
|
||||
.sort((a, b) => {
|
||||
const aSavings = a.expectedTotalCost - (autoByBpmnId.get(a.bpmnElementId)?.expectedTotalCost ?? a.expectedTotalCost)
|
||||
const bSavings = b.expectedTotalCost - (autoByBpmnId.get(b.bpmnElementId)?.expectedTotalCost ?? b.expectedTotalCost)
|
||||
return bSavings - aSavings
|
||||
})
|
||||
.map((act) => {
|
||||
const autoAct = autoByBpmnId.get(act.bpmnElementId)
|
||||
const domainAct = actByBpmnId.get(act.bpmnElementId)
|
||||
const isAutomatable = domainAct?.automatable ?? false
|
||||
const automatedTime = domainAct?.automatedTimeMinutes ?? act.executionTimeMinutes
|
||||
|
||||
const autoCostDirect = autoAct?.expectedDirectCost ?? act.expectedDirectCost
|
||||
const autoCostIndirect = autoAct?.expectedIndirectCost ?? act.expectedIndirectCost
|
||||
const autoCostTotal = autoAct?.expectedTotalCost ?? act.expectedTotalCost
|
||||
const savings = act.expectedTotalCost - autoCostTotal
|
||||
const savingsPct = act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
|
||||
|
||||
return [
|
||||
act.bpmnElementId,
|
||||
act.activityName,
|
||||
act.bpmnElementId.includes('subprocess') ? 'subproceso' : 'tarea',
|
||||
isAutomatable ? 'true' : 'false',
|
||||
formatDecimalForCsv(act.executionProbability * 100, currency),
|
||||
formatDecimalForCsv(act.expectedDirectCost, currency),
|
||||
formatDecimalForCsv(act.expectedIndirectCost, currency),
|
||||
formatDecimalForCsv(act.expectedTotalCost, currency),
|
||||
formatDecimalForCsv(act.executionTimeMinutes, currency),
|
||||
formatDecimalForCsv(autoCostDirect, currency),
|
||||
formatDecimalForCsv(autoCostIndirect, currency),
|
||||
formatDecimalForCsv(autoCostTotal, currency),
|
||||
formatDecimalForCsv(automatedTime, currency),
|
||||
formatDecimalForCsv(savings, currency),
|
||||
formatDecimalForCsv(savingsPct, currency),
|
||||
buildResourcesCell(act, resources),
|
||||
]
|
||||
})
|
||||
|
||||
const csvBody = Papa.unparse({ fields: headers, data: rows }, { delimiter, newline: '\r\n', quotes: true })
|
||||
return '' + meta + csvBody
|
||||
}
|
||||
|
||||
export function exportToCsv(params: CsvExportParams): void {
|
||||
const content = generateCsvContent(params)
|
||||
const tab = params.tab ?? 'actual'
|
||||
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
|
||||
const fileName = buildFileName(
|
||||
params.process.name,
|
||||
params.process.clientName,
|
||||
new Date(params.simulation.executedAt),
|
||||
'csv'
|
||||
'csv',
|
||||
suffix
|
||||
)
|
||||
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { buildFileName } from './slug'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import {
|
||||
drawHeader, drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
|
||||
drawAnalysisSection, drawMethodologySection, addPageNumbers, PDF,
|
||||
} from './pdf-sections'
|
||||
import type { Process, Simulation, Resource } from '@/domain/types'
|
||||
import {
|
||||
drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig,
|
||||
drawRoiAnalysisPage, drawRoiMethodologySection,
|
||||
} from './pdf-sections-roi'
|
||||
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
|
||||
|
||||
export type ReportTab = 'actual' | 'automatizado' | 'roi'
|
||||
|
||||
export interface PdfExportParams {
|
||||
process: Process
|
||||
simulation: Simulation
|
||||
resources: Resource[]
|
||||
heatmapImageData: string | null // data URL de la imagen capturada del canvas
|
||||
heatmapImageData: string | null
|
||||
tab?: ReportTab // default: 'actual'
|
||||
activities?: Activity[] // requerido para tab='roi' (transparencia + tabla)
|
||||
}
|
||||
|
||||
export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
const { process, simulation, resources, heatmapImageData } = params
|
||||
const result = simulation.result
|
||||
const { process, simulation, resources, heatmapImageData, tab = 'actual', activities = [] } = params
|
||||
const currency = process.currency
|
||||
|
||||
// Carga dinámica: jsPDF + autotable solo se descargan al hacer click
|
||||
const [{ jsPDF }, { default: autoTable }] = await Promise.all([
|
||||
import('jspdf'),
|
||||
import('jspdf-autotable'),
|
||||
@@ -25,8 +32,36 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
|
||||
|
||||
// Metadata del PDF
|
||||
doc.setProperties({
|
||||
if (tab === 'roi') {
|
||||
await exportRoiPdf(doc, autoTable, process, simulation, activities, currency)
|
||||
} else {
|
||||
const result = tab === 'automatizado'
|
||||
? (simulation.resultAutomated ?? simulation.result)
|
||||
: simulation.result
|
||||
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency)
|
||||
}
|
||||
|
||||
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
|
||||
const fileName = buildFileName(process.name, process.clientName, new Date(simulation.executedAt), 'pdf', suffix)
|
||||
doc.save(fileName)
|
||||
}
|
||||
|
||||
// ─── Escenario único (actual o automatizado) ──────────────────────────────────
|
||||
|
||||
async function exportScenarioPdf(
|
||||
doc: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
autoTable: Function,
|
||||
process: Process,
|
||||
simulation: Simulation,
|
||||
result: Simulation['result'],
|
||||
resources: Resource[],
|
||||
heatmapImageData: string | null,
|
||||
currency: string
|
||||
): Promise<void> {
|
||||
const docAny = doc as any
|
||||
|
||||
docAny.setProperties({
|
||||
title: `Análisis de costos - ${process.name}`,
|
||||
author: process.clientName || '',
|
||||
subject: 'Simulación de proceso BPMN',
|
||||
@@ -34,44 +69,37 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
keywords: 'bpmn, costos, simulación',
|
||||
})
|
||||
|
||||
// ── PÁGINA 1: Header + KPIs + Heatmap ────────────────────────────────────
|
||||
let y = drawHeader(doc as any, process, simulation.executedAt)
|
||||
y = drawKpiCards(doc as any, result, currency, y)
|
||||
let y = drawHeader(docAny, process, simulation.executedAt)
|
||||
y = drawKpiCards(docAny, result, currency, y)
|
||||
y += 4
|
||||
|
||||
// Título del heatmap
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Mapa de calor de costos', PDF.margin, y)
|
||||
docAny.setFont('helvetica', 'bold')
|
||||
docAny.setFontSize(10)
|
||||
docAny.setTextColor(...PDF.slate900)
|
||||
docAny.text('Mapa de calor de costos', PDF.margin, y)
|
||||
y += 5
|
||||
|
||||
y = drawHeatmapImage(doc as any, heatmapImageData, y)
|
||||
y = drawHeatmapLegend(doc as any, y)
|
||||
y = drawHeatmapImage(docAny, heatmapImageData, y)
|
||||
y = drawHeatmapLegend(docAny, y)
|
||||
y += 4
|
||||
|
||||
// ── PÁGINA 2: Análisis ────────────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
docAny.addPage()
|
||||
y = PDF.margin
|
||||
y = drawAnalysisSection(doc as any, result, currency, y)
|
||||
y = drawAnalysisSection(docAny, result, currency, y)
|
||||
y += 6
|
||||
|
||||
// ── PÁGINA 3+: Tabla detalle (autotable maneja paginación automática) ─────
|
||||
void resources // disponible para uso futuro (recursos en tabla de actividades)
|
||||
void resources
|
||||
|
||||
const RA = 'right' as const
|
||||
|
||||
const tableHead = [
|
||||
[
|
||||
{ content: 'Actividad', styles: { cellWidth: 48 } },
|
||||
{ content: `Dir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Indir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Total (${currency})`, styles: { halign: RA, cellWidth: 28 } },
|
||||
{ content: '% total', styles: { halign: RA, cellWidth: 16 } },
|
||||
{ content: 'Tiempo', styles: { halign: RA, cellWidth: 14 } },
|
||||
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
|
||||
],
|
||||
]
|
||||
const tableHead = [[
|
||||
{ content: 'Actividad', styles: { cellWidth: 48 } },
|
||||
{ content: `Dir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Indir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
|
||||
{ content: `Total (${currency})`, styles: { halign: RA, cellWidth: 28 } },
|
||||
{ content: '% total', styles: { halign: RA, cellWidth: 16 } },
|
||||
{ content: 'Tiempo', styles: { halign: RA, cellWidth: 14 } },
|
||||
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
|
||||
]]
|
||||
|
||||
const tableBody = result.perActivity.map((act) => [
|
||||
{ content: act.activityName },
|
||||
@@ -83,40 +111,92 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA } },
|
||||
])
|
||||
|
||||
autoTable(doc, {
|
||||
autoTable(docAny, {
|
||||
head: tableHead,
|
||||
body: tableBody,
|
||||
startY: y,
|
||||
margin: { left: PDF.margin, right: PDF.margin },
|
||||
styles: { fontSize: 8, cellPadding: 2.5, overflow: 'ellipsize' },
|
||||
headStyles: {
|
||||
fillColor: PDF.blue,
|
||||
textColor: [255, 255, 255],
|
||||
fontStyle: 'bold',
|
||||
fontSize: 8,
|
||||
},
|
||||
headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 },
|
||||
alternateRowStyles: { fillColor: PDF.slate50 },
|
||||
columnStyles: { 0: { cellWidth: 48 } },
|
||||
didDrawPage: () => {
|
||||
// Placeholder — los footers se añaden al final con addPageNumbers
|
||||
},
|
||||
})
|
||||
|
||||
// ── PÁGINA FINAL: Metodología ────────────────────────────────────────────
|
||||
docAny.addPage()
|
||||
drawMethodologySection(docAny, process, PDF.margin)
|
||||
addPageNumbers(docAny, 'Process Cost Platform', simulation.executedAt)
|
||||
}
|
||||
|
||||
// ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
|
||||
|
||||
async function exportRoiPdf(
|
||||
doc: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
autoTable: Function,
|
||||
process: Process,
|
||||
simulation: Simulation,
|
||||
activities: Activity[],
|
||||
currency: string
|
||||
): Promise<void> {
|
||||
const result = simulation.result
|
||||
const resultAutomated = simulation.resultAutomated ?? result
|
||||
|
||||
const roi = calculateRoi({
|
||||
costPerExecutionActual: result.totalCost,
|
||||
costPerExecutionAutomated: resultAutomated.totalCost,
|
||||
annualFrequency: process.annualFrequency,
|
||||
analysisHorizonYears: process.analysisHorizonYears,
|
||||
automationInvestment: process.automationInvestment,
|
||||
})
|
||||
|
||||
doc.setProperties({
|
||||
title: `Análisis de ROI - ${process.name}`,
|
||||
author: process.clientName || '',
|
||||
subject: 'Análisis de retorno de inversión para automatización BPMN',
|
||||
creator: 'Process Cost Platform',
|
||||
keywords: 'bpmn, roi, automatización, payback',
|
||||
})
|
||||
|
||||
// ── Página 1: Header + resumen ejecutivo + 5 KPI cards ─────────────────────
|
||||
let y = drawHeader(doc, process, simulation.executedAt)
|
||||
y = drawRoiExecutiveSummary(doc, process, roi, currency, y)
|
||||
y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y)
|
||||
|
||||
// ── Página 2: Tabla comparativa ────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
drawMethodologySection(doc as any, process, PDF.margin)
|
||||
y = PDF.margin
|
||||
|
||||
// ── FOOTER en todas las páginas ───────────────────────────────────────────
|
||||
addPageNumbers(doc as any, 'Process Cost Platform', simulation.executedAt)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Comparación por actividad', PDF.margin, y)
|
||||
y += 4
|
||||
|
||||
// ── Descargar ─────────────────────────────────────────────────────────────
|
||||
const fileName = buildFileName(
|
||||
process.name,
|
||||
process.clientName,
|
||||
new Date(simulation.executedAt),
|
||||
'pdf'
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.text('★ Actividades en negrita están marcadas como automatizables', PDF.margin, y + 3)
|
||||
y += 8
|
||||
|
||||
const tableConfig = buildComparisonTableConfig(
|
||||
result.perActivity,
|
||||
resultAutomated.perActivity,
|
||||
activities,
|
||||
currency,
|
||||
y
|
||||
)
|
||||
doc.save(fileName)
|
||||
autoTable(doc, tableConfig)
|
||||
|
||||
// ── Página 3: Análisis textual + transparencia ─────────────────────────────
|
||||
doc.addPage()
|
||||
y = PDF.margin
|
||||
drawRoiAnalysisPage(doc, roi, process, result.perActivity, resultAutomated.perActivity, activities, currency, y)
|
||||
|
||||
// ── Página 4: Metodología ──────────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
drawRoiMethodologySection(doc, process, PDF.margin)
|
||||
|
||||
addPageNumbers(doc, 'Process Cost Platform · ROI', simulation.executedAt)
|
||||
}
|
||||
|
||||
function formatNum(n: number): string {
|
||||
|
||||
458
src/lib/export/pdf-sections-roi.ts
Normal file
458
src/lib/export/pdf-sections-roi.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { formatCurrency, formatPercent } from '@/lib/format'
|
||||
import type { ActivitySimResult, Process, Activity } from '@/domain/types'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
import { PDF, type DocLike } from './pdf-sections'
|
||||
|
||||
const ROI_HIGH_THRESHOLD = 1000
|
||||
|
||||
// ─── Resumen ejecutivo ────────────────────────────────────────────────────────
|
||||
|
||||
export function drawRoiExecutiveSummary(
|
||||
doc: DocLike,
|
||||
process: Process,
|
||||
roi: RoiResult,
|
||||
currency: string,
|
||||
startY: number
|
||||
): number {
|
||||
let y = startY
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Análisis de retorno de inversión', PDF.margin, y)
|
||||
y += 6
|
||||
|
||||
doc.setDrawColor(...PDF.slate200)
|
||||
doc.setLineWidth(0.2)
|
||||
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
|
||||
y += 5
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF.slate700)
|
||||
|
||||
const investmentStr = process.automationInvestment > 0
|
||||
? `inversión estimada de ${formatCurrency(process.automationInvestment, currency)}`
|
||||
: 'inversión aún no configurada'
|
||||
|
||||
const summary = `Análisis de retorno de inversión para automatización del proceso "${process.name}"` +
|
||||
(process.clientName ? ` del cliente ${process.clientName}` : '') +
|
||||
`, con frecuencia anual de ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones,` +
|
||||
` horizonte de ${process.analysisHorizonYears} años e ${investmentStr}.`
|
||||
|
||||
const summaryLines = doc.splitTextToSize(summary, PDF.contentW)
|
||||
doc.text(summaryLines, PDF.margin, y)
|
||||
y += summaryLines.length * 4.5 + 3
|
||||
|
||||
// Sub-resumen de resultado
|
||||
const resultText = roi.savingsPerExecution > 0
|
||||
? `Resultado: ahorro de ${formatCurrency(roi.savingsPerExecution, currency)} por ejecución (${formatPercent(roi.savingsPerExecutionPercent)} de reducción). ` +
|
||||
(Number.isFinite(roi.paybackMonths)
|
||||
? roi.breaksEvenInHorizon
|
||||
? `La inversión se recupera en ${roi.paybackMonths < 24 ? `${roi.paybackMonths.toFixed(1)} meses` : `${roi.paybackYears.toFixed(1)} años`}, dentro del horizonte de análisis.`
|
||||
: `El payback ocurre en ${roi.paybackYears.toFixed(1)} años, fuera del horizonte analizado.`
|
||||
: 'Sin ahorro neto — la automatización no se recupera con los datos actuales.')
|
||||
: 'El escenario automatizado no genera ahorro con los datos actuales. Revisá los costos configurados.'
|
||||
|
||||
const resultLines = doc.splitTextToSize(resultText, PDF.contentW)
|
||||
doc.setFont('helvetica', 'italic')
|
||||
doc.setFontSize(8.5)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.text(resultLines, PDF.margin, y)
|
||||
y += resultLines.length * 4 + 4
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setTextColor(...PDF.slate700)
|
||||
return y
|
||||
}
|
||||
|
||||
// ─── 5 KPI cards (grid 2×3 — 5 celdas, 6ta vacía o con meta-info) ────────────
|
||||
|
||||
export function drawRoiKpiCards(
|
||||
doc: DocLike,
|
||||
roi: RoiResult,
|
||||
currency: string,
|
||||
horizonYears: number,
|
||||
startY: number
|
||||
): number {
|
||||
const cardW = 52, cardH = 28, gapX = 7, gapY = 5
|
||||
const col1 = PDF.margin, col2 = PDF.margin + cardW + gapX, col3 = PDF.margin + (cardW + gapX) * 2
|
||||
|
||||
const paybackStr = !Number.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 = !Number.isFinite(roi.roiAnnualPercent)
|
||||
? '∞'
|
||||
: `${roi.roiAnnualPercent.toFixed(0)}%`
|
||||
|
||||
const kpis = [
|
||||
// Fila 1: 3 cards
|
||||
{ label: 'AHORRO POR EJECUCIÓN', value: formatCurrency(roi.savingsPerExecution, currency), sub: `${formatPercent(roi.savingsPerExecutionPercent)} vs. actual`, positive: roi.savingsPerExecution >= 0 },
|
||||
{ label: 'AHORRO ANUAL', value: formatCurrency(roi.annualSavings, currency), sub: `× ${horizonYears} años`, positive: roi.annualSavings >= 0 },
|
||||
{ label: 'PAYBACK', value: paybackStr, sub: roi.breaksEvenInHorizon ? '✓ Dentro del horizonte' : 'Fuera del horizonte', positive: roi.breaksEvenInHorizon },
|
||||
// Fila 2: 2 cards + 1 vacía
|
||||
{ label: `ACUMULADO (${horizonYears} AÑOS)`, value: formatCurrency(roi.cumulativeSavingsHorizon, currency), sub: 'neto de inversión', positive: roi.cumulativeSavingsHorizon >= 0 },
|
||||
{ label: 'ROI ANUAL', value: roiStr, sub: 'retorno sobre la inversión', positive: roi.roiAnnualPercent >= 0 },
|
||||
]
|
||||
|
||||
const cols = [col1, col2, col3]
|
||||
let maxY = startY
|
||||
|
||||
kpis.forEach((kpi, idx) => {
|
||||
const row = Math.floor(idx / 3)
|
||||
const col = idx % 3
|
||||
const x = cols[col]
|
||||
const y = startY + row * (cardH + gapY)
|
||||
|
||||
// Fondo
|
||||
doc.setFillColor(...PDF.slate50)
|
||||
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'F')
|
||||
|
||||
// Borde
|
||||
doc.setDrawColor(...PDF.slate200)
|
||||
doc.setLineWidth(0.2)
|
||||
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'S')
|
||||
|
||||
// Label
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(5.5)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.text(kpi.label, x + 3, y + 5.5)
|
||||
|
||||
// Valor
|
||||
const valueColor = kpi.positive ? [16, 185, 129] as [number,number,number] : [239, 68, 68] as [number,number,number]
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(kpi.value.length > 14 ? 9 : 11)
|
||||
doc.setTextColor(...valueColor)
|
||||
doc.text(kpi.value, x + 3, y + 16)
|
||||
|
||||
// Sub-texto
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(6)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
const subLines = doc.splitTextToSize(kpi.sub, cardW - 6)
|
||||
doc.text(subLines, x + 3, y + 22)
|
||||
|
||||
maxY = Math.max(maxY, y + cardH)
|
||||
})
|
||||
|
||||
return maxY + 6
|
||||
}
|
||||
|
||||
// ─── Config de autotable para la tabla comparativa ────────────────────────────
|
||||
// Se llama desde pdf-export.ts donde autoTable ya está importado dinámicamente.
|
||||
|
||||
export function buildComparisonTableConfig(
|
||||
perActivityActual: ActivitySimResult[],
|
||||
perActivityAutomated: ActivitySimResult[],
|
||||
activities: Activity[],
|
||||
currency: string,
|
||||
startY: number
|
||||
): Record<string, unknown> {
|
||||
const autoByBpmnId = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
|
||||
const isAutomatableMap = new Map(activities.map((a) => [a.bpmnElementId, a.automatable]))
|
||||
|
||||
const RA = 'right' as const
|
||||
|
||||
const head = [[
|
||||
{ content: 'Actividad', styles: { cellWidth: 42 } },
|
||||
{ content: `Act. (${currency})`, styles: { halign: RA, cellWidth: 22 } },
|
||||
{ content: `Auto. (${currency})`, styles: { halign: RA, cellWidth: 22 } },
|
||||
{ content: `Ahorro (${currency})`, styles: { halign: RA, cellWidth: 22 } },
|
||||
{ content: 'Ahorro %', styles: { halign: RA, cellWidth: 16 } },
|
||||
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
|
||||
]]
|
||||
|
||||
const body = [...perActivityActual]
|
||||
.sort((a, b) => {
|
||||
const aCost = autoByBpmnId.get(a.bpmnElementId)?.expectedTotalCost ?? a.expectedTotalCost
|
||||
const bCost = autoByBpmnId.get(b.bpmnElementId)?.expectedTotalCost ?? b.expectedTotalCost
|
||||
return (b.expectedTotalCost - bCost) - (a.expectedTotalCost - aCost)
|
||||
})
|
||||
.map((act) => {
|
||||
const auto = autoByBpmnId.get(act.bpmnElementId)
|
||||
const autoCost = auto?.expectedTotalCost ?? act.expectedTotalCost
|
||||
const savings = act.expectedTotalCost - autoCost
|
||||
const savingsPct = act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
|
||||
const isAutomatable = isAutomatableMap.get(act.bpmnElementId) ?? false
|
||||
|
||||
const savingsColor = savings > 0 ? [16, 185, 129] : savings < 0 ? [239, 68, 68] : [100, 116, 139]
|
||||
const rowFill = isAutomatable ? [255, 255, 255] : [248, 250, 252]
|
||||
|
||||
return [
|
||||
{ content: act.activityName, styles: { fillColor: rowFill, fontStyle: isAutomatable ? 'bold' : 'normal' as const } },
|
||||
{ content: fmtNum(act.expectedTotalCost), styles: { halign: RA, font: 'courier', fillColor: rowFill } },
|
||||
{ content: fmtNum(autoCost), styles: { halign: RA, font: 'courier', fillColor: rowFill } },
|
||||
{ content: (savings >= 0 ? '+' : '') + fmtNum(savings), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: savingsColor } },
|
||||
{ content: (savings >= 0 ? '+' : '') + savingsPct.toFixed(1) + '%', styles: { halign: RA, fillColor: rowFill, textColor: savingsColor } },
|
||||
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA, fillColor: rowFill } },
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
head,
|
||||
body,
|
||||
startY,
|
||||
margin: { left: PDF.margin, right: PDF.margin },
|
||||
styles: { fontSize: 7.5, cellPadding: 2, overflow: 'ellipsize' },
|
||||
headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 },
|
||||
columnStyles: { 0: { cellWidth: 42 } },
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Página 3: resúmenes textuales + transparencia ────────────────────────────
|
||||
|
||||
export function drawRoiAnalysisPage(
|
||||
doc: DocLike,
|
||||
roi: RoiResult,
|
||||
process: Process,
|
||||
perActivityActual: ActivitySimResult[],
|
||||
perActivityAutomated: ActivitySimResult[],
|
||||
activities: Activity[],
|
||||
currency: string,
|
||||
startY: number
|
||||
): number {
|
||||
let y = startY
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Análisis del ahorro', PDF.margin, y)
|
||||
y += 6
|
||||
|
||||
doc.setDrawColor(...PDF.slate200)
|
||||
doc.setLineWidth(0.2)
|
||||
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
|
||||
y += 6
|
||||
|
||||
// ── Composición del ahorro ──────────────────────────────────────────────────
|
||||
const autoByBpmnId = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
|
||||
const savingsPerAct = perActivityActual
|
||||
.map((a) => {
|
||||
const auto = autoByBpmnId.get(a.bpmnElementId)
|
||||
return { name: a.activityName, savings: a.expectedTotalCost - (auto?.expectedTotalCost ?? a.expectedTotalCost) }
|
||||
})
|
||||
.filter((s) => s.savings > 0)
|
||||
.sort((a, b) => b.savings - a.savings)
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF.slate700)
|
||||
doc.text('Composición del ahorro', PDF.margin, y)
|
||||
y += 5
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8.5)
|
||||
|
||||
if (savingsPerAct.length > 0) {
|
||||
const top3 = savingsPerAct.slice(0, 3)
|
||||
const top3TotalSavings = top3.reduce((s, a) => s + a.savings, 0)
|
||||
const totalSavingsAll = savingsPerAct.reduce((s, a) => s + a.savings, 0)
|
||||
const top3Pct = totalSavingsAll > 0 ? (top3TotalSavings / totalSavingsAll * 100).toFixed(0) : '0'
|
||||
|
||||
const compositionText = `El ${top3Pct}% del ahorro total proviene de las primeras ${Math.min(3, savingsPerAct.length)} actividades automatizadas:`
|
||||
const compositionLines = doc.splitTextToSize(compositionText, PDF.contentW)
|
||||
doc.text(compositionLines, PDF.margin, y)
|
||||
y += compositionLines.length * 4.5 + 2
|
||||
|
||||
for (const act of top3) {
|
||||
const actPct = totalSavingsAll > 0 ? (act.savings / totalSavingsAll * 100).toFixed(1) : '0'
|
||||
doc.text(` • ${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`, PDF.margin, y)
|
||||
y += 4.5
|
||||
}
|
||||
} else {
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.text('Sin actividades con ahorro positivo en el escenario actual.', PDF.margin, y)
|
||||
doc.setTextColor(...PDF.slate700)
|
||||
y += 5
|
||||
}
|
||||
y += 4
|
||||
|
||||
// ── Trayectoria del ahorro ──────────────────────────────────────────────────
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(9)
|
||||
doc.text('Trayectoria del ahorro acumulado', PDF.margin, y)
|
||||
y += 5
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8.5)
|
||||
|
||||
let trajectoryText: string
|
||||
if (process.automationInvestment > 0 && Number.isFinite(roi.paybackMonths)) {
|
||||
const recoveryStr = roi.paybackMonths < 24
|
||||
? `${roi.paybackMonths.toFixed(1)} meses`
|
||||
: `${roi.paybackYears.toFixed(1)} años`
|
||||
const multiple = process.automationInvestment > 0
|
||||
? (roi.cumulativeSavingsHorizon / process.automationInvestment + 1).toFixed(1)
|
||||
: '∞'
|
||||
trajectoryText =
|
||||
`La inversión de ${formatCurrency(process.automationInvestment, currency)} se recupera en ${recoveryStr}. ` +
|
||||
`A ${process.analysisHorizonYears} años, el ahorro acumulado proyectado es ` +
|
||||
`${formatCurrency(roi.cumulativeSavingsHorizon, currency)}, equivalente a ${multiple}x la inversión inicial.`
|
||||
} else if (process.automationInvestment === 0) {
|
||||
trajectoryText =
|
||||
`Sin inversión configurada — el ahorro anual de ${formatCurrency(roi.annualSavings, currency)} ` +
|
||||
`equivale a ${formatCurrency(roi.cumulativeSavingsHorizon, currency)} acumulados en ${process.analysisHorizonYears} años.`
|
||||
} else {
|
||||
trajectoryText = 'Sin ahorro proyectado con los datos actuales. Revisá los costos automatizados configurados.'
|
||||
}
|
||||
|
||||
const trajectoryLines = doc.splitTextToSize(trajectoryText, PDF.contentW)
|
||||
doc.text(trajectoryLines, PDF.margin, y)
|
||||
y += trajectoryLines.length * 4.5 + 6
|
||||
|
||||
// ── Transparencia metodológica (si aplica) ────────────────────────────────
|
||||
y = drawTransparencyBlock(doc, activities, process.automationInvestment, roi, y)
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
export function drawTransparencyBlock(
|
||||
doc: DocLike,
|
||||
activities: Activity[],
|
||||
investment: number,
|
||||
roi: RoiResult,
|
||||
startY: number
|
||||
): number {
|
||||
const zeroCost = activities.filter(
|
||||
(a) => a.automatable && a.automatedCostFixed === 0 && a.automatedTimeMinutes === 0
|
||||
)
|
||||
const investmentZero = investment === 0
|
||||
const roiHigh = Number.isFinite(roi.roiAnnualPercent) && roi.roiAnnualPercent > ROI_HIGH_THRESHOLD
|
||||
|
||||
if (zeroCost.length === 0 && !investmentZero && !roiHigh) return startY
|
||||
|
||||
const boxX = PDF.margin
|
||||
const boxW = PDF.contentW
|
||||
|
||||
// ── Paso 1: recopilar todo el contenido para calcular altura ─────────────
|
||||
type TextBlock = { text: string[]; bold?: boolean; fontSize: number }
|
||||
const blocks: TextBlock[] = []
|
||||
|
||||
blocks.push({ text: ['Transparencia metodológica'], bold: true, fontSize: 9 })
|
||||
|
||||
if (zeroCost.length > 0) {
|
||||
const msg = zeroCost.length === 1
|
||||
? `1 actividad automatizable sin costo configurado: "${zeroCost[0].name || zeroCost[0].bpmnElementId}". El ahorro puede estar inflado artificialmente.`
|
||||
: `${zeroCost.length} actividades sin costo automatizado: ${zeroCost.map((a) => a.name || a.bpmnElementId).join(', ')}. El ahorro puede estar inflado.`
|
||||
blocks.push({ text: doc.splitTextToSize(msg, boxW - 8), fontSize: 8 })
|
||||
}
|
||||
|
||||
if (investmentZero) {
|
||||
blocks.push({
|
||||
text: doc.splitTextToSize(
|
||||
'Inversión en automatización no configurada. El payback aparece como inmediato y el ROI como infinito. Configurá la inversión en el workspace para un análisis realista.',
|
||||
boxW - 8
|
||||
),
|
||||
fontSize: 8,
|
||||
})
|
||||
}
|
||||
|
||||
if (roiHigh) {
|
||||
blocks.push({
|
||||
text: doc.splitTextToSize(
|
||||
`ROI inusualmente alto detectado: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente. Revisá antes de presentar al cliente.`,
|
||||
boxW - 8
|
||||
),
|
||||
fontSize: 8,
|
||||
})
|
||||
}
|
||||
|
||||
// Calcular altura total del bloque
|
||||
let height = 6 // padding top
|
||||
for (const b of blocks) {
|
||||
height += b.text.length * (b.bold ? 4.5 : 3.8) + 3
|
||||
}
|
||||
height += 3 // padding bottom
|
||||
|
||||
// ── Paso 2: dibujar fondo y borde ────────────────────────────────────────
|
||||
doc.setFillColor(255, 251, 235) // amber-50
|
||||
doc.setDrawColor(253, 230, 138) // amber-200
|
||||
doc.setLineWidth(0.3)
|
||||
doc.rect(boxX, startY, boxW, height, 'FD')
|
||||
|
||||
// ── Paso 3: dibujar texto encima del rectángulo ───────────────────────────
|
||||
let y = startY + 6
|
||||
|
||||
for (const b of blocks) {
|
||||
if (b.bold) {
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(b.fontSize)
|
||||
doc.setTextColor(146, 64, 14) // amber-800
|
||||
} else {
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(b.fontSize)
|
||||
doc.setTextColor(180, 83, 9) // amber-700
|
||||
}
|
||||
doc.text(b.text, boxX + 4, y)
|
||||
y += b.text.length * (b.bold ? 4.5 : 3.8) + 3
|
||||
}
|
||||
|
||||
return startY + height + 4
|
||||
}
|
||||
|
||||
// ─── Sección metodológica ampliada para el tab ROI ────────────────────────────
|
||||
|
||||
export function drawRoiMethodologySection(doc: DocLike, process: Process, startY: number): number {
|
||||
let y = startY
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Nota metodológica — Análisis de ROI', PDF.margin, y)
|
||||
y += 5
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Costo por ejecución actual:',
|
||||
body: `Σ(actividades) [costo_fijo × prob_ejecución] × (1 + overhead ${(process.overheadPercentage * 100).toFixed(0)}%). Probabilidades calculadas por propagación hacia adelante en el grafo BPMN.`,
|
||||
},
|
||||
{
|
||||
title: 'Costo por ejecución automatizado:',
|
||||
body: 'Mismo modelo, pero las actividades marcadas como automatizables usan su costo automatizado configurado. Las no marcadas mantienen el costo actual.',
|
||||
},
|
||||
{
|
||||
title: 'Ahorro anual:',
|
||||
body: `(Costo actual − Costo automatizado) × ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones/año.`,
|
||||
},
|
||||
{
|
||||
title: 'Payback (meses):',
|
||||
body: `(Inversión / Ahorro anual) × 12. Nominal simple — no incluye tasa de descuento ni inflación.`,
|
||||
},
|
||||
{
|
||||
title: 'Ahorro acumulado:',
|
||||
body: `Ahorro anual × ${process.analysisHorizonYears} años − Inversión inicial. Nominal, sin VP.`,
|
||||
},
|
||||
{
|
||||
title: 'ROI anualizado:',
|
||||
body: '(Ahorro anual / Inversión) × 100%. Referencia: METHODOLOGY_AUTOMATED_COST.md.',
|
||||
},
|
||||
]
|
||||
|
||||
for (const s of sections) {
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text(s.title, PDF.margin, y)
|
||||
y += 4
|
||||
doc.setFont('helvetica', 'normal')
|
||||
const lines = doc.splitTextToSize(s.body, PDF.contentW - 4)
|
||||
doc.text(lines, PDF.margin + 4, y)
|
||||
y += lines.length * 3.8 + 2
|
||||
}
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
// ─── Helpers locales ──────────────────────────────────────────────────────────
|
||||
|
||||
function fmtNum(n: number): string {
|
||||
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export const PDF = {
|
||||
}
|
||||
|
||||
// Tipo mínimo de doc para los helpers (evitar importar jsPDF en este archivo)
|
||||
type DocLike = {
|
||||
export type DocLike = {
|
||||
setFont: (name: string, style: string) => void
|
||||
setFontSize: (size: number) => void
|
||||
setTextColor: (...args: number[]) => void
|
||||
|
||||
@@ -12,7 +12,8 @@ export function buildFileName(
|
||||
processName: string,
|
||||
clientName: string | undefined,
|
||||
date: Date,
|
||||
ext: 'pdf' | 'csv'
|
||||
ext: 'pdf' | 'csv',
|
||||
suffix = '' // '_actual' | '_automatizado' | '_roi' | ''
|
||||
): string {
|
||||
const dateStr = [
|
||||
date.getFullYear(),
|
||||
@@ -24,5 +25,5 @@ export function buildFileName(
|
||||
if (clientName?.trim()) parts.push(slugify(clientName.trim()))
|
||||
parts.push(dateStr)
|
||||
|
||||
return `${parts.join('_')}.${ext}`
|
||||
return `${parts.join('_')}${suffix}.${ext}`
|
||||
}
|
||||
|
||||
@@ -18,6 +18,29 @@ export class ProcessCostDb extends Dexie {
|
||||
resources: 'id, processId, name',
|
||||
simulations: 'id, processId, executedAt',
|
||||
})
|
||||
|
||||
// Sprint 1: agrega campos de automatización a Activity y volumetría a Process.
|
||||
// El upgrade popula datos existentes con defaults para garantizar invariante de tipo.
|
||||
this.version(2)
|
||||
.stores({
|
||||
processes: 'id, name, updatedAt',
|
||||
activities: 'id, processId, bpmnElementId',
|
||||
gateways: 'id, processId, bpmnElementId',
|
||||
resources: 'id, processId, name',
|
||||
simulations: 'id, processId, executedAt',
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
await tx.table('activities').toCollection().modify((act) => {
|
||||
if (act.automatable === undefined) act.automatable = false
|
||||
if (act.automatedCostFixed === undefined) act.automatedCostFixed = 0
|
||||
if (act.automatedTimeMinutes === undefined) act.automatedTimeMinutes = 0
|
||||
})
|
||||
await tx.table('processes').toCollection().modify((proc) => {
|
||||
if (proc.annualFrequency === undefined) proc.annualFrequency = 1000
|
||||
if (proc.analysisHorizonYears === undefined) proc.analysisHorizonYears = 3
|
||||
if (proc.automationInvestment === undefined) proc.automationInvestment = 0
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* Repositorios de acceso a IndexedDB (Dexie).
|
||||
*
|
||||
* REGLA ARQUITECTÓNICA: este módulo solo debe importarse desde `src/store/**`.
|
||||
* Las features y hooks deben mutar datos a través de los stores (useProcessStore,
|
||||
* useSimulationStore), que garantizan la invalidación del resultado de simulación
|
||||
* cuando los datos cambian.
|
||||
*
|
||||
* Excepciones permitidas (ver eslint.config.js):
|
||||
* - src/features/import/** → operación de importación inicial (no hay simulación previa)
|
||||
* - src/features/report/useReportData.ts → solo lectura, sin mutaciones
|
||||
* - tests/** → acceso directo controlado en contexto de testing
|
||||
*
|
||||
* Cualquier import desde fuera de estos paths falla `npm run lint`.
|
||||
*/
|
||||
|
||||
import { db } from './db'
|
||||
import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types'
|
||||
|
||||
// ─── Process ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
||||
export const processRepo = {
|
||||
async save(process: Process): Promise<void> {
|
||||
await db.processes.put(process)
|
||||
@@ -29,6 +48,7 @@ export const processRepo = {
|
||||
|
||||
// ─── Activity ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
||||
export const activityRepo = {
|
||||
async save(activity: Activity): Promise<void> {
|
||||
await db.activities.put(activity)
|
||||
@@ -56,6 +76,7 @@ export const activityRepo = {
|
||||
|
||||
// ─── GatewayConfig ────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
||||
export const gatewayRepo = {
|
||||
async save(gateway: GatewayConfig): Promise<void> {
|
||||
await db.gateways.put(gateway)
|
||||
@@ -76,6 +97,7 @@ export const gatewayRepo = {
|
||||
|
||||
// ─── Resource ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/process-store.ts */
|
||||
export const resourceRepo = {
|
||||
async save(resource: Resource): Promise<void> {
|
||||
await db.resources.put(resource)
|
||||
@@ -96,6 +118,7 @@ export const resourceRepo = {
|
||||
|
||||
// ─── Simulation ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/simulation-store.ts */
|
||||
export const simulationRepo = {
|
||||
async save(simulation: Simulation): Promise<void> {
|
||||
await db.simulations.put(simulation)
|
||||
|
||||
@@ -1,28 +1,76 @@
|
||||
import { create } from 'zustand'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { SimulationResult } from '@/domain/types'
|
||||
import { simulationRepo } from '@/persistence/repositories'
|
||||
|
||||
// Excepción arquitectónica documentada: simulation-store importa process-store en una dirección
|
||||
// para suscribirse a cambios de datos y invalidar resultados. No existe dependencia inversa.
|
||||
import { useProcessStore } from '@/store/process-store'
|
||||
|
||||
interface SimulationState {
|
||||
result: SimulationResult | null
|
||||
resultActual: SimulationResult | null // resultado del escenario actual
|
||||
resultAutomated: SimulationResult | null // resultado del escenario automatizado
|
||||
lastSimulatedAt: Date | null // timestamp de la última simulación exitosa
|
||||
isRunning: boolean
|
||||
error: string | null
|
||||
selectedActivityId: string | null
|
||||
|
||||
setResult: (result: SimulationResult) => void
|
||||
// Persiste ambos resultados en IndexedDB y actualiza el estado en memoria.
|
||||
// REGLA: los dos resultados siempre se guardan y actualizan en conjunto.
|
||||
persistResults: (processId: string, actual: SimulationResult, automated: SimulationResult) => Promise<void>
|
||||
setRunning: (running: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
selectActivity: (bpmnElementId: string | null) => void
|
||||
// Invalida AMBOS resultados a null. Se llama cuando cambian datos del proceso.
|
||||
invalidateResults: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useSimulationStore = create<SimulationState>((set) => ({
|
||||
result: null,
|
||||
resultActual: null,
|
||||
resultAutomated: null,
|
||||
lastSimulatedAt: null,
|
||||
isRunning: false,
|
||||
error: null,
|
||||
selectedActivityId: null,
|
||||
|
||||
setResult: (result) => set({ result, error: null }),
|
||||
persistResults: async (processId, actual, automated) => {
|
||||
await simulationRepo.save({
|
||||
id: uuidv4(),
|
||||
processId,
|
||||
executedAt: Date.now(),
|
||||
result: actual,
|
||||
resultAutomated: automated,
|
||||
})
|
||||
set({ resultActual: actual, resultAutomated: automated, lastSimulatedAt: new Date(), error: null })
|
||||
},
|
||||
|
||||
setRunning: (isRunning) => set({ isRunning }),
|
||||
setError: (error) => set({ error }),
|
||||
selectActivity: (selectedActivityId) => set({ selectedActivityId }),
|
||||
reset: () => set({ result: null, isRunning: false, error: null, selectedActivityId: null }),
|
||||
|
||||
invalidateResults: () =>
|
||||
set({ resultActual: null, resultAutomated: null, lastSimulatedAt: null }),
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
resultActual: null,
|
||||
resultAutomated: null,
|
||||
lastSimulatedAt: null,
|
||||
isRunning: false,
|
||||
error: null,
|
||||
selectedActivityId: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Suscripción reactiva: cualquier cambio en activities o currentProcess del process-store
|
||||
// invalida los resultados para garantizar que nunca haya resultados "viejos" visibles.
|
||||
// La comparación por referencia de Zustand asegura que solo fires en cambios reales.
|
||||
useProcessStore.subscribe((state, prevState) => {
|
||||
if (
|
||||
state.activities !== prevState.activities ||
|
||||
state.currentProcess !== prevState.currentProcess
|
||||
) {
|
||||
useSimulationStore.getState().invalidateResults()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user