"chore(claude-code): configurar permisos compartidos del proyecto

- settings.json: deny patterns críticos (rm -rf root, force push, etc.)
- settings.local.json en gitignore — cada dev configura sus allows

Refs: methodology/PATTERNS.md B.4 (reglas no negociables en repo)
This commit is contained in:
2026-05-17 07:49:00 -03:00
parent 5ef385c4a3
commit 63c4fbac99
11 changed files with 718 additions and 36 deletions

View File

@@ -1,13 +1,14 @@
import { buildFileName } from './slug'
import { calculateRoi } from '@/domain/roi'
import {
drawHeader, drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
drawAnalysisSection, drawMethodologySection, addPageNumbers, PDF,
drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
drawAnalysisSection, drawMethodologySection, PDF,
} from './pdf-sections'
import {
drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig,
drawRoiAnalysisPage, drawRoiMethodologySection,
} from './pdf-sections-roi'
import { drawSharedHeader, applyFootersToAllPages, PDF_COLORS } from './pdf-sections-shared'
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
export type ReportTab = 'actual' | 'automatizado' | 'roi'
@@ -69,7 +70,10 @@ async function exportScenarioPdf(
keywords: 'bpmn, costos, simulación',
})
let y = drawHeader(docAny, process, simulation.executedAt)
const headerOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
// Página 1
let y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 1 })
y = drawKpiCards(docAny, result, currency, y)
y += 4
@@ -83,8 +87,9 @@ async function exportScenarioPdf(
y = drawHeatmapLegend(docAny, y)
y += 4
// Página 2 — análisis + tabla de costos
docAny.addPage()
y = PDF.margin
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 2 })
y = drawAnalysisSection(docAny, result, currency, y)
y += 6
@@ -117,14 +122,16 @@ async function exportScenarioPdf(
startY: y,
margin: { left: PDF.margin, right: PDF.margin },
styles: { fontSize: 8, cellPadding: 2.5, overflow: 'ellipsize' },
headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 },
headStyles: { fillColor: PDF_COLORS.inqOrange, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 },
alternateRowStyles: { fillColor: PDF.slate50 },
columnStyles: { 0: { cellWidth: 48 } },
})
// Página 3 — nota metodológica
docAny.addPage()
drawMethodologySection(docAny, process, PDF.margin)
addPageNumbers(docAny, 'InQ ROI · Powered by InQuality', simulation.executedAt)
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 3 })
drawMethodologySection(docAny, process, y)
applyFootersToAllPages(docAny, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
}
// ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
@@ -157,14 +164,16 @@ async function exportRoiPdf(
keywords: 'bpmn, roi, automatización, payback',
})
const roiHeaderOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
// ── Página 1: Header + resumen ejecutivo + 5 KPI cards ─────────────────────
let y = drawHeader(doc, process, simulation.executedAt)
let y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 1 })
y = drawRoiExecutiveSummary(doc, process, roi, currency, y)
y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y)
// ── Página 2: Tabla comparativa ────────────────────────────────────────────
doc.addPage()
y = PDF.margin
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 2 })
doc.setFont('helvetica', 'bold')
doc.setFontSize(12)
@@ -189,14 +198,15 @@ async function exportRoiPdf(
// ── Página 3: Análisis textual + transparencia ─────────────────────────────
doc.addPage()
y = PDF.margin
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 3 })
drawRoiAnalysisPage(doc, roi, process, result.perActivity, resultAutomated.perActivity, activities, currency, y)
// ── Página 4: Metodología ──────────────────────────────────────────────────
doc.addPage()
drawRoiMethodologySection(doc, process, PDF.margin)
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 4 })
drawRoiMethodologySection(doc, process, y)
addPageNumbers(doc, 'InQ ROI · Powered by InQuality', simulation.executedAt)
applyFootersToAllPages(doc, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
}
function formatNum(n: number): string {

View File

@@ -2,6 +2,7 @@ import { formatCurrency, formatPayback, 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'
import { PDF_COLORS } from './pdf-sections-shared'
const ROI_HIGH_THRESHOLD = 1000
@@ -194,7 +195,7 @@ export function buildComparisonTableConfig(
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 },
headStyles: { fillColor: PDF_COLORS.inqOrange, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 },
columnStyles: { 0: { cellWidth: 42 } },
}
}

View File

@@ -0,0 +1,191 @@
/**
* Módulo compartido de header/footer PDF — Sprint 1.5 Etapa 4.
*
* Provee drawSharedHeader, drawSharedFooter, applyFootersToAllPages
* y PDF_COLORS para uso en los 3 PDFs (Actual, Automatizado, ROI).
*
* Diseñado para ser compatible con páginas landscape (Etapa 5):
* usa doc.internal.pageSize.getWidth/Height() en lugar de constantes A4.
*/
import { formatSimulationTimestamp } from '@/lib/format'
// ─── Tipo DocLike ─────────────────────────────────────────────────────────────
// Superset del DocLike anterior: agrega pageSize para compatibilidad landscape.
// pdf-sections.ts re-exporta este tipo para mantener compatibilidad de imports.
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
pageSize: {
getWidth: () => number
getHeight: () => number
}
}
splitTextToSize: (text: string, maxWidth: number) => string[]
}
// ─── Paleta PDF ───────────────────────────────────────────────────────────────
export const PDF_COLORS = {
inqOrange: [245, 152, 69] as [number, number, number], // #F59845
inqOrangeDark: [180, 83, 9] as [number, number, number], // #B45309
inqMagenta: [237, 62, 143] as [number, number, number], // #ED3E8F
textPrimary: [15, 23, 42] as [number, number, number], // #0F172A slate-900
textSecondary: [71, 85, 105] as [number, number, number], // #475569 slate-600
textTertiary: [100, 116, 139] as [number, number, number], // #64748B slate-500
textDisabled: [148, 163, 184] as [number, number, number], // #94A3B8 slate-400
borderLight: [226, 232, 240] as [number, number, number], // #E2E8F0 slate-200
slate50: [248, 250, 252] as [number, number, number], // #F8FAFC
successGreen: [22, 163, 74] as [number, number, number], // #16A34A
dangerRed: [239, 68, 68] as [number, number, number], // #EF4444
warningAmber: [245, 158, 11] as [number, number, number], // #F59E0B
} as const
const MARGIN = 20 // mm
// ─── Header compartido ────────────────────────────────────────────────────────
export interface PdfHeaderOptions {
pageNumber: number // 1-based — determina grosor del border (3px pág 1, 2px resto)
processName: string
sectionTitle?: string // subtítulo de sección, ej. "Análisis de retorno de inversión"
clientName?: string
simulatedAt: number // Unix timestamp ms
}
/**
* Dibuja el header InQ-branded.
* - Logo "InQ ROI" naranja a la izquierda, fecha a la derecha
* - Nombre del proceso + cliente
* - Border-bottom naranja (más grueso en página 1)
* Retorna la coordenada y donde empieza el contenido.
*/
export function drawSharedHeader(doc: DocLike, options: PdfHeaderOptions): number {
const pageW = doc.internal.pageSize.getWidth()
const contentRight = pageW - MARGIN
let y = MARGIN
// Logo "InQ ROI" — naranja bold
doc.setFont('helvetica', 'bold')
doc.setFontSize(13)
doc.setTextColor(...PDF_COLORS.inqOrange)
doc.text('InQ ROI', MARGIN, y)
// Fecha — derecha, slate-400
const { date } = formatSimulationTimestamp(options.simulatedAt)
doc.setFont('helvetica', 'normal')
doc.setFontSize(7.5)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text(date, contentRight, y, { align: 'right' } as Record<string, unknown>)
y += 7
// Nombre del proceso — bold slate-900
doc.setFont('helvetica', 'bold')
doc.setFontSize(11)
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(options.processName, MARGIN, y)
// Cliente — derecha, slate-500
if (options.clientName) {
doc.setFont('helvetica', 'normal')
doc.setFontSize(7.5)
doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text(options.clientName, contentRight, y, { align: 'right' } as Record<string, unknown>)
}
y += 5
// Subtítulo de sección (opcional)
if (options.sectionTitle) {
doc.setFont('helvetica', 'normal')
doc.setFontSize(8.5)
doc.setTextColor(...PDF_COLORS.textSecondary)
doc.text(options.sectionTitle, MARGIN, y)
y += 4.5
}
// Border-bottom naranja InQ: 0.8mm (≈3pt) en página 1, 0.5mm (≈2pt) en el resto
const borderWidth = options.pageNumber === 1 ? 0.8 : 0.5
doc.setDrawColor(...PDF_COLORS.inqOrange)
doc.setLineWidth(borderWidth)
doc.line(MARGIN, y, contentRight, y)
y += 5
// Reset lineWidth para no afectar el contenido siguiente
doc.setLineWidth(0.2)
return y
}
// ─── Footer compartido ────────────────────────────────────────────────────────
export interface PdfFooterOptions {
clientName?: string
simulatedAt: number
}
/**
* Dibuja el footer en la posición inferior fija de la página actual.
* - Border-top slate-200
* - Izquierda: "InQ ROI · Powered by InQuality · [cliente] · [fecha]"
* - Derecha: "Página X de Y"
* No incluye versión del producto.
*/
export function drawSharedFooter(
doc: DocLike,
options: PdfFooterOptions & { pageNumber: number; totalPages: number }
): void {
const pageW = doc.internal.pageSize.getWidth()
const pageH = doc.internal.pageSize.getHeight()
const contentRight = pageW - MARGIN
const footerLineY = pageH - 14
const footerTextY = pageH - 9
// Border-top
doc.setDrawColor(...PDF_COLORS.borderLight)
doc.setLineWidth(0.2)
doc.line(MARGIN, footerLineY, contentRight, footerLineY)
// Texto izquierda
const { date } = formatSimulationTimestamp(options.simulatedAt)
const parts: string[] = ['InQ ROI', 'Powered by InQuality']
if (options.clientName) parts.push(options.clientName)
parts.push(date)
doc.setFont('helvetica', 'normal')
doc.setFontSize(7)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.text(parts.join(' · '), MARGIN, footerTextY)
// Paginación derecha
doc.text(
`Página ${options.pageNumber} de ${options.totalPages}`,
contentRight,
footerTextY,
{ align: 'right' } as Record<string, unknown>
)
}
/**
* Aplica footer a TODAS las páginas del documento.
* Llamar después de agregar todo el contenido.
*/
export function applyFootersToAllPages(doc: DocLike, options: PdfFooterOptions): void {
const total = doc.internal.getNumberOfPages()
for (let i = 1; i <= total; i++) {
doc.setPage(i)
drawSharedFooter(doc, { ...options, pageNumber: i, totalPages: total })
}
}

View File

@@ -1,12 +1,17 @@
import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
import type { SimulationResult, Process } 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'
// 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,
blue: [30, 64, 175] as [number, number, number],
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],
@@ -17,25 +22,6 @@ export const PDF = {
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
@@ -109,7 +95,7 @@ export function drawKpiCards(
// 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.setTextColor(...(card.accent ? PDF.inqOrange : PDF.slate900))
doc.text(card.value, x + 4, y + 16)
maxY = Math.max(maxY, y + cardH)