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:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { PlusCircle, Trash2, Info, AlertTriangle } from 'lucide-react'
|
||||
import { PlusCircle, Trash2, Info, AlertTriangle, X } from 'lucide-react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -26,7 +26,11 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
|
||||
// Sincronizar con el store cuando cambia la selección
|
||||
useEffect(() => {
|
||||
setLocalActivity(activity ? { ...activity, assignedResources: [...activity.assignedResources] } : null)
|
||||
setLocalActivity(activity ? {
|
||||
...activity,
|
||||
assignedResources: [...activity.assignedResources],
|
||||
automatedAssignedResources: [...(activity.automatedAssignedResources ?? [])],
|
||||
} : null)
|
||||
setIsDirty(false)
|
||||
}, [selectedElementId, activities])
|
||||
|
||||
@@ -84,6 +88,36 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Handlers TO-BE ────────────────────────────────────────────────────────
|
||||
|
||||
function addAutomatedResource(resourceId: string) {
|
||||
if (!localActivity) return
|
||||
const already = (localActivity.automatedAssignedResources ?? []).some((r) => r.resourceId === resourceId)
|
||||
if (already) return
|
||||
handleChange('automatedAssignedResources', [
|
||||
...(localActivity.automatedAssignedResources ?? []),
|
||||
{ resourceId, utilizationMinutes: 60, units: 1 },
|
||||
])
|
||||
}
|
||||
|
||||
function removeAutomatedResource(resourceId: string) {
|
||||
if (!localActivity) return
|
||||
handleChange(
|
||||
'automatedAssignedResources',
|
||||
(localActivity.automatedAssignedResources ?? []).filter((r) => r.resourceId !== resourceId)
|
||||
)
|
||||
}
|
||||
|
||||
function updateAutomatedAssignment(resourceId: string, field: 'utilizationMinutes' | 'units', value: number) {
|
||||
if (!localActivity) return
|
||||
handleChange(
|
||||
'automatedAssignedResources',
|
||||
(localActivity.automatedAssignedResources ?? []).map((r) =>
|
||||
r.resourceId === resourceId ? { ...r, [field]: value } : r
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!selectedElementId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-12">
|
||||
@@ -119,6 +153,12 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
|
||||
const localTotalCost = localActivity.directCostFixed + localResourceCost
|
||||
|
||||
const availableAutomatedResources = resources.filter(
|
||||
(r) => !(localActivity.automatedAssignedResources ?? []).some((ar) => ar.resourceId === r.id)
|
||||
)
|
||||
|
||||
const currency = currentProcess?.currency ?? 'USD'
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-5">
|
||||
@@ -370,7 +410,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
</div>
|
||||
|
||||
{/* Campos — aparecen con transición suave cuando toggle=ON */}
|
||||
<div className={`space-y-3 overflow-hidden transition-all duration-200 ${localActivity.automatable ? 'max-h-80 opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
<div className={`space-y-3 overflow-hidden transition-all duration-200 ${localActivity.automatable ? 'max-h-[640px] opacity-100' : 'max-h-0 opacity-0'}`}>
|
||||
|
||||
{/* Costo automatizado */}
|
||||
<div className="space-y-1.5">
|
||||
@@ -417,6 +457,101 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Recursos TO-BE ───────────────────────────────────────── */}
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
Recursos TO-BE
|
||||
</p>
|
||||
{availableAutomatedResources.length > 0 && (
|
||||
<Select onValueChange={addAutomatedResource}>
|
||||
<SelectTrigger className="h-7 text-xs w-[120px]">
|
||||
<SelectValue placeholder="+ Agregar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableAutomatedResources.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id} className="text-xs">
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(localActivity.automatedAssignedResources ?? []).length === 0 ? (
|
||||
<p className="text-xs text-slate-400 italic">
|
||||
Sin recursos TO-BE — solo costo fijo automatizado.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(localActivity.automatedAssignedResources ?? []).map((assignment) => {
|
||||
const resource = resources.find((r) => r.id === assignment.resourceId)
|
||||
if (!resource) return null
|
||||
const cost = resource.costPerHour * (assignment.utilizationMinutes / 60) * assignment.units
|
||||
return (
|
||||
<div key={assignment.resourceId} className="rounded-lg border border-border bg-card p-2 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium truncate max-w-[120px]">{resource.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-slate-500">
|
||||
{formatCurrency(cost, currency)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAutomatedResource(assignment.resourceId)}
|
||||
className="text-slate-400 hover:text-red-500 transition-colors"
|
||||
aria-label={`Quitar ${resource.name}`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<p className="text-[10px] text-slate-400">Min. por ejecución</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={assignment.utilizationMinutes || ''}
|
||||
onChange={(e) =>
|
||||
updateAutomatedAssignment(
|
||||
assignment.resourceId,
|
||||
'utilizationMinutes',
|
||||
Math.max(0, parseFloat(e.target.value) || 0)
|
||||
)
|
||||
}
|
||||
className="h-6 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-16 space-y-0.5">
|
||||
<p className="text-[10px] text-slate-400">Unidades</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={assignment.units || ''}
|
||||
onChange={(e) =>
|
||||
updateAutomatedAssignment(
|
||||
assignment.resourceId,
|
||||
'units',
|
||||
Math.max(1, parseInt(e.target.value) || 1)
|
||||
)
|
||||
}
|
||||
className="h-6 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Helper ámbar — siempre en el DOM para que la transición funcione
|
||||
al tipear un valor (no desaparece instantáneo sino con fade).
|
||||
La condición controla opacity + max-h, no mount/unmount. */}
|
||||
@@ -424,6 +559,7 @@ export function ActivityPanel({ selectedElementId }: ActivityPanelProps) {
|
||||
const showHelper = localActivity.automatable
|
||||
&& localActivity.automatedCostFixed === 0
|
||||
&& localActivity.automatedTimeMinutes === 0
|
||||
&& (localActivity.automatedAssignedResources ?? []).length === 0
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!showHelper}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import BpmnViewer from 'bpmn-js/lib/Viewer'
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
|
||||
export type BpmnElementCategory = 'activity' | 'gateway' | 'other'
|
||||
|
||||
@@ -152,5 +153,34 @@ export function BpmnCanvas({
|
||||
}
|
||||
}, [selectedElementId])
|
||||
|
||||
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />
|
||||
return (
|
||||
<div className={className} style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
<button
|
||||
onClick={() => {
|
||||
const canvas = viewerRef.current?.get('canvas') as any
|
||||
canvas?.zoom('fit-viewport')
|
||||
}}
|
||||
title="Encuadrar diagrama"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
background: 'hsl(var(--background))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user