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:
@@ -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 */}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user