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:
2026-05-18 10:03:37 -03:00
parent 2f4633a33c
commit 3834c9f1c1
18 changed files with 1904 additions and 112 deletions

View File

@@ -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.

View File

@@ -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 } },
})

View File

@@ -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)