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:
458
src/lib/export/pdf-sections-roi.ts
Normal file
458
src/lib/export/pdf-sections-roi.ts
Normal 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user