feat(sprint-1.5): PDFs Actual/Auto con identidad InQ + badge ⚡ naranja (Etapas 8-9)
Etapa 8 — PDFs Actual y Automatizado: - Página 1: header denso + metadata strip + grid 2×2 KPI cards con paleta #FEF3E2 (naranja claro InQ), sin gradiente (jerarquía visual preservada) - Página 3: tabla con header naranja claro #FFF8ED + #92400E, em dash en filas no-automatizables, título ANÁLISIS DE COMPOSICIÓN uppercase - Página 4: NOTA METODOLÓGICA uppercase + caja contenedora slate-50 - 526 tests verdes (+ 15 tests nuevos) Etapa 9 — Badge ⚡ en BpmnCanvas: - Reemplaza badge legacy (Bot SVG ámbar #fffbeb) por círculo naranja sólido #F59845 con símbolo ⚡ blanco, 14×14px, border white 1.5px - Usa var(--inq-orange) definido en globals.css (sin hex hardcodeados) - Elimina los 3 hex legacy del audit Etapa 1: #f59e0b, #fffbeb, #e2e8f0 - 5 tests E2E de lifecycle del badge + 2 screenshots Política de iniciativa refinada post-Sprint 1.5 (CLAUDE.md actualizado) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,31 +19,24 @@ function classifyElement(type: string): BpmnElementCategory {
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// SVG inline del ícono Bot de Lucide (12×12 px, color ámbar #f59e0b).
|
||||
// Copiado de lucide.dev/icons/bot — robot con antena, cuerpo rectangular y ojos.
|
||||
const BOT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8V4H8"/>
|
||||
<rect width="16" height="12" x="4" y="8" rx="2"/>
|
||||
<path d="M2 14h2"/>
|
||||
<path d="M20 14h2"/>
|
||||
<path d="M15 13v2"/>
|
||||
<path d="M9 13v2"/>
|
||||
</svg>`
|
||||
|
||||
// HTML del badge de automatización.
|
||||
// Fondo #fffbeb (amber-50): identidad cromática con el ícono Bot ámbar,
|
||||
// contrasta con el fondo blanco de los nodos BPMN.
|
||||
// Badge de automatización — identidad InQ naranja sólido (Etapa 9 Sprint 1.5).
|
||||
// Usa var(--inq-orange) definido en globals.css :root para consistencia con el sistema de design.
|
||||
// Emoji ⚡ como símbolo provisional — reemplazo por <Bot /> agendado Sprint 2-3.
|
||||
function makeBadgeHtml(): string {
|
||||
return `<div title="Actividad marcada como automatizable" style="
|
||||
width:20px;height:20px;
|
||||
return `<div class="automatable-badge" title="Actividad marcada como automatizable" style="
|
||||
width:14px;height:14px;
|
||||
border-radius:50%;
|
||||
background:#fffbeb;
|
||||
border:1px solid #e2e8f0;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,0.12);
|
||||
background:var(--inq-orange);
|
||||
border:1.5px solid white;
|
||||
box-shadow:0 1px 2px rgba(0,0,0,0.10);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
color:white;
|
||||
font-size:8px;font-weight:700;
|
||||
line-height:14px;
|
||||
cursor:default;
|
||||
pointer-events:none;
|
||||
">${BOT_SVG}</div>`
|
||||
z-index:10;
|
||||
">⚡</div>`
|
||||
}
|
||||
|
||||
// Identificador del tipo de overlay para poder remover solo los nuestros.
|
||||
|
||||
@@ -2,14 +2,15 @@ import { buildFileName } from './slug'
|
||||
import { formatPayback } from '@/lib/format'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import {
|
||||
drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
|
||||
drawScenarioPage1,
|
||||
drawHeatmapImage, drawHeatmapLegend,
|
||||
drawAnalysisSection, drawMethodologySection, PDF,
|
||||
} from './pdf-sections'
|
||||
import {
|
||||
drawRoiPage1, buildComparisonTableConfig,
|
||||
drawRoiAnalysisPage, drawRoiMethodologySection, drawTrajectoryChart,
|
||||
} from './pdf-sections-roi'
|
||||
import { drawSharedHeader, applyFootersToAllPages, PDF_COLORS } from './pdf-sections-shared'
|
||||
import { drawSharedHeader, applyFootersToAllPages } from './pdf-sections-shared'
|
||||
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
|
||||
|
||||
export type ReportTab = 'actual' | 'automatizado' | 'roi'
|
||||
@@ -40,7 +41,7 @@ export async function exportToPdf(params: PdfExportParams): Promise<void> {
|
||||
const result = tab === 'automatizado'
|
||||
? (simulation.resultAutomated ?? simulation.result)
|
||||
: simulation.result
|
||||
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency)
|
||||
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency, tab as 'actual' | 'automatizado')
|
||||
}
|
||||
|
||||
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
|
||||
@@ -59,12 +60,14 @@ async function exportScenarioPdf(
|
||||
result: Simulation['result'],
|
||||
resources: Resource[],
|
||||
heatmapImageData: string | null,
|
||||
currency: string
|
||||
currency: string,
|
||||
tab: 'actual' | 'automatizado'
|
||||
): Promise<void> {
|
||||
const docAny = doc as any
|
||||
const scenarioLabel = tab === 'actual' ? 'Escenario actual' : 'Escenario automatizado'
|
||||
|
||||
docAny.setProperties({
|
||||
title: `Análisis de costos - ${process.name}`,
|
||||
title: `Análisis de costos — ${scenarioLabel} — ${process.name}`,
|
||||
author: process.clientName || '',
|
||||
subject: 'Simulación de proceso BPMN',
|
||||
creator: 'InQ ROI',
|
||||
@@ -73,9 +76,9 @@ async function exportScenarioPdf(
|
||||
|
||||
const headerOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
|
||||
|
||||
// ── Página 1 (portrait): KPI cards ──────────────────────────────────────────
|
||||
let y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 1 })
|
||||
y = drawKpiCards(docAny, result, currency, y)
|
||||
// ── Página 1 (portrait): Header denso + metadata + KPI grid (Etapa 8) ────────
|
||||
drawScenarioPage1(docAny, tab, process, simulation.executedAt, result, currency)
|
||||
let y: number
|
||||
|
||||
// ── Página 2 (landscape): Diagrama BPMN con heatmap ─────────────────────────
|
||||
// Método obligatorio: doc.addPage('a4', 'landscape') — ver ETAPA_5_PROMPT §3.1
|
||||
@@ -123,8 +126,15 @@ async function exportScenarioPdf(
|
||||
body: tableBody,
|
||||
startY: y,
|
||||
margin: { left: PDF.margin, right: PDF.margin },
|
||||
styles: { fontSize: 8, cellPadding: 2.5, overflow: 'ellipsize' },
|
||||
headStyles: { fillColor: PDF_COLORS.inqOrange, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 },
|
||||
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,
|
||||
},
|
||||
alternateRowStyles: { fillColor: PDF.slate50 },
|
||||
columnStyles: { 0: { cellWidth: 48 } },
|
||||
})
|
||||
|
||||
@@ -5,6 +5,15 @@ import { PDF_COLORS, type DocLike } from './pdf-sections-shared'
|
||||
// Re-exportar DocLike para compatibilidad de imports existentes (pdf-sections-roi, tests)
|
||||
export type { DocLike } from './pdf-sections-shared'
|
||||
|
||||
// Colores locales para página 1 de PDFs Actual/Automatizado (sin gradiente — Etapa 8)
|
||||
const _SC = {
|
||||
orange: [245, 152, 69] as [number, number, number], // #F59845
|
||||
orangeLight: [254, 243, 226] as [number, number, number], // #FEF3E2
|
||||
orangeDarker: [146, 64, 14] as [number, number, number], // #92400E
|
||||
orangeDark: [180, 83, 9] as [number, number, number], // #B45309
|
||||
metaBg: [250, 251, 252] as [number, number, number], // #FAFBFC
|
||||
}
|
||||
|
||||
// Unidades: mm. A4 = 210 × 297mm, márgenes 20mm, área útil 170 × 257mm
|
||||
// PDF.blue eliminado en Etapa 4 — usar PDF_COLORS.inqOrange del módulo compartido
|
||||
export const PDF = {
|
||||
@@ -181,48 +190,49 @@ export function drawAnalysisSection(
|
||||
startY: number
|
||||
): number {
|
||||
let y = startY
|
||||
const m = PDF.margin, cW = PDF.contentW
|
||||
|
||||
// Título uppercase naranja oscuro
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
doc.setTextColor(...PDF.slate900)
|
||||
doc.text('Análisis de composición', PDF.margin, y)
|
||||
y += 6
|
||||
|
||||
doc.setDrawColor(...PDF.slate200)
|
||||
doc.setLineWidth(0.2)
|
||||
doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y)
|
||||
doc.setFontSize(11)
|
||||
doc.setTextColor(..._SC.orangeDarker)
|
||||
doc.text('ANÁLISIS DE COMPOSICIÓN', m, y)
|
||||
y += 5
|
||||
|
||||
doc.setDrawColor(..._SC.orange)
|
||||
doc.setLineWidth(0.5)
|
||||
doc.line(m, y, m + cW, y)
|
||||
doc.setLineWidth(0.2)
|
||||
y += 7
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF.slate700)
|
||||
doc.setTextColor(...PDF_COLORS.textPrimary)
|
||||
|
||||
// Composición directo/indirecto
|
||||
const directPct = result.totalCost > 0
|
||||
? ((result.totalDirectCost / result.totalCost) * 100).toFixed(1)
|
||||
: '0'
|
||||
? ((result.totalDirectCost / result.totalCost) * 100).toFixed(1) : '0'
|
||||
const indirectPct = result.totalCost > 0
|
||||
? ((result.totalIndirectCost / result.totalCost) * 100).toFixed(1)
|
||||
: '0'
|
||||
doc.text(`Composición del costo: ${directPct}% directo (${formatCurrency(result.totalDirectCost, currency)}) · ${indirectPct}% overhead (${formatCurrency(result.totalIndirectCost, currency)})`, PDF.margin, y)
|
||||
y += 6
|
||||
? ((result.totalIndirectCost / result.totalCost) * 100).toFixed(1) : '0'
|
||||
|
||||
const compositionText = `Composición del costo: ${directPct}% directo (${formatCurrency(result.totalDirectCost, currency)}) · ${indirectPct}% overhead (${formatCurrency(result.totalIndirectCost, currency)})`
|
||||
const compositionLines = doc.splitTextToSize(compositionText, cW)
|
||||
doc.text(compositionLines, m, y)
|
||||
y += compositionLines.length * 4.5 + 4
|
||||
|
||||
// Top 3 actividades
|
||||
const top3 = result.perActivity.slice(0, 3)
|
||||
const top3Text = top3.map((a, i) => `${i + 1}. ${a.activityName} (${a.percentOfTotal.toFixed(1)}% — ${formatCurrency(a.expectedTotalCost, currency)})`).join(' ')
|
||||
const lines = doc.splitTextToSize(`Top 3 actividades por costo: ${top3Text}`, PDF.contentW)
|
||||
doc.text(lines, PDF.margin, y)
|
||||
y += lines.length * 4.5 + 2
|
||||
const top3Text = top3.map((a, i) => `${i + 1}. ${a.activityName} (${a.percentOfTotal.toFixed(1)}% - ${formatCurrency(a.expectedTotalCost, currency)})`).join(' ')
|
||||
doc.setTextColor(...PDF_COLORS.textSecondary)
|
||||
const top3Lines = doc.splitTextToSize(`Top 3 actividades por costo: ${top3Text}`, cW)
|
||||
doc.text(top3Lines, m, y)
|
||||
y += top3Lines.length * 4.5 + 2
|
||||
|
||||
// Recurso más costoso (si hay)
|
||||
if (result.perResource.length > 0) {
|
||||
const topResource = [...result.perResource].sort((a, b) => b.totalCost - a.totalCost)[0]
|
||||
doc.text(`Recurso más costoso: "${topResource.resourceName}" con ${formatCurrency(topResource.totalCost, currency)} en total`, PDF.margin, y)
|
||||
doc.text(`Recurso principal: "${topResource.resourceName}" — ${formatCurrency(topResource.totalCost, currency)}`, m, y)
|
||||
y += 5
|
||||
} else {
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.text('Sin recursos asignados (costos son fijos por actividad)', PDF.margin, y)
|
||||
doc.setTextColor(...PDF.slate700)
|
||||
doc.setTextColor(...PDF_COLORS.textTertiary)
|
||||
doc.text('Sin recursos asignados (costos fijos por actividad)', m, y)
|
||||
y += 5
|
||||
}
|
||||
|
||||
@@ -235,34 +245,227 @@ export function drawMethodologySection(
|
||||
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', PDF.margin, y)
|
||||
doc.setFontSize(11)
|
||||
doc.setTextColor(..._SC.orangeDarker)
|
||||
doc.text('NOTA METODOLÓGICA', m, y)
|
||||
y += 5
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(...PDF.slate500)
|
||||
doc.setDrawColor(..._SC.orange)
|
||||
doc.setLineWidth(0.5)
|
||||
doc.line(m, y, m + cW, y)
|
||||
doc.setLineWidth(0.2)
|
||||
y += 8
|
||||
|
||||
const lines = [
|
||||
`Costo total = Σ(actividades) costo_directo_esperado + costo_indirecto_esperado.`,
|
||||
`Costo directo por actividad = (costo_fijo + Σ(recurso × costo_hora × tiempo × utilización / 60)) × probabilidad_ejecución.`,
|
||||
`Costo indirecto = costo_directo_total × overhead_global (${(process.overheadPercentage * 100).toFixed(0)}%).`,
|
||||
`Probabilidades de ejecución calculadas mediante propagación hacia adelante desde el StartEvent,`,
|
||||
`considerando gateways XOR y AND configurados. Modelo determinístico agregado — MVP Fase 0.`,
|
||||
// Caja contenedora slate-50 con border-left gris (mismo patrón que ROI Etapa 7)
|
||||
const sections = [
|
||||
{
|
||||
title: 'Costo total:',
|
||||
body: `Costo total = SUM(actividades) costo_directo_esperado + costo_indirecto_esperado.`,
|
||||
},
|
||||
{
|
||||
title: 'Costo directo por actividad:',
|
||||
body: `(costo_fijo + SUM(recurso x costo_hora x tiempo x utilizacion / 60)) x probabilidad_ejecucion.`,
|
||||
},
|
||||
{
|
||||
title: 'Costo indirecto:',
|
||||
body: `costo_directo_total x overhead_global (${(process.overheadPercentage * 100).toFixed(0)}%).`,
|
||||
},
|
||||
{
|
||||
title: 'Probabilidades de ejecución:',
|
||||
body: 'Calculadas mediante propagacion hacia adelante desde el StartEvent, considerando gateways XOR y AND. Modelo determinístico agregado.',
|
||||
},
|
||||
]
|
||||
|
||||
for (const line of lines) {
|
||||
const wrapped = doc.splitTextToSize(line, PDF.contentW)
|
||||
doc.text(wrapped, PDF.margin, y)
|
||||
y += wrapped.length * 4
|
||||
let boxHeight = boxPad * 2
|
||||
for (const s of sections) {
|
||||
const bodyLines = doc.splitTextToSize(s.body, cW - boxPad * 2 - 4)
|
||||
boxHeight += 5 + bodyLines.length * 4.2 + 4
|
||||
}
|
||||
|
||||
doc.setFillColor(248, 250, 252) // slate-50
|
||||
doc.rect(m, y, cW, boxHeight, 'F')
|
||||
doc.setFillColor(..._SC.metaBg)
|
||||
doc.rect(m, y, 2, boxHeight, 'F')
|
||||
|
||||
let ty = y + boxPad
|
||||
const contentX = m + boxPad + 3
|
||||
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, cW - boxPad * 2 - 4)
|
||||
doc.text(lines, contentX, ty)
|
||||
ty += lines.length * 4.2 + 4
|
||||
}
|
||||
|
||||
return y + boxHeight + 4
|
||||
}
|
||||
|
||||
// ─── Página 1 Actual/Automatizado: header denso + metadata + KPI grid ─────────
|
||||
// Sin gradiente — jerarquía visual: ROI es el reporte principal (Etapa 8).
|
||||
|
||||
export function drawScenarioPage1(
|
||||
doc: DocLike,
|
||||
tab: 'actual' | 'automatizado',
|
||||
process: Process,
|
||||
simulatedAt: number,
|
||||
result: SimulationResult,
|
||||
currency: string
|
||||
): number {
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const m = 20, cW = pageW - 2 * m
|
||||
let y = m
|
||||
y = _drawScenarioDenseHeader(doc, pageW, m, cW, tab, y)
|
||||
y = _drawScenarioMetadataStrip(doc, process, simulatedAt, m, cW, y)
|
||||
y = drawScenarioKpiGrid(doc, result, currency, m, cW, y)
|
||||
return y
|
||||
}
|
||||
|
||||
function _drawScenarioDenseHeader(
|
||||
doc: DocLike, pageW: number, m: number, cW: number,
|
||||
tab: 'actual' | 'automatizado', y: number
|
||||
): number {
|
||||
const right = pageW - m
|
||||
const subtitle = tab === 'actual'
|
||||
? 'ANÁLISIS DE COSTOS — ESCENARIO ACTUAL'
|
||||
: 'ANÁLISIS DE COSTOS — ESCENARIO AUTOMATIZADO'
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(22)
|
||||
doc.setTextColor(..._SC.orange)
|
||||
doc.text('InQ ROI', m, y)
|
||||
|
||||
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
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(...PDF_COLORS.textDisabled)
|
||||
doc.text(subtitle, m, y)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
doc.setDrawColor(..._SC.orange)
|
||||
doc.setLineWidth(0.8)
|
||||
doc.line(m, y, m + cW, y)
|
||||
doc.setLineWidth(0.2)
|
||||
y += 10
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
function _drawScenarioMetadataStrip(
|
||||
doc: DocLike, process: Process, simulatedAt: number,
|
||||
m: number, cW: number, y: number
|
||||
): number {
|
||||
const h = 16, r = 2
|
||||
doc.setFillColor(..._SC.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: 'FECHA', value: date, x: col2 + 4 },
|
||||
{ label: 'MONEDA', value: process.currency, 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 val = cell.value.length > 28 ? cell.value.slice(0, 26) + '...' : cell.value
|
||||
doc.text(val, cell.x, y + 12)
|
||||
}
|
||||
|
||||
return y + h + 6
|
||||
}
|
||||
|
||||
export function drawScenarioKpiGrid(
|
||||
doc: DocLike,
|
||||
result: SimulationResult,
|
||||
currency: string,
|
||||
m: number,
|
||||
cW: number,
|
||||
startY: number
|
||||
): number {
|
||||
const cardW = (cW - 4) / 2
|
||||
const cardH = 26
|
||||
const gapX = 4, gapY = 4
|
||||
const r = 2
|
||||
|
||||
const directPct = result.totalCost > 0
|
||||
? (result.totalDirectCost / result.totalCost * 100).toFixed(1) : '0'
|
||||
|
||||
const cards = [
|
||||
{ label: 'COSTO TOTAL ESPERADO', value: formatCurrency(result.totalCost, currency), sub: '', col: 0, row: 0 },
|
||||
{ label: 'COSTO DIRECTO / OVERHEAD', value: formatCurrency(result.totalDirectCost, currency), sub: `${directPct}% directo`, col: 1, row: 0 },
|
||||
{ label: 'TIEMPO TOTAL ESPERADO', value: formatMinutes(result.totalTimeMinutes), sub: '', col: 0, row: 1 },
|
||||
{ label: 'ACTIVIDADES COSTEADAS', value: String(result.perActivity.length), sub: '', col: 1, row: 1 },
|
||||
]
|
||||
|
||||
let maxY = startY
|
||||
for (const card of cards) {
|
||||
const cx = m + card.col * (cardW + gapX)
|
||||
const cy = startY + card.row * (cardH + gapY)
|
||||
|
||||
doc.setFillColor(..._SC.orangeLight)
|
||||
doc.roundedRect(cx, cy, cardW, cardH, r, r, 'F')
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(7)
|
||||
doc.setTextColor(..._SC.orangeDarker)
|
||||
doc.text(card.label, cx + 5, cy + 7)
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(card.value.length > 16 ? 10 : 14)
|
||||
doc.setTextColor(...PDF_COLORS.textPrimary)
|
||||
doc.text(card.value, cx + 5, cy + 17)
|
||||
|
||||
if (card.sub) {
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(7.5)
|
||||
doc.setTextColor(..._SC.orangeDark)
|
||||
doc.text(card.sub, cx + 5, cy + 23)
|
||||
}
|
||||
|
||||
maxY = Math.max(maxY, cy + cardH)
|
||||
}
|
||||
|
||||
return maxY + 8
|
||||
}
|
||||
|
||||
export function addPageNumbers(doc: DocLike, platformName: string, generatedAt: number): void {
|
||||
const total = doc.internal.getNumberOfPages()
|
||||
const { date } = formatSimulationTimestamp(generatedAt)
|
||||
|
||||
Reference in New Issue
Block a user