feat(sprint-1-5): cierre Etapa 2 - formatPayback + fixes de marca

- Helper formatPayback con 12 casos cubiertos
- Aplicado en RoiKpiBar, drawRoiKpiCards, csv-export
- Auditoría WCAG: ratio 2.22:1, opción D (font-bold) para Etapa 3
- Adelanto: strings 'InQ ROI' y 'Powered by InQuality' en footers PDF
- Threshold ROI: '1.000%' sin decimal
- 455 tests verdes + 2 E2E

Refs: sprints/sprint-1-5/ETAPA_2_PROMPT.md
This commit is contained in:
2026-05-16 20:33:18 -03:00
parent d620b7e8ae
commit 7838c0cfa2
22 changed files with 271 additions and 106 deletions

View File

@@ -65,16 +65,16 @@ export function ComparisonTable({
}
return (
<div className="rounded-xl border border-slate-200 overflow-hidden">
<div className="rounded-xl border border-inq-orange/20 overflow-hidden">
<Table>
<TableHeader className="bg-slate-50">
<TableHeader style={{ background: '#FFF8ED', borderBottom: '1px solid #F59845' }}>
<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="w-8 text-xs text-amber-800/70" />
<TableHead className="text-xs text-amber-800/70 font-medium uppercase tracking-wide">Actividad</TableHead>
<TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">Costo actual</TableHead>
<TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">Costo automatizado</TableHead>
<TableHead
className="text-xs text-right cursor-pointer select-none hover:text-foreground"
className="text-xs text-right cursor-pointer select-none text-amber-800/70 font-medium uppercase tracking-wide hover:text-amber-900"
onClick={() => setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'))}
>
<span className="inline-flex items-center justify-end gap-1">
@@ -82,7 +82,7 @@ export function ComparisonTable({
<ArrowUpDown className="h-3 w-3 opacity-40" />
</span>
</TableHead>
<TableHead className="text-xs text-right">% ahorro</TableHead>
<TableHead className="text-xs text-right text-amber-800/70 font-medium uppercase tracking-wide">% ahorro</TableHead>
</tr>
</TableHeader>
<TableBody>
@@ -93,8 +93,10 @@ export function ComparisonTable({
: row.savings < 0
? 'text-red-600'
: 'text-slate-400'
const rowClass = row.automatable ? '' : 'bg-slate-50/60'
const textClass = row.automatable ? 'text-slate-800' : 'text-slate-500'
return (
<TableRow key={row.activityId}>
<TableRow key={row.activityId} className={rowClass}>
<TableCell className="py-2">
{row.automatable && (
<Bot
@@ -103,22 +105,24 @@ export function ComparisonTable({
/>
)}
</TableCell>
<TableCell className="text-xs font-medium text-slate-800 max-w-48 truncate">
<TableCell className={`text-xs font-medium max-w-48 truncate ${textClass}`}>
{row.activityName}
</TableCell>
<TableCell className="text-xs font-mono text-slate-600 text-right">
<TableCell className={`text-xs font-mono text-right ${row.automatable ? 'text-slate-600' : 'text-slate-400'}`}>
{formatCurrency(row.actualCost, currency)}
</TableCell>
<TableCell className="text-xs font-mono text-slate-600 text-right">
<TableCell className={`text-xs font-mono text-right ${row.automatable ? 'text-slate-600' : 'text-slate-400'}`}>
{formatCurrency(row.automatedCost, currency)}
</TableCell>
<TableCell className={`text-xs font-mono font-semibold text-right ${savingsColor}`}>
{row.savings > 0 ? '+' : ''}
{formatCurrency(row.savings, currency)}
<TableCell className={`text-xs font-mono font-semibold text-right ${row.automatable ? savingsColor : 'text-slate-300'}`}>
{row.automatable
? (row.savings > 0 ? '+' : '') + formatCurrency(row.savings, currency)
: '—'}
</TableCell>
<TableCell className={`text-xs font-mono text-right ${savingsColor}`}>
{row.savings > 0 ? '+' : ''}
{formatPercent(row.savingsPercent)}
<TableCell className={`text-xs font-mono text-right ${row.automatable ? savingsColor : 'text-slate-300'}`}>
{row.automatable
? (row.savings > 0 ? '+' : '') + formatPercent(row.savingsPercent)
: '—'}
</TableCell>
</TableRow>
)

View File

@@ -29,9 +29,9 @@ export function CumulativeSavingsChart({
formatter: `Payback\n${roi.paybackYears.toFixed(1)} años`,
position: 'insideEndTop',
fontSize: 10,
color: '#10b981',
color: '#16A34A',
},
lineStyle: { color: '#10b981', type: 'dashed', width: 2 },
lineStyle: { color: '#16A34A', type: 'dashed', width: 2 },
})
}
@@ -52,7 +52,7 @@ export function CumulativeSavingsChart({
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>Acumulado neto: <strong style="color:${val >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(val, currency)}</strong></div>
</div>`
},
},
@@ -88,18 +88,18 @@ export function CumulativeSavingsChart({
type: 'line',
data: years.map((y, i) => [y, values[i]]),
smooth: false,
lineStyle: { color: '#3b82f6', width: 2.5 },
lineStyle: { color: '#F59845', 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)' },
{ offset: 0, color: 'rgba(245,152,69,0.18)' },
{ offset: 1, color: 'rgba(245,152,69,0.02)' },
],
},
},
itemStyle: { color: '#3b82f6' },
itemStyle: { color: '#F59845' },
symbolSize: 6,
markLine: {
silent: true,

View File

@@ -1,5 +1,5 @@
import { formatCurrency, formatPercent } from '@/lib/format'
import { heatmapLegendBounds } from '@/lib/colors'
import { heatmapLegendBounds, HEATMAP_LOW_HEX, HEATMAP_MID_HEX, HEATMAP_HIGH_HEX } from '@/lib/colors'
import type { HeatmapMode } from '@/lib/colors'
import type { ActivitySimResult } from '@/domain/types'
@@ -21,7 +21,7 @@ export function HeatmapLegend({ perActivity, mode, currency }: HeatmapLegendProp
<div className="flex items-center gap-3 mt-3">
<span className="text-xs text-slate-500 shrink-0">Bajo costo</span>
<div className="flex-1 h-3 rounded-full" style={{
background: 'linear-gradient(to right, #10b981, #f59e0b, #ef4444)'
background: `linear-gradient(to right, ${HEATMAP_LOW_HEX}, ${HEATMAP_MID_HEX}, ${HEATMAP_HIGH_HEX})`
}} />
<span className="text-xs text-slate-500 shrink-0">Alto costo</span>
<div className="h-4 border-l border-slate-200 mx-1" />

View File

@@ -1,4 +1,4 @@
import { TrendingDown, Calendar, BarChart3, DollarSign, Percent } from 'lucide-react'
import { TrendingDown, Calendar, DollarSign, Percent } from 'lucide-react'
import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
import type { RoiResult } from '@/domain/roi'
@@ -8,6 +8,28 @@ interface RoiKpiBarProps {
horizonYears: number
}
function RoiKpiHero({
label, value, sub,
}: {
label: string
value: string
sub?: string
}) {
return (
<div
data-testid="roi-kpi-card"
className="rounded-xl p-5 mb-4"
style={{ background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)' }}
>
<p className="text-xs font-semibold text-white/90 uppercase tracking-widest mb-1">{label}</p>
<p className="text-3xl font-bold font-mono text-white leading-tight" style={{ letterSpacing: '-0.5px' }}>
{value}
</p>
{sub && <p className="text-sm text-white/85 mt-1">{sub}</p>}
</div>
)
}
function RoiKpiCard({
label, value, sub, icon, iconColor, valueColor, compact,
}: {
@@ -20,13 +42,13 @@ function RoiKpiCard({
compact?: boolean
}) {
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}`}>
<div data-testid="roi-kpi-card" className="bg-white rounded-xl border border-slate-200 shadow-sm p-4 flex items-start gap-3">
<div className={`shrink-0 p-2.5 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={`${compact ? 'text-sm' : 'text-2xl'} font-bold font-mono leading-tight ${valueColor ?? 'text-slate-900'}`}>{value}</p>
<p className={`${compact ? 'text-sm' : 'text-xl'} 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>
@@ -52,55 +74,55 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
? 'text-red-600'
: 'text-slate-900'
const cumulativePosColor = roi.cumulativeSavingsHorizon > 0
? 'text-emerald-700'
: roi.cumulativeSavingsHorizon < 0
? 'text-red-600'
: 'text-slate-900'
const cumulativeSub = roi.cumulativeSavingsHorizon > 0
? `neto de inversión inicial · ${horizonYears} años`
: 'sin recuperación neta en el horizonte'
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'}
compact
/>
<RoiKpiCard
label={`Acumulado (${horizonYears} años)`}
<div className="mb-8">
{/* KPI Hero — Ahorro proyectado a N años */}
<RoiKpiHero
label={`Ahorro proyectado a ${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'}
sub={cumulativeSub}
/>
{/* KPIs secundarios — grid 4 columnas */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<RoiKpiCard
label="Ahorro por ejecución"
value={formatCurrency(roi.savingsPerExecution, currency)}
sub={`${formatPercent(roi.savingsPerExecutionPercent)} vs. escenario actual`}
icon={<TrendingDown className="h-4 w-4 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-4 w-4 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-4 w-4 text-primary" />}
iconColor="bg-primary/10"
valueColor={roi.breaksEvenInHorizon ? 'text-emerald-700' : 'text-slate-900'}
compact
/>
<RoiKpiCard
label="ROI anual"
value={roiStr}
sub="retorno anual sobre la inversión"
icon={<Percent className="h-4 w-4 text-amber-600" />}
iconColor="bg-amber-50"
valueColor={roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'}
/>
</div>
</div>
)
}

View File

@@ -70,13 +70,13 @@ export function TopSavingsChart({
type: 'bar',
data: reversed.map((s) => s.value),
barMaxWidth: 32,
itemStyle: { color: '#10b981', borderRadius: [0, 4, 4, 0] },
itemStyle: { color: '#16A34A', borderRadius: [0, 4, 4, 0] },
label: {
show: true,
position: 'right',
fontSize: 10,
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
color: '#10b981',
color: '#16A34A',
},
},
],