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:
242
src/lib/chart-options.ts
Normal file
242
src/lib/chart-options.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { activityColor, type HeatmapMode } from './colors'
|
||||
import { formatCurrency } from './format'
|
||||
import type { ActivitySimResult, Resource, ResourceType, SimulationResult } from '@/domain/types'
|
||||
|
||||
// Nombres y colores por tipo de recurso — única fuente de verdad
|
||||
export const RESOURCE_TYPE_LABELS: Record<ResourceType, string> = {
|
||||
role: 'Roles', person: 'Personas', system: 'Sistemas',
|
||||
equipment: 'Equipos', supply: 'Insumos',
|
||||
}
|
||||
|
||||
export const RESOURCE_TYPE_COLORS: Record<ResourceType, string> = {
|
||||
role: '#3b82f6', person: '#10b981', system: '#8b5cf6',
|
||||
equipment: '#f59e0b', supply: '#64748b',
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max - 1) + '…' : s
|
||||
}
|
||||
|
||||
// ─── Top actividades por costo — barras horizontales ─────────────────────────
|
||||
|
||||
export function buildTopActivitiesOption(
|
||||
perActivity: ActivitySimResult[],
|
||||
mode: HeatmapMode,
|
||||
currency: string
|
||||
): Record<string, unknown> {
|
||||
const top10 = perActivity.slice(0, 10)
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: unknown[]) => {
|
||||
const p = params[0] as { dataIndex: number; value: number }
|
||||
const act = top10[p.dataIndex]
|
||||
return `<div style="font-size:12px">
|
||||
<div style="font-weight:600;margin-bottom:4px">${act.activityName}</div>
|
||||
<div>Costo esperado: <strong>${formatCurrency(act.expectedTotalCost, currency)}</strong></div>
|
||||
<div>% del total: ${act.percentOfTotal.toFixed(1)}%</div>
|
||||
<div>Directo: ${formatCurrency(act.expectedDirectCost, currency)}</div>
|
||||
<div>Indirecto: ${formatCurrency(act.expectedIndirectCost, currency)}</div>
|
||||
</div>`
|
||||
},
|
||||
},
|
||||
grid: { left: 12, right: 24, top: 8, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
fontSize: 11,
|
||||
formatter: (v: number) => {
|
||||
if (v === 0) return '0'
|
||||
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
|
||||
if (v >= 1_000) return `${(v / 1_000).toFixed(0)}k`
|
||||
return String(v)
|
||||
},
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: top10.map((a) => truncate(a.activityName, 28)),
|
||||
inverse: true,
|
||||
axisLabel: { fontSize: 11, color: '#475569', width: 160, overflow: 'truncate' },
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: top10.map((a) => ({
|
||||
value: a.expectedTotalCost,
|
||||
itemStyle: { color: activityColor(a, perActivity, mode) },
|
||||
})),
|
||||
barMaxWidth: 28,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
fontSize: 10,
|
||||
color: '#64748b',
|
||||
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Composición directo / indirecto — donut ──────────────────────────────────
|
||||
|
||||
export function buildCostCompositionOption(
|
||||
result: Pick<SimulationResult, 'totalDirectCost' | 'totalIndirectCost' | 'totalCost'>,
|
||||
currency: string
|
||||
): Record<string, unknown> {
|
||||
const hasIndirect = result.totalIndirectCost > 0
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (p: { name: string; value: number; percent: number }) =>
|
||||
`<div style="font-size:12px">
|
||||
<div style="font-weight:600;margin-bottom:4px">${p.name}</div>
|
||||
<div>${formatCurrency(p.value, currency)} (${p.percent.toFixed(1)}%)</div>
|
||||
</div>`,
|
||||
},
|
||||
legend: {
|
||||
bottom: '2%',
|
||||
left: 'center',
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
textStyle: { fontSize: 11, color: '#475569' },
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['48%', '68%'],
|
||||
center: ['50%', '44%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 11,
|
||||
formatter: (p: { percent: number }) => `${p.percent.toFixed(1)}%`,
|
||||
},
|
||||
labelLine: { length: 10, length2: 8 },
|
||||
emphasis: { label: { fontSize: 13, fontWeight: 'bold' } },
|
||||
data: [
|
||||
{ value: result.totalDirectCost, name: 'Costo directo', itemStyle: { color: '#3b82f6' } },
|
||||
...(hasIndirect ? [{ value: result.totalIndirectCost, name: 'Costo indirecto (overhead)', itemStyle: { color: '#94a3b8' } }] : []),
|
||||
],
|
||||
}],
|
||||
graphic: [{
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: '38%',
|
||||
style: {
|
||||
text: formatCurrency(result.totalCost, currency),
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
fill: '#1e293b',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Costo por tipo de recurso — barras verticales ────────────────────────────
|
||||
|
||||
interface ResourceTypeTotals {
|
||||
byType: Partial<Record<ResourceType, number>>
|
||||
byTypeDetails: Partial<Record<ResourceType, { name: string; cost: number }[]>>
|
||||
activeTypes: ResourceType[]
|
||||
}
|
||||
|
||||
export function aggregateResourceCosts(
|
||||
perActivity: ActivitySimResult[],
|
||||
resources: Resource[]
|
||||
): ResourceTypeTotals {
|
||||
const resourceById = new Map(resources.map((r) => [r.id, r]))
|
||||
const byType: Partial<Record<ResourceType, number>> = {}
|
||||
const byTypeDetails: Partial<Record<ResourceType, { name: string; cost: number }[]>> = {}
|
||||
|
||||
for (const act of perActivity) {
|
||||
for (const rb of act.resourceCostBreakdown) {
|
||||
if (rb.cost <= 0) continue
|
||||
const res = resourceById.get(rb.resourceId)
|
||||
if (!res) continue
|
||||
byType[res.type] = (byType[res.type] ?? 0) + rb.cost
|
||||
if (!byTypeDetails[res.type]) byTypeDetails[res.type] = []
|
||||
const existing = byTypeDetails[res.type]!.find((d) => d.name === res.name)
|
||||
if (existing) existing.cost += rb.cost
|
||||
else byTypeDetails[res.type]!.push({ name: res.name, cost: rb.cost })
|
||||
}
|
||||
}
|
||||
|
||||
const activeTypes = (Object.keys(RESOURCE_TYPE_LABELS) as ResourceType[]).filter(
|
||||
(t) => (byType[t] ?? 0) > 0
|
||||
)
|
||||
|
||||
return { byType, byTypeDetails, activeTypes }
|
||||
}
|
||||
|
||||
export function buildCostByResourceOption(
|
||||
perActivity: ActivitySimResult[],
|
||||
resources: Resource[],
|
||||
currency: string
|
||||
): Record<string, unknown> | null {
|
||||
const { byType, byTypeDetails, activeTypes } = aggregateResourceCosts(perActivity, resources)
|
||||
if (activeTypes.length === 0) return null
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
formatter: (params: unknown[]) => {
|
||||
const p = params[0] as { dataIndex: number; value: number }
|
||||
const type = activeTypes[p.dataIndex]
|
||||
const dets = byTypeDetails[type] ?? []
|
||||
const detHtml = dets
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.map((d) => `<div style="font-size:11px;padding-left:8px">• ${d.name}: ${formatCurrency(d.cost, currency)}</div>`)
|
||||
.join('')
|
||||
return `<div style="font-size:12px">
|
||||
<div style="font-weight:600;margin-bottom:4px">${RESOURCE_TYPE_LABELS[type]}</div>
|
||||
<div>Total: <strong>${formatCurrency(p.value, currency)}</strong></div>${detHtml}
|
||||
</div>`
|
||||
},
|
||||
},
|
||||
grid: { left: 12, right: 12, top: 12, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: activeTypes.map((t) => RESOURCE_TYPE_LABELS[t]),
|
||||
axisLabel: { fontSize: 11, color: '#475569' },
|
||||
axisTick: { show: false },
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
color: '#94a3b8',
|
||||
formatter: (v: number) => {
|
||||
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`
|
||||
if (v >= 1_000) return `${(v / 1_000).toFixed(0)}k`
|
||||
return String(v)
|
||||
},
|
||||
},
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: activeTypes.map((t) => ({
|
||||
value: byType[t] ?? 0,
|
||||
itemStyle: { color: RESOURCE_TYPE_COLORS[t], borderRadius: [4, 4, 0, 0] },
|
||||
})),
|
||||
barMaxWidth: 48,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
fontSize: 10,
|
||||
color: '#64748b',
|
||||
formatter: (p: { value: number }) => formatCurrency(p.value, currency),
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
149
src/lib/colors.ts
Normal file
149
src/lib/colors.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// Paleta heatmap: verde → amarillo → rojo
|
||||
const HEATMAP_LOW = { r: 16, g: 185, b: 129 } // #10b981
|
||||
const HEATMAP_MID = { r: 245, g: 158, b: 11 } // #f59e0b
|
||||
const HEATMAP_HIGH = { r: 239, g: 68, b: 68 } // #ef4444
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return Math.round(a + (b - a) * t)
|
||||
}
|
||||
|
||||
// t en [0, 1]
|
||||
export function heatmapColor(t: number): string {
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (clamped <= 0.5) {
|
||||
const localT = clamped * 2
|
||||
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
|
||||
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
|
||||
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
|
||||
} else {
|
||||
const localT = (clamped - 0.5) * 2
|
||||
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
|
||||
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
|
||||
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
|
||||
}
|
||||
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
|
||||
export function heatmapColorHex(t: number): string {
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (clamped <= 0.5) {
|
||||
const localT = clamped * 2
|
||||
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
|
||||
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
|
||||
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
|
||||
} else {
|
||||
const localT = (clamped - 0.5) * 2
|
||||
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
|
||||
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
|
||||
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
|
||||
}
|
||||
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Retorna opacidad para colores con alpha (útil para overlays sobre bpmn-js)
|
||||
export function heatmapColorWithAlpha(t: number, alpha = 0.75): string {
|
||||
const clamped = Math.max(0, Math.min(1, t))
|
||||
let r: number, g: number, b: number
|
||||
|
||||
if (clamped <= 0.5) {
|
||||
const localT = clamped * 2
|
||||
r = lerp(HEATMAP_LOW.r, HEATMAP_MID.r, localT)
|
||||
g = lerp(HEATMAP_LOW.g, HEATMAP_MID.g, localT)
|
||||
b = lerp(HEATMAP_LOW.b, HEATMAP_MID.b, localT)
|
||||
} else {
|
||||
const localT = (clamped - 0.5) * 2
|
||||
r = lerp(HEATMAP_MID.r, HEATMAP_HIGH.r, localT)
|
||||
g = lerp(HEATMAP_MID.g, HEATMAP_HIGH.g, localT)
|
||||
b = lerp(HEATMAP_MID.b, HEATMAP_HIGH.b, localT)
|
||||
}
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
||||
}
|
||||
|
||||
// Determina si el texto sobre el color debe ser claro u oscuro (WCAG contrast)
|
||||
export function getContrastText(t: number): 'white' | 'black' {
|
||||
return t > 0.5 ? 'white' : 'black'
|
||||
}
|
||||
|
||||
// ─── Helpers para el heatmap del reporte ──────────────────────────────────────
|
||||
|
||||
export type HeatmapMode = 'relative' | 'absolute'
|
||||
|
||||
export interface ActivityCostData {
|
||||
expectedTotalCost: number
|
||||
percentOfTotal: number
|
||||
}
|
||||
|
||||
// Color neutro (gris claro) cuando no hay varianza significativa entre actividades.
|
||||
// Evita comunicar una falsa diferenciación cuando todos los costos son similares.
|
||||
export const NEUTRAL_HEATMAP_COLOR = '#cbd5e1' // slate-300
|
||||
|
||||
// Umbral: si el rango relativo (max-min)/max < 5%, la varianza no es significativa.
|
||||
export const VARIANCE_THRESHOLD = 0.05
|
||||
|
||||
export function hasSignificantCostVariance(activities: ActivityCostData[]): boolean {
|
||||
if (activities.length < 2) return false
|
||||
const costs = activities.map((a) => a.expectedTotalCost)
|
||||
const max = Math.max(...costs)
|
||||
const min = Math.min(...costs)
|
||||
if (max <= 0) return false
|
||||
return (max - min) / max > VARIANCE_THRESHOLD
|
||||
}
|
||||
|
||||
// Calcula t ∈ [0,1] para una actividad dada la lista completa y el modo.
|
||||
// Modo relativo: normaliza sobre la actividad más costosa (t=1 = la que más pesa).
|
||||
// Modo absoluto: normaliza sobre el rango min-max del proceso.
|
||||
// Pre-condición: llamar solo cuando hasSignificantCostVariance = true.
|
||||
export function computeActivityT(
|
||||
activity: ActivityCostData,
|
||||
allActivities: ActivityCostData[],
|
||||
mode: HeatmapMode
|
||||
): number {
|
||||
if (allActivities.length === 0) return 0
|
||||
|
||||
if (mode === 'relative') {
|
||||
const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal), 0.001)
|
||||
return Math.max(0, Math.min(1, activity.percentOfTotal / maxPercent))
|
||||
}
|
||||
|
||||
const costs = allActivities.map((a) => a.expectedTotalCost)
|
||||
const minCost = Math.min(...costs)
|
||||
const maxCost = Math.max(...costs)
|
||||
if (maxCost <= minCost) return 0.5
|
||||
return Math.max(0, Math.min(1, (activity.expectedTotalCost - minCost) / (maxCost - minCost)))
|
||||
}
|
||||
|
||||
// Función unificada: devuelve el color correcto considerando varianza.
|
||||
// Usar en HeatmapCanvas, ActivitiesTable y los gráficos para consistencia.
|
||||
export function activityColor(
|
||||
activity: ActivityCostData,
|
||||
allActivities: ActivityCostData[],
|
||||
mode: HeatmapMode
|
||||
): string {
|
||||
if (!hasSignificantCostVariance(allActivities)) return NEUTRAL_HEATMAP_COLOR
|
||||
return heatmapColorHex(computeActivityT(activity, allActivities, mode))
|
||||
}
|
||||
|
||||
// Valores de los extremos para la leyenda del heatmap
|
||||
export function heatmapLegendBounds(
|
||||
allActivities: ActivityCostData[],
|
||||
mode: HeatmapMode
|
||||
): { min: number; mid: number; max: number; unit: 'currency' | 'percent' } {
|
||||
if (allActivities.length === 0) return { min: 0, mid: 50, max: 100, unit: 'percent' }
|
||||
|
||||
if (mode === 'relative') {
|
||||
const maxPercent = Math.max(...allActivities.map((a) => a.percentOfTotal))
|
||||
return { min: 0, mid: maxPercent / 2, max: maxPercent, unit: 'percent' }
|
||||
}
|
||||
|
||||
const costs = allActivities.map((a) => a.expectedTotalCost)
|
||||
const minCost = Math.min(...costs)
|
||||
const maxCost = Math.max(...costs)
|
||||
return { min: minCost, mid: (minCost + maxCost) / 2, max: maxCost, unit: 'currency' }
|
||||
}
|
||||
115
src/lib/export/csv-export.ts
Normal file
115
src/lib/export/csv-export.ts
Normal 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)
|
||||
}
|
||||
131
src/lib/export/pdf-export.ts
Normal file
131
src/lib/export/pdf-export.ts
Normal 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`
|
||||
}
|
||||
291
src/lib/export/pdf-sections.ts
Normal file
291
src/lib/export/pdf-sections.ts
Normal 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
28
src/lib/export/slug.ts
Normal 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}`
|
||||
}
|
||||
36
src/lib/format.ts
Normal file
36
src/lib/format.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export function formatCurrency(amount: number, currency = 'USD'): string {
|
||||
return new Intl.NumberFormat('es-PY', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export function formatNumber(n: number, decimals = 1): string {
|
||||
return new Intl.NumberFormat('es-PY', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(n)
|
||||
}
|
||||
|
||||
export function formatPercent(n: number, decimals = 1): string {
|
||||
return `${formatNumber(n, decimals)}%`
|
||||
}
|
||||
|
||||
export function formatMinutes(minutes: number): string {
|
||||
if (minutes < 60) return `${Math.round(minutes)} min`
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = Math.round(minutes % 60)
|
||||
return m > 0 ? `${h}h ${m}min` : `${h}h`
|
||||
}
|
||||
|
||||
// Formato de timestamp de simulación: "13 de mayo de 2026 a las 09:45"
|
||||
// Locale explícito es-PY — nunca depende del locale del browser del cliente.
|
||||
export function formatSimulationTimestamp(ts: number): { date: string; time: string } {
|
||||
const d = new Date(ts)
|
||||
return {
|
||||
date: d.toLocaleDateString('es-PY', { day: '2-digit', month: 'long', year: 'numeric' }),
|
||||
time: d.toLocaleTimeString('es-PY', { hour: '2-digit', minute: '2-digit' }),
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user