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
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
This commit is contained in:
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
122
src/features/report/SensitivityPanel.tsx
Normal file
122
src/features/report/SensitivityPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user