feat(sprint-1-5): cierre Etapa 2 - formatPayback + fixes de marca
[
This commit is contained in:
@@ -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)`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
Reference in New Issue
Block a user