feat(sprint-2): recursos por actividad — modelo, UX y PDF

Etapas 3-6 del Sprint 2. Resumen de cambios:

**Reporte web (Etapa 3)**
- ActivitiesTable: filas expandibles con desglose fijo + recursos por actividad
- CumulativeSavingsChart: fix payback label superpuesto con eje Y
- BpmnCanvas: badge  14→20px
- TECH_DEBT cerrado: chart -inversión (ya estaba), payback label, badge

**Workspace (Etapa 4)**
- WorkspacePage: edición inline de nombre de proceso y cliente
  (click → input → Enter/blur guarda en IndexedDB, checkmark naranja 1.5s)

**PDF (Etapas 5-6)**
- pdf-sections: drawResourcesSection — tabla condicional de recursos por actividad
  con chips de tipo (rol/persona/sistema/equipamiento/insumo)
- pdf-export: página de recursos (pág. 4 cuando hay datos, metodología pasa a pág. 5)
- Fix truncamiento: splitTextToSize en resourceName y top3 ROI
- Fix texto: "ejecución" con tilde, header "Esp. (USD)" sin truncar
- TECH_DEBT cerrado: truncamiento notas metodológicas

**Tests**
- 7 tests nuevos: drawResourcesSection (4) + comportamiento condicional PDF (3)
- Total: 541 → 548 tests verdes

BREAKING: exportScenarioPdf ahora recibe activities[] como parámetro adicional.
This commit is contained in:
2026-05-20 08:24:32 -03:00
parent 8b6ae5fa64
commit 1ff2724735
9 changed files with 1330 additions and 17 deletions

View File

@@ -2,7 +2,8 @@ import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } f
import { useParams, useNavigate } from '@tanstack/react-router'
import {
Loader2, BarChart3, Play, AlertTriangle,
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2,
Check, Pencil
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -23,7 +24,7 @@ type SelectedCategory = 'activity' | 'gateway' | null
export function WorkspacePage() {
const { processId } = useParams({ from: '/workspace/$processId' })
const navigate = useNavigate()
const { currentProcess, activities, isLoading, error, loadProcess, gateways } = useProcessStore()
const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore()
const { isRunning, resultActual, error: simError, selectActivity } = useSimulationStore()
const { simulate } = useSimulate()
const { toast } = useToast()
@@ -34,6 +35,12 @@ export function WorkspacePage() {
const [selectedCategory, setSelectedCategory] = useState<SelectedCategory>(null)
const [activeTab, setActiveTab] = useState('elemento')
// Estado para edición inline del header
const [editingField, setEditingField] = useState<'name' | 'client' | null>(null)
const [editValue, setEditValue] = useState('')
const [savedField, setSavedField] = useState<'name' | 'client' | null>(null)
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Usamos un ref para saber si loadProcess ya terminó al menos una vez.
// Esto evita que el redirect se dispare en el render inicial donde
// isLoading=false y currentProcess=null (antes del primer efecto).
@@ -93,6 +100,24 @@ export function WorkspacePage() {
selectActivity(null)
}
async function saveField(field: 'name' | 'client') {
if (field === 'name') {
const trimmed = editValue.trim()
if (trimmed && trimmed !== currentProcess?.name) {
await updateProcess({ name: trimmed })
}
} else {
const trimmed = editValue.trim()
if (trimmed !== (currentProcess?.clientName ?? '')) {
await updateProcess({ clientName: trimmed })
}
}
setEditingField(null)
setSavedField(field)
if (savedTimerRef.current) clearTimeout(savedTimerRef.current)
savedTimerRef.current = setTimeout(() => setSavedField(null), 1500)
}
async function handleSimulate() {
if (!canSimulate) return
const simResult = await simulate()
@@ -137,10 +162,63 @@ export function WorkspacePage() {
<Separator orientation="vertical" className="h-5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
{currentProcess.clientName && (
<p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
{/* Nombre del proceso — editable inline */}
{editingField === 'name' ? (
<input
autoFocus
className="text-sm font-semibold text-slate-800 bg-transparent border-b border-[#F59845] outline-none w-full"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => saveField('name')}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); saveField('name') }
if (e.key === 'Escape') setEditingField(null)
}}
/>
) : (
<div
className="flex items-center gap-1 group cursor-text"
onClick={() => { setEditValue(currentProcess.name); setEditingField('name') }}
>
<p className="text-sm font-semibold text-slate-800 truncate">{currentProcess.name}</p>
{savedField === 'name'
? <Check className="h-3.5 w-3.5 text-[#F59845] shrink-0" />
: <Pencil className="h-3 w-3 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
}
</div>
)}
{/* Cliente — editable inline */}
{editingField === 'client' ? (
<input
autoFocus
className="text-xs text-slate-400 bg-transparent border-b border-[#F59845] outline-none w-full"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => saveField('client')}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); saveField('client') }
if (e.key === 'Escape') setEditingField(null)
}}
placeholder="Agregar cliente…"
/>
) : (
<div
className="flex items-center gap-1 group cursor-text"
onClick={() => { setEditValue(currentProcess.clientName ?? ''); setEditingField('client') }}
>
{currentProcess.clientName
? <p className="text-xs text-slate-400 truncate">{currentProcess.clientName}</p>
: <p className="text-xs text-slate-300 truncate italic">Agregar cliente</p>
}
{savedField === 'client'
? <Check className="h-3 w-3 text-[#F59845] shrink-0" />
: <Pencil className="h-2.5 w-2.5 text-slate-300 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
}
</div>
)}
</div>
{/* Errores de simulación */}

View File

@@ -4,7 +4,7 @@ import { calculateRoi } from '@/domain/roi'
import {
drawScenarioPage1,
drawHeatmapImage, drawHeatmapLegend,
drawAnalysisSection, drawMethodologySection, PDF,
drawAnalysisSection, drawMethodologySection, drawResourcesSection, PDF,
} from './pdf-sections'
import {
drawRoiPage1, buildComparisonTableConfig,
@@ -41,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, tab as 'actual' | 'automatizado')
await exportScenarioPdf(doc, autoTable, process, simulation, result, resources, heatmapImageData, currency, tab as 'actual' | 'automatizado', activities)
}
const suffix = tab === 'roi' ? '_roi' : tab === 'automatizado' ? '_automatizado' : '_actual'
@@ -61,7 +61,8 @@ async function exportScenarioPdf(
resources: Resource[],
heatmapImageData: string | null,
currency: string,
tab: 'actual' | 'automatizado'
tab: 'actual' | 'automatizado',
activities: Activity[] = []
): Promise<void> {
const docAny = doc as any
const scenarioLabel = tab === 'actual' ? 'Escenario actual' : 'Escenario automatizado'
@@ -98,8 +99,6 @@ async function exportScenarioPdf(
y = drawAnalysisSection(docAny, result, currency, y)
y += 6
void resources
const RA = 'right' as const
const tableHead = [[
{ content: 'Actividad', styles: { cellWidth: 48 } },
@@ -139,9 +138,18 @@ async function exportScenarioPdf(
columnStyles: { 0: { cellWidth: 48 } },
})
// ── Página 4 (portrait): Nota metodológica ────────────────────────────────────
// ── Página 4 condicional: Tabla de recursos (solo si hay actividades con recursos)
const hasResources = result.perActivity.some((act) => act.resourceCostBreakdown.length > 0)
if (hasResources) {
docAny.addPage('a4', 'portrait')
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 4 })
drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y)
}
// ── Página 4 o 5: Nota metodológica ──────────────────────────────────────────
docAny.addPage('a4', 'portrait')
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 4 })
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: hasResources ? 5 : 4 })
drawMethodologySection(docAny, process, y)
applyFootersToAllPages(docAny, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })

View File

@@ -723,8 +723,12 @@ export function drawRoiAnalysisPage(
doc.setTextColor(...PDF_COLORS.textSecondary)
for (const act of top3) {
const actPct = totalSavingsAll > 0 ? (act.savings / totalSavingsAll * 100).toFixed(1) : '0'
doc.text(` - ${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`, m, y)
y += 4.5
const actLine = doc.splitTextToSize(
` - ${act.name}: ${formatCurrency(act.savings, currency)} (${actPct}% del ahorro total)`,
cW
)
doc.text(actLine, m, y)
y += actLine.length * 4.5
}
} else {
doc.setTextColor(...PDF_COLORS.textTertiary)

View File

@@ -1,5 +1,5 @@
import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
import type { SimulationResult, Process } from '@/domain/types'
import type { SimulationResult, Process, ActivitySimResult, Resource, Activity } from '@/domain/types'
import { PDF_COLORS, type DocLike } from './pdf-sections-shared'
// Re-exportar DocLike para compatibilidad de imports existentes (pdf-sections-roi, tests)
@@ -228,8 +228,12 @@ export function drawAnalysisSection(
if (result.perResource.length > 0) {
const topResource = [...result.perResource].sort((a, b) => b.totalCost - a.totalCost)[0]
doc.text(`Recurso principal: "${topResource.resourceName}" — ${formatCurrency(topResource.totalCost, currency)}`, m, y)
y += 5
const resourceLine = doc.splitTextToSize(
`Recurso principal: "${topResource.resourceName}" — ${formatCurrency(topResource.totalCost, currency)}`,
cW
)
doc.text(resourceLine, m, y)
y += resourceLine.length * 4.5
} else {
doc.setTextColor(...PDF_COLORS.textTertiary)
doc.text('Sin recursos asignados (costos fijos por actividad)', m, y)
@@ -466,6 +470,129 @@ export function drawScenarioKpiGrid(
return maxY + 8
}
// ─── Tabla de recursos por actividad ─────────────────────────────────────────
function _fmtNum(n: number): string {
return n.toLocaleString('es-PY', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function drawResourcesSection(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
doc: any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
autoTable: Function,
result: { perActivity: ActivitySimResult[] },
resources: Resource[],
activities: Activity[],
currency: string,
startY: number
): void {
let y = startY
const m = PDF.margin
const cW = PDF.contentW
doc.setFont('helvetica', 'bold')
doc.setFontSize(10)
doc.setTextColor(...PDF.slate900)
doc.text('DESGLOSE DE RECURSOS POR ACTIVIDAD', m, y)
y += 2
doc.setDrawColor(...PDF.slate200)
doc.setLineWidth(0.5)
doc.line(m, y, m + cW, y)
doc.setLineWidth(0.2)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
doc.setTextColor(...PDF.slate500)
doc.text('Costo por ejecución, ponderado por probabilidad de ejecución de cada actividad.', m, y)
y += 6
const resourceMap = new Map(resources.map((r) => [r.id, r]))
const activityMap = new Map(activities.map((a) => [a.id, a]))
const TYPE_LABEL: Record<string, string> = {
role: 'Rol', person: 'Persona', system: 'Sistema', equipment: 'Equipamiento', supply: 'Insumo',
}
const TYPE_COLOR: Record<string, [number, number, number]> = {
role: [255, 237, 213],
person: [220, 252, 231],
system: [219, 234, 254],
equipment: [241, 245, 249],
supply: [254, 249, 195],
}
const RA = 'right' as const
const tableHead = [[
{ content: 'Actividad', styles: { cellWidth: 40 } },
{ content: 'Recurso', styles: { cellWidth: 38 } },
{ content: 'Tipo', styles: { cellWidth: 20 } },
{ content: `$/h (${currency})`, styles: { halign: RA, cellWidth: 20 } },
{ content: 'Min.', styles: { halign: RA, cellWidth: 14 } },
{ content: 'Uds.', styles: { halign: RA, cellWidth: 12 } },
{ content: `Esp. (${currency})`, styles: { halign: RA, cellWidth: 26 } },
]]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tableBody: any[] = []
for (const act of result.perActivity) {
if (act.resourceCostBreakdown.length === 0) continue
const activityRow = activityMap.get(act.activityId)
for (const br of act.resourceCostBreakdown) {
const resource = resourceMap.get(br.resourceId)
const assignment = activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId)
const typeName = resource ? (TYPE_LABEL[resource.type] ?? resource.type) : '—'
const typeColor = resource ? (TYPE_COLOR[resource.type] ?? PDF.slate50) : PDF.slate50
const costPerHour = resource ? _fmtNum(resource.costPerHour) : '—'
const minutes = assignment ? String(assignment.utilizationMinutes) : '—'
const units = assignment ? String(assignment.units) : '—'
// Nombre de actividad repetido por fila — rowSpan no está soportado en body de autotable
tableBody.push([
{ content: act.activityName },
{ content: br.resourceName },
{ content: typeName, styles: { fillColor: typeColor, fontStyle: 'bold', fontSize: 7 } },
{ content: costPerHour, styles: { halign: RA, font: 'courier' } },
{ content: minutes, styles: { halign: RA, font: 'courier' } },
{ content: units, styles: { halign: RA, font: 'courier' } },
{ content: _fmtNum(br.cost), styles: { halign: RA, font: 'courier' } },
])
}
// Fila de subtotal por actividad
const subtotal = act.resourceCostBreakdown.reduce((s, r) => s + r.cost, 0)
tableBody.push([
{ content: '', styles: { fillColor: [255, 248, 237] } },
{ content: 'Subtotal recursos', colSpan: 5, styles: { fontStyle: 'italic', fontSize: 7.5, fillColor: [255, 248, 237] } },
{ content: _fmtNum(subtotal), styles: { halign: RA, font: 'courier', fontStyle: 'bold', fillColor: [255, 248, 237] } },
])
}
if (tableBody.length === 0) return
autoTable(doc, {
head: tableHead,
body: tableBody,
startY: y,
margin: { left: m, right: m },
styles: { fontSize: 7.5, cellPadding: 2.5, overflow: 'ellipsize', lineColor: [226, 232, 240], lineWidth: 0.2 },
headStyles: {
fillColor: [255, 248, 237],
textColor: [146, 64, 14],
fontStyle: 'bold',
fontSize: 8,
lineColor: [245, 152, 69],
lineWidth: 0.5,
},
alternateRowStyles: { fillColor: PDF.slate50 },
})
}
export function addPageNumbers(doc: DocLike, platformName: string, generatedAt: number): void {
const total = doc.internal.getNumberOfPages()
const { date } = formatSimulationTimestamp(generatedAt)