diff --git a/src/features/report/CumulativeSavingsChart.tsx b/src/features/report/CumulativeSavingsChart.tsx index de809e6..c27f369 100644 --- a/src/features/report/CumulativeSavingsChart.tsx +++ b/src/features/report/CumulativeSavingsChart.tsx @@ -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[] = [] + + 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 `
+ 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 = `
Año ${year}
-
Acumulado neto: ${formatCurrency(val, currency)}
-
` +
Acumulado ajustado: ${formatCurrency(val, currency)}
` + if (baseP) { + const baseVal = baseP.value[1] + html += `
Acumulado base: ${formatCurrency(baseVal, currency)}
` + } + html += '
' + 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 } diff --git a/src/features/report/ReportPage.tsx b/src/features/report/ReportPage.tsx index 85884ef..5f30f42 100644 --- a/src/features/report/ReportPage.tsx +++ b/src/features/report/ReportPage.tsx @@ -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() { ) : ( <> - {/* KPIs de ROI */} + {/* KPIs de ROI — actualizados por sliders de sensibilidad */} - {/* Tabla de comparación actividad por actividad */} + {/* Panel de sensibilidad — colapsado por defecto */} + { setVolumeAdjust(0); setInvestmentAdjust(0) }} + /> + + {/* Tabla de comparación actividad por actividad — per-ejecución, no varía con los sliders */}

Comparación por actividad @@ -536,10 +565,11 @@ export function ReportPage() { Ahorro acumulado neto ({process.analysisHorizonYears} años)

diff --git a/src/features/report/SensitivityPanel.tsx b/src/features/report/SensitivityPanel.tsx new file mode 100644 index 0000000..8b82d00 --- /dev/null +++ b/src/features/report/SensitivityPanel.tsx @@ -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 ( +
+ + + {expanded && ( +
+ {/* Volumen anual */} +
+ +

+ {formatNumber(adjustedVolume, 0)} ej/año {deltaLabel(volumeAdjust)} +

+ onVolumeChange(Number(e.target.value))} + className="w-full mt-2" + style={{ accentColor: '#F59845' }} + aria-label="Ajuste de volumen anual" + /> +
+ -50% + 0% + +100% +
+
+ + {/* Inversión en automatización */} +
+ +

+ {formatCurrency(adjustedInvestment, process.currency)} {deltaLabel(investmentAdjust)} +

+ onInvestmentChange(Number(e.target.value))} + className="w-full mt-2" + style={{ accentColor: '#F59845' }} + aria-label="Ajuste de inversión en automatización" + /> +
+ -50% + 0% + +100% +
+
+ + {isActive && ( +
+ +
+ )} +
+ )} +
+ ) +} diff --git a/tests/features/report/sensitivity.test.ts b/tests/features/report/sensitivity.test.ts new file mode 100644 index 0000000..081e4ea --- /dev/null +++ b/tests/features/report/sensitivity.test.ts @@ -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 = { + 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) + }) +})