import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format' import type { SimulationResult, Process, ActivitySimResult, Resource, Activity } from '@/domain/types' 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 = { pageW: 210, pageH: 297, margin: 20, contentW: 170, inqOrange: PDF_COLORS.inqOrange, slate900: [15, 23, 42] as [number, number, number], slate700: [51, 65, 85] as [number, number, number], slate500: [100, 116, 139] as [number, number, number], slate200: [226, 232, 240] as [number, number, number], slate50: [248, 250, 252] as [number, number, number], heatLow: [16, 185, 129] as [number, number, number], heatMid: [245, 158, 11] as [number, number, number], heatHigh: [239, 68, 68] as [number, number, number], } export function drawHeader(doc: DocLike, process: Process, simulatedAt: number): number { let y = PDF.margin // Nombre del proceso doc.setFont('helvetica', 'bold') doc.setFontSize(20) doc.setTextColor(...PDF.slate900) doc.text(process.name, PDF.margin, y) y += 8 // Cliente + fecha + moneda const { date, time } = formatSimulationTimestamp(simulatedAt) const subtitle = [ process.clientName ? `Cliente: ${process.clientName}` : null, `Simulado el ${date} a las ${time}`, `Moneda: ${process.currency}`, ].filter(Boolean).join(' · ') doc.setFont('helvetica', 'normal') doc.setFontSize(9) doc.setTextColor(...PDF.slate500) doc.text(subtitle, PDF.margin, y) y += 5 // Línea separadora doc.setDrawColor(...PDF.slate200) doc.setLineWidth(0.3) doc.line(PDF.margin, y, PDF.margin + PDF.contentW, y) y += 5 return y } export function drawKpiCards( doc: DocLike, result: SimulationResult, currency: string, startY: number ): number { const cardW = 80, cardH = 24, gap = 10 const col1 = PDF.margin, col2 = PDF.margin + cardW + gap const cards = [ { label: 'COSTO TOTAL ESPERADO', value: formatCurrency(result.totalCost, currency), accent: true }, { label: 'COSTO DIRECTO / OVERHEAD', value: `${formatCurrency(result.totalDirectCost, currency)} / ${formatCurrency(result.totalIndirectCost, currency)}`, accent: false }, { label: 'TIEMPO TOTAL ESPERADO', value: formatMinutes(result.totalTimeMinutes), accent: false }, { label: 'ACTIVIDADES COSTEADAS', value: String(result.perActivity.length), accent: false }, ] let maxY = startY cards.forEach((card, idx) => { const x = idx % 2 === 0 ? col1 : col2 const y = idx < 2 ? startY : startY + cardH + 4 // 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 pequeño doc.setFont('helvetica', 'normal') doc.setFontSize(6.5) doc.setTextColor(...PDF.slate500) doc.text(card.label, x + 4, y + 6) // Valor grande doc.setFont('helvetica', 'bold') doc.setFontSize(card.value.length > 16 ? 9 : 12) doc.setTextColor(...(card.accent ? PDF.inqOrange : PDF.slate900)) doc.text(card.value, x + 4, y + 16) maxY = Math.max(maxY, y + cardH) }) return maxY + 6 } export function drawHeatmapImage( doc: DocLike, imageDataUrl: string | null, startY: number ): number { if (!imageDataUrl) return startY // 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 > pageH - PDF.margin - 10) { doc.addPage() startY = PDF.margin } // Borde sutil doc.setDrawColor(...PDF.slate200) doc.setLineWidth(0.2) doc.rect(x, startY, imgW, imgH, 'S') doc.addImage(imageDataUrl, 'JPEG', x, startY, imgW, imgH) return startY + imgH + 4 } export function drawHeatmapLegend(doc: DocLike, startY: number): number { 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) doc.setFont('helvetica', 'normal') // Etiqueta izquierda doc.setTextColor(...PDF.heatLow) doc.text('Bajo costo', x, labelY) // Barra de gradiente: dibujada como N segmentos de colores const steps = 40 const segW = barW / steps for (let i = 0; i < steps; i++) { const t = i / (steps - 1) let r: number, g: number, b: number if (t <= 0.5) { const lt = t * 2 r = Math.round(PDF.heatLow[0] + (PDF.heatMid[0] - PDF.heatLow[0]) * lt) g = Math.round(PDF.heatLow[1] + (PDF.heatMid[1] - PDF.heatLow[1]) * lt) b = Math.round(PDF.heatLow[2] + (PDF.heatMid[2] - PDF.heatLow[2]) * lt) } else { const lt = (t - 0.5) * 2 r = Math.round(PDF.heatMid[0] + (PDF.heatHigh[0] - PDF.heatMid[0]) * lt) g = Math.round(PDF.heatMid[1] + (PDF.heatHigh[1] - PDF.heatMid[1]) * lt) b = Math.round(PDF.heatMid[2] + (PDF.heatHigh[2] - PDF.heatMid[2]) * lt) } doc.setFillColor(r, g, b) doc.rect(x + i * segW, startY, segW + 0.1, barH, 'F') } // Etiqueta derecha doc.setTextColor(...PDF.heatHigh) doc.text('Alto costo', x + barW, labelY, { align: 'right' } as any) return labelY + 3 } export function drawAnalysisSection( doc: DocLike, result: SimulationResult, currency: string, startY: number ): number { let y = startY const m = PDF.margin, cW = PDF.contentW // Título uppercase naranja oscuro doc.setFont('helvetica', 'bold') 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_COLORS.textPrimary) const directPct = result.totalCost > 0 ? ((result.totalDirectCost / result.totalCost) * 100).toFixed(1) : '0' const indirectPct = result.totalCost > 0 ? ((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 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(' ') 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 if (result.perResource.length > 0) { const topResource = [...result.perResource].sort((a, b) => b.totalCost - a.totalCost)[0] const resourceLine = doc.splitTextToSize( `Recurso principal: "${topResource.resourceName}" — ${formatCurrency(topResource.totalCost, currency)}`, cW ) doc.text(resourceLine, m, y) y += resourceLine.length * 4.5 } else { doc.setTextColor(...PDF_COLORS.textTertiary) doc.text('Sin recursos asignados (costos fijos por actividad)', m, y) y += 5 } return y + 2 } export function drawMethodologySection( 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(11) doc.setTextColor(..._SC.orangeDarker) doc.text('NOTA METODOLÓGICA', m, y) y += 5 doc.setDrawColor(..._SC.orange) doc.setLineWidth(0.5) doc.line(m, y, m + cW, y) doc.setLineWidth(0.2) y += 8 // 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.', }, ] 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) 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) y += 6 doc.setFont('helvetica', 'normal') doc.setFontSize(9) doc.setTextColor(...PDF_COLORS.textTertiary) doc.text('InQuality', right, y, { align: 'right' } as Record) 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 } // ─── Tabla de recursos por actividad ───────────────────────────────────────── function _fmtNum(n: number): string { return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } export function drawResourcesSection( // eslint-disable-next-line @typescript-eslint/no-explicit-any doc: any, // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type autoTable: Function, result: { perActivity: ActivitySimResult[] }, resources: Resource[], activities: Activity[], currency: string, startY: number ): void { let y = startY const m = PDF.margin const cW = PDF.contentW doc.setFont('helvetica', 'bold') doc.setFontSize(10) doc.setTextColor(...PDF.slate900) doc.text('DESGLOSE DE RECURSOS POR ACTIVIDAD', m, y) y += 2 doc.setDrawColor(...PDF.slate200) doc.setLineWidth(0.5) doc.line(m, y, m + cW, y) doc.setLineWidth(0.2) y += 5 doc.setFont('helvetica', 'normal') doc.setFontSize(8) doc.setTextColor(...PDF.slate500) doc.text('Costo por ejecución, ponderado por probabilidad de ejecución de cada actividad.', m, y) y += 6 const resourceMap = new Map(resources.map((r) => [r.id, r])) const activityMap = new Map(activities.map((a) => [a.id, a])) const TYPE_LABEL: Record = { role: 'Rol', person: 'Persona', system: 'Sistema', equipment: 'Equipamiento', supply: 'Insumo', } const TYPE_COLOR: Record = { role: [255, 237, 213], person: [220, 252, 231], system: [219, 234, 254], equipment: [241, 245, 249], supply: [254, 249, 195], } const RA = 'right' as const const tableHead = [[ { content: 'Actividad', styles: { cellWidth: 40 } }, { content: 'Recurso', styles: { cellWidth: 38 } }, { content: 'Tipo', styles: { cellWidth: 20 } }, { content: `$/h (${currency})`, styles: { halign: RA, cellWidth: 20 } }, { content: 'Min.', styles: { halign: RA, cellWidth: 14 } }, { content: 'Uds.', styles: { halign: RA, cellWidth: 12 } }, { content: `Esp. (${currency})`, styles: { halign: RA, cellWidth: 26 } }, ]] // eslint-disable-next-line @typescript-eslint/no-explicit-any const tableBody: any[] = [] for (const act of result.perActivity) { if (act.resourceCostBreakdown.length === 0) continue const activityRow = activityMap.get(act.activityId) for (const br of act.resourceCostBreakdown) { const resource = resourceMap.get(br.resourceId) const assignment = activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId) const typeName = resource ? (TYPE_LABEL[resource.type] ?? resource.type) : '—' const typeColor = resource ? (TYPE_COLOR[resource.type] ?? PDF.slate50) : PDF.slate50 const costPerHour = resource ? _fmtNum(resource.costPerHour) : '—' const minutes = assignment ? String(assignment.utilizationMinutes) : '—' const units = assignment ? String(assignment.units) : '—' // Nombre de actividad repetido por fila — rowSpan no está soportado en body de autotable tableBody.push([ { content: act.activityName }, { content: br.resourceName }, { content: typeName, styles: { fillColor: typeColor, fontStyle: 'bold', fontSize: 7 } }, { content: costPerHour, styles: { halign: RA, font: 'courier' } }, { content: minutes, styles: { halign: RA, font: 'courier' } }, { content: units, styles: { halign: RA, font: 'courier' } }, { content: _fmtNum(br.cost), styles: { halign: RA, font: 'courier' } }, ]) } // Fila de subtotal por actividad const subtotal = act.resourceCostBreakdown.reduce((s, r) => s + r.cost, 0) tableBody.push([ { content: '', styles: { fillColor: [255, 248, 237] } }, { content: 'Subtotal recursos', colSpan: 5, styles: { fontStyle: 'italic', fontSize: 7.5, fillColor: [255, 248, 237] } }, { content: _fmtNum(subtotal), styles: { halign: RA, font: 'courier', fontStyle: 'bold', fillColor: [255, 248, 237] } }, ]) } if (tableBody.length === 0) return autoTable(doc, { head: tableHead, body: tableBody, startY: y, margin: { left: m, right: m }, styles: { fontSize: 7.5, cellPadding: 2.5, overflow: 'ellipsize', lineColor: [226, 232, 240], lineWidth: 0.2 }, headStyles: { fillColor: [255, 248, 237], textColor: [146, 64, 14], fontStyle: 'bold', fontSize: 8, lineColor: [245, 152, 69], lineWidth: 0.5, }, alternateRowStyles: { fillColor: PDF.slate50 }, }) } export function addPageNumbers(doc: DocLike, platformName: string, generatedAt: number): void { const total = doc.internal.getNumberOfPages() const { date } = formatSimulationTimestamp(generatedAt) for (let i = 1; i <= total; i++) { doc.setPage(i) doc.setFont('helvetica', 'normal') doc.setFontSize(7) doc.setTextColor(180, 180, 180) doc.text( `${platformName} · ${date} · Página ${i} de ${total}`, 105, PDF.pageH - 8, { align: 'center' } as any ) } }