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

[
This commit is contained in:
2026-05-16 19:26:19 -03:00
parent f9a008bce5
commit eaaac88fc3
16 changed files with 1626 additions and 55 deletions

View File

@@ -1,5 +1,5 @@
import { TrendingDown, Calendar, BarChart3, DollarSign, Percent } from 'lucide-react'
import { formatCurrency, formatPercent } from '@/lib/format'
import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
import type { RoiResult } from '@/domain/roi'
interface RoiKpiBarProps {
@@ -9,7 +9,7 @@ interface RoiKpiBarProps {
}
function RoiKpiCard({
label, value, sub, icon, iconColor, valueColor,
label, value, sub, icon, iconColor, valueColor, compact,
}: {
label: string
value: string
@@ -17,6 +17,7 @@ function RoiKpiCard({
icon: React.ReactNode
iconColor: string
valueColor?: string
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">
@@ -25,7 +26,7 @@ function RoiKpiCard({
</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={`text-2xl font-bold font-mono leading-tight ${valueColor ?? 'text-slate-900'}`}>{value}</p>
<p className={`${compact ? 'text-sm' : 'text-2xl'} 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>
@@ -33,13 +34,7 @@ function RoiKpiCard({
}
export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
const paybackStr = !isFinite(roi.paybackMonths)
? 'No se recupera'
: roi.paybackMonths === 0
? 'Inmediato'
: roi.paybackMonths < 24
? `${roi.paybackMonths.toFixed(1)} meses`
: `${roi.paybackYears.toFixed(1)} años`
const paybackStr = formatPayback(roi.paybackMonths)
const roiStr = !isFinite(roi.roiAnnualPercent)
? '∞%'
@@ -88,6 +83,7 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
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)`}

View File

@@ -73,7 +73,7 @@ export function TransparencySection({ activities, investment, roi }: Transparenc
<span className="font-medium">
ROI inusualmente alto detectado: {formatPercent(roi.roiAnnualPercent)}.
</span>{' '}
Un ROI superior al {formatPercent(ROI_HIGH_THRESHOLD)} anual suele indicar uno de estos casos:
Un ROI superior al {formatPercent(ROI_HIGH_THRESHOLD, 0)} anual suele indicar uno de estos casos:
</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>Frecuencia anual del proceso sobreestimada</li>

View File

@@ -1,5 +1,5 @@
import Papa from 'papaparse'
import { formatSimulationTimestamp } from '@/lib/format'
import { formatPayback, formatSimulationTimestamp } from '@/lib/format'
import { calculateRoi } from '@/domain/roi'
import { buildFileName } from './slug'
import type { Process, Simulation, Resource, Activity, ActivitySimResult } from '@/domain/types'
@@ -109,12 +109,6 @@ function generateRoiCsvContent(
automationInvestment: process.automationInvestment,
})
const paybackStr = !Number.isFinite(roi.paybackMonths)
? 'N/A'
: roi.paybackMonths === 0
? '0'
: formatDecimalForCsv(roi.paybackMonths, currency)
const roiStr = !Number.isFinite(roi.roiAnnualPercent)
? 'Infinito'
: formatDecimalForCsv(roi.roiAnnualPercent, currency)
@@ -128,7 +122,8 @@ function generateRoiCsvContent(
`# Costo total automatizado (${currency}): ${formatDecimalForCsv(resultAutomated.totalCost, currency)}`,
`# Ahorro por ejecución (${currency}): ${formatDecimalForCsv(roi.savingsPerExecution, currency)}`,
`# Ahorro anual (${currency}): ${formatDecimalForCsv(roi.annualSavings, currency)}`,
`# Payback (meses): ${paybackStr}`,
`# Payback (meses): ${formatPayback(roi.paybackMonths)}`,
`# Payback (meses, raw): ${Number.isFinite(roi.paybackMonths) ? roi.paybackMonths.toFixed(4) : roi.paybackMonths}`,
`# Ahorro acumulado a ${process.analysisHorizonYears} años (${currency}): ${formatDecimalForCsv(roi.cumulativeSavingsHorizon, currency)}`,
`# ROI anualizado (%): ${roiStr}`,
`# Frecuencia anual: ${process.annualFrequency}`,

View File

@@ -65,7 +65,7 @@ async function exportScenarioPdf(
title: `Análisis de costos - ${process.name}`,
author: process.clientName || '',
subject: 'Simulación de proceso BPMN',
creator: 'Process Cost Platform',
creator: 'InQ ROI',
keywords: 'bpmn, costos, simulación',
})
@@ -124,7 +124,7 @@ async function exportScenarioPdf(
docAny.addPage()
drawMethodologySection(docAny, process, PDF.margin)
addPageNumbers(docAny, 'Process Cost Platform', simulation.executedAt)
addPageNumbers(docAny, 'InQ ROI · Powered by InQuality', simulation.executedAt)
}
// ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
@@ -153,7 +153,7 @@ async function exportRoiPdf(
title: `Análisis de ROI - ${process.name}`,
author: process.clientName || '',
subject: 'Análisis de retorno de inversión para automatización BPMN',
creator: 'Process Cost Platform',
creator: 'InQ ROI',
keywords: 'bpmn, roi, automatización, payback',
})
@@ -175,7 +175,7 @@ async function exportRoiPdf(
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF.slate500)
doc.text(' Actividades en negrita están marcadas como automatizables', PDF.margin, y + 3)
doc.text('(*) Actividades en negrita están marcadas como automatizables', PDF.margin, y + 3)
y += 8
const tableConfig = buildComparisonTableConfig(
@@ -196,7 +196,7 @@ async function exportRoiPdf(
doc.addPage()
drawRoiMethodologySection(doc, process, PDF.margin)
addPageNumbers(doc, 'Process Cost Platform · ROI', simulation.executedAt)
addPageNumbers(doc, 'InQ ROI · Powered by InQuality', simulation.executedAt)
}
function formatNum(n: number): string {

View File

@@ -1,4 +1,4 @@
import { formatCurrency, formatPercent } from '@/lib/format'
import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
import type { ActivitySimResult, Process, Activity } from '@/domain/types'
import type { RoiResult } from '@/domain/roi'
import { PDF, type DocLike } from './pdf-sections'
@@ -78,13 +78,7 @@ export function drawRoiKpiCards(
const cardW = 52, cardH = 28, gapX = 7, gapY = 5
const col1 = PDF.margin, col2 = PDF.margin + cardW + gapX, col3 = PDF.margin + (cardW + gapX) * 2
const paybackStr = !Number.isFinite(roi.paybackMonths)
? 'No se recupera'
: roi.paybackMonths === 0
? 'Inmediato'
: roi.paybackMonths < 24
? `${roi.paybackMonths.toFixed(1)} meses`
: `${roi.paybackYears.toFixed(1)} años`
const paybackStr = formatPayback(roi.paybackMonths)
const roiStr = !Number.isFinite(roi.roiAnnualPercent)
? '∞'
@@ -94,7 +88,7 @@ export function drawRoiKpiCards(
// Fila 1: 3 cards
{ label: 'AHORRO POR EJECUCIÓN', value: formatCurrency(roi.savingsPerExecution, currency), sub: `${formatPercent(roi.savingsPerExecutionPercent)} vs. actual`, positive: roi.savingsPerExecution >= 0 },
{ label: 'AHORRO ANUAL', value: formatCurrency(roi.annualSavings, currency), sub: `× ${horizonYears} años`, positive: roi.annualSavings >= 0 },
{ label: 'PAYBACK', value: paybackStr, sub: roi.breaksEvenInHorizon ? 'Dentro del horizonte' : 'Fuera del horizonte', positive: roi.breaksEvenInHorizon },
{ label: 'PAYBACK', value: paybackStr, sub: roi.breaksEvenInHorizon ? 'Dentro del horizonte' : 'Fuera del horizonte', positive: roi.breaksEvenInHorizon },
// Fila 2: 2 cards + 1 vacía
{ label: `ACUMULADO (${horizonYears} AÑOS)`, value: formatCurrency(roi.cumulativeSavingsHorizon, currency), sub: 'neto de inversión', positive: roi.cumulativeSavingsHorizon >= 0 },
{ label: 'ROI ANUAL', value: roiStr, sub: 'retorno sobre la inversión', positive: roi.roiAnnualPercent >= 0 },
@@ -127,7 +121,7 @@ export function drawRoiKpiCards(
// Valor
const valueColor = kpi.positive ? [16, 185, 129] as [number,number,number] : [239, 68, 68] as [number,number,number]
doc.setFont('helvetica', 'bold')
doc.setFontSize(kpi.value.length > 14 ? 9 : 11)
doc.setFontSize(kpi.value.length > 22 ? 7 : kpi.value.length > 14 ? 9 : 11)
doc.setTextColor(...valueColor)
doc.text(kpi.value, x + 3, y + 16)
@@ -356,7 +350,7 @@ export function drawTransparencyBlock(
if (roiHigh) {
blocks.push({
text: doc.splitTextToSize(
`ROI inusualmente alto detectado: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente. Revisá antes de presentar al cliente.`,
`ROI inusualmente alto detectado: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD, 0)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente. Revisá antes de presentar al cliente.`,
boxW - 8
),
fontSize: 8,

View File

@@ -25,6 +25,32 @@ export function formatMinutes(minutes: number): string {
return m > 0 ? `${h}h ${m}min` : `${h}h`
}
export function formatPayback(months: number): string {
if (Number.isNaN(months) || !isFinite(months)) {
return 'No recupera en horizonte'
}
if (months < 0) {
return 'Sin payback (costo aumenta)'
}
if (months === 0) {
return 'Inmediato'
}
if (months < 0.1) {
return '< 1 día'
}
if (months < 1) {
return `< 1 mes (${months.toFixed(2)} meses)`
}
if (months < 12) {
return `${months.toFixed(1)} meses`
}
if (months < 24) {
const years = (months / 12).toFixed(1)
return `${months.toFixed(1)} meses (~${years} años)`
}
return `${(months / 12).toFixed(1)} años`
}
// Formato de timestamp de simulación: "13 de mayo de 2026 a las 09:45"
// Locale explícito es-PY — nunca depende del locale del browser del cliente.
export function formatSimulationTimestamp(ts: number): { date: string; time: string } {