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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user