feat: MVP Fase 0 — Process Cost Platform v0.1.0

Plataforma completa de análisis de costos operativos basada en BPMN 2.0.

Funcionalidades incluidas:
- Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo
- Visualización read-only del diagrama (bpmn-js)
- Configuración de actividades (costo, tiempo, recursos asignados)
- CRUD de recursos (rol, persona, sistema, equipo, insumo)
- Configuración global (moneda, overhead %, nombre, cliente)
- Motor de simulación determinístico agregado con propagación de gateways XOR/AND
- Reporte visual con mapa de calor en dos modos (relativo/absoluto)
- Gráficos: KPIs, top actividades, composición, costo por recurso
- Export PDF (jsPDF + html2canvas) y CSV (papaparse)
- Persistencia en IndexedDB (Dexie)
- 268 tests unitarios/integración + 6 E2E con Playwright
- Deploy: https://process-cost-platform.pages.dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 01:58:40 -03:00
commit bed161c92a
97 changed files with 19638 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
import Papa from 'papaparse'
import { formatSimulationTimestamp } from '@/lib/format'
import { buildFileName } from './slug'
import type { Process, Simulation, Resource, ActivitySimResult } from '@/domain/types'
// Monedas que usan coma como separador decimal (necesitan punto y coma en el CSV)
const COMMA_DECIMAL_CURRENCIES = new Set(['PYG', 'BRL', 'EUR', 'ARS', 'COP', 'MXN', 'CLP'])
function getDelimiter(currency: string): string {
return COMMA_DECIMAL_CURRENCIES.has(currency) ? ';' : ','
}
function formatDecimalForCsv(value: number, currency: string): string {
const formatted = value.toFixed(2)
if (COMMA_DECIMAL_CURRENCIES.has(currency)) {
return formatted.replace('.', ',')
}
return formatted
}
function buildResourcesCell(
act: ActivitySimResult,
resources: Resource[]
): string {
if (act.resourceCostBreakdown.length === 0) return ''
const resourceById = new Map(resources.map((r) => [r.id, r]))
return act.resourceCostBreakdown
.map((rb) => {
const res = resourceById.get(rb.resourceId)
if (!res) return rb.resourceName
// Calcular % de utilización desde el costo relativo
return res.name
})
.join('; ')
}
export interface CsvExportParams {
process: Process
simulation: Simulation
resources: Resource[]
}
export function generateCsvContent(params: CsvExportParams): string {
const { process, simulation, resources } = params
const { currency } = process
const result = simulation.result
const delimiter = getDelimiter(currency)
const { date } = formatSimulationTimestamp(simulation.executedAt)
// ── Filas de metadata como comentarios ──────────────────────────────────
const meta = [
`# Proceso: ${process.name}`,
`# Cliente: ${process.clientName || 'N/A'}`,
`# Fecha simulación: ${date}`,
`# Moneda: ${currency}`,
`# Costo total (${currency}): ${formatDecimalForCsv(result.totalCost, currency)}`,
`# Costo directo: ${formatDecimalForCsv(result.totalDirectCost, currency)}`,
`# Costo indirecto (overhead ${(process.overheadPercentage * 100).toFixed(0)}%): ${formatDecimalForCsv(result.totalIndirectCost, currency)}`,
'',
].join('\r\n')
// ── Headers ──────────────────────────────────────────────────────────────
const headers = [
'ID BPMN',
'Actividad',
'Tipo',
`Costo directo (${currency})`,
`Costo indirecto (${currency})`,
`Costo total (${currency})`,
'% del total',
'Tiempo (min)',
'Ejecuciones esperadas',
'Recursos asignados',
]
// ── Filas ─────────────────────────────────────────────────────────────────
const rows = result.perActivity.map((act) => [
act.bpmnElementId,
act.activityName,
act.bpmnElementId.includes('subprocess') ? 'subproceso' : 'tarea',
formatDecimalForCsv(act.expectedDirectCost, currency),
formatDecimalForCsv(act.expectedIndirectCost, currency),
formatDecimalForCsv(act.expectedTotalCost, currency),
formatDecimalForCsv(act.percentOfTotal, currency),
formatDecimalForCsv(act.executionTimeMinutes, currency),
formatDecimalForCsv(act.executionProbability * 100, currency),
buildResourcesCell(act, resources),
])
const csvBody = Papa.unparse(
{ fields: headers, data: rows },
{ delimiter, newline: '\r\n', quotes: true }
)
// BOM UTF-8 + metadata + datos
return '' + meta + csvBody
}
export function exportToCsv(params: CsvExportParams): void {
const content = generateCsvContent(params)
const fileName = buildFileName(
params.process.name,
params.process.clientName,
new Date(params.simulation.executedAt),
'csv'
)
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}

View File

@@ -0,0 +1,131 @@
import { buildFileName } from './slug'
import {
drawHeader, drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
drawAnalysisSection, drawMethodologySection, addPageNumbers, PDF,
} from './pdf-sections'
import type { Process, Simulation, Resource } from '@/domain/types'
export interface PdfExportParams {
process: Process
simulation: Simulation
resources: Resource[]
heatmapImageData: string | null // data URL de la imagen capturada del canvas
}
export async function exportToPdf(params: PdfExportParams): Promise<void> {
const { process, simulation, resources, heatmapImageData } = params
const result = simulation.result
const currency = process.currency
// Carga dinámica: jsPDF + autotable solo se descargan al hacer click
const [{ jsPDF }, { default: autoTable }] = await Promise.all([
import('jspdf'),
import('jspdf-autotable'),
])
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' })
// Metadata del PDF
doc.setProperties({
title: `Análisis de costos - ${process.name}`,
author: process.clientName || '',
subject: 'Simulación de proceso BPMN',
creator: 'Process Cost Platform',
keywords: 'bpmn, costos, simulación',
})
// ── PÁGINA 1: Header + KPIs + Heatmap ────────────────────────────────────
let y = drawHeader(doc as any, process, simulation.executedAt)
y = drawKpiCards(doc as any, result, currency, y)
y += 4
// Título del heatmap
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('Mapa de calor de costos', PDF.margin, y)
y += 5
y = drawHeatmapImage(doc as any, heatmapImageData, y)
y = drawHeatmapLegend(doc as any, y)
y += 4
// ── PÁGINA 2: Análisis ────────────────────────────────────────────────────
doc.addPage()
y = PDF.margin
y = drawAnalysisSection(doc as any, result, currency, y)
y += 6
// ── PÁGINA 3+: Tabla detalle (autotable maneja paginación automática) ─────
void resources // disponible para uso futuro (recursos en tabla de actividades)
const RA = 'right' as const
const tableHead = [
[
{ content: 'Actividad', styles: { cellWidth: 48 } },
{ content: `Dir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
{ content: `Indir. (${currency})`, styles: { halign: RA, cellWidth: 25 } },
{ content: `Total (${currency})`, styles: { halign: RA, cellWidth: 28 } },
{ content: '% total', styles: { halign: RA, cellWidth: 16 } },
{ content: 'Tiempo', styles: { halign: RA, cellWidth: 14 } },
{ content: 'Prob.', styles: { halign: RA, cellWidth: 14 } },
],
]
const tableBody = result.perActivity.map((act) => [
{ content: act.activityName },
{ content: formatNum(act.expectedDirectCost), styles: { halign: RA, font: 'courier' } },
{ content: formatNum(act.expectedIndirectCost), styles: { halign: RA, font: 'courier' } },
{ content: formatNum(act.expectedTotalCost), styles: { halign: RA, font: 'courier' } },
{ content: `${act.percentOfTotal.toFixed(1)}%`, styles: { halign: RA, font: 'courier' } },
{ content: act.executionTimeMinutes > 0 ? formatTime(act.executionTimeMinutes) : '—', styles: { halign: RA } },
{ content: `${(act.executionProbability * 100).toFixed(0)}%`, styles: { halign: RA } },
])
autoTable(doc, {
head: tableHead,
body: tableBody,
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,
},
alternateRowStyles: { fillColor: PDF.slate50 },
columnStyles: { 0: { cellWidth: 48 } },
didDrawPage: () => {
// Placeholder — los footers se añaden al final con addPageNumbers
},
})
// ── PÁGINA FINAL: Metodología ────────────────────────────────────────────
doc.addPage()
drawMethodologySection(doc as any, process, PDF.margin)
// ── FOOTER en todas las páginas ───────────────────────────────────────────
addPageNumbers(doc as any, 'Process Cost Platform', simulation.executedAt)
// ── Descargar ─────────────────────────────────────────────────────────────
const fileName = buildFileName(
process.name,
process.clientName,
new Date(simulation.executedAt),
'pdf'
)
doc.save(fileName)
}
function formatNum(n: number): string {
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function formatTime(minutes: number): string {
if (minutes < 60) return `${Math.round(minutes)}m`
const h = Math.floor(minutes / 60)
const m = Math.round(minutes % 60)
return m > 0 ? `${h}h ${m}m` : `${h}h`
}

View File

@@ -0,0 +1,291 @@
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)
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
)
}
}

28
src/lib/export/slug.ts Normal file
View File

@@ -0,0 +1,28 @@
export function slugify(text: string): string {
return text
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // eliminar diacríticos (á→a, ñ→n, ü→u)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // todo lo no-alfanumérico → guión
.replace(/^-+|-+$/g, '') // recortar guiones del inicio/fin
.slice(0, 60) // limitar longitud para nombres de archivo
}
export function buildFileName(
processName: string,
clientName: string | undefined,
date: Date,
ext: 'pdf' | 'csv'
): string {
const dateStr = [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('')
const parts = [slugify(processName)]
if (clientName?.trim()) parts.push(slugify(clientName.trim()))
parts.push(dateStr)
return `${parts.join('_')}.${ext}`
}