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,7 +1,7 @@
|
||||
export function AppFooter() {
|
||||
return (
|
||||
<footer className="mt-12 py-4 border-t border-slate-100 text-center">
|
||||
<p className="text-xs text-slate-300">
|
||||
<p className="text-xs text-slate-400">
|
||||
InQ ROI · Powered by InQuality · v0.1.0 · Hecho desde Paraguay 🇵🇾
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
39
src/components/AppHeader.tsx
Normal file
39
src/components/AppHeader.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
export function AppHeader() {
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F59845 0%, #ED3E8F 100%)',
|
||||
height: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingLeft: 20,
|
||||
paddingRight: 20,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Link to="/" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img
|
||||
src="/inquality-logo-white.png"
|
||||
alt="InQuality"
|
||||
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
color: 'white',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.04em',
|
||||
opacity: 0.92,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
InQ ROI
|
||||
</Link>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -204,7 +204,11 @@ export function runSimulation(input: SimulationInput): SimulationResult {
|
||||
const resourceBreakdown: ActivitySimResult['resourceCostBreakdown'] = []
|
||||
let resourceCostDirect = 0
|
||||
|
||||
for (const assignment of activity.assignedResources) {
|
||||
const resourceAssignments = useAutomated
|
||||
? (activity.automatedAssignedResources ?? [])
|
||||
: activity.assignedResources
|
||||
|
||||
for (const assignment of resourceAssignments) {
|
||||
const resource = resources.get(assignment.resourceId)
|
||||
if (!resource) continue
|
||||
const hoursUsed = (assignment.utilizationMinutes / 60) * assignment.units
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Process {
|
||||
automationInvestment: number // 🆕 Sprint 1 — inversión total del proyecto, default 0
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar")
|
||||
tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda
|
||||
}
|
||||
|
||||
export type ActivityType = 'task' | 'subprocess'
|
||||
@@ -32,6 +34,7 @@ export interface Activity {
|
||||
automatable: boolean // 🆕 Sprint 1 — si la actividad es candidata a automatización
|
||||
automatedCostFixed: number // 🆕 Sprint 1 — costo recurrente por ejecución automatizada
|
||||
automatedTimeMinutes: number // 🆕 Sprint 1 — tiempo de la actividad en escenario automatizado
|
||||
automatedAssignedResources: ActivityResourceAssignment[] // 🆕 Sprint 3 — recursos del escenario automatizado (requerido desde Etapa 6)
|
||||
}
|
||||
|
||||
export interface ActivityResourceAssignment {
|
||||
@@ -74,6 +77,20 @@ export interface Simulation {
|
||||
resultAutomated?: SimulationResult // 🆕 Sprint 1 — escenario automatizado (opcional por retrocompatibilidad)
|
||||
}
|
||||
|
||||
// ─── Process Library ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProcessGroup {
|
||||
id: UUID // generado con crypto.randomUUID()
|
||||
name: string
|
||||
createdAt: number // timestamp Unix ms
|
||||
updatedAt: number // timestamp Unix ms
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
id: 'singleton' // siempre un único registro por instalación
|
||||
groupLabel: string // etiqueta configurable para el nivel de agrupación. Default: 'Cliente'
|
||||
}
|
||||
|
||||
// ─── Motor de simulación: Input/Output ───────────────────────────────────────
|
||||
|
||||
// Escenario de simulación: actual (campos directCostFixed/executionTimeMinutes)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
@@ -9,6 +9,7 @@ import type { Process, Activity, GatewayConfig } from '@/domain/types'
|
||||
import { bpmnTypeToGatewayType } from '@/domain/types'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AppFooter } from '@/components/AppFooter'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
|
||||
const SAMPLE_PROCESSES = [
|
||||
{
|
||||
@@ -38,6 +39,10 @@ export function ImportPage() {
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null)
|
||||
|
||||
// groupId viene de la URL cuando el usuario importa desde dentro de un grupo
|
||||
const search = useSearch({ from: '/import' })
|
||||
const targetGroupId = (search as { groupId?: string }).groupId ?? null
|
||||
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
if (!file.name.endsWith('.bpmn') && !file.name.endsWith('.xml')) {
|
||||
toast({
|
||||
@@ -69,6 +74,8 @@ export function ImportPage() {
|
||||
automationInvestment: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
groupId: targetGroupId,
|
||||
tags: [],
|
||||
}
|
||||
|
||||
const activityElements = extractActivityElements(graph)
|
||||
@@ -84,6 +91,7 @@ export function ImportPage() {
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
automatedAssignedResources: [],
|
||||
}))
|
||||
|
||||
const gatewayElements = extractGatewayElements(graph)
|
||||
@@ -167,7 +175,9 @@ export function ImportPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col items-center justify-center p-6">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
|
||||
<AppHeader />
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full">
|
||||
@@ -278,6 +288,7 @@ export function ImportPage() {
|
||||
</p>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
621
src/features/library/LibraryPage.tsx
Normal file
621
src/features/library/LibraryPage.tsx
Normal file
@@ -0,0 +1,621 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
Upload, Search, Plus, Building2, FolderX, GitBranch,
|
||||
Clock, TrendingUp, BarChart2, FileX2, X, Settings,
|
||||
} from 'lucide-react'
|
||||
import { useLibraryStore } from '@/store/library-store'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import type { Process, ProcessGroup, Simulation } from '@/domain/types'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function relativeDate(ts: number): string {
|
||||
const days = Math.floor((Date.now() - ts) / 86_400_000)
|
||||
if (days === 0) return 'hoy'
|
||||
if (days === 1) return 'hace 1 día'
|
||||
return `hace ${days} días`
|
||||
}
|
||||
|
||||
function tagCloudData(processes: Process[]): Array<{ tag: string; count: number; fontSize: string }> {
|
||||
const freq = new Map<string, number>()
|
||||
processes.forEach((p) => p.tags.forEach((t) => freq.set(t, (freq.get(t) ?? 0) + 1)))
|
||||
const sorted = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)
|
||||
if (sorted.length === 0) return []
|
||||
const maxCount = sorted[0][1]
|
||||
const minCount = sorted[sorted.length - 1][1]
|
||||
const range = maxCount - minCount || 1
|
||||
return sorted.map(([tag, count]) => {
|
||||
const t = (count - minCount) / range
|
||||
return { tag, count, fontSize: `${Math.round(10 + t * 3)}px` }
|
||||
})
|
||||
}
|
||||
|
||||
function matchesSearch(p: Process, q: string): boolean {
|
||||
const lower = q.toLowerCase()
|
||||
return p.name.toLowerCase().includes(lower) || p.tags.some((t) => t.includes(lower))
|
||||
}
|
||||
|
||||
function matchesTags(p: Process, activeTags: string[]): boolean {
|
||||
return activeTags.every((t) => p.tags.includes(t))
|
||||
}
|
||||
|
||||
// ─── KPI Badge ────────────────────────────────────────────────────────────────
|
||||
|
||||
function KpiBadge({ process, simulation }: { process: Process; simulation: Simulation | null | undefined }) {
|
||||
if (simulation === undefined) return null // cargando
|
||||
|
||||
if (!simulation) {
|
||||
return <span className="text-[11px] italic text-muted-foreground">Sin simular</span>
|
||||
}
|
||||
|
||||
if (simulation.resultAutomated) {
|
||||
const ahorro = (simulation.result.totalCost - simulation.resultAutomated.totalCost) * process.annualFrequency
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium"
|
||||
style={{ background: '#f0fdf4', border: '1px solid #86efac', color: '#15803d' }}>
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{formatCurrency(ahorro, process.currency)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">Ahorro anual proyectado</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const costoAnual = simulation.result.totalCost * process.annualFrequency
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border border-border bg-secondary text-secondary-foreground">
|
||||
<BarChart2 className="h-3 w-3" />
|
||||
{formatCurrency(costoAnual, process.currency)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">Costo anual actual</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Process Card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ProcessCard({ process, simulation, showGroupChip, groupName }: {
|
||||
process: Process
|
||||
simulation: Simulation | null | undefined
|
||||
showGroupChip?: boolean
|
||||
groupName?: string
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-card hover:border-border/80 hover:shadow-sm transition-all cursor-pointer"
|
||||
onClick={() => navigate({ to: '/workspace/$processId', params: { processId: process.id } })}
|
||||
>
|
||||
{/* Ícono */}
|
||||
<div className="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center"
|
||||
style={{ background: '#fff3e6' }}>
|
||||
<GitBranch className="h-4 w-4" style={{ color: '#F59845' }} />
|
||||
</div>
|
||||
|
||||
{/* Info central */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-[11px] text-muted-foreground">Modificado {relativeDate(process.updatedAt)}</span>
|
||||
</div>
|
||||
{(process.tags.length > 0 || showGroupChip) && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{showGroupChip && groupName && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border"
|
||||
style={{ background: '#fff3e6', borderColor: '#F59845', color: '#7a3e00' }}>
|
||||
{groupName}
|
||||
</span>
|
||||
)}
|
||||
{process.tags.map((tag) => (
|
||||
<span key={tag} className="text-[10px] px-2 py-0.5 rounded-full bg-secondary border border-border text-secondary-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* KPI derecha */}
|
||||
<div className="flex-shrink-0 min-w-[90px]">
|
||||
<KpiBadge process={process} simulation={simulation} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Group Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function GroupCard({ group, processCount, lastUpdated, onClick }: {
|
||||
group: ProcessGroup
|
||||
processCount: number
|
||||
lastUpdated: number | null
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="text-left w-full rounded-xl p-3 transition-all"
|
||||
style={{
|
||||
background: 'hsl(var(--card))',
|
||||
border: '0.5px solid hsl(var(--border))',
|
||||
borderLeft: '3px solid #F59845',
|
||||
borderRadius: '0.5rem',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border) / 0.8)'; (e.currentTarget as HTMLButtonElement).style.borderLeftColor = '#F59845' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border))'; (e.currentTarget as HTMLButtonElement).style.borderLeftColor = '#F59845' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<p className="text-2xl font-medium" style={{ color: '#F59845' }}>{processCount}</p>
|
||||
<p className="text-[13px] font-medium truncate mt-0.5">{group.name}</p>
|
||||
{lastUpdated && (
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Actualizado {relativeDate(lastUpdated)}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── New Group Card ───────────────────────────────────────────────────────────
|
||||
|
||||
function NewGroupCard({ label, onCreate }: { label: string; onCreate: (name: string) => void }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed) onCreate(trimmed)
|
||||
setValue('')
|
||||
setEditing(false)
|
||||
}, [value, onCreate])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setValue('')
|
||||
setEditing(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.focus()
|
||||
}, [editing])
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="rounded-xl p-3 transition-all"
|
||||
style={{ border: '1.5px solid #F59845', background: 'hsl(var(--card))', borderRadius: '0.5rem' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') confirm()
|
||||
if (e.key === 'Escape') cancel()
|
||||
}}
|
||||
placeholder={`Nombre del ${label.toLowerCase()}...`}
|
||||
className="w-full text-[12px] bg-transparent border-none outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<button
|
||||
onClick={confirm}
|
||||
className="text-[11px] px-2 py-0.5 rounded font-medium text-white"
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
Crear
|
||||
</button>
|
||||
<button
|
||||
onClick={cancel}
|
||||
className="text-[11px] px-2 py-0.5 rounded font-medium text-muted-foreground border border-border"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="text-left w-full rounded-xl p-3 flex flex-col items-center justify-center gap-1.5 transition-all group"
|
||||
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem', minHeight: '80px' }}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = '#F59845'; (e.currentTarget as HTMLButtonElement).style.color = '#F59845' }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = 'hsl(var(--border))'; (e.currentTarget as HTMLButtonElement).style.color = '' }}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 text-muted-foreground group-hover:text-[#F59845] transition-colors" />
|
||||
<span className="text-[12px] text-muted-foreground group-hover:text-[#F59845] transition-colors">
|
||||
Nuevo {label.toLowerCase()}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Home View ────────────────────────────────────────────────────────────────
|
||||
|
||||
function HomeView({
|
||||
onNavigateToGroup,
|
||||
onImport,
|
||||
}: {
|
||||
onNavigateToGroup: (groupId: string | null) => void
|
||||
onImport: (groupId?: string) => void
|
||||
}) {
|
||||
const { groups, processes, settings, createGroup } = useLibraryStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeTags, setActiveTags] = useState<string[]>([])
|
||||
|
||||
const cloud = tagCloudData(processes)
|
||||
const unclassifiedProcesses = processes.filter((p) => p.groupId == null)
|
||||
|
||||
const isSearching = search.trim().length > 0 || activeTags.length > 0
|
||||
|
||||
const filteredProcesses = isSearching
|
||||
? processes.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
|
||||
: []
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
setActiveTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag])
|
||||
}
|
||||
|
||||
// Empty state: sin grupos ni procesos
|
||||
if (groups.length === 0 && processes.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<Building2 className="h-16 w-16 text-muted-foreground/30" />
|
||||
<p className="text-base font-medium">Tu biblioteca está vacía</p>
|
||||
<p className="text-[13px] text-muted-foreground">Importá tu primer BPMN para empezar</p>
|
||||
<button
|
||||
onClick={() => onImport()}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Importar BPMN
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Búsqueda */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar proceso por nombre..."
|
||||
className="w-full pl-9 pr-4 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resultados de búsqueda */}
|
||||
{isSearching && (
|
||||
<div className="space-y-2">
|
||||
{/* Filtros activos */}
|
||||
{activeTags.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[11px] text-muted-foreground">Filtros activos:</span>
|
||||
{activeTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border"
|
||||
style={{ background: '#fff3e6', borderColor: '#F59845', color: '#7a3e00' }}
|
||||
>
|
||||
{tag} <X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setActiveTags([])}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Limpiar todo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-wide font-medium">
|
||||
{filteredProcesses.length} resultado{filteredProcesses.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
{filteredProcesses.length === 0 && (
|
||||
<p className="text-[13px] text-muted-foreground py-4 text-center">Sin resultados para esa búsqueda.</p>
|
||||
)}
|
||||
{filteredProcesses.map((p) => {
|
||||
const group = groups.find((g) => g.id === p.groupId)
|
||||
return (
|
||||
<ProcessCardWithSim
|
||||
key={p.id}
|
||||
process={p}
|
||||
showGroupChip
|
||||
groupName={group?.name}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vista normal — grupos */}
|
||||
{!isSearching && (
|
||||
<>
|
||||
{/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */}
|
||||
{cloud.length >= 3 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] text-muted-foreground shrink-0">Tags:</span>
|
||||
{cloud.map(({ tag, fontSize }) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className="px-2 py-0.5 rounded-full border transition-all hover:border-[#F59845] hover:bg-[#fff3e6] hover:text-[#7a3e00]"
|
||||
style={{ fontSize, background: 'white', borderColor: 'hsl(var(--border))' }}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Tags simples cuando hay 1-2 */}
|
||||
{cloud.length > 0 && cloud.length < 3 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[12px] text-muted-foreground">Tags:</span>
|
||||
{cloud.map(({ tag }) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className="text-[11px] px-2.5 py-0.5 rounded-full border transition-all"
|
||||
style={{ background: 'white', borderColor: 'hsl(var(--border))' }}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header sección grupos */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{settings.groupLabel}s
|
||||
</span>
|
||||
<span className="text-[10px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground">
|
||||
{groups.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Grid de grupos */}
|
||||
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
|
||||
{groups.map((group) => {
|
||||
const groupProcesses = processes.filter((p) => p.groupId === group.id)
|
||||
const lastUpdated = groupProcesses.length > 0
|
||||
? Math.max(...groupProcesses.map((p) => p.updatedAt))
|
||||
: null
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
processCount={groupProcesses.length}
|
||||
lastUpdated={lastUpdated}
|
||||
onClick={() => onNavigateToGroup(group.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<NewGroupCard
|
||||
label={settings.groupLabel}
|
||||
onCreate={(name) => createGroup(name)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sin clasificar */}
|
||||
{unclassifiedProcesses.length > 0 && (
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left transition-all hover:border-border/80"
|
||||
style={{ border: '0.5px dashed hsl(var(--border))', background: 'hsl(var(--card))', borderRadius: '0.5rem' }}
|
||||
onClick={() => onNavigateToGroup(null)}
|
||||
>
|
||||
<FolderX className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[13px] font-medium text-secondary-foreground">
|
||||
Procesos sin {settings.groupLabel.toLowerCase()} asignado
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground flex-shrink-0">
|
||||
{unclassifiedProcesses.length} proceso{unclassifiedProcesses.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ProcessCardWithSim (carga simulación lazy) ───────────────────────────────
|
||||
|
||||
function ProcessCardWithSim({ process, showGroupChip, groupName }: {
|
||||
process: Process
|
||||
showGroupChip?: boolean
|
||||
groupName?: string
|
||||
}) {
|
||||
const { getLatestSimulation } = useLibraryStore()
|
||||
const [simulation, setSimulation] = useState<Simulation | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
getLatestSimulation(process.id).then((sim) => {
|
||||
if (!cancelled) setSimulation(sim ?? null)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [process.id, getLatestSimulation])
|
||||
|
||||
return <ProcessCard process={process} simulation={simulation} showGroupChip={showGroupChip} groupName={groupName} />
|
||||
}
|
||||
|
||||
// ─── Group View ───────────────────────────────────────────────────────────────
|
||||
|
||||
function GroupView({
|
||||
groupId,
|
||||
onBack,
|
||||
}: {
|
||||
groupId: string | null
|
||||
onBack: () => void
|
||||
}) {
|
||||
const { groups, processes, settings } = useLibraryStore()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const group = groupId ? groups.find((g) => g.id === groupId) : null
|
||||
const groupName = group?.name ?? `Procesos sin ${settings.groupLabel.toLowerCase()}`
|
||||
const groupProcesses = processes
|
||||
.filter((p) => groupId === null ? p.groupId == null : p.groupId === groupId)
|
||||
.filter((p) => matchesSearch(p, search))
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-[13px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: '#F59845' }}
|
||||
>
|
||||
← Biblioteca
|
||||
</button>
|
||||
<span className="text-muted-foreground text-[13px]">/</span>
|
||||
<span className="text-[13px] font-medium">{groupName}</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-muted-foreground -mt-3">
|
||||
{groupProcesses.length} proceso{groupProcesses.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
|
||||
{/* Búsqueda */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={`Buscar en este ${settings.groupLabel.toLowerCase()}...`}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{groupProcesses.length === 0 && !search && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<FileX2 className="h-12 w-12 text-muted-foreground/30" />
|
||||
<p className="text-[13px] font-medium text-secondary-foreground">
|
||||
No hay procesos en este {settings.groupLabel.toLowerCase()}.
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground">Importá un BPMN para comenzar.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lista de procesos */}
|
||||
<div className="space-y-2">
|
||||
{groupProcesses.map((p) => (
|
||||
<ProcessCardWithSim key={p.id} process={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── LibraryPage ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function LibraryPage() {
|
||||
const navigate = useNavigate()
|
||||
const { load, isLoading, processes, groups, settings } = useLibraryStore()
|
||||
const [view, setView] = useState<'home' | 'group'>('home')
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
|
||||
const [transitioning, setTransitioning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
function navigateToGroup(groupId: string | null) {
|
||||
setTransitioning(true)
|
||||
setTimeout(() => {
|
||||
setActiveGroupId(groupId)
|
||||
setView('group')
|
||||
setTransitioning(false)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
function navigateHome() {
|
||||
setTransitioning(true)
|
||||
setTimeout(() => {
|
||||
setView('home')
|
||||
setActiveGroupId(null)
|
||||
setTransitioning(false)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
function handleImport(groupId?: string) {
|
||||
if (groupId) {
|
||||
navigate({ to: '/import', search: { groupId } })
|
||||
} else {
|
||||
navigate({ to: '/import' })
|
||||
}
|
||||
}
|
||||
|
||||
// Preflight fix: contar grupos reales en lugar del clientName heredado
|
||||
const totalGroups = groups.length
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-[18px] font-medium">Biblioteca de procesos</h1>
|
||||
<p className="text-[12px] text-muted-foreground mt-0.5">
|
||||
{processes.length} proceso{processes.length !== 1 ? 's' : ''} · {totalGroups} {settings.groupLabel.toLowerCase()}{totalGroups !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/settings' })}
|
||||
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
|
||||
title="Configuración"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
|
||||
style={{ background: '#F59845' }}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Importar BPMN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenido con transición */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="transition-all duration-150"
|
||||
style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }}
|
||||
>
|
||||
{view === 'home' ? (
|
||||
<HomeView
|
||||
onNavigateToGroup={navigateToGroup}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
) : (
|
||||
<GroupView
|
||||
groupId={activeGroupId}
|
||||
onBack={navigateHome}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ type SortKey = 'activityName' | 'expectedDirectCost' | 'expectedIndirectCost' |
|
||||
|
||||
interface ActivitiesTableProps {
|
||||
perActivity: ActivitySimResult[]
|
||||
pairedBreakdown?: Record<string, Array<{ resourceId: string; resourceName: string; cost: number }>>
|
||||
mode: HeatmapMode
|
||||
currency: string
|
||||
highlightedId: string | null
|
||||
@@ -23,7 +24,7 @@ function SortIcon({ column, sortKey, dir }: { column: SortKey; sortKey: SortKey;
|
||||
: <ArrowDown className="h-3 w-3 ml-1 text-primary" />
|
||||
}
|
||||
|
||||
export function ActivitiesTable({ perActivity, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) {
|
||||
export function ActivitiesTable({ perActivity, pairedBreakdown, mode, currency, highlightedId, onRowClick }: ActivitiesTableProps) {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('expectedTotalCost')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
@@ -167,23 +168,50 @@ export function ActivitiesTable({ perActivity, mode, currency, highlightedId, on
|
||||
<TableCell />
|
||||
<TableCell colSpan={7} className="py-2 pl-4 pr-6">
|
||||
<div className="space-y-1">
|
||||
{fixedCostExpected > 0 && (
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>Fijo (insumos, licencias)</span>
|
||||
<span className="font-mono">{formatCurrency(fixedCostExpected, currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
{act.resourceCostBreakdown.map((r) => (
|
||||
<div key={r.resourceId} className="flex justify-between text-xs text-slate-500">
|
||||
<span>{r.resourceName}</span>
|
||||
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
{act.resourceCostBreakdown.length === 0 && fixedCostExpected === 0 && (
|
||||
<p className="text-xs text-slate-400">Sin desglose disponible</p>
|
||||
)}
|
||||
{act.resourceCostBreakdown.length === 0 && fixedCostExpected > 0 && (
|
||||
<p className="text-xs text-slate-400 mt-0.5">Sin recursos asignados — costo es 100% fijo</p>
|
||||
{pairedBreakdown?.[act.activityId] ? (
|
||||
<>
|
||||
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mt-1">AS-IS</p>
|
||||
{pairedBreakdown[act.activityId].map((r) => (
|
||||
<div key={r.resourceId} className="flex justify-between text-xs">
|
||||
<span className="text-slate-600">{r.resourceName}</span>
|
||||
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
{pairedBreakdown[act.activityId].length === 0 && (
|
||||
<p className="text-xs text-slate-400 italic">Sin recursos AS-IS</p>
|
||||
)}
|
||||
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mt-2">TO-BE</p>
|
||||
{act.resourceCostBreakdown.length === 0
|
||||
? <p className="text-xs text-slate-400 italic">Sin recursos TO-BE</p>
|
||||
: act.resourceCostBreakdown.map((r) => (
|
||||
<div key={r.resourceId} className="flex justify-between text-xs">
|
||||
<span className="text-slate-600">{r.resourceName}</span>
|
||||
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{fixedCostExpected > 0 && (
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>Fijo (insumos, licencias)</span>
|
||||
<span className="font-mono">{formatCurrency(fixedCostExpected, currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
{act.resourceCostBreakdown.map((r) => (
|
||||
<div key={r.resourceId} className="flex justify-between text-xs text-slate-500">
|
||||
<span>{r.resourceName}</span>
|
||||
<span className="font-mono">{formatCurrency(r.cost, currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
{act.resourceCostBreakdown.length === 0 && fixedCostExpected === 0 && (
|
||||
<p className="text-xs text-slate-400">Sin desglose disponible</p>
|
||||
)}
|
||||
{act.resourceCostBreakdown.length === 0 && fixedCostExpected > 0 && (
|
||||
<p className="text-xs text-slate-400 mt-0.5">Sin recursos asignados — costo es 100% fijo</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams, useNavigate } from '@tanstack/react-router'
|
||||
import { AlertTriangle, Home, Info } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,6 +27,8 @@ import { TransparencySection } from './TransparencySection'
|
||||
import { AutomationScenarioNote } from './AutomationScenarioNote'
|
||||
import { MethodologyFooter } from './MethodologyFooter'
|
||||
import type { HeatmapMode } from '@/lib/colors'
|
||||
import { useSimulationStore } from '@/store/simulation-store'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
|
||||
export function ReportPage() {
|
||||
const { processId } = useParams({ from: '/report/$processId' })
|
||||
@@ -35,6 +37,12 @@ export function ReportPage() {
|
||||
|
||||
const { toast } = useToast()
|
||||
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>('relative')
|
||||
const { lastPersistedExecutedAt, loadLatestForProcess } = useSimulationStore()
|
||||
|
||||
// Cargar executedAt persistido si el usuario llega directo al reporte (sin pasar por workspace)
|
||||
useEffect(() => {
|
||||
if (process) loadLatestForProcess(process.id)
|
||||
}, [process?.id, loadLatestForProcess])
|
||||
|
||||
// Set de bpmnElementIds marcados como automatizables — debe estar antes de los early returns
|
||||
const automatableIds = useMemo(
|
||||
@@ -56,6 +64,14 @@ export function ReportPage() {
|
||||
})
|
||||
}, [simulation, process])
|
||||
|
||||
// Breakdown del escenario actual, indexado por activityId, para comparación lado a lado en tab Automatizado
|
||||
const actualBreakdownById = useMemo(() => {
|
||||
if (!simulation?.result) return undefined
|
||||
return Object.fromEntries(
|
||||
simulation.result.perActivity.map((a) => [a.activityId, a.resourceCostBreakdown])
|
||||
)
|
||||
}, [simulation?.result])
|
||||
|
||||
// Estado y refs para el tab "Actual"
|
||||
const [selectedIdActual, setSelectedIdActual] = useState<string | null>(null)
|
||||
const heatmapRef = useRef<HeatmapCanvasHandle>(null)
|
||||
@@ -219,9 +235,29 @@ export function ReportPage() {
|
||||
// marcadas como automatizables. Sin automatizables, los tabs muestran datos idénticos al actual.
|
||||
const hasAutomatedScenario = hasAutomatedResult && automatableIds.size > 0
|
||||
|
||||
const isStale = Boolean(process && process.updatedAt > (lastPersistedExecutedAt ?? 0))
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<AppHeader />
|
||||
{/* Banner stale — cambios sin simular */}
|
||||
{isStale && (
|
||||
<div className="flex items-center justify-between px-6 py-2.5 text-[12px] font-medium"
|
||||
style={{ background: '#fffbeb', borderBottom: '1px solid #f59e0b', color: '#92400e' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
Los resultados pueden estar desactualizados — hay cambios sin simular.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate({ to: '/workspace/$processId', params: { processId } })}
|
||||
className="underline hover:no-underline shrink-0 ml-4"
|
||||
>
|
||||
Ir al workspace →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-screen-xl mx-auto px-6 py-10">
|
||||
|
||||
{/* ── Header (fuera de los tabs: aplica a todos los escenarios) ──── */}
|
||||
@@ -449,6 +485,7 @@ export function ReportPage() {
|
||||
</div>
|
||||
<ActivitiesTable
|
||||
perActivity={resultAutomated!.perActivity}
|
||||
pairedBreakdown={actualBreakdownById}
|
||||
mode={heatmapMode}
|
||||
currency={process.currency}
|
||||
highlightedId={selectedIdAuto}
|
||||
|
||||
@@ -58,9 +58,11 @@ function RoiKpiCard({
|
||||
export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
|
||||
const paybackStr = formatPayback(roi.paybackMonths)
|
||||
|
||||
const roiStr = !isFinite(roi.roiAnnualPercent)
|
||||
? '∞%'
|
||||
: formatPercent(roi.roiAnnualPercent)
|
||||
const roiIsUndefined = !isFinite(roi.roiAnnualPercent)
|
||||
const roiStr = roiIsUndefined ? '—' : formatPercent(roi.roiAnnualPercent)
|
||||
const roiSub = roiIsUndefined
|
||||
? 'Configurá la inversión del proyecto en Configuración Global'
|
||||
: 'retorno anual sobre la inversión'
|
||||
|
||||
const savingsPosColor = roi.savingsPerExecution > 0
|
||||
? 'text-emerald-700'
|
||||
@@ -117,10 +119,10 @@ export function RoiKpiBar({ roi, currency, horizonYears }: RoiKpiBarProps) {
|
||||
<RoiKpiCard
|
||||
label="ROI anual"
|
||||
value={roiStr}
|
||||
sub="retorno anual sobre la inversión"
|
||||
sub={roiSub}
|
||||
icon={<Percent className="h-4 w-4 text-amber-600" />}
|
||||
iconColor="bg-amber-50"
|
||||
valueColor={roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'}
|
||||
valueColor={roiIsUndefined ? 'text-slate-400' : roi.roiAnnualPercent > 0 ? 'text-emerald-700' : roi.roiAnnualPercent < 0 ? 'text-red-600' : 'text-slate-900'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
82
src/features/settings/SettingsPage.tsx
Normal file
82
src/features/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useLibraryStore } from '@/store/library-store'
|
||||
import { AppHeader } from '@/components/AppHeader'
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { settings, updateSettings, load } = useLibraryStore()
|
||||
const [localLabel, setLocalLabel] = useState(settings.groupLabel)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
// Cargar settings si el store no fue inicializado (navegación directa a /settings)
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Sincronizar con el store solo cuando el usuario no está editando
|
||||
useEffect(() => {
|
||||
if (!isDirty) setLocalLabel(settings.groupLabel)
|
||||
}, [settings.groupLabel, isDirty])
|
||||
|
||||
async function save() {
|
||||
const trimmed = localLabel.trim() || 'Cliente'
|
||||
setLocalLabel(trimmed)
|
||||
setIsDirty(false)
|
||||
await updateSettings({ groupLabel: trimmed })
|
||||
}
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => navigate({ to: '/' })}
|
||||
className="text-sm font-medium mb-4 inline-block transition-opacity hover:opacity-80"
|
||||
style={{ color: '#F59845' }}
|
||||
>
|
||||
← Volver
|
||||
</button>
|
||||
<h1 className="text-[18px] font-medium">Configuración</h1>
|
||||
<p className="text-[12px] text-muted-foreground mt-0.5">Personalización del workspace</p>
|
||||
</div>
|
||||
|
||||
{/* Sección Biblioteca */}
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-border pb-2">
|
||||
<h2 className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Biblioteca
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[13px] font-medium block">Etiqueta de grupos</label>
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Cómo se llama el nivel de agrupación en la biblioteca. Por defecto: 'Cliente'.
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={localLabel}
|
||||
onChange={(e) => { setLocalLabel(e.target.value); setIsDirty(true) }}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); inputRef.current?.blur() }
|
||||
if (e.key === 'Escape') { setLocalLabel(settings.groupLabel); setIsDirty(false); inputRef.current?.blur() }
|
||||
}}
|
||||
className="text-sm px-3 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-1 focus:ring-primary w-full max-w-xs"
|
||||
placeholder="Cliente"
|
||||
/>
|
||||
{localLabel.trim() && (
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Los procesos se organizarán por <strong>{localLabel.trim()}</strong>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -144,7 +144,7 @@ async function exportScenarioPdf(
|
||||
if (hasResources) {
|
||||
docAny.addPage('a4', 'portrait')
|
||||
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 4 })
|
||||
drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y)
|
||||
drawResourcesSection(docAny, autoTable, result, resources, activities, currency, y, tab)
|
||||
}
|
||||
|
||||
// ── Página 4 o 5: Nota metodológica ──────────────────────────────────────────
|
||||
|
||||
@@ -485,7 +485,8 @@ export function drawResourcesSection(
|
||||
resources: Resource[],
|
||||
activities: Activity[],
|
||||
currency: string,
|
||||
startY: number
|
||||
startY: number,
|
||||
scenario: 'actual' | 'automatizado' = 'actual'
|
||||
): void {
|
||||
let y = startY
|
||||
const m = PDF.margin
|
||||
@@ -544,7 +545,9 @@ export function drawResourcesSection(
|
||||
|
||||
for (const br of act.resourceCostBreakdown) {
|
||||
const resource = resourceMap.get(br.resourceId)
|
||||
const assignment = activityRow?.assignedResources.find((a) => a.resourceId === br.resourceId)
|
||||
const assignment = scenario === 'automatizado'
|
||||
? activityRow?.automatedAssignedResources?.find((a) => a.resourceId === br.resourceId)
|
||||
: 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Dexie, { type Table } from 'dexie'
|
||||
import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types'
|
||||
import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types'
|
||||
|
||||
export class ProcessCostDb extends Dexie {
|
||||
processes!: Table<Process>
|
||||
@@ -7,6 +7,8 @@ export class ProcessCostDb extends Dexie {
|
||||
gateways!: Table<GatewayConfig>
|
||||
resources!: Table<Resource>
|
||||
simulations!: Table<Simulation>
|
||||
groups!: Table<ProcessGroup> // 🆕 Sprint 3
|
||||
settings!: Table<AppSettings> // 🆕 Sprint 3
|
||||
|
||||
constructor() {
|
||||
super('ProcessCostPlatform')
|
||||
@@ -79,6 +81,44 @@ export class ProcessCostDb extends Dexie {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Sprint 3: agrega tabla de grupos (Process Library), tabla de settings,
|
||||
// campos groupId/tags en Process, y automatedAssignedResources en Activity.
|
||||
this.version(4)
|
||||
.stores({
|
||||
processes: 'id, name, updatedAt, groupId', // groupId indexado para queries por grupo
|
||||
activities: 'id, processId, bpmnElementId',
|
||||
gateways: 'id, processId, bpmnElementId',
|
||||
resources: 'id, processId, name',
|
||||
simulations: 'id, processId, executedAt',
|
||||
groups: 'id, name, updatedAt', // nueva tabla
|
||||
settings: 'id', // nueva tabla (singleton)
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
// Process: agregar groupId (null) y tags ([])
|
||||
await tx.table('processes').toCollection().modify((proc) => {
|
||||
if (proc.groupId === undefined) proc.groupId = null
|
||||
if (!Array.isArray(proc.tags)) proc.tags = []
|
||||
})
|
||||
// Activity: agregar automatedAssignedResources ([])
|
||||
await tx.table('activities').toCollection().modify((act) => {
|
||||
if (!Array.isArray(act.automatedAssignedResources)) {
|
||||
act.automatedAssignedResources = []
|
||||
}
|
||||
})
|
||||
// Settings: crear singleton con defaults (put es idempotente por clave primaria)
|
||||
await tx.table('settings').put({ id: 'singleton', groupLabel: 'Cliente' })
|
||||
})
|
||||
|
||||
// Belt-and-suspenders: garantizar singleton en fresh installs y entornos de test.
|
||||
// El upgrade también lo crea; este handler cubre el caso en que la tabla sea nueva
|
||||
// pero el upgrade no haya persistido el put (conocido con fake-indexeddb).
|
||||
this.on('ready', async () => {
|
||||
const existing = await this.settings.get('singleton')
|
||||
if (!existing) {
|
||||
await this.settings.put({ id: 'singleton', groupLabel: 'Cliente' })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import { db } from './db'
|
||||
import type { Process, Activity, GatewayConfig, Resource, Simulation } from '@/domain/types'
|
||||
import type { Process, Activity, GatewayConfig, Resource, Simulation, ProcessGroup, AppSettings } from '@/domain/types'
|
||||
|
||||
// ─── Process ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -125,13 +125,56 @@ export const simulationRepo = {
|
||||
},
|
||||
|
||||
async getLatestByProcess(processId: string): Promise<Simulation | undefined> {
|
||||
return db.simulations
|
||||
// Ordena por executedAt descendente para retornar la simulación más reciente.
|
||||
// No se usa .last() porque el primary key es UUID (no ordenado cronológicamente).
|
||||
const all = await db.simulations
|
||||
.where('processId')
|
||||
.equals(processId)
|
||||
.last()
|
||||
.toArray()
|
||||
if (!all.length) return undefined
|
||||
return all.reduce((latest, sim) => sim.executedAt > latest.executedAt ? sim : latest)
|
||||
},
|
||||
|
||||
async getByProcess(processId: string): Promise<Simulation[]> {
|
||||
return db.simulations.where('processId').equals(processId).toArray()
|
||||
},
|
||||
}
|
||||
|
||||
// ─── ProcessGroup ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/library-store.ts */
|
||||
export const groupRepo = {
|
||||
async save(group: ProcessGroup): Promise<void> {
|
||||
await db.groups.put(group)
|
||||
},
|
||||
|
||||
async getAll(): Promise<ProcessGroup[]> {
|
||||
return db.groups.orderBy('name').toArray()
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<ProcessGroup | undefined> {
|
||||
return db.groups.get(id)
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
// Procesos del grupo pasan a "Sin clasificar" (groupId = null)
|
||||
await db.transaction('rw', [db.groups, db.processes], async () => {
|
||||
await db.processes.where('groupId').equals(id).modify({ groupId: null })
|
||||
await db.groups.delete(id)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ─── AppSettings ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** @internal Solo llamar desde src/store/library-store.ts */
|
||||
export const settingsRepo = {
|
||||
async get(): Promise<AppSettings> {
|
||||
const s = await db.settings.get('singleton')
|
||||
return s ?? { id: 'singleton', groupLabel: 'Cliente' }
|
||||
},
|
||||
|
||||
async update(updates: Partial<Omit<AppSettings, 'id'>>): Promise<void> {
|
||||
await db.settings.update('singleton', updates)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { lazy, Suspense } from 'react'
|
||||
import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router'
|
||||
import { WorkspacePage } from '@/features/workspace/WorkspacePage'
|
||||
import { ImportPage } from '@/features/import/ImportPage'
|
||||
import { LibraryPage } from '@/features/library/LibraryPage'
|
||||
import { SettingsPage } from '@/features/settings/SettingsPage'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
// ECharts es grande (373KB gzip). Lazy-load del ReportPage para que el
|
||||
@@ -28,6 +30,12 @@ const rootRoute = createRootRoute({ component: () => <Outlet /> })
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: LibraryPage,
|
||||
})
|
||||
|
||||
const importRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/import',
|
||||
component: ImportPage,
|
||||
})
|
||||
|
||||
@@ -43,7 +51,13 @@ const reportRoute = createRoute({
|
||||
component: ReportPageWithSuspense,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, workspaceRoute, reportRoute])
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/settings',
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, importRoute, workspaceRoute, reportRoute, settingsRoute])
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
||||
98
src/store/library-store.ts
Normal file
98
src/store/library-store.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { create } from 'zustand'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { processRepo, groupRepo, settingsRepo, simulationRepo } from '@/persistence/repositories'
|
||||
import type { Process, ProcessGroup, AppSettings, Simulation } from '@/domain/types'
|
||||
|
||||
interface LibraryState {
|
||||
groups: ProcessGroup[]
|
||||
processes: Process[]
|
||||
settings: AppSettings
|
||||
isLoading: boolean
|
||||
|
||||
load: () => Promise<void>
|
||||
|
||||
createGroup: (name: string) => Promise<ProcessGroup>
|
||||
deleteGroup: (id: string) => Promise<void>
|
||||
|
||||
assignProcessToGroup: (processId: string, groupId: string | null) => Promise<void>
|
||||
|
||||
getProcessesByGroup: (groupId: string | null) => Process[]
|
||||
getLatestSimulation: (processId: string) => Promise<Simulation | undefined>
|
||||
|
||||
updateSettings: (updates: Partial<Omit<AppSettings, 'id'>>) => Promise<void>
|
||||
getAllTags: () => string[]
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = { id: 'singleton', groupLabel: 'Cliente' }
|
||||
|
||||
export const useLibraryStore = create<LibraryState>((set, get) => ({
|
||||
groups: [],
|
||||
processes: [],
|
||||
settings: DEFAULT_SETTINGS,
|
||||
isLoading: false,
|
||||
|
||||
load: async () => {
|
||||
set({ isLoading: true })
|
||||
const [groups, rawProcesses, settings] = await Promise.all([
|
||||
groupRepo.getAll(),
|
||||
processRepo.getAll(),
|
||||
settingsRepo.get(),
|
||||
])
|
||||
// Normalizar datos legacy que puedan llegar sin tags/groupId del IndexedDB
|
||||
const processes = rawProcesses.map((p) => ({
|
||||
...p,
|
||||
groupId: p.groupId ?? null,
|
||||
tags: Array.isArray(p.tags) ? p.tags : [],
|
||||
}))
|
||||
set({ groups, processes, settings, isLoading: false })
|
||||
},
|
||||
|
||||
createGroup: async (name: string) => {
|
||||
const now = Date.now()
|
||||
const group: ProcessGroup = { id: uuidv4(), name, createdAt: now, updatedAt: now }
|
||||
await groupRepo.save(group)
|
||||
set((state) => ({ groups: [...state.groups, group].sort((a, b) => a.name.localeCompare(b.name)) }))
|
||||
return group
|
||||
},
|
||||
|
||||
deleteGroup: async (id: string) => {
|
||||
await groupRepo.delete(id)
|
||||
set((state) => ({
|
||||
groups: state.groups.filter((g) => g.id !== id),
|
||||
// Los procesos del grupo pasan a groupId = null
|
||||
processes: state.processes.map((p) => p.groupId === id ? { ...p, groupId: null } : p),
|
||||
}))
|
||||
},
|
||||
|
||||
assignProcessToGroup: async (processId: string, groupId: string | null) => {
|
||||
const process = get().processes.find((p) => p.id === processId)
|
||||
if (!process) return
|
||||
const updated = { ...process, groupId, updatedAt: Date.now() }
|
||||
await processRepo.save(updated)
|
||||
set((state) => ({
|
||||
processes: state.processes.map((p) => p.id === processId ? updated : p),
|
||||
}))
|
||||
},
|
||||
|
||||
getProcessesByGroup: (groupId: string | null) => {
|
||||
const processes = get().processes
|
||||
return groupId === null
|
||||
? processes.filter((p) => p.groupId == null)
|
||||
: processes.filter((p) => p.groupId === groupId)
|
||||
},
|
||||
|
||||
getLatestSimulation: (processId: string) => {
|
||||
return simulationRepo.getLatestByProcess(processId)
|
||||
},
|
||||
|
||||
updateSettings: async (updates) => {
|
||||
await settingsRepo.update(updates)
|
||||
set((state) => ({ settings: { ...state.settings, ...updates } }))
|
||||
},
|
||||
|
||||
getAllTags: () => {
|
||||
const tagSet = new Set<string>()
|
||||
get().processes.forEach((p) => p.tags.forEach((t) => tagSet.add(t)))
|
||||
return [...tagSet].sort()
|
||||
},
|
||||
}))
|
||||
@@ -66,33 +66,80 @@ export const useProcessStore = create<ProcessState>((set, get) => ({
|
||||
|
||||
updateActivity: async (activity) => {
|
||||
await activityRepo.save(activity)
|
||||
set((state) => ({
|
||||
activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
|
||||
}))
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
await processRepo.save(updatedProcess)
|
||||
set((state) => ({
|
||||
activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
|
||||
currentProcess: updatedProcess,
|
||||
}))
|
||||
} else {
|
||||
set((state) => ({
|
||||
activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
updateGateway: async (gateway) => {
|
||||
await gatewayRepo.save(gateway)
|
||||
set((state) => ({
|
||||
gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)),
|
||||
}))
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
await processRepo.save(updatedProcess)
|
||||
set((state) => ({
|
||||
gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)),
|
||||
currentProcess: updatedProcess,
|
||||
}))
|
||||
} else {
|
||||
set((state) => ({
|
||||
gateways: state.gateways.map((g) => (g.id === gateway.id ? gateway : g)),
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
addResource: async (resource) => {
|
||||
await resourceRepo.save(resource)
|
||||
set((state) => ({ resources: [...state.resources, resource] }))
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
await processRepo.save(updatedProcess)
|
||||
set((state) => ({ resources: [...state.resources, resource], currentProcess: updatedProcess }))
|
||||
} else {
|
||||
set((state) => ({ resources: [...state.resources, resource] }))
|
||||
}
|
||||
},
|
||||
|
||||
updateResource: async (resource) => {
|
||||
await resourceRepo.save(resource)
|
||||
set((state) => ({
|
||||
resources: state.resources.map((r) => (r.id === resource.id ? resource : r)),
|
||||
}))
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
await processRepo.save(updatedProcess)
|
||||
set((state) => ({
|
||||
resources: state.resources.map((r) => (r.id === resource.id ? resource : r)),
|
||||
currentProcess: updatedProcess,
|
||||
}))
|
||||
} else {
|
||||
set((state) => ({
|
||||
resources: state.resources.map((r) => (r.id === resource.id ? resource : r)),
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
deleteResource: async (resourceId) => {
|
||||
await resourceRepo.delete(resourceId)
|
||||
set((state) => ({ resources: state.resources.filter((r) => r.id !== resourceId) }))
|
||||
const current = get().currentProcess
|
||||
if (current) {
|
||||
const updatedProcess = { ...current, updatedAt: Date.now() }
|
||||
await processRepo.save(updatedProcess)
|
||||
set((state) => ({
|
||||
resources: state.resources.filter((r) => r.id !== resourceId),
|
||||
currentProcess: updatedProcess,
|
||||
}))
|
||||
} else {
|
||||
set((state) => ({ resources: state.resources.filter((r) => r.id !== resourceId) }))
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => set({ currentProcess: null, activities: [], gateways: [], resources: [], error: null }),
|
||||
|
||||
@@ -11,6 +11,7 @@ interface SimulationState {
|
||||
resultActual: SimulationResult | null // resultado del escenario actual
|
||||
resultAutomated: SimulationResult | null // resultado del escenario automatizado
|
||||
lastSimulatedAt: Date | null // timestamp de la última simulación exitosa
|
||||
lastPersistedExecutedAt: number | null // executedAt de la última sim guardada en IndexedDB (sobrevive recargas)
|
||||
isRunning: boolean
|
||||
error: string | null
|
||||
selectedActivityId: string | null
|
||||
@@ -24,25 +25,34 @@ interface SimulationState {
|
||||
// Invalida AMBOS resultados a null. Se llama cuando cambian datos del proceso.
|
||||
invalidateResults: () => void
|
||||
reset: () => void
|
||||
loadLatestForProcess: (processId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSimulationStore = create<SimulationState>((set) => ({
|
||||
resultActual: null,
|
||||
resultAutomated: null,
|
||||
lastSimulatedAt: null,
|
||||
lastPersistedExecutedAt: null,
|
||||
isRunning: false,
|
||||
error: null,
|
||||
selectedActivityId: null,
|
||||
|
||||
persistResults: async (processId, actual, automated) => {
|
||||
const executedAt = Date.now()
|
||||
await simulationRepo.save({
|
||||
id: uuidv4(),
|
||||
processId,
|
||||
executedAt: Date.now(),
|
||||
executedAt,
|
||||
result: actual,
|
||||
resultAutomated: automated,
|
||||
})
|
||||
set({ resultActual: actual, resultAutomated: automated, lastSimulatedAt: new Date(), error: null })
|
||||
set({
|
||||
resultActual: actual,
|
||||
resultAutomated: automated,
|
||||
lastSimulatedAt: new Date(),
|
||||
lastPersistedExecutedAt: executedAt,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
setRunning: (isRunning) => set({ isRunning }),
|
||||
@@ -57,10 +67,16 @@ export const useSimulationStore = create<SimulationState>((set) => ({
|
||||
resultActual: null,
|
||||
resultAutomated: null,
|
||||
lastSimulatedAt: null,
|
||||
lastPersistedExecutedAt: null,
|
||||
isRunning: false,
|
||||
error: null,
|
||||
selectedActivityId: null,
|
||||
}),
|
||||
|
||||
loadLatestForProcess: async (processId) => {
|
||||
const sim = await simulationRepo.getLatestByProcess(processId)
|
||||
set({ lastPersistedExecutedAt: sim?.executedAt ?? null })
|
||||
},
|
||||
}))
|
||||
|
||||
// Suscripción reactiva: cualquier cambio en activities o currentProcess del process-store
|
||||
|
||||
Reference in New Issue
Block a user