feat(sprint-6/etapa-2): panel de análisis de sensibilidad + doble línea en CumulativeSavingsChart
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-06-24 16:42:32 -03:00
parent b9cd57d148
commit 1e4dbc0bed
4 changed files with 373 additions and 39 deletions

View File

@@ -4,10 +4,11 @@ import { formatCurrency } from '@/lib/format'
import type { RoiResult } from '@/domain/roi'
interface CumulativeSavingsChartProps {
roi: RoiResult
roi: RoiResult // caso ajustado (o base si no hay ajuste)
horizonYears: number
currency: string
investment: number
investment: number // inversión ajustada (usada para cálculo de valores del eje Y)
roiBase?: RoiResult // caso base original — cuando presente, se muestra como línea secundaria
}
export function CumulativeSavingsChart({
@@ -15,6 +16,7 @@ export function CumulativeSavingsChart({
horizonYears,
currency,
investment,
roiBase,
}: CumulativeSavingsChartProps) {
const option = useMemo(() => {
const years = Array.from({ length: horizonYears + 1 }, (_, i) => i)
@@ -45,17 +47,111 @@ export function CumulativeSavingsChart({
lineStyle: { color: '#94a3b8', type: 'solid', width: 1 },
})
// Caso base (sliders de sensibilidad activos) — recupera la inversión original a partir
// de cumulativeSavingsHorizon (= annualSavings × horizonYears investment), sin necesidad
// de una prop adicional: roiBase ya contiene toda la información necesaria.
let baseValues: number[] | null = null
if (roiBase) {
const investmentBase = roiBase.annualSavings * horizonYears - roiBase.cumulativeSavingsHorizon
baseValues = years.map((y) => roiBase.annualSavings * y - investmentBase)
}
// Fill-between: dos series stackeadas (lower invisible + diff coloreado) para sombrear
// exactamente el área entre la línea base y la ajustada, sin importar cuál es mayor.
const lowerValues = baseValues ? values.map((v, i) => Math.min(v, baseValues![i])) : null
const diffValues = baseValues ? values.map((v, i) => Math.abs(v - baseValues![i])) : null
const allValues = baseValues ? [...values, ...baseValues] : values
const yMin = Math.min(...allValues, 0)
const yMax = Math.max(...allValues, 0)
// Margen del 10% para que las líneas no queden pegadas al borde del gráfico
const yPad = Math.max((yMax - yMin) * 0.1, 1)
const series: Record<string, unknown>[] = []
if (lowerValues && diffValues) {
series.push({
name: 'fill-lower',
type: 'line',
data: years.map((y, i) => [y, lowerValues![i]]),
stack: 'fill-band',
symbol: 'none',
lineStyle: { opacity: 0 },
silent: true,
tooltip: { show: false },
})
series.push({
name: 'fill-diff',
type: 'line',
data: years.map((y, i) => [y, diffValues![i]]),
stack: 'fill-band',
symbol: 'none',
lineStyle: { opacity: 0 },
areaStyle: { color: '#F59845', opacity: 0.08 },
silent: true,
tooltip: { show: false },
})
}
if (baseValues) {
series.push({
name: 'Base',
type: 'line',
data: years.map((y, i) => [y, baseValues![i]]),
smooth: false,
symbol: 'circle',
symbolSize: 5,
lineStyle: { color: '#94a3b8', width: 1.5, type: 'dashed' },
itemStyle: { color: '#94a3b8' },
})
}
series.push({
name: 'Ajustado',
type: 'line',
data: years.map((y, i) => [y, values[i]]),
smooth: false,
lineStyle: { color: '#F59845', width: 2.5 },
areaStyle: baseValues
? undefined
: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(245,152,69,0.18)' },
{ offset: 1, color: 'rgba(245,152,69,0.02)' },
],
},
},
itemStyle: { color: '#F59845' },
symbolSize: 6,
markLine: {
silent: true,
symbol: ['none', 'none'],
data: markLineData,
},
})
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">
const typedParams = params as Array<{ seriesName: string; value: [number, number] }>
const adjustedP = typedParams.find((p) => p.seriesName === 'Ajustado')
const baseP = typedParams.find((p) => p.seriesName === 'Base')
if (!adjustedP) return ''
const year = adjustedP.value[0]
const val = adjustedP.value[1]
let html = `<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 ? '#16A34A' : '#EF4444'}">${formatCurrency(val, currency)}</strong></div>
</div>`
<div>Acumulado ajustado: <strong style="color:${val >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(val, currency)}</strong></div>`
if (baseP) {
const baseVal = baseP.value[1]
html += `<div>Acumulado base: <strong style="color:${baseVal >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(baseVal, currency)}</strong></div>`
}
html += '</div>'
return html
},
},
grid: { left: 16, right: 24, top: 16, bottom: 8, containLabel: true },
@@ -72,6 +168,8 @@ export function CumulativeSavingsChart({
},
yAxis: {
type: 'value',
min: baseValues ? yMin - yPad : undefined,
max: baseValues ? yMax + yPad : undefined,
axisLabel: {
fontSize: 11,
formatter: (v: number) => {
@@ -85,33 +183,9 @@ export function CumulativeSavingsChart({
},
splitLine: { lineStyle: { color: '#f1f5f9' } },
},
series: [
{
type: 'line',
data: years.map((y, i) => [y, values[i]]),
smooth: false,
lineStyle: { color: '#F59845', width: 2.5 },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(245,152,69,0.18)' },
{ offset: 1, color: 'rgba(245,152,69,0.02)' },
],
},
},
itemStyle: { color: '#F59845' },
symbolSize: 6,
markLine: {
silent: true,
symbol: ['none', 'none'],
data: markLineData,
},
},
],
series,
}
}, [roi, horizonYears, currency, investment])
}, [roi, horizonYears, currency, investment, roiBase])
return <ReactECharts option={option} style={{ height: 280 }} notMerge />
}

View File

@@ -22,6 +22,7 @@ import { CostByResourceChart } from './CostByResourceChart'
import { ActivitiesTable } from './ActivitiesTable'
import { ComparisonTable } from './ComparisonTable'
import { CumulativeSavingsChart } from './CumulativeSavingsChart'
import { SensitivityPanel } from './SensitivityPanel'
import { TopSavingsChart } from './TopSavingsChart'
import { TransparencySection } from './TransparencySection'
import { AutomationScenarioNote } from './AutomationScenarioNote'
@@ -67,6 +68,24 @@ export function ReportPage() {
})
}, [simulation, process])
// Sliders del panel de sensibilidad — efímero, no persiste (Sprint 6 Etapa 2)
const [volumeAdjust, setVolumeAdjust] = useState(0) // entero: -50 a +100
const [investmentAdjust, setInvestmentAdjust] = useState(0) // entero: -50 a +100
// ROI ajustado por sliders de sensibilidad — recalcula solo los agregados anuales,
// sin tocar costPerExecutionActual ni costPerExecutionAutomated (son estables, no se re-simula).
const adjustedRoi = useMemo(() => {
if (!roi || !simulation?.result || !simulation?.resultAutomated || !process) return null
if (volumeAdjust === 0 && investmentAdjust === 0) return roi // sin cambios → mismo objeto
return calculateRoi({
costPerExecutionActual: simulation.result.totalCost,
costPerExecutionAutomated: simulation.resultAutomated.totalCost,
annualFrequency: process.annualFrequency * (1 + volumeAdjust / 100),
analysisHorizonYears: process.analysisHorizonYears,
automationInvestment: process.automationInvestment * (1 + investmentAdjust / 100),
})
}, [roi, simulation, process, volumeAdjust, investmentAdjust])
// Breakdown del escenario actual, indexado por activityId, para comparación lado a lado en tab Automatizado
const actualBreakdownById = useMemo(() => {
if (!simulation?.result) return undefined
@@ -509,14 +528,24 @@ export function ReportPage() {
<NeedsResimulate processId={processId} />
) : (
<>
{/* KPIs de ROI */}
{/* KPIs de ROI — actualizados por sliders de sensibilidad */}
<RoiKpiBar
roi={roi}
roi={adjustedRoi ?? roi}
currency={process.currency}
horizonYears={process.analysisHorizonYears}
/>
{/* Tabla de comparación actividad por actividad */}
{/* Panel de sensibilidad — colapsado por defecto */}
<SensitivityPanel
process={process}
volumeAdjust={volumeAdjust}
investmentAdjust={investmentAdjust}
onVolumeChange={setVolumeAdjust}
onInvestmentChange={setInvestmentAdjust}
onReset={() => { setVolumeAdjust(0); setInvestmentAdjust(0) }}
/>
{/* Tabla de comparación actividad por actividad — per-ejecución, no varía con los sliders */}
<div className="mb-6">
<h2 className="text-base font-semibold text-slate-800 mb-4">
Comparación por actividad
@@ -536,10 +565,11 @@ export function ReportPage() {
Ahorro acumulado neto ({process.analysisHorizonYears} años)
</h3>
<CumulativeSavingsChart
roi={roi}
roi={adjustedRoi ?? roi}
roiBase={volumeAdjust !== 0 || investmentAdjust !== 0 ? roi : undefined}
horizonYears={process.analysisHorizonYears}
currency={process.currency}
investment={process.automationInvestment}
investment={process.automationInvestment * (1 + investmentAdjust / 100)}
/>
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">

View File

@@ -0,0 +1,122 @@
import { useState } from 'react'
import { ChevronDown, ChevronUp, SlidersHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { formatCurrency, formatNumber } from '@/lib/format'
interface SensitivityPanelProps {
process: {
annualFrequency: number
automationInvestment: number
currency: string
}
volumeAdjust: number // entero -50 a +100
investmentAdjust: number // entero -50 a +100
onVolumeChange: (v: number) => void
onInvestmentChange: (v: number) => void
onReset: () => void
}
function deltaLabel(adjust: number): string {
if (adjust === 0) return '(valor base)'
return `(${adjust > 0 ? '+' : ''}${adjust}%)`
}
export function SensitivityPanel({
process,
volumeAdjust,
investmentAdjust,
onVolumeChange,
onInvestmentChange,
onReset,
}: SensitivityPanelProps) {
const [expanded, setExpanded] = useState(false)
const isActive = volumeAdjust !== 0 || investmentAdjust !== 0
const adjustedVolume = process.annualFrequency * (1 + volumeAdjust / 100)
const adjustedInvestment = process.automationInvestment * (1 + investmentAdjust / 100)
return (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6">
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="flex items-center justify-between w-full text-left"
>
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4 text-amber-700" />
<span className="text-sm font-semibold text-amber-900">Análisis de sensibilidad</span>
{isActive && (
<span className="bg-orange-100 text-orange-700 text-xs font-medium px-2 py-0.5 rounded-full">
Análisis activo
</span>
)}
</div>
{expanded ? (
<ChevronUp className="h-4 w-4 text-amber-700" />
) : (
<ChevronDown className="h-4 w-4 text-amber-700" />
)}
</button>
{expanded && (
<div className="mt-4 space-y-5">
{/* Volumen anual */}
<div>
<label className="text-xs font-medium text-slate-700">Volumen anual</label>
<p className="text-sm font-mono text-slate-800 mt-0.5">
{formatNumber(adjustedVolume, 0)} ej/año {deltaLabel(volumeAdjust)}
</p>
<input
type="range"
min={-50}
max={100}
step={10}
value={volumeAdjust}
onChange={(e) => onVolumeChange(Number(e.target.value))}
className="w-full mt-2"
style={{ accentColor: '#F59845' }}
aria-label="Ajuste de volumen anual"
/>
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
<span>-50%</span>
<span>0%</span>
<span>+100%</span>
</div>
</div>
{/* Inversión en automatización */}
<div>
<label className="text-xs font-medium text-slate-700">Inversión en automatización</label>
<p className="text-sm font-mono text-slate-800 mt-0.5">
{formatCurrency(adjustedInvestment, process.currency)} {deltaLabel(investmentAdjust)}
</p>
<input
type="range"
min={-50}
max={100}
step={10}
value={investmentAdjust}
onChange={(e) => onInvestmentChange(Number(e.target.value))}
className="w-full mt-2"
style={{ accentColor: '#F59845' }}
aria-label="Ajuste de inversión en automatización"
/>
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
<span>-50%</span>
<span>0%</span>
<span>+100%</span>
</div>
</div>
{isActive && (
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={onReset}>
Restablecer
</Button>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,108 @@
/**
* Tests del recálculo de ROI ajustado por sliders de sensibilidad (Sprint 6, Etapa 2).
*
* La lógica vive inline en el useMemo `adjustedRoi` de ReportPage.tsx — no hay una función
* pura exportada (no se modifica domain/roi.ts por scope de la etapa). Estos tests replican
* exactamente esa transformación de inputs y verifican el comportamiento de calculateRoi()
* con los valores ajustados, igual que lo hace el componente.
*/
import { describe, it, expect } from 'vitest'
import { calculateRoi, type RoiInput } from '@/domain/roi'
const BASE: Omit<RoiInput, 'annualFrequency' | 'automationInvestment'> = {
costPerExecutionActual: 600,
costPerExecutionAutomated: 60,
analysisHorizonYears: 3,
}
const BASE_FREQUENCY = 1200
const BASE_INVESTMENT = 50_000
function adjustedRoi(volumeAdjust: number, investmentAdjust: number) {
return calculateRoi({
...BASE,
annualFrequency: BASE_FREQUENCY * (1 + volumeAdjust / 100),
automationInvestment: BASE_INVESTMENT * (1 + investmentAdjust / 100),
})
}
describe('Sensibilidad — ajuste de volumen anual', () => {
it('volumen +20%: annualFrequency y annualSavings escalan 1.2x respecto al base', () => {
const base = adjustedRoi(0, 0)
const adjusted = adjustedRoi(20, 0)
expect(adjusted.annualSavings).toBeCloseTo(base.annualSavings * 1.2)
})
it('volumen -30%: annualSavings escala 0.7x, payback se alarga', () => {
const base = adjustedRoi(0, 0)
const adjusted = adjustedRoi(-30, 0)
expect(adjusted.annualSavings).toBeCloseTo(base.annualSavings * 0.7)
expect(adjusted.paybackMonths).toBeGreaterThan(base.paybackMonths)
})
})
describe('Sensibilidad — ajuste de inversión en automatización', () => {
it('inversión +50%: payback se alarga 1.5x, ROI% baja proporcionalmente', () => {
const base = adjustedRoi(0, 0)
const adjusted = adjustedRoi(0, 50)
expect(adjusted.paybackMonths).toBeCloseTo(base.paybackMonths * 1.5)
expect(adjusted.roiAnnualPercent).toBeCloseTo(base.roiAnnualPercent / 1.5)
})
it('inversión -50%: payback se acorta a la mitad, ROI% se duplica', () => {
const base = adjustedRoi(0, 0)
const adjusted = adjustedRoi(0, -50)
expect(adjusted.paybackMonths).toBeCloseTo(base.paybackMonths * 0.5)
expect(adjusted.roiAnnualPercent).toBeCloseTo(base.roiAnnualPercent * 2)
})
})
describe('Sensibilidad — ajuste combinado', () => {
it('volumen +20% e inversión +50% simultáneos: ambos efectos se componen', () => {
const base = adjustedRoi(0, 0)
const adjusted = adjustedRoi(20, 50)
// annualSavings depende solo de volumen
expect(adjusted.annualSavings).toBeCloseTo(base.annualSavings * 1.2)
// payback = inversión / annualSavings → (1.5 * inv) / (1.2 * savings) = base * 1.25
expect(adjusted.paybackMonths).toBeCloseTo(base.paybackMonths * (1.5 / 1.2))
})
it('volumen -30% e inversión -50% simultáneos', () => {
const base = adjustedRoi(0, 0)
const adjusted = adjustedRoi(-30, -50)
expect(adjusted.annualSavings).toBeCloseTo(base.annualSavings * 0.7)
expect(adjusted.paybackMonths).toBeCloseTo(base.paybackMonths * (0.5 / 0.7))
})
})
describe('Sensibilidad — reset a 0', () => {
it('volumeAdjust=0 e investmentAdjust=0 reproduce exactamente el ROI base', () => {
const base = calculateRoi({
...BASE,
annualFrequency: BASE_FREQUENCY,
automationInvestment: BASE_INVESTMENT,
})
const reset = adjustedRoi(0, 0)
expect(reset).toEqual(base)
})
it('después de mover sliders, volver a 0/0 coincide con el valor base original', () => {
adjustedRoi(40, -20) // simula haber movido los sliders
const resetAgain = adjustedRoi(0, 0)
const base = adjustedRoi(0, 0)
expect(resetAgain).toEqual(base)
})
})
describe('Sensibilidad — límites del rango (-50 a +100)', () => {
it('volumen en el límite inferior (-50%) no produce frecuencia negativa', () => {
const adjusted = adjustedRoi(-50, 0)
expect(adjusted.annualSavings).toBeGreaterThanOrEqual(0)
})
it('volumen en el límite superior (+100%) duplica el ahorro anual', () => {
const base = adjustedRoi(0, 0)
const adjusted = adjustedRoi(100, 0)
expect(adjusted.annualSavings).toBeCloseTo(base.annualSavings * 2)
})
})