292 lines
9.6 KiB
TypeScript
292 lines
9.6 KiB
TypeScript
import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
|
||
import type { SimulationResult, Process } from '@/domain/types'
|
||
|
||
// Unidades: mm. A4 = 210 × 297mm, márgenes 20mm, área útil 170 × 257mm
|
||
export const PDF = {
|
||
pageW: 210, pageH: 297,
|
||
margin: 20,
|
||
contentW: 170,
|
||
blue: [30, 64, 175] as [number, number, number],
|
||
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],
|
||
}
|
||
|
||
// Tipo mínimo de doc para los helpers (evitar importar jsPDF en este archivo)
|
||
export type DocLike = {
|
||
setFont: (name: string, style: string) => void
|
||
setFontSize: (size: number) => void
|
||
setTextColor: (...args: number[]) => void
|
||
setFillColor: (...args: number[]) => void
|
||
setDrawColor: (...args: number[]) => void
|
||
setLineWidth: (w: number) => void
|
||
text: (text: string | string[], x: number, y: number, opts?: Record<string, unknown>) => void
|
||
rect: (x: number, y: number, w: number, h: number, style?: string) => void
|
||
roundedRect: (x: number, y: number, w: number, h: number, rx: number, ry: number, style?: string) => void
|
||
line: (x1: number, y1: number, x2: number, y2: number) => void
|
||
addImage: (img: string, format: string, x: number, y: number, w: number, h: number) => void
|
||
addPage: () => void
|
||
setPage: (page: number) => void
|
||
internal: { getNumberOfPages: () => number }
|
||
splitTextToSize: (text: string, maxWidth: number) => string[]
|
||
}
|
||
|
||
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.blue[0] : PDF.slate900[0], card.accent ? PDF.blue[1] : PDF.slate900[1], card.accent ? PDF.blue[2] : PDF.slate900[2])
|
||
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
|
||
|
||
// 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
|
||
|
||
const x = PDF.margin
|
||
if (startY + imgH > PDF.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 x = PDF.margin, barW = PDF.contentW, 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
|
||
|
||
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)
|
||
y += 5
|
||
|
||
doc.setFont('helvetica', 'normal')
|
||
doc.setFontSize(9)
|
||
doc.setTextColor(...PDF.slate700)
|
||
|
||
// Composición directo/indirecto
|
||
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'
|
||
doc.text(`Composición del costo: ${directPct}% directo (${formatCurrency(result.totalDirectCost, currency)}) · ${indirectPct}% overhead (${formatCurrency(result.totalIndirectCost, currency)})`, PDF.margin, y)
|
||
y += 6
|
||
|
||
// 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
|
||
|
||
// 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)
|
||
y += 5
|
||
} else {
|
||
doc.setTextColor(...PDF.slate500)
|
||
doc.text('Sin recursos asignados (costos son fijos por actividad)', PDF.margin, y)
|
||
doc.setTextColor(...PDF.slate700)
|
||
y += 5
|
||
}
|
||
|
||
return y + 2
|
||
}
|
||
|
||
export function drawMethodologySection(
|
||
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', PDF.margin, y)
|
||
y += 5
|
||
|
||
doc.setFont('helvetica', 'normal')
|
||
doc.setFontSize(8)
|
||
doc.setTextColor(...PDF.slate500)
|
||
|
||
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.`,
|
||
]
|
||
|
||
for (const line of lines) {
|
||
const wrapped = doc.splitTextToSize(line, PDF.contentW)
|
||
doc.text(wrapped, PDF.margin, y)
|
||
y += wrapped.length * 4
|
||
}
|
||
|
||
return y
|
||
}
|
||
|
||
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
|
||
)
|
||
}
|
||
}
|