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