Antes de la última revisión de etapa 4. Sprint 1. Previo al ajuste de look n feel de app, reportes, nombre y colores.

This commit is contained in:
2026-05-15 22:14:53 -03:00
parent b6f31f4371
commit 8b71c6ee13
54 changed files with 6497 additions and 261 deletions

View File

@@ -1,7 +1,10 @@
import Papa from 'papaparse'
import { formatSimulationTimestamp } from '@/lib/format'
import { calculateRoi } from '@/domain/roi'
import { buildFileName } from './slug'
import type { Process, Simulation, Resource, ActivitySimResult } from '@/domain/types'
import type { Process, Simulation, Resource, Activity, ActivitySimResult } from '@/domain/types'
export type ReportTab = 'actual' | 'automatizado' | 'roi'
// Monedas que usan coma como separador decimal (necesitan punto y coma en el CSV)
const COMMA_DECIMAL_CURRENCIES = new Set(['PYG', 'BRL', 'EUR', 'ARS', 'COP', 'MXN', 'CLP'])
@@ -18,19 +21,11 @@ function formatDecimalForCsv(value: number, currency: string): string {
return formatted
}
function buildResourcesCell(
act: ActivitySimResult,
resources: Resource[]
): string {
function buildResourcesCell(act: ActivitySimResult, resources: Resource[]): string {
if (act.resourceCostBreakdown.length === 0) return ''
const resourceById = new Map(resources.map((r) => [r.id, r]))
return act.resourceCostBreakdown
.map((rb) => {
const res = resourceById.get(rb.resourceId)
if (!res) return rb.resourceName
// Calcular % de utilización desde el costo relativo
return res.name
})
.map((rb) => resourceById.get(rb.resourceId)?.name ?? rb.resourceName)
.join('; ')
}
@@ -38,42 +33,43 @@ export interface CsvExportParams {
process: Process
simulation: Simulation
resources: Resource[]
tab?: ReportTab // default: 'actual'
activities?: Activity[] // requerido para tab='roi'
}
export function generateCsvContent(params: CsvExportParams): string {
const { process, simulation, resources } = params
const { process, simulation, resources, tab = 'actual', activities = [] } = params
const { currency } = process
const result = simulation.result
if (tab === 'roi') {
return generateRoiCsvContent(process, simulation, resources, activities, currency)
}
const result = tab === 'automatizado'
? (simulation.resultAutomated ?? simulation.result)
: simulation.result
const delimiter = getDelimiter(currency)
const { date } = formatSimulationTimestamp(simulation.executedAt)
// ── Filas de metadata como comentarios ──────────────────────────────────
const meta = [
`# Proceso: ${process.name}`,
`# Cliente: ${process.clientName || 'N/A'}`,
`# Fecha simulación: ${date}`,
`# Moneda: ${currency}`,
`# Escenario: ${tab === 'automatizado' ? 'Automatizado' : 'Actual'}`,
`# Costo total (${currency}): ${formatDecimalForCsv(result.totalCost, currency)}`,
`# Costo directo: ${formatDecimalForCsv(result.totalDirectCost, currency)}`,
`# Costo indirecto (overhead ${(process.overheadPercentage * 100).toFixed(0)}%): ${formatDecimalForCsv(result.totalIndirectCost, currency)}`,
'',
].join('\r\n')
// ── Headers ──────────────────────────────────────────────────────────────
const headers = [
'ID BPMN',
'Actividad',
'Tipo',
`Costo directo (${currency})`,
`Costo indirecto (${currency})`,
`Costo total (${currency})`,
'% del total',
'Tiempo (min)',
'Ejecuciones esperadas',
'Recursos asignados',
'ID BPMN', 'Actividad', 'Tipo',
`Costo directo (${currency})`, `Costo indirecto (${currency})`, `Costo total (${currency})`,
'% del total', 'Tiempo (min)', 'Ejecuciones esperadas', 'Recursos asignados',
]
// ── Filas ─────────────────────────────────────────────────────────────────
const rows = result.perActivity.map((act) => [
act.bpmnElementId,
act.activityName,
@@ -87,22 +83,135 @@ export function generateCsvContent(params: CsvExportParams): string {
buildResourcesCell(act, resources),
])
const csvBody = Papa.unparse(
{ fields: headers, data: rows },
{ delimiter, newline: '\r\n', quotes: true }
)
const csvBody = Papa.unparse({ fields: headers, data: rows }, { delimiter, newline: '\r\n', quotes: true })
return '' + meta + csvBody
}
// BOM UTF-8 + metadata + datos
// ─── CSV de ROI: 16 columnas + metadata extendida ────────────────────────────
function generateRoiCsvContent(
process: Process,
simulation: Simulation,
resources: Resource[],
activities: Activity[],
currency: string
): string {
const resultActual = simulation.result
const resultAutomated = simulation.resultAutomated ?? simulation.result
const delimiter = getDelimiter(currency)
const { date } = formatSimulationTimestamp(simulation.executedAt)
const roi = calculateRoi({
costPerExecutionActual: resultActual.totalCost,
costPerExecutionAutomated: resultAutomated.totalCost,
annualFrequency: process.annualFrequency,
analysisHorizonYears: process.analysisHorizonYears,
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)
const meta = [
`# Proceso: ${process.name}`,
`# Cliente: ${process.clientName || 'N/A'}`,
`# Fecha simulación: ${date}`,
`# Moneda: ${currency}`,
`# Costo total actual (${currency}): ${formatDecimalForCsv(resultActual.totalCost, currency)}`,
`# 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}`,
`# Ahorro acumulado a ${process.analysisHorizonYears} años (${currency}): ${formatDecimalForCsv(roi.cumulativeSavingsHorizon, currency)}`,
`# ROI anualizado (%): ${roiStr}`,
`# Frecuencia anual: ${process.annualFrequency}`,
`# Horizonte de análisis: ${process.analysisHorizonYears} años`,
`# Inversión en automatización (${currency}): ${formatDecimalForCsv(process.automationInvestment, currency)}`,
'',
].join('\r\n')
// Mapas para lookup eficiente
const autoByBpmnId = new Map(resultAutomated.perActivity.map((a) => [a.bpmnElementId, a]))
const actByBpmnId = new Map(activities.map((a) => [a.bpmnElementId, a]))
const headers = [
'ID BPMN',
'Actividad',
'Tipo',
'Automatizable',
'Probabilidad (%)',
`Costo actual directo (${currency})`,
`Costo actual indirecto (${currency})`,
`Costo actual total (${currency})`,
'Tiempo actual (min)',
`Costo automatizado directo (${currency})`,
`Costo automatizado indirecto (${currency})`,
`Costo automatizado total (${currency})`,
'Tiempo automatizado (min)',
`Ahorro (${currency})`,
'Ahorro (%)',
'Recursos asignados',
]
const rows = [...resultActual.perActivity]
.sort((a, b) => {
const aSavings = a.expectedTotalCost - (autoByBpmnId.get(a.bpmnElementId)?.expectedTotalCost ?? a.expectedTotalCost)
const bSavings = b.expectedTotalCost - (autoByBpmnId.get(b.bpmnElementId)?.expectedTotalCost ?? b.expectedTotalCost)
return bSavings - aSavings
})
.map((act) => {
const autoAct = autoByBpmnId.get(act.bpmnElementId)
const domainAct = actByBpmnId.get(act.bpmnElementId)
const isAutomatable = domainAct?.automatable ?? false
const automatedTime = domainAct?.automatedTimeMinutes ?? act.executionTimeMinutes
const autoCostDirect = autoAct?.expectedDirectCost ?? act.expectedDirectCost
const autoCostIndirect = autoAct?.expectedIndirectCost ?? act.expectedIndirectCost
const autoCostTotal = autoAct?.expectedTotalCost ?? act.expectedTotalCost
const savings = act.expectedTotalCost - autoCostTotal
const savingsPct = act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
return [
act.bpmnElementId,
act.activityName,
act.bpmnElementId.includes('subprocess') ? 'subproceso' : 'tarea',
isAutomatable ? 'true' : 'false',
formatDecimalForCsv(act.executionProbability * 100, currency),
formatDecimalForCsv(act.expectedDirectCost, currency),
formatDecimalForCsv(act.expectedIndirectCost, currency),
formatDecimalForCsv(act.expectedTotalCost, currency),
formatDecimalForCsv(act.executionTimeMinutes, currency),
formatDecimalForCsv(autoCostDirect, currency),
formatDecimalForCsv(autoCostIndirect, currency),
formatDecimalForCsv(autoCostTotal, currency),
formatDecimalForCsv(automatedTime, currency),
formatDecimalForCsv(savings, currency),
formatDecimalForCsv(savingsPct, currency),
buildResourcesCell(act, resources),
]
})
const csvBody = Papa.unparse({ fields: headers, data: rows }, { delimiter, newline: '\r\n', quotes: true })
return '' + meta + csvBody
}
export function exportToCsv(params: CsvExportParams): void {
const content = generateCsvContent(params)
const tab = params.tab ?? 'actual'
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
const fileName = buildFileName(
params.process.name,
params.process.clientName,
new Date(params.simulation.executedAt),
'csv'
'csv',
suffix
)
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })

View File

@@ -1,23 +1,30 @@
import { buildFileName } from './slug'
import { calculateRoi } from '@/domain/roi'
import {
drawHeader, drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
drawAnalysisSection, drawMethodologySection, addPageNumbers, PDF,
} from './pdf-sections'
import type { Process, Simulation, Resource } from '@/domain/types'
import {
drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig,
drawRoiAnalysisPage, drawRoiMethodologySection,
} from './pdf-sections-roi'
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
export type ReportTab = 'actual' | 'automatizado' | 'roi'
export interface PdfExportParams {
process: Process
simulation: Simulation
resources: Resource[]
heatmapImageData: string | null // data URL de la imagen capturada del canvas
heatmapImageData: string | null
tab?: ReportTab // default: 'actual'
activities?: Activity[] // requerido para tab='roi' (transparencia + tabla)
}
export async function exportToPdf(params: PdfExportParams): Promise<void> {
const { process, simulation, resources, heatmapImageData } = params
const result = simulation.result
const { process, simulation, resources, heatmapImageData, tab = 'actual', activities = [] } = params
const currency = process.currency
// Carga dinámica: jsPDF + autotable solo se descargan al hacer click
const [{ jsPDF }, { default: autoTable }] = await Promise.all([
import('jspdf'),
import('jspdf-autotable'),
@@ -25,8 +32,36 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
// Metadata del PDF
doc.setProperties({
if (tab === 'roi') {
await exportRoiPdf(doc, autoTable, process, simulation, activities, currency)
} else {
const result = tab === 'automatizado'
? (simulation.resultAutomated ?? simulation.result)
: simulation.result
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency)
}
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
const fileName = buildFileName(process.name, process.clientName, new Date(simulation.executedAt), 'pdf', suffix)
doc.save(fileName)
}
// ─── Escenario único (actual o automatizado) ──────────────────────────────────
async function exportScenarioPdf(
doc: any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
autoTable: Function,
process: Process,
simulation: Simulation,
result: Simulation['result'],
resources: Resource[],
heatmapImageData: string | null,
currency: string
): Promise<void> {
const docAny = doc as any
docAny.setProperties({
title: `Análisis de costos - ${process.name}`,
author: process.clientName || '',
subject: 'Simulación de proceso BPMN',
@@ -34,44 +69,37 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
keywords: 'bpmn, costos, simulación',
})
// ── PÁGINA 1: Header + KPIs + Heatmap ────────────────────────────────────
let y = drawHeader(doc as any, process, simulation.executedAt)
y = drawKpiCards(doc as any, result, currency, y)
let y = drawHeader(docAny, process, simulation.executedAt)
y = drawKpiCards(docAny, result, currency, y)
y += 4
// Título del heatmap
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('Mapa de calor de costos', PDF.margin, y)
docAny.setFont('helvetica', 'bold')
docAny.setFontSize(10)
docAny.setTextColor(...PDF.slate900)
docAny.text('Mapa de calor de costos', PDF.margin, y)
y += 5
y = drawHeatmapImage(doc as any, heatmapImageData, y)
y = drawHeatmapLegend(doc as any, y)
y = drawHeatmapImage(docAny, heatmapImageData, y)
y = drawHeatmapLegend(docAny, y)
y += 4
// ── PÁGINA 2: Análisis ────────────────────────────────────────────────────
doc.addPage()
docAny.addPage()
y = PDF.margin
y = drawAnalysisSection(doc as any, result, currency, y)
y = drawAnalysisSection(docAny, result, currency, y)
y += 6
// ── PÁGINA 3+: Tabla detalle (autotable maneja paginación automática) ─────
void resources // disponible para uso futuro (recursos en tabla de actividades)
void resources
const RA = 'right' as const
const tableHead = [
[
{ content: 'Actividad', styles: { cellWidth: 48 } },
{ content: `Dir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
{ content: `Indir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
{ content: `Total (${currency})`, styles: { halign: RA, cellWidth: 28 } },
{ content: '% total', styles: { halign: RA, cellWidth: 16 } },
{ content: 'Tiempo', styles: { halign: RA, cellWidth: 14 } },
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
],
]
const tableHead = [[
{ content: 'Actividad', styles: { cellWidth: 48 } },
{ content: `Dir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
{ content: `Indir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
{ content: `Total (${currency})`, styles: { halign: RA, cellWidth: 28 } },
{ content: '% total', styles: { halign: RA, cellWidth: 16 } },
{ content: 'Tiempo', styles: { halign: RA, cellWidth: 14 } },
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
]]
const tableBody = result.perActivity.map((act) => [
{ content: act.activityName },
@@ -83,40 +111,92 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA } },
])
autoTable(doc, {
autoTable(docAny, {
head: tableHead,
body: tableBody,
startY: y,
margin: { left: PDF.margin, right: PDF.margin },
styles: { fontSize: 8, cellPadding: 2.5, overflow: 'ellipsize' },
headStyles: {
fillColor: PDF.blue,
textColor: [255, 255, 255],
fontStyle: 'bold',
fontSize: 8,
},
headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 },
alternateRowStyles: { fillColor: PDF.slate50 },
columnStyles: { 0: { cellWidth: 48 } },
didDrawPage: () => {
// Placeholder — los footers se añaden al final con addPageNumbers
},
})
// ── PÁGINA FINAL: Metodología ────────────────────────────────────────────
docAny.addPage()
drawMethodologySection(docAny, process, PDF.margin)
addPageNumbers(docAny, 'Process Cost Platform', simulation.executedAt)
}
// ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
async function exportRoiPdf(
doc: any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
autoTable: Function,
process: Process,
simulation: Simulation,
activities: Activity[],
currency: string
): Promise<void> {
const result = simulation.result
const resultAutomated = simulation.resultAutomated ?? result
const roi = calculateRoi({
costPerExecutionActual: result.totalCost,
costPerExecutionAutomated: resultAutomated.totalCost,
annualFrequency: process.annualFrequency,
analysisHorizonYears: process.analysisHorizonYears,
automationInvestment: process.automationInvestment,
})
doc.setProperties({
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',
keywords: 'bpmn, roi, automatización, payback',
})
// ── Página 1: Header + resumen ejecutivo + 5 KPI cards ─────────────────────
let y = drawHeader(doc, process, simulation.executedAt)
y = drawRoiExecutiveSummary(doc, process, roi, currency, y)
y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y)
// ── Página 2: Tabla comparativa ────────────────────────────────────────────
doc.addPage()
drawMethodologySection(doc as any, process, PDF.margin)
y = PDF.margin
// ── FOOTER en todas las páginas ───────────────────────────────────────────
addPageNumbers(doc as any, 'Process Cost Platform', simulation.executedAt)
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
doc.setTextColor(...PDF.slate900)
doc.text('Comparación por actividad', PDF.margin, y)
y += 4
// ── Descargar ─────────────────────────────────────────────────────────────
const fileName = buildFileName(
process.name,
process.clientName,
new Date(simulation.executedAt),
'pdf'
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)
y += 8
const tableConfig = buildComparisonTableConfig(
result.perActivity,
resultAutomated.perActivity,
activities,
currency,
y
)
doc.save(fileName)
autoTable(doc, tableConfig)
// ── Página 3: Análisis textual + transparencia ─────────────────────────────
doc.addPage()
y = PDF.margin
drawRoiAnalysisPage(doc, roi, process, result.perActivity, resultAutomated.perActivity, activities, currency, y)
// ── Página 4: Metodología ──────────────────────────────────────────────────
doc.addPage()
drawRoiMethodologySection(doc, process, PDF.margin)
addPageNumbers(doc, 'Process Cost Platform · ROI', simulation.executedAt)
}
function formatNum(n: number): string {

View File

@@ -0,0 +1,458 @@
import { formatCurrency, 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'
const ROI_HIGH_THRESHOLD = 1000
// ─── Resumen ejecutivo ────────────────────────────────────────────────────────
export function drawRoiExecutiveSummary(
doc: DocLike,
process: Process,
roi: RoiResult,
currency: string,
startY: number
): number {
let y = startY
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
doc.setTextColor(...PDF.slate900)
doc.text('Análisis de retorno de inversión', PDF.margin, y)
y += 6
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.2)
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF.slate700)
const investmentStr = process.automationInvestment > 0
? `inversión estimada de ${formatCurrency(process.automationInvestment, currency)}`
: 'inversión aún no configurada'
const summary = `Análisis de retorno de inversión para automatización del proceso "${process.name}"` +
(process.clientName ? ` del cliente ${process.clientName}` : '') +
`, con frecuencia anual de ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones,` +
` horizonte de ${process.analysisHorizonYears} años e ${investmentStr}.`
const summaryLines = doc.splitTextToSize(summary, PDF.contentW)
doc.text(summaryLines, PDF.margin, y)
y += summaryLines.length * 4.5 + 3
// Sub-resumen de resultado
const resultText = roi.savingsPerExecution > 0
? `Resultado: ahorro de ${formatCurrency(roi.savingsPerExecution, currency)} por ejecución (${formatPercent(roi.savingsPerExecutionPercent)} de reducción). ` +
(Number.isFinite(roi.paybackMonths)
? roi.breaksEvenInHorizon
? `La inversión se recupera en ${roi.paybackMonths < 24 ? `${roi.paybackMonths.toFixed(1)} meses` : `${roi.paybackYears.toFixed(1)} años`}, dentro del horizonte de análisis.`
: `El payback ocurre en ${roi.paybackYears.toFixed(1)} años, fuera del horizonte analizado.`
: 'Sin ahorro neto — la automatización no se recupera con los datos actuales.')
: 'El escenario automatizado no genera ahorro con los datos actuales. Revisá los costos configurados.'
const resultLines = doc.splitTextToSize(resultText, PDF.contentW)
doc.setFont('helvetica', 'italic')
doc.setFontSize(8.5)
doc.setTextColor(...PDF.slate500)
doc.text(resultLines, PDF.margin, y)
y += resultLines.length * 4 + 4
doc.setFont('helvetica', 'normal')
doc.setTextColor(...PDF.slate700)
return y
}
// ─── 5 KPI cards (grid 2×3 — 5 celdas, 6ta vacía o con meta-info) ────────────
export function drawRoiKpiCards(
doc: DocLike,
roi: RoiResult,
currency: string,
horizonYears: number,
startY: number
): number {
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 roiStr = !Number.isFinite(roi.roiAnnualPercent)
? '∞'
: `${roi.roiAnnualPercent.toFixed(0)}%`
const kpis = [
// 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 },
// 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 },
]
const cols = [col1, col2, col3]
let maxY = startY
kpis.forEach((kpi, idx) => {
const row = Math.floor(idx / 3)
const col = idx % 3
const x = cols[col]
const y = startY + row * (cardH + gapY)
// Fondo
doc.setFillColor(...PDF.slate50)
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'F')
// Borde
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.2)
doc.roundedRect(x, y, cardW, cardH, 2, 2, 'S')
// Label
doc.setFont('helvetica', 'normal')
doc.setFontSize(5.5)
doc.setTextColor(...PDF.slate500)
doc.text(kpi.label, x + 3, y + 5.5)
// 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.setTextColor(...valueColor)
doc.text(kpi.value, x + 3, y + 16)
// Sub-texto
doc.setFont('helvetica', 'normal')
doc.setFontSize(6)
doc.setTextColor(...PDF.slate500)
const subLines = doc.splitTextToSize(kpi.sub, cardW - 6)
doc.text(subLines, x + 3, y + 22)
maxY = Math.max(maxY, y + cardH)
})
return maxY + 6
}
// ─── Config de autotable para la tabla comparativa ────────────────────────────
// Se llama desde pdf-export.ts donde autoTable ya está importado dinámicamente.
export function buildComparisonTableConfig(
perActivityActual: ActivitySimResult[],
perActivityAutomated: ActivitySimResult[],
activities: Activity[],
currency: string,
startY: number
): Record<string, unknown> {
const autoByBpmnId = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
const isAutomatableMap = new Map(activities.map((a) => [a.bpmnElementId, a.automatable]))
const RA = 'right' as const
const head = [[
{ content: 'Actividad', styles: { cellWidth: 42 } },
{ content: `Act. (${currency})`, styles: { halign: RA, cellWidth: 22 } },
{ content: `Auto. (${currency})`, styles: { halign: RA, cellWidth: 22 } },
{ content: `Ahorro (${currency})`, styles: { halign: RA, cellWidth: 22 } },
{ content: 'Ahorro %', styles: { halign: RA, cellWidth: 16 } },
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
]]
const body = [...perActivityActual]
.sort((a, b) => {
const aCost = autoByBpmnId.get(a.bpmnElementId)?.expectedTotalCost ?? a.expectedTotalCost
const bCost = autoByBpmnId.get(b.bpmnElementId)?.expectedTotalCost ?? b.expectedTotalCost
return (b.expectedTotalCost - bCost) - (a.expectedTotalCost - aCost)
})
.map((act) => {
const auto = autoByBpmnId.get(act.bpmnElementId)
const autoCost = auto?.expectedTotalCost ?? act.expectedTotalCost
const savings = act.expectedTotalCost - autoCost
const savingsPct = act.expectedTotalCost > 0 ? (savings / act.expectedTotalCost) * 100 : 0
const isAutomatable = isAutomatableMap.get(act.bpmnElementId) ?? false
const savingsColor = savings > 0 ? [16, 185, 129] : savings < 0 ? [239, 68, 68] : [100, 116, 139]
const rowFill = isAutomatable ? [255, 255, 255] : [248, 250, 252]
return [
{ content: act.activityName, styles: { fillColor: rowFill, fontStyle: isAutomatable ? 'bold' : 'normal' as const } },
{ content: fmtNum(act.expectedTotalCost), styles: { halign: RA, font: 'courier', fillColor: rowFill } },
{ content: fmtNum(autoCost), styles: { halign: RA, font: 'courier', fillColor: rowFill } },
{ content: (savings >= 0 ? '+' : '') + fmtNum(savings), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: savingsColor } },
{ content: (savings >= 0 ? '+' : '') + savingsPct.toFixed(1) + '%', styles: { halign: RA, fillColor: rowFill, textColor: savingsColor } },
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA, fillColor: rowFill } },
]
})
return {
head,
body,
startY,
margin: { left: PDF.margin, right: PDF.margin },
styles: { fontSize: 7.5, cellPadding: 2, overflow: 'ellipsize' },
headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 },
columnStyles: { 0: { cellWidth: 42 } },
}
}
// ─── Página 3: resúmenes textuales + transparencia ────────────────────────────
export function drawRoiAnalysisPage(
doc: DocLike,
roi: RoiResult,
process: Process,
perActivityActual: ActivitySimResult[],
perActivityAutomated: ActivitySimResult[],
activities: Activity[],
currency: string,
startY: number
): number {
let y = startY
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
doc.setTextColor(...PDF.slate900)
doc.text('Análisis del ahorro', PDF.margin, y)
y += 6
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.2)
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
y += 6
// ── Composición del ahorro ──────────────────────────────────────────────────
const autoByBpmnId = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
const savingsPerAct = perActivityActual
.map((a) => {
const auto = autoByBpmnId.get(a.bpmnElementId)
return { name: a.activityName, savings: a.expectedTotalCost - (auto?.expectedTotalCost ?? a.expectedTotalCost) }
})
.filter((s) => s.savings > 0)
.sort((a, b) => b.savings - a.savings)
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(...PDF.slate700)
doc.text('Composición del ahorro', PDF.margin, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8.5)
if (savingsPerAct.length > 0) {
const top3 = savingsPerAct.slice(0, 3)
const top3TotalSavings = top3.reduce((s, a) => s + a.savings, 0)
const totalSavingsAll = savingsPerAct.reduce((s, a) => s + a.savings, 0)
const top3Pct = totalSavingsAll > 0 ? (top3TotalSavings / totalSavingsAll * 100).toFixed(0) : '0'
const compositionText = `El ${top3Pct}% del ahorro total proviene de las primeras ${Math.min(3, savingsPerAct.length)} actividades automatizadas:`
const compositionLines = doc.splitTextToSize(compositionText, PDF.contentW)
doc.text(compositionLines, PDF.margin, y)
y += compositionLines.length * 4.5 + 2
for (const act of top3) {
const actPct = totalSavingsAll > 0 ? (act.savings / totalSavingsAll * 100).toFixed(1) : '0'
doc.text(`${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`, PDF.margin, y)
y += 4.5
}
} else {
doc.setTextColor(...PDF.slate500)
doc.text('Sin actividades con ahorro positivo en el escenario actual.', PDF.margin, y)
doc.setTextColor(...PDF.slate700)
y += 5
}
y += 4
// ── Trayectoria del ahorro ──────────────────────────────────────────────────
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.text('Trayectoria del ahorro acumulado', PDF.margin, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8.5)
let trajectoryText: string
if (process.automationInvestment > 0 && Number.isFinite(roi.paybackMonths)) {
const recoveryStr = roi.paybackMonths < 24
? `${roi.paybackMonths.toFixed(1)} meses`
: `${roi.paybackYears.toFixed(1)} años`
const multiple = process.automationInvestment > 0
? (roi.cumulativeSavingsHorizon / process.automationInvestment + 1).toFixed(1)
: '∞'
trajectoryText =
`La inversión de ${formatCurrency(process.automationInvestment, currency)} se recupera en ${recoveryStr}. ` +
`A ${process.analysisHorizonYears} años, el ahorro acumulado proyectado es ` +
`${formatCurrency(roi.cumulativeSavingsHorizon, currency)}, equivalente a ${multiple}x la inversión inicial.`
} else if (process.automationInvestment === 0) {
trajectoryText =
`Sin inversión configurada — el ahorro anual de ${formatCurrency(roi.annualSavings, currency)} ` +
`equivale a ${formatCurrency(roi.cumulativeSavingsHorizon, currency)} acumulados en ${process.analysisHorizonYears} años.`
} else {
trajectoryText = 'Sin ahorro proyectado con los datos actuales. Revisá los costos automatizados configurados.'
}
const trajectoryLines = doc.splitTextToSize(trajectoryText, PDF.contentW)
doc.text(trajectoryLines, PDF.margin, y)
y += trajectoryLines.length * 4.5 + 6
// ── Transparencia metodológica (si aplica) ────────────────────────────────
y = drawTransparencyBlock(doc, activities, process.automationInvestment, roi, y)
return y
}
export function drawTransparencyBlock(
doc: DocLike,
activities: Activity[],
investment: number,
roi: RoiResult,
startY: number
): number {
const zeroCost = activities.filter(
(a) => a.automatable && a.automatedCostFixed === 0 && a.automatedTimeMinutes === 0
)
const investmentZero = investment === 0
const roiHigh = Number.isFinite(roi.roiAnnualPercent) && roi.roiAnnualPercent > ROI_HIGH_THRESHOLD
if (zeroCost.length === 0 && !investmentZero && !roiHigh) return startY
const boxX = PDF.margin
const boxW = PDF.contentW
// ── Paso 1: recopilar todo el contenido para calcular altura ─────────────
type TextBlock = { text: string[]; bold?: boolean; fontSize: number }
const blocks: TextBlock[] = []
blocks.push({ text: ['Transparencia metodológica'], bold: true, fontSize: 9 })
if (zeroCost.length > 0) {
const msg = zeroCost.length === 1
? `1 actividad automatizable sin costo configurado: "${zeroCost[0].name || zeroCost[0].bpmnElementId}". El ahorro puede estar inflado artificialmente.`
: `${zeroCost.length} actividades sin costo automatizado: ${zeroCost.map((a) => a.name || a.bpmnElementId).join(', ')}. El ahorro puede estar inflado.`
blocks.push({ text: doc.splitTextToSize(msg, boxW - 8), fontSize: 8 })
}
if (investmentZero) {
blocks.push({
text: doc.splitTextToSize(
'Inversión en automatización no configurada. El payback aparece como inmediato y el ROI como infinito. Configurá la inversión en el workspace para un análisis realista.',
boxW - 8
),
fontSize: 8,
})
}
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.`,
boxW - 8
),
fontSize: 8,
})
}
// Calcular altura total del bloque
let height = 6 // padding top
for (const b of blocks) {
height += b.text.length * (b.bold ? 4.5 : 3.8) + 3
}
height += 3 // padding bottom
// ── Paso 2: dibujar fondo y borde ────────────────────────────────────────
doc.setFillColor(255, 251, 235) // amber-50
doc.setDrawColor(253, 230, 138) // amber-200
doc.setLineWidth(0.3)
doc.rect(boxX, startY, boxW, height, 'FD')
// ── Paso 3: dibujar texto encima del rectángulo ───────────────────────────
let y = startY + 6
for (const b of blocks) {
if (b.bold) {
doc.setFont('helvetica', 'bold')
doc.setFontSize(b.fontSize)
doc.setTextColor(146, 64, 14) // amber-800
} else {
doc.setFont('helvetica', 'normal')
doc.setFontSize(b.fontSize)
doc.setTextColor(180, 83, 9) // amber-700
}
doc.text(b.text, boxX + 4, y)
y += b.text.length * (b.bold ? 4.5 : 3.8) + 3
}
return startY + height + 4
}
// ─── Sección metodológica ampliada para el tab ROI ────────────────────────────
export function drawRoiMethodologySection(doc: DocLike, process: Process, startY: number): number {
let y = startY
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('Nota metodológica — Análisis de ROI', PDF.margin, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF.slate500)
const sections = [
{
title: 'Costo por ejecución actual:',
body: `Σ(actividades) [costo_fijo × prob_ejecución] × (1 + overhead ${(process.overheadPercentage * 100).toFixed(0)}%). Probabilidades calculadas por propagación hacia adelante en el grafo BPMN.`,
},
{
title: 'Costo por ejecución automatizado:',
body: 'Mismo modelo, pero las actividades marcadas como automatizables usan su costo automatizado configurado. Las no marcadas mantienen el costo actual.',
},
{
title: 'Ahorro anual:',
body: `(Costo actual Costo automatizado) × ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones/año.`,
},
{
title: 'Payback (meses):',
body: `(Inversión / Ahorro anual) × 12. Nominal simple — no incluye tasa de descuento ni inflación.`,
},
{
title: 'Ahorro acumulado:',
body: `Ahorro anual × ${process.analysisHorizonYears} años Inversión inicial. Nominal, sin VP.`,
},
{
title: 'ROI anualizado:',
body: '(Ahorro anual / Inversión) × 100%. Referencia: METHODOLOGY_AUTOMATED_COST.md.',
},
]
for (const s of sections) {
doc.setFont('helvetica', 'bold')
doc.text(s.title, PDF.margin, y)
y += 4
doc.setFont('helvetica', 'normal')
const lines = doc.splitTextToSize(s.body, PDF.contentW - 4)
doc.text(lines, PDF.margin + 4, y)
y += lines.length * 3.8 + 2
}
return y
}
// ─── Helpers locales ──────────────────────────────────────────────────────────
function fmtNum(n: number): string {
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}

View File

@@ -18,7 +18,7 @@ export const PDF = {
}
// Tipo mínimo de doc para los helpers (evitar importar jsPDF en este archivo)
type DocLike = {
export type DocLike = {
setFont: (name: string, style: string) => void
setFontSize: (size: number) => void
setTextColor: (...args: number[]) => void

View File

@@ -12,7 +12,8 @@ export function buildFileName(
processName: string,
clientName: string | undefined,
date: Date,
ext: 'pdf' | 'csv'
ext: 'pdf' | 'csv',
suffix = '' // '_actual' | '_automatizado' | '_roi' | ''
): string {
const dateStr = [
date.getFullYear(),
@@ -24,5 +25,5 @@ export function buildFileName(
if (clientName?.trim()) parts.push(slugify(clientName.trim()))
parts.push(dateStr)
return `${parts.join('_')}.${ext}`
return `${parts.join('_')}${suffix}.${ext}`
}