feat(sprint-3): biblioteca, recursos TO-BE, ROI mejorado e identidad de marca

Sprint 3 completo — 7 etapas:

- Etapa 1-2: biblioteca de procesos con agrupación por cliente (Dexie v4,
  LibraryPage, library-store, groupId/tags en Process)
- Etapa 3: modal de configuración global por proceso (horizonte, frecuencia,
  inversión, moneda) con validación y persistencia
- Etapa 4: panel de recursos rediseñado con soporte de unidades y minutos
  de utilización (utilizationMinutes + units)
- Etapa 5: recursos en escenario automatizado — motor de simulación,
  UI de edición en ActivityPanel, comparación AS-IS/TO-BE en reporte
- Etapa 6: PDF recursos automatizados, automatedAssignedResources requerido,
  561 tests pasando
- Etapa 7: identidad de marca — AppHeader gradiente naranja/magenta con logo
  InQuality en 5 páginas, favicon con logo real, logo/marca navegan a home

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 11:49:43 -03:00
parent d2746f07d1
commit 67e259e6ed
57 changed files with 4372 additions and 82 deletions

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, useCallback, useMemo, useDeferredValue } from 'react'
import { useParams, useNavigate } from '@tanstack/react-router'
import {
Loader2, BarChart3, Play, AlertTriangle,
Loader2, BarChart3, Play, AlertTriangle, AlertCircle,
PanelRightClose, PanelRightOpen, Home, GitMerge, MousePointer2,
Check, Pencil
} from 'lucide-react'
@@ -18,6 +18,8 @@ import { GlobalSettingsPanel } from './GlobalSettingsPanel'
import { useProcessStore } from '@/store/process-store'
import { useSimulationStore } from '@/store/simulation-store'
import { useSimulate } from '../simulation/useSimulate'
import { useLibraryStore } from '@/store/library-store'
import { AppHeader } from '@/components/AppHeader'
type SelectedCategory = 'activity' | 'gateway' | null
@@ -25,7 +27,7 @@ export function WorkspacePage() {
const { processId } = useParams({ from: '/workspace/$processId' })
const navigate = useNavigate()
const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore()
const { isRunning, resultActual, error: simError, selectActivity } = useSimulationStore()
const { isRunning, resultActual, error: simError, selectActivity, loadLatestForProcess, lastPersistedExecutedAt } = useSimulationStore()
const { simulate } = useSimulate()
const { toast } = useToast()
@@ -41,6 +43,11 @@ export function WorkspacePage() {
const [savedField, setSavedField] = useState<'name' | 'client' | null>(null)
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Tags chip input
const [tagInput, setTagInput] = useState('')
const [showTagSuggestions, setShowTagSuggestions] = useState(false)
const getAllTags = useLibraryStore((state) => state.getAllTags)
// 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).
@@ -48,6 +55,11 @@ export function WorkspacePage() {
useEffect(() => { loadProcess(processId) }, [processId, loadProcess])
// Cargar el último executedAt persistido para comparar contra updatedAt (indicador stale)
useEffect(() => {
if (currentProcess) loadLatestForProcess(currentProcess.id)
}, [currentProcess?.id, loadLatestForProcess])
useEffect(() => {
if (isLoading) { hasLoadedOnce.current = true; return }
if (!hasLoadedOnce.current) return // carga no iniciada aún
@@ -71,6 +83,12 @@ export function WorkspacePage() {
const canSimulate = !isRunning && xorValidation.valid
// Hay cambios sin simular cuando updatedAt supera al executedAt de la última simulación persistida
const isStale = Boolean(
currentProcess &&
currentProcess.updatedAt > (lastPersistedExecutedAt ?? 0)
)
// IDs de actividades marcadas como automatizables — alimenta los overlays del canvas.
// useDeferredValue evita re-renders en cascada mientras el usuario edita el panel.
const automatableElementIds = useMemo(
@@ -118,6 +136,26 @@ export function WorkspacePage() {
savedTimerRef.current = setTimeout(() => setSavedField(null), 1500)
}
function addTag(raw: string) {
const normalized = raw.trim().toLowerCase()
if (!normalized) return
const current = currentProcess?.tags ?? []
if (current.some((t) => t === normalized)) return
updateProcess({ tags: [...current, normalized] })
setTagInput('')
setShowTagSuggestions(false)
}
function removeTag(tag: string) {
const current = currentProcess?.tags ?? []
updateProcess({ tags: current.filter((t) => t !== tag) })
}
function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') { e.preventDefault(); if (tagInput.trim()) addTag(tagInput) }
if (e.key === 'Escape') { setTagInput(''); setShowTagSuggestions(false) }
}
async function handleSimulate() {
if (!canSimulate) return
const simResult = await simulate()
@@ -147,9 +185,10 @@ export function WorkspacePage() {
return (
<TooltipProvider>
<div className="h-screen flex flex-col bg-slate-100 overflow-hidden">
<AppHeader />
{/* ── Topbar ─────────────────────────────────────────────────── */}
<header className="h-12 bg-white border-b border-slate-200 flex items-center px-3 gap-3 shrink-0 z-10">
<header className="min-h-12 bg-white border-b border-slate-200 flex items-center px-3 gap-3 shrink-0 z-10">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navigate({ to: '/' })}>
@@ -219,6 +258,59 @@ export function WorkspacePage() {
</div>
)}
{/* Tags — chips removibles + input inline */}
{(() => {
const processTags = currentProcess.tags ?? []
const suggestions = getAllTags()
.filter((t) => t.includes(tagInput.toLowerCase()) && !processTags.includes(t))
.slice(0, 6)
return (
<div className="flex flex-wrap items-center gap-1 mt-0.5">
{processTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full bg-secondary border border-border text-secondary-foreground"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-0.5 text-muted-foreground hover:text-foreground leading-none"
>
×
</button>
</span>
))}
<div className="relative">
<input
type="text"
value={tagInput}
onChange={(e) => { setTagInput(e.target.value); setShowTagSuggestions(true) }}
onKeyDown={handleTagKeyDown}
onFocus={() => setShowTagSuggestions(true)}
onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}
placeholder="Agregar tag..."
className="text-xs bg-transparent border-b border-muted outline-none w-24 placeholder:text-muted-foreground/50"
/>
{showTagSuggestions && suggestions.length > 0 && (
<div className="absolute top-full left-0 mt-1 bg-background border border-border rounded-md shadow-sm z-50 min-w-[120px]">
{suggestions.map((s) => (
<button
key={s}
type="button"
className="w-full text-left px-2 py-1 text-[11px] hover:bg-secondary transition-colors"
onMouseDown={(e) => { e.preventDefault(); addTag(s) }}
>
{s}
</button>
))}
</div>
)}
</div>
</div>
)
})()}
</div>
{/* Errores de simulación */}
@@ -261,6 +353,15 @@ export function WorkspacePage() {
</Button>
)}
{/* Indicador stale */}
{isStale && (
<div className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium"
style={{ background: '#fff3e6', border: '1px solid #F59845', color: '#7a3e00' }}>
<AlertCircle className="h-3 w-3 shrink-0" />
Hay cambios sin simular
</div>
)}
{/* Simular */}
<Tooltip>
<TooltipTrigger asChild>