feat(sprint-1.5): rediseño visual PDF ROI + identidad InQ (Etapas 4-7)
- Header/footer compartidos con paleta InQ naranja en los 3 PDFs (Etapa 4) - Layout híbrido portrait+landscape en PDFs Actual/Automatizado: página 2 landscape para diagrama BPMN heatmap (Etapa 5) - Página 1 PDF ROI rediseñada: header denso, banner narrativo, KPI Hero con gradiente naranja→magenta, grid 2×2 de KPIs secundarios, Top 3 actividades por ahorro (Etapa 6) - Páginas 2-5 PDF ROI con identidad InQ: tabla con paleta naranja, análisis del ahorro, nota metodológica, trayectoria del ahorro acumulado con gráfico SVG nativo (Etapa 7) - 500 tests verdes (+ 34 tests nuevos del Sprint 1.5) - BACKLOG.md, TECH_DEBT.md, OBSERVATIONS.md: sistema de planificación reemplaza TODO.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { buildFileName } from './slug'
|
||||
import { formatPayback } from '@/lib/format'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import {
|
||||
drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
|
||||
drawAnalysisSection, drawMethodologySection, PDF,
|
||||
} from './pdf-sections'
|
||||
import {
|
||||
drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig,
|
||||
drawRoiAnalysisPage, drawRoiMethodologySection,
|
||||
drawRoiPage1, buildComparisonTableConfig,
|
||||
drawRoiAnalysisPage, drawRoiMethodologySection, drawTrajectoryChart,
|
||||
} from './pdf-sections-roi'
|
||||
import { drawSharedHeader, applyFootersToAllPages, PDF_COLORS } from './pdf-sections-shared'
|
||||
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
|
||||
@@ -72,24 +73,25 @@ async function exportScenarioPdf(
|
||||
|
||||
const headerOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
|
||||
|
||||
// Página 1
|
||||
// ── Página 1 (portrait): KPI cards ──────────────────────────────────────────
|
||||
let y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 1 })
|
||||
y = drawKpiCards(docAny, result, currency, y)
|
||||
y += 4
|
||||
|
||||
// ── Página 2 (landscape): Diagrama BPMN con heatmap ─────────────────────────
|
||||
// Método obligatorio: doc.addPage('a4', 'landscape') — ver ETAPA_5_PROMPT §3.1
|
||||
docAny.addPage('a4', 'landscape')
|
||||
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 2 })
|
||||
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(docAny, heatmapImageData, y)
|
||||
y = drawHeatmapLegend(docAny, y)
|
||||
y += 4
|
||||
|
||||
// Página 2 — análisis + tabla de costos
|
||||
docAny.addPage()
|
||||
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 2 })
|
||||
// ── Página 3 (portrait): Análisis + tabla + metodología ──────────────────────
|
||||
docAny.addPage('a4', 'portrait')
|
||||
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 3 })
|
||||
y = drawAnalysisSection(docAny, result, currency, y)
|
||||
y += 6
|
||||
|
||||
@@ -127,14 +129,15 @@ async function exportScenarioPdf(
|
||||
columnStyles: { 0: { cellWidth: 48 } },
|
||||
})
|
||||
|
||||
// Página 3 — nota metodológica
|
||||
docAny.addPage()
|
||||
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 3 })
|
||||
// ── Página 4 (portrait): Nota metodológica ────────────────────────────────────
|
||||
docAny.addPage('a4', 'portrait')
|
||||
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 4 })
|
||||
drawMethodologySection(docAny, process, y)
|
||||
|
||||
applyFootersToAllPages(docAny, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
|
||||
}
|
||||
|
||||
// ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
|
||||
// ─── ROI — 5 páginas (Etapa 6) ───────────────────────────────────────────────
|
||||
|
||||
async function exportRoiPdf(
|
||||
doc: any,
|
||||
@@ -166,14 +169,12 @@ async function exportRoiPdf(
|
||||
|
||||
const roiHeaderOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
|
||||
|
||||
// ── Página 1: Header + resumen ejecutivo + 5 KPI cards ─────────────────────
|
||||
let y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 1 })
|
||||
y = drawRoiExecutiveSummary(doc, process, roi, currency, y)
|
||||
y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y)
|
||||
// ── Página 1: Rediseño completo InQ (Etapa 6) ───────────────────────────────
|
||||
drawRoiPage1(doc, process, roi, simulation.executedAt, result.perActivity, resultAutomated.perActivity, currency)
|
||||
|
||||
// ── Página 2: Tabla comparativa ────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 2 })
|
||||
let y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 2 })
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
@@ -206,6 +207,30 @@ async function exportRoiPdf(
|
||||
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 4 })
|
||||
drawRoiMethodologySection(doc, process, y)
|
||||
|
||||
// ── Página 5: Trayectoria del ahorro acumulado (gráfico SVG nativo) ──────────
|
||||
doc.addPage()
|
||||
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 5 })
|
||||
|
||||
// Título y subtítulo de la página
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(11)
|
||||
doc.setTextColor(146, 64, 14) // #92400E naranja oscuro
|
||||
doc.text('TRAYECTORIA DEL AHORRO ACUMULADO', PDF.margin, y)
|
||||
y += 6
|
||||
|
||||
const subtitleText = roi.breaksEvenInHorizon
|
||||
? `Evolución del ahorro neto acumulado a lo largo de ${process.analysisHorizonYears} años. El payback se alcanza en ${formatPayback(roi.paybackMonths)}.`
|
||||
: `Evolución del ahorro neto acumulado a lo largo de ${process.analysisHorizonYears} años. El payback no se alcanza en el horizonte analizado.`
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
const subtitleLines = doc.splitTextToSize(subtitleText, PDF.contentW)
|
||||
doc.text(subtitleLines, PDF.margin, y)
|
||||
y += subtitleLines.length * 4.5 + 6
|
||||
|
||||
drawTrajectoryChart(doc, roi, process, currency, PDF.margin, PDF.contentW, y)
|
||||
|
||||
applyFootersToAllPages(doc, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,421 @@
|
||||
import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
|
||||
import { formatCurrency, formatPayback, formatPercent, formatSimulationTimestamp } from '@/lib/format'
|
||||
import type { ActivitySimResult, Process, Activity } from '@/domain/types'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
import { PDF, type DocLike } from './pdf-sections'
|
||||
import { PDF_COLORS } from './pdf-sections-shared'
|
||||
|
||||
// ─── Colores locales de página 1 ROI ─────────────────────────────────────────
|
||||
|
||||
const C = {
|
||||
heroOrange: [245, 152, 69] as [number, number, number], // #F59845
|
||||
heroMagenta: [237, 62, 143] as [number, number, number], // #ED3E8F
|
||||
orangeLight: [254, 243, 226] as [number, number, number], // #FEF3E2
|
||||
orangeLighter: [255, 248, 237] as [number, number, number], // #FFF8ED
|
||||
orangeBar: [254, 215, 170] as [number, number, number], // #FED7AA
|
||||
metaBg: [250, 251, 252] as [number, number, number], // #FAFBFC
|
||||
orangeDark: [180, 83, 9] as [number, number, number], // #B45309
|
||||
orangeDarker: [146, 64, 14] as [number, number, number], // #92400E
|
||||
white: [255, 255, 255] as [number, number, number],
|
||||
}
|
||||
|
||||
// ─── Página 1 ROI — diseño completo (Etapa 6) ────────────────────────────────
|
||||
|
||||
export function drawRoiPage1(
|
||||
doc: DocLike,
|
||||
process: Process,
|
||||
roi: RoiResult,
|
||||
simulatedAt: number,
|
||||
perActivityActual: ActivitySimResult[],
|
||||
perActivityAutomated: ActivitySimResult[],
|
||||
currency: string
|
||||
): void {
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const m = 20 // margin mm
|
||||
const cW = pageW - 2 * m // content width
|
||||
|
||||
let y = m
|
||||
y = _drawDenseHeader(doc, pageW, m, cW, y)
|
||||
y = _drawMetadataStrip(doc, process, simulatedAt, m, cW, y)
|
||||
y = _drawNarrativeBanner(doc, process, roi, currency, m, cW, y)
|
||||
y = _drawHeroCard(doc, roi, process, currency, m, cW, y)
|
||||
y = _drawSecondaryGrid(doc, roi, process, currency, m, cW, y)
|
||||
_drawTop3Activities(doc, perActivityActual, perActivityAutomated, currency, m, cW, y)
|
||||
}
|
||||
|
||||
// ── 5.1 Header denso de página 1 ─────────────────────────────────────────────
|
||||
|
||||
function _drawDenseHeader(
|
||||
doc: DocLike, pageW: number, m: number, cW: number, y: number
|
||||
): number {
|
||||
const right = pageW - m
|
||||
|
||||
// Izquierda: "InQ ROI" 22pt bold naranja
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(22)
|
||||
doc.setTextColor(...C.heroOrange)
|
||||
doc.text('InQ ROI', m, y)
|
||||
|
||||
// Derecha: "UN PRODUCTO DE" 9pt slate-400
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textDisabled)
|
||||
doc.text('UN PRODUCTO DE', right, y, { align: 'right' } as Record<string, unknown>)
|
||||
y += 9
|
||||
|
||||
// Izquierda: subtítulo 9pt slate-400 (margin-top 4mm ya incluido en line-height)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textDisabled)
|
||||
doc.text('ANÁLISIS DE RETORNO DE INVERSIÓN', m, y)
|
||||
|
||||
// Derecha: "InQ" 16pt bold slate-600
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(16)
|
||||
doc.setTextColor(...PDF_COLORS.textSecondary)
|
||||
doc.text('InQ', right, y, { align: 'right' } as Record<string, unknown>)
|
||||
y += 6
|
||||
|
||||
// Derecha: "InQuality" 9pt slate-500
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textTertiary)
|
||||
doc.text('InQuality', right, y, { align: 'right' } as Record<string, unknown>)
|
||||
y += 10
|
||||
|
||||
// Border-bottom naranja 0.8mm
|
||||
doc.setDrawColor(...C.heroOrange)
|
||||
doc.setLineWidth(0.8)
|
||||
doc.line(m, y, m + cW, y)
|
||||
doc.setLineWidth(0.2)
|
||||
y += 10 // margin-bottom below border
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
// ── 5.2 Strip de metadata ─────────────────────────────────────────────────────
|
||||
|
||||
function _drawMetadataStrip(
|
||||
doc: DocLike, process: Process, simulatedAt: number,
|
||||
m: number, cW: number, y: number
|
||||
): number {
|
||||
const h = 16, r = 2
|
||||
doc.setFillColor(...C.metaBg)
|
||||
doc.roundedRect(m, y, cW, h, r, r, 'F')
|
||||
|
||||
const { date } = formatSimulationTimestamp(simulatedAt)
|
||||
const col2 = m + cW * 0.5
|
||||
const col3 = m + cW * 0.75
|
||||
|
||||
const cells = [
|
||||
{ label: 'PROCESO', value: process.name, x: m + 4 },
|
||||
{ label: 'CLIENTE', value: process.clientName || '—', x: col2 + 4 },
|
||||
{ label: 'FECHA', value: date, x: col3 + 4 },
|
||||
]
|
||||
|
||||
for (const cell of cells) {
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(7)
|
||||
doc.setTextColor(...PDF_COLORS.textDisabled)
|
||||
doc.text(cell.label, cell.x, y + 5)
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textPrimary)
|
||||
const truncated = cell.value.length > 28 ? cell.value.slice(0, 26) + '…' : cell.value
|
||||
doc.text(truncated, cell.x, y + 12)
|
||||
}
|
||||
|
||||
return y + h + 6
|
||||
}
|
||||
|
||||
// ── 5.3 Banner narrativo ──────────────────────────────────────────────────────
|
||||
|
||||
function _drawNarrativeBanner(
|
||||
doc: DocLike, process: Process, roi: RoiResult, currency: string,
|
||||
m: number, cW: number, y: number
|
||||
): number {
|
||||
const padH = 5, padV = 8, borderW = 3
|
||||
|
||||
const investStr = process.automationInvestment > 0
|
||||
? `inversión estimada de ${formatCurrency(process.automationInvestment, currency)}`
|
||||
: 'inversión aún no configurada'
|
||||
|
||||
const line1 = `Análisis de retorno de inversión para automatización del proceso` +
|
||||
(process.clientName ? ` del cliente "${process.clientName}"` : '') +
|
||||
`, con frecuencia anual de ${process.annualFrequency.toLocaleString('es-PY')} ejecuciones,` +
|
||||
` horizonte de ${process.analysisHorizonYears} años e ${investStr}.`
|
||||
|
||||
const resultStr = roi.savingsPerExecution > 0
|
||||
? `Resultado: ahorro de ${formatCurrency(roi.savingsPerExecution, currency)} por ejecución` +
|
||||
` (${formatPercent(roi.savingsPerExecutionPercent)} de reducción).` +
|
||||
(Number.isFinite(roi.paybackMonths)
|
||||
? ` La inversión se recupera en ${formatPayback(roi.paybackMonths)}, ` +
|
||||
(roi.breaksEvenInHorizon ? 'dentro del horizonte de análisis.' : 'fuera del horizonte analizado.')
|
||||
: ' Sin ahorro neto con los datos actuales.')
|
||||
: 'El escenario automatizado no genera ahorro con los datos actuales.'
|
||||
|
||||
doc.setFontSize(9)
|
||||
const l1 = doc.splitTextToSize(line1, cW - padH * 2 - borderW - 4)
|
||||
const l2 = doc.splitTextToSize(resultStr, cW - padH * 2 - borderW - 4)
|
||||
const totalLines = l1.length + 1 + l2.length
|
||||
const textH = totalLines * 4.2
|
||||
const boxH = textH + padV * 2
|
||||
|
||||
// Fondo naranja ligero
|
||||
doc.setFillColor(...C.orangeLighter)
|
||||
doc.setDrawColor(...C.orangeLighter)
|
||||
doc.rect(m + borderW, y, cW - borderW, boxH, 'F')
|
||||
|
||||
// Border-left naranja
|
||||
doc.setFillColor(...C.heroOrange)
|
||||
doc.rect(m, y, borderW, boxH, 'F')
|
||||
|
||||
const tx = m + borderW + padH
|
||||
let ty = y + padV
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textSecondary)
|
||||
doc.text(l1, tx, ty)
|
||||
ty += l1.length * 4.2 + 3
|
||||
doc.text(l2, tx, ty)
|
||||
|
||||
return y + boxH + 8
|
||||
}
|
||||
|
||||
// ── 5.4 KPI Hero con gradiente ────────────────────────────────────────────────
|
||||
|
||||
function _createGradientDataUrl(wMm: number, hMm: number): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
try {
|
||||
const scale = 3
|
||||
const w = Math.round(wMm * scale)
|
||||
const h = Math.round(hMm * scale)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
const grad = ctx.createLinearGradient(0, 0, w, h) // 135deg approx
|
||||
grad.addColorStop(0, '#F59845')
|
||||
grad.addColorStop(1, '#ED3E8F')
|
||||
ctx.fillStyle = grad
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
return canvas.toDataURL('image/png')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function _drawGradientFallback(
|
||||
doc: DocLike, x: number, y: number, w: number, h: number
|
||||
): void {
|
||||
const steps = 50
|
||||
const segW = w / steps
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const t = i / (steps - 1)
|
||||
const r = Math.round(245 + (237 - 245) * t)
|
||||
const g = Math.round(152 + (62 - 152) * t)
|
||||
const b = Math.round(69 + (143 - 69) * t)
|
||||
doc.setFillColor(r, g, b)
|
||||
doc.rect(x + i * segW, y, segW + 0.1, h, 'F')
|
||||
}
|
||||
}
|
||||
|
||||
function _drawHeroCard(
|
||||
doc: DocLike, roi: RoiResult, process: Process, currency: string,
|
||||
m: number, cW: number, y: number
|
||||
): number {
|
||||
const heroH = 46
|
||||
|
||||
// Gradiente de fondo
|
||||
const dataUrl = _createGradientDataUrl(cW, heroH)
|
||||
if (dataUrl) {
|
||||
doc.addImage(dataUrl, 'PNG', m, y, cW, heroH)
|
||||
} else {
|
||||
_drawGradientFallback(doc, m, y, cW, heroH)
|
||||
}
|
||||
|
||||
// Esquinas redondeadas — simular con rect blanco en las esquinas (Nivel 1)
|
||||
// jsPDF no soporta clip en addImage, así que el gradiente queda sin border-radius.
|
||||
// Aceptable para esta etapa.
|
||||
|
||||
// Texto sobre el gradiente
|
||||
const heroX = m + 14
|
||||
let ty = y + 12
|
||||
|
||||
// Label superior
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(255, 255, 255)
|
||||
doc.text(`AHORRO PROYECTADO A ${process.analysisHorizonYears} AÑOS`, heroX, ty)
|
||||
ty += 8
|
||||
|
||||
// Cifra principal 28pt
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(28)
|
||||
doc.setTextColor(...C.white)
|
||||
doc.text(formatCurrency(roi.cumulativeSavingsHorizon, currency), heroX, ty)
|
||||
ty += 9
|
||||
|
||||
// Subtítulo
|
||||
const multiple = process.automationInvestment > 0 && Number.isFinite(roi.roiAnnualPercent)
|
||||
? `neto de inversión · ${(roi.cumulativeSavingsHorizon / process.automationInvestment + 1).toFixed(1)}× retorno`
|
||||
: 'neto de inversión'
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(255, 255, 255)
|
||||
doc.text(multiple, heroX, ty)
|
||||
|
||||
return y + heroH + 8
|
||||
}
|
||||
|
||||
// ── 5.5 Grid 2x2 KPIs secundarios ────────────────────────────────────────────
|
||||
|
||||
function _drawSecondaryGrid(
|
||||
doc: DocLike, roi: RoiResult, process: Process, currency: string,
|
||||
m: number, cW: number, y: number
|
||||
): number {
|
||||
const cardW = (cW - 4) / 2
|
||||
const cardH = 24
|
||||
const gapX = 4, gapY = 4
|
||||
const r = 2
|
||||
|
||||
const paybackStr = formatPayback(roi.paybackMonths)
|
||||
const roiStr = !Number.isFinite(roi.roiAnnualPercent) ? 'sin inv.' : `${roi.roiAnnualPercent.toFixed(0)}%`
|
||||
const savingsPct = formatPercent(roi.savingsPerExecutionPercent)
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: 'AHORRO POR EJECUCIÓN',
|
||||
value: formatCurrency(roi.savingsPerExecution, currency),
|
||||
delta: `-${savingsPct} vs. actual`,
|
||||
col: 0, row: 0,
|
||||
},
|
||||
{
|
||||
label: 'AHORRO ANUAL',
|
||||
value: formatCurrency(roi.annualSavings, currency),
|
||||
delta: `× ${process.analysisHorizonYears} años`,
|
||||
col: 1, row: 0,
|
||||
},
|
||||
{
|
||||
label: 'PAYBACK',
|
||||
value: paybackStr,
|
||||
delta: roi.breaksEvenInHorizon ? 'Dentro del horizonte' : 'Fuera del horizonte',
|
||||
col: 0, row: 1,
|
||||
},
|
||||
{
|
||||
label: 'ROI ANUAL',
|
||||
value: roiStr,
|
||||
delta: 'retorno anual',
|
||||
col: 1, row: 1,
|
||||
},
|
||||
]
|
||||
|
||||
let maxY = y
|
||||
for (const card of cards) {
|
||||
const cx = m + card.col * (cardW + gapX)
|
||||
const cy = y + card.row * (cardH + gapY)
|
||||
|
||||
doc.setFillColor(...C.orangeLight)
|
||||
doc.roundedRect(cx, cy, cardW, cardH, r, r, 'F')
|
||||
|
||||
// Label
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(6.5)
|
||||
doc.setTextColor(...C.orangeDarker)
|
||||
doc.text(card.label, cx + 5, cy + 6)
|
||||
|
||||
// Valor
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(card.value.length > 16 ? 9 : 12)
|
||||
doc.setTextColor(...PDF_COLORS.textPrimary)
|
||||
doc.text(card.value, cx + 5, cy + 14)
|
||||
|
||||
// Delta
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(7.5)
|
||||
doc.setTextColor(...C.orangeDark)
|
||||
doc.text(card.delta, cx + 5, cy + 20)
|
||||
|
||||
maxY = Math.max(maxY, cy + cardH)
|
||||
}
|
||||
|
||||
return maxY + 8
|
||||
}
|
||||
|
||||
// ── 5.6 Top 3 actividades por ahorro ─────────────────────────────────────────
|
||||
|
||||
function _drawTop3Activities(
|
||||
doc: DocLike,
|
||||
perActivityActual: ActivitySimResult[],
|
||||
perActivityAutomated: ActivitySimResult[],
|
||||
currency: string,
|
||||
m: number, cW: number, y: number
|
||||
): void {
|
||||
const autoMap = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
|
||||
const top3 = perActivityActual
|
||||
.map((a) => ({
|
||||
name: a.activityName,
|
||||
savings: a.expectedTotalCost - (autoMap.get(a.bpmnElementId)?.expectedTotalCost ?? a.expectedTotalCost),
|
||||
}))
|
||||
.filter((s) => s.savings > 0)
|
||||
.sort((a, b) => b.savings - a.savings)
|
||||
.slice(0, 3)
|
||||
|
||||
// Título
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(...PDF_COLORS.textDisabled)
|
||||
doc.text('TOP 3 ACTIVIDADES POR AHORRO', m, y)
|
||||
y += 7
|
||||
|
||||
if (top3.length === 0) {
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(...PDF_COLORS.textTertiary)
|
||||
doc.text('Sin actividades con ahorro positivo con los datos actuales.', m, y)
|
||||
return
|
||||
}
|
||||
|
||||
const maxSavings = top3[0].savings
|
||||
const totalSavings = top3.reduce((s, a) => s + a.savings, 0)
|
||||
const nameColW = cW * 0.38
|
||||
const barColW = cW * 0.38
|
||||
const valColW = cW * 0.24
|
||||
const barH = 5
|
||||
|
||||
for (const act of top3) {
|
||||
const pct = totalSavings > 0 ? (act.savings / totalSavings * 100).toFixed(0) : '0'
|
||||
const barFill = maxSavings > 0 ? (act.savings / maxSavings) * barColW : 0
|
||||
|
||||
// Nombre (truncado a 30 chars)
|
||||
const name = act.name.length > 30 ? act.name.slice(0, 28) + '…' : act.name
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textPrimary)
|
||||
doc.text(name, m, y + barH - 1)
|
||||
|
||||
// Barra de fondo
|
||||
doc.setFillColor(...C.orangeBar)
|
||||
doc.roundedRect(m + nameColW, y, barColW, barH, 1, 1, 'F')
|
||||
|
||||
// Barra de fill
|
||||
if (barFill > 0) {
|
||||
doc.setFillColor(...C.heroOrange)
|
||||
doc.roundedRect(m + nameColW, y, barFill, barH, 1, 1, 'F')
|
||||
}
|
||||
|
||||
// Valor + porcentaje
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(...C.orangeDark)
|
||||
const valStr = `${formatCurrency(act.savings, currency)} (${pct}%)`
|
||||
doc.text(valStr, m + nameColW + barColW + valColW, y + barH - 1, { align: 'right' } as Record<string, unknown>)
|
||||
|
||||
y += barH + 5
|
||||
}
|
||||
}
|
||||
|
||||
const ROI_HIGH_THRESHOLD = 1000
|
||||
|
||||
// ─── Resumen ejecutivo ────────────────────────────────────────────────────────
|
||||
@@ -178,14 +590,22 @@ export function buildComparisonTableConfig(
|
||||
|
||||
const savingsColor = savings > 0 ? [16, 185, 129] : savings < 0 ? [239, 68, 68] : [100, 116, 139]
|
||||
const rowFill = isAutomatable ? [255, 255, 255] : [248, 250, 252]
|
||||
const nonAutoText = [148, 163, 184] // slate-400, más tenue
|
||||
|
||||
const savingsCell = isAutomatable
|
||||
? { content: (savings >= 0 ? '+' : '') + fmtNum(savings), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: savingsColor } }
|
||||
: { content: '—', styles: { halign: RA, fillColor: rowFill, textColor: nonAutoText } }
|
||||
const savingsPctCell = isAutomatable
|
||||
? { content: (savings >= 0 ? '+' : '') + savingsPct.toFixed(1) + '%', styles: { halign: RA, fillColor: rowFill, textColor: savingsColor } }
|
||||
: { content: '—', styles: { halign: RA, fillColor: rowFill, textColor: nonAutoText } }
|
||||
|
||||
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 } },
|
||||
{ content: act.activityName, styles: { fillColor: rowFill, fontStyle: isAutomatable ? 'bold' : 'normal' as const, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
|
||||
{ content: fmtNum(act.expectedTotalCost), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
|
||||
{ content: fmtNum(autoCost), styles: { halign: RA, font: 'courier', fillColor: rowFill, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
|
||||
savingsCell,
|
||||
savingsPctCell,
|
||||
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA, fillColor: rowFill, textColor: isAutomatable ? [15, 23, 42] : nonAutoText } },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -194,8 +614,15 @@ export function buildComparisonTableConfig(
|
||||
body,
|
||||
startY,
|
||||
margin: { left: PDF.margin, right: PDF.margin },
|
||||
styles: { fontSize: 7.5, cellPadding: 2, overflow: 'ellipsize' },
|
||||
headStyles: { fillColor: PDF_COLORS.inqOrange, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 },
|
||||
styles: { fontSize: 7.5, cellPadding: 2.5, overflow: 'ellipsize', lineColor: [226, 232, 240], lineWidth: 0.2 },
|
||||
headStyles: {
|
||||
fillColor: [255, 248, 237], // #FFF8ED naranja claro
|
||||
textColor: [146, 64, 14], // #92400E naranja oscuro
|
||||
fontStyle: 'bold',
|
||||
fontSize: 8,
|
||||
lineColor: [245, 152, 69], // #F59845 border naranja
|
||||
lineWidth: 0.5,
|
||||
},
|
||||
columnStyles: { 0: { cellWidth: 42 } },
|
||||
}
|
||||
}
|
||||
@@ -213,19 +640,28 @@ export function drawRoiAnalysisPage(
|
||||
startY: number
|
||||
): number {
|
||||
let y = startY
|
||||
const m = PDF.margin, cW = PDF.contentW
|
||||
|
||||
// Título de página
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Análisis del ahorro', PDF.margin, y)
|
||||
y += 6
|
||||
doc.setFontSize(11)
|
||||
doc.setTextColor(...C.orangeDarker)
|
||||
doc.text('ANÁLISIS DEL AHORRO', m, y)
|
||||
y += 5
|
||||
|
||||
doc.setDrawColor(...PDF.slate200)
|
||||
doc.setDrawColor(...C.heroOrange)
|
||||
doc.setLineWidth(0.5)
|
||||
doc.line(m, y, m + cW, y)
|
||||
doc.setLineWidth(0.2)
|
||||
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
|
||||
y += 6
|
||||
y += 7
|
||||
|
||||
// ── COMPOSICIÓN DEL AHORRO ────────────────────────────────────────────────
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...C.orangeDarker)
|
||||
doc.text('COMPOSICIÓN DEL AHORRO', m, y)
|
||||
y += 7
|
||||
|
||||
// ── Composición del ahorro ──────────────────────────────────────────────────
|
||||
const autoByBpmnId = new Map(perActivityAutomated.map((a) => [a.bpmnElementId, a]))
|
||||
const savingsPerAct = perActivityActual
|
||||
.map((a) => {
|
||||
@@ -235,14 +671,9 @@ export function drawRoiAnalysisPage(
|
||||
.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)
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textPrimary)
|
||||
|
||||
if (savingsPerAct.length > 0) {
|
||||
const top3 = savingsPerAct.slice(0, 3)
|
||||
@@ -251,55 +682,57 @@ export function drawRoiAnalysisPage(
|
||||
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
|
||||
const compositionLines = doc.splitTextToSize(compositionText, cW)
|
||||
doc.text(compositionLines, m, y)
|
||||
y += compositionLines.length * 4.5 + 4
|
||||
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textSecondary)
|
||||
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)
|
||||
doc.text(` - ${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`, m, 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)
|
||||
doc.setTextColor(...PDF_COLORS.textTertiary)
|
||||
doc.text('Sin actividades con ahorro positivo en el escenario actual.', m, y)
|
||||
y += 5
|
||||
}
|
||||
y += 4
|
||||
y += 6
|
||||
|
||||
// ── Trayectoria del ahorro ──────────────────────────────────────────────────
|
||||
// ── TRAYECTORIA DEL AHORRO ACUMULADO (texto narrativo) ───────────────────
|
||||
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)
|
||||
doc.setTextColor(...C.orangeDarker)
|
||||
doc.text('TRAYECTORIA DEL AHORRO ACUMULADO', m, y)
|
||||
y += 7
|
||||
|
||||
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)
|
||||
: '∞'
|
||||
const multiple = (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.`
|
||||
`${formatCurrency(roi.cumulativeSavingsHorizon, currency)}, equivalente a ${multiple}x la inversión inicial. ` +
|
||||
`Ver gráfico en página 5.`
|
||||
} 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.`
|
||||
`Sin inversión configurada. El ahorro anual de ${formatCurrency(roi.annualSavings, currency)} ` +
|
||||
`equivale a ${formatCurrency(roi.cumulativeSavingsHorizon, currency)} acumulados en ${process.analysisHorizonYears} años. ` +
|
||||
`Ver gráfico en página 5.`
|
||||
} 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
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textSecondary)
|
||||
const trajectoryLines = doc.splitTextToSize(trajectoryText, cW)
|
||||
doc.text(trajectoryLines, m, y)
|
||||
y += trajectoryLines.length * 4.5 + 8
|
||||
|
||||
// ── Transparencia metodológica (si aplica) ────────────────────────────────
|
||||
y = drawTransparencyBlock(doc, activities, process.automationInvestment, roi, y)
|
||||
@@ -324,8 +757,9 @@ export function drawTransparencyBlock(
|
||||
|
||||
const boxX = PDF.margin
|
||||
const boxW = PDF.contentW
|
||||
const padH = 5, padV = 6
|
||||
|
||||
// ── Paso 1: recopilar todo el contenido para calcular altura ─────────────
|
||||
// ── Paso 1: recopilar contenido para calcular altura ─────────────────────
|
||||
type TextBlock = { text: string[]; bold?: boolean; fontSize: number }
|
||||
const blocks: TextBlock[] = []
|
||||
|
||||
@@ -333,121 +767,310 @@ export function drawTransparencyBlock(
|
||||
|
||||
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.`
|
||||
? `Actividad "${zeroCost[0].name || zeroCost[0].bpmnElementId}" automatizable sin costo configurado. 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 })
|
||||
blocks.push({ text: doc.splitTextToSize(msg, boxW - padH * 2), fontSize: 8.5 })
|
||||
}
|
||||
|
||||
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
|
||||
'Inversión en automatización no configurada. El payback aparece como inmediato y el ROI como infinito. Configurá la inversión para un análisis realista.',
|
||||
boxW - padH * 2
|
||||
),
|
||||
fontSize: 8,
|
||||
fontSize: 8.5,
|
||||
})
|
||||
}
|
||||
|
||||
if (roiHigh) {
|
||||
blocks.push({
|
||||
text: doc.splitTextToSize(
|
||||
`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
|
||||
`ROI inusualmente alto: ${formatPercent(roi.roiAnnualPercent)}. Un ROI > ${formatPercent(ROI_HIGH_THRESHOLD, 0)} anual suele indicar frecuencia sobreestimada, costo automatizado subestimado o inversión insuficiente.`,
|
||||
boxW - padH * 2
|
||||
),
|
||||
fontSize: 8,
|
||||
fontSize: 8.5,
|
||||
})
|
||||
}
|
||||
|
||||
// Calcular altura total del bloque
|
||||
let height = 6 // padding top
|
||||
// Calcular altura total
|
||||
let height = padV
|
||||
for (const b of blocks) {
|
||||
height += b.text.length * (b.bold ? 4.5 : 3.8) + 3
|
||||
height += b.text.length * (b.bold ? 5 : 4.2) + 3
|
||||
}
|
||||
height += 3 // padding bottom
|
||||
height += padV
|
||||
|
||||
// ── Paso 2: dibujar fondo y borde ────────────────────────────────────────
|
||||
doc.setFillColor(255, 251, 235) // amber-50
|
||||
doc.setDrawColor(253, 230, 138) // amber-200
|
||||
doc.setLineWidth(0.3)
|
||||
// ── Paso 2: fondo + borde ─────────────────────────────────────────────────
|
||||
doc.setFillColor(255, 251, 235) // #FFFBEB warning-bg
|
||||
doc.setDrawColor(245, 158, 11) // #F59E0B warning-border
|
||||
doc.setLineWidth(0.4)
|
||||
doc.rect(boxX, startY, boxW, height, 'FD')
|
||||
|
||||
// ── Paso 3: dibujar texto encima del rectángulo ───────────────────────────
|
||||
let y = startY + 6
|
||||
// ── Paso 3: texto ─────────────────────────────────────────────────────────
|
||||
let y = startY + padV
|
||||
|
||||
for (const b of blocks) {
|
||||
if (b.bold) {
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(b.fontSize)
|
||||
doc.setTextColor(146, 64, 14) // amber-800
|
||||
doc.setTextColor(...C.orangeDarker)
|
||||
} else {
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(b.fontSize)
|
||||
doc.setTextColor(180, 83, 9) // amber-700
|
||||
doc.setTextColor(120, 53, 15) // #78350F
|
||||
}
|
||||
doc.text(b.text, boxX + 4, y)
|
||||
y += b.text.length * (b.bold ? 4.5 : 3.8) + 3
|
||||
doc.text(b.text, boxX + padH, y)
|
||||
y += b.text.length * (b.bold ? 5 : 4.2) + 3
|
||||
}
|
||||
|
||||
return startY + height + 4
|
||||
return startY + height + 6
|
||||
}
|
||||
|
||||
// ─── Sección metodológica ampliada para el tab ROI ────────────────────────────
|
||||
// ─── Página 4: nota metodológica con identidad InQ ───────────────────────────
|
||||
|
||||
export function drawRoiMethodologySection(doc: DocLike, process: Process, startY: number): number {
|
||||
let y = startY
|
||||
const m = PDF.margin, cW = PDF.contentW
|
||||
const boxPad = 6
|
||||
|
||||
// Título uppercase naranja oscuro
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Nota metodológica — Análisis de ROI', PDF.margin, y)
|
||||
doc.setFontSize(11)
|
||||
doc.setTextColor(...C.orangeDarker)
|
||||
doc.text('NOTA METODOLOGICA - ANALISIS DE ROI', m, y)
|
||||
y += 5
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.setDrawColor(...C.heroOrange)
|
||||
doc.setLineWidth(0.5)
|
||||
doc.line(m, y, m + cW, y)
|
||||
doc.setLineWidth(0.2)
|
||||
y += 8
|
||||
|
||||
// Caja contenedora: fondo slate-50 + border-left #94A3B8
|
||||
const boxX = m
|
||||
const boxW = cW
|
||||
const contentX = m + boxPad + 3 // +3 para el border-left visual
|
||||
|
||||
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 ejecucion actual:',
|
||||
body: `SUM(actividades) [costo_fijo x prob_ejecucion] x (1 + overhead ${(process.overheadPercentage * 100).toFixed(0)}%). Probabilidades calculadas por propagacion hacia adelante en el grafo BPMN.`,
|
||||
},
|
||||
{
|
||||
title: 'Costo por ejecución automatizado:',
|
||||
title: 'Costo por ejecucion 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.`,
|
||||
body: `(Costo actual - Costo automatizado) x ${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.`,
|
||||
body: '(Inversion / Ahorro anual) x 12. Nominal simple, sin tasa de descuento ni inflacion.',
|
||||
},
|
||||
{
|
||||
title: 'Ahorro acumulado:',
|
||||
body: `Ahorro anual × ${process.analysisHorizonYears} años − Inversión inicial. Nominal, sin VP.`,
|
||||
body: `Ahorro anual x ${process.analysisHorizonYears} años - Inversion inicial. Nominal, sin VP.`,
|
||||
},
|
||||
{
|
||||
title: 'ROI anualizado:',
|
||||
body: '(Ahorro anual / Inversión) × 100%. Referencia: METHODOLOGY_AUTOMATED_COST.md.',
|
||||
body: '(Ahorro anual / Inversion) x 100%. Referencia: METHODOLOGY_AUTOMATED_COST.md.',
|
||||
},
|
||||
]
|
||||
|
||||
// Pre-calcular altura total para dibujar el fondo primero
|
||||
let boxHeight = boxPad * 2
|
||||
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
|
||||
const bodyLines = doc.splitTextToSize(s.body, boxW - boxPad * 2 - 4)
|
||||
boxHeight += 5 + bodyLines.length * 4.2 + 5
|
||||
}
|
||||
|
||||
return y
|
||||
doc.setFillColor(248, 250, 252) // #F8FAFC slate-50
|
||||
doc.rect(boxX, y, boxW, boxHeight, 'F')
|
||||
doc.setFillColor(...C.metaBg)
|
||||
doc.rect(boxX, y, 2, boxHeight, 'F') // border-left visual: 2mm rect gris
|
||||
|
||||
let ty = y + boxPad
|
||||
for (const s of sections) {
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textPrimary)
|
||||
doc.text(s.title, contentX, ty)
|
||||
ty += 5
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8.5)
|
||||
doc.setTextColor(...PDF_COLORS.textSecondary)
|
||||
const lines = doc.splitTextToSize(s.body, boxW - boxPad * 2 - 4)
|
||||
doc.text(lines, contentX, ty)
|
||||
ty += lines.length * 4.2 + 5
|
||||
}
|
||||
|
||||
return y + boxHeight + 4
|
||||
}
|
||||
|
||||
// ─── Página 5: Trayectoria del ahorro acumulado (SVG nativo) ─────────────────
|
||||
|
||||
export function drawTrajectoryChart(
|
||||
doc: DocLike,
|
||||
roi: RoiResult,
|
||||
process: Process,
|
||||
currency: string,
|
||||
m: number,
|
||||
cW: number,
|
||||
startY: number
|
||||
): number {
|
||||
const horizonMonths = process.analysisHorizonYears * 12
|
||||
const investment = process.automationInvestment
|
||||
const annualSavings = roi.annualSavings
|
||||
const finalCumulative = roi.cumulativeSavingsHorizon
|
||||
|
||||
// Función de la trayectoria: lineal desde -investment hasta finalCumulative
|
||||
const valueAt = (month: number) => -investment + (annualSavings / 12) * month
|
||||
|
||||
// Área del gráfico
|
||||
const labelColW = 20 // mm para etiquetas eje Y
|
||||
const chartLeft = m + labelColW
|
||||
const chartRight = m + cW
|
||||
const chartWidth = chartRight - chartLeft
|
||||
const chartTop = startY
|
||||
const chartHeight = 90 // mm — 60-70% del alto disponible
|
||||
const chartBottom = chartTop + chartHeight
|
||||
|
||||
// Rango de valores Y
|
||||
const yMin = Math.min(-investment, 0)
|
||||
const yMax = Math.max(finalCumulative, 0)
|
||||
const yRange = yMax - yMin || 1
|
||||
|
||||
// Conversiones coordenadas
|
||||
const toX = (month: number) => chartLeft + (month / (horizonMonths || 1)) * chartWidth
|
||||
const toY = (value: number) => chartBottom - ((value - yMin) / yRange) * chartHeight
|
||||
|
||||
const zeroY = toY(0)
|
||||
const paybackX = roi.breaksEvenInHorizon && roi.paybackMonths > 0
|
||||
? toX(roi.paybackMonths)
|
||||
: chartLeft // sin payback: toda el área es negativa o positiva
|
||||
|
||||
// 1. Fondo del gráfico (slate-50)
|
||||
doc.setFillColor(248, 250, 252)
|
||||
doc.rect(chartLeft, chartTop, chartWidth, chartHeight, 'F')
|
||||
|
||||
// 2. Área negativa (rojo claro) y positiva (naranja claro) — 100 tiras verticales
|
||||
const strips = 100
|
||||
for (let i = 0; i < strips; i++) {
|
||||
const m1 = (i / strips) * horizonMonths
|
||||
const m2 = ((i + 1) / strips) * horizonMonths
|
||||
const mMid = (m1 + m2) / 2
|
||||
const val = valueAt(mMid)
|
||||
const x1 = toX(m1)
|
||||
const x2 = toX(m2)
|
||||
const cy = toY(val)
|
||||
const yTop = Math.max(Math.min(cy, zeroY), chartTop)
|
||||
const yBot = Math.min(Math.max(cy, zeroY), chartBottom)
|
||||
const h = yBot - yTop
|
||||
if (h > 0.1) {
|
||||
if (val < 0) {
|
||||
doc.setFillColor(254, 226, 226) // #FEE2E2 rojo claro
|
||||
} else {
|
||||
doc.setFillColor(254, 215, 170) // #FED7AA naranja claro
|
||||
}
|
||||
doc.rect(x1, yTop, Math.max(x2 - x1, 0.1), h, 'F')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Línea principal naranja (segmentos mensuales)
|
||||
doc.setDrawColor(245, 152, 69) // #F59845
|
||||
doc.setLineWidth(0.5)
|
||||
for (let mo = 0; mo < horizonMonths; mo++) {
|
||||
doc.line(toX(mo), toY(valueAt(mo)), toX(mo + 1), toY(valueAt(mo + 1)))
|
||||
}
|
||||
|
||||
// 4. Ejes
|
||||
doc.setDrawColor(148, 163, 184) // slate-400
|
||||
doc.setLineWidth(0.3)
|
||||
doc.line(chartLeft, chartTop, chartLeft, chartBottom) // eje Y
|
||||
doc.line(chartLeft, zeroY, chartRight, zeroY) // eje X (línea cero)
|
||||
|
||||
// 5. Labels eje X: Año 0, 1, 2, ... N
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(7)
|
||||
doc.setTextColor(100, 116, 139) // slate-500
|
||||
for (let yr = 0; yr <= process.analysisHorizonYears; yr++) {
|
||||
const lx = toX(yr * 12)
|
||||
doc.text(`Año ${yr}`, lx, chartBottom + 5, { align: 'center' } as Record<string, unknown>)
|
||||
doc.setDrawColor(200, 200, 200)
|
||||
doc.setLineWidth(0.2)
|
||||
doc.line(lx, chartBottom, lx, chartBottom + 2)
|
||||
}
|
||||
|
||||
// 6. Labels eje Y: valor mínimo (abajo), 0 (línea cero), valor máximo (arriba)
|
||||
doc.setDrawColor(148, 163, 184)
|
||||
doc.setLineWidth(0.2)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(6.5)
|
||||
doc.setTextColor(100, 116, 139)
|
||||
|
||||
// Máximo
|
||||
doc.text(_fmtAxisVal(yMax), chartLeft - 2, chartTop + 4, { align: 'right' } as Record<string, unknown>)
|
||||
// Cero (solo si hay rango negativo)
|
||||
if (yMin < 0) {
|
||||
doc.text('0', chartLeft - 2, zeroY + 1, { align: 'right' } as Record<string, unknown>)
|
||||
// Mínimo
|
||||
doc.text(_fmtAxisVal(yMin), chartLeft - 2, chartBottom, { align: 'right' } as Record<string, unknown>)
|
||||
}
|
||||
|
||||
// 7. Marker de payback (línea vertical punteada, solo si hay payback en horizonte)
|
||||
if (roi.breaksEvenInHorizon && roi.paybackMonths > 0 && roi.paybackMonths <= horizonMonths) {
|
||||
doc.setDrawColor(180, 83, 9) // #B45309
|
||||
doc.setLineWidth(0.4)
|
||||
const dashLen = 2.5, gapLen = 1.8
|
||||
let dy = chartTop
|
||||
while (dy < chartBottom) {
|
||||
doc.line(paybackX, dy, paybackX, Math.min(dy + dashLen, chartBottom))
|
||||
dy += dashLen + gapLen
|
||||
}
|
||||
// Label del payback (offset a la derecha para no tapar el eje Y)
|
||||
const labelX = Math.min(paybackX + 3, chartLeft + chartWidth - 35)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(7)
|
||||
doc.setTextColor(180, 83, 9)
|
||||
doc.text(`Payback: ${formatPayback(roi.paybackMonths)}`, labelX, Math.max(zeroY - 3, chartTop + 5))
|
||||
}
|
||||
|
||||
doc.setLineWidth(0.2)
|
||||
|
||||
// 8. Anotaciones al pie del gráfico
|
||||
const annY = chartBottom + 12
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(...PDF_COLORS.textSecondary)
|
||||
|
||||
const annotations: string[] = []
|
||||
if (investment > 0) {
|
||||
annotations.push(`Inversión inicial: ${formatCurrency(investment, currency)}`)
|
||||
}
|
||||
annotations.push(`Ahorro acumulado al año ${process.analysisHorizonYears}: ${formatCurrency(finalCumulative, currency)}`)
|
||||
if (investment > 0 && finalCumulative > 0) {
|
||||
const mult = (finalCumulative / investment + 1).toFixed(1)
|
||||
annotations.push(`Multiplicador de retorno: ${mult}x la inversión`)
|
||||
}
|
||||
|
||||
const annotText = annotations.join(' | ')
|
||||
const annotLines = doc.splitTextToSize(annotText, cW)
|
||||
doc.text(annotLines, m, annY)
|
||||
|
||||
return annY + annotLines.length * 4.5 + 4
|
||||
}
|
||||
|
||||
// ─── Helpers locales ──────────────────────────────────────────────────────────
|
||||
|
||||
function _fmtAxisVal(n: number): string {
|
||||
const abs = Math.abs(n)
|
||||
const sign = n < 0 ? '-' : ''
|
||||
if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`
|
||||
if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(0)}K`
|
||||
return `${sign}${abs.toFixed(0)}`
|
||||
}
|
||||
|
||||
function fmtNum(n: number): string {
|
||||
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
@@ -111,12 +111,16 @@ export function drawHeatmapImage(
|
||||
): number {
|
||||
if (!imageDataUrl) return startY
|
||||
|
||||
// Calcular altura proporcional (asumiendo aspect ratio ~2:1 del canvas BPMN)
|
||||
const imgW = PDF.contentW
|
||||
const imgH = Math.min(imgW / 2.2, 80) // máximo 80mm de alto
|
||||
// Usar dimensiones dinámicas — funciona en portrait (210mm) y landscape (297mm)
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const pageH = doc.internal.pageSize.getHeight()
|
||||
const imgW = pageW - 2 * PDF.margin
|
||||
// Altura: proporción ~2.2:1 del canvas BPMN, limitada por espacio disponible hasta el footer
|
||||
const maxH = pageH - startY - PDF.margin - 20 // 20mm reservado para footer
|
||||
const imgH = Math.min(imgW / 2.2, maxH)
|
||||
|
||||
const x = PDF.margin
|
||||
if (startY + imgH > PDF.pageH - PDF.margin - 10) {
|
||||
if (startY + imgH > pageH - PDF.margin - 10) {
|
||||
doc.addPage()
|
||||
startY = PDF.margin
|
||||
}
|
||||
@@ -131,7 +135,8 @@ export function drawHeatmapImage(
|
||||
}
|
||||
|
||||
export function drawHeatmapLegend(doc: DocLike, startY: number): number {
|
||||
const x = PDF.margin, barW = PDF.contentW, barH = 4
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const x = PDF.margin, barW = pageW - 2 * PDF.margin, barH = 4
|
||||
const labelY = startY + barH + 3.5
|
||||
|
||||
doc.setFontSize(7)
|
||||
|
||||
Reference in New Issue
Block a user