862 lines
33 KiB
TypeScript
862 lines
33 KiB
TypeScript
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,
|
|
MoreVertical, Trash2, Share2, Lock, User,
|
|
} from 'lucide-react'
|
|
import { useLibraryStore } from '@/store/library-store'
|
|
import { useAuth } from '@/auth/AuthContext'
|
|
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
|
|
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))
|
|
}
|
|
|
|
// ─── Ownership Badge ──────────────────────────────────────────────────────────
|
|
|
|
function OwnershipBadge({ process, currentUserId }: { process: Process; currentUserId: string }) {
|
|
const isOwn = process.ownerId === currentUserId
|
|
return (
|
|
<div
|
|
className="flex-shrink-0 flex items-center"
|
|
title={isOwn ? 'Solo vos' : 'De otro consultor'}
|
|
>
|
|
{isOwn
|
|
? <Lock className="h-3 w-3 text-muted-foreground/60" />
|
|
: <User className="h-3 w-3 text-muted-foreground/60" />
|
|
}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Toast ────────────────────────────────────────────────────────────────────
|
|
|
|
function useToast() {
|
|
const [message, setMessage] = useState<string | null>(null)
|
|
|
|
function show(msg: string) {
|
|
setMessage(msg)
|
|
setTimeout(() => setMessage(null), 2800)
|
|
}
|
|
|
|
return { message, show }
|
|
}
|
|
|
|
function Toast({ message }: { message: string | null }) {
|
|
if (!message) return null
|
|
return (
|
|
<div
|
|
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2.5 rounded-lg text-[13px] font-medium text-white shadow-lg transition-all"
|
|
style={{ background: '#15803d' }}
|
|
>
|
|
{message}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 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, onDelete, currentUserId, onShared }: {
|
|
process: Process
|
|
simulation: Simulation | null | undefined
|
|
showGroupChip?: boolean
|
|
groupName?: string
|
|
onDelete?: () => void
|
|
currentUserId: string
|
|
onShared?: () => void
|
|
}) {
|
|
const navigate = useNavigate()
|
|
const [menuState, setMenuState] = useState<null | 'menu' | 'confirm'>(null)
|
|
const isOwn = process.ownerId === currentUserId
|
|
|
|
// Cierra el menú al hacer click fuera de él
|
|
useEffect(() => {
|
|
if (!menuState) return
|
|
function handleDocClick() { setMenuState(null) }
|
|
document.addEventListener('click', handleDocClick)
|
|
return () => document.removeEventListener('click', handleDocClick)
|
|
}, [menuState])
|
|
|
|
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 group"
|
|
onClick={() => {
|
|
if (menuState) { setMenuState(null); return }
|
|
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">
|
|
<div className="flex items-center gap-1.5">
|
|
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p>
|
|
<OwnershipBadge process={process} currentUserId={currentUserId} />
|
|
</div>
|
|
<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>
|
|
|
|
{/* Menú de opciones (⋮) — en el flujo flex, a la derecha del KPI */}
|
|
{onDelete && (
|
|
<div
|
|
className="relative flex-shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
className={`p-1 rounded hover:bg-secondary text-muted-foreground hover:text-foreground transition-all ${menuState === null ? 'opacity-0 group-hover:opacity-100' : 'opacity-100'}`}
|
|
onClick={(e) => { e.stopPropagation(); setMenuState(menuState === 'menu' ? null : 'menu') }}
|
|
title="Opciones"
|
|
>
|
|
<MoreVertical className="h-3.5 w-3.5" />
|
|
</button>
|
|
{menuState === 'menu' && (
|
|
<div
|
|
className="absolute right-0 top-7 z-20 bg-card border border-border rounded-lg shadow-lg py-1 min-w-[160px]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{isOwn && (
|
|
<button
|
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-foreground hover:bg-secondary transition-colors"
|
|
onClick={async (e) => {
|
|
e.stopPropagation()
|
|
await supabaseProcessRepo.setShared(process.id, true)
|
|
setMenuState(null)
|
|
onShared?.()
|
|
}}
|
|
>
|
|
<Share2 className="h-3.5 w-3.5" />
|
|
Compartir con equipo
|
|
</button>
|
|
)}
|
|
<button
|
|
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-destructive hover:bg-destructive/10 transition-colors"
|
|
onClick={(e) => { e.stopPropagation(); setMenuState('confirm') }}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Eliminar
|
|
</button>
|
|
</div>
|
|
)}
|
|
{menuState === 'confirm' && (
|
|
<div
|
|
className="absolute right-0 top-6 z-20 bg-card border border-border rounded-lg shadow-lg p-3 min-w-[170px]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<p className="text-[12px] font-medium mb-2 text-foreground">¿Eliminar proceso?</p>
|
|
<p className="text-[11px] text-muted-foreground mb-3">Esta acción no se puede deshacer.</p>
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
className="flex-1 text-[11px] px-2 py-1 rounded font-medium text-white transition-opacity hover:opacity-90"
|
|
style={{ background: '#ef4444' }}
|
|
onClick={(e) => { e.stopPropagation(); onDelete(); setMenuState(null) }}
|
|
>
|
|
Eliminar
|
|
</button>
|
|
<button
|
|
className="flex-1 text-[11px] px-2 py-1 rounded border border-border text-muted-foreground hover:bg-secondary transition-colors"
|
|
onClick={(e) => { e.stopPropagation(); setMenuState(null) }}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</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,
|
|
ownerFilter,
|
|
currentUserId,
|
|
onShared,
|
|
}: {
|
|
onNavigateToGroup: (groupId: string | null) => void
|
|
onImport: (groupId?: string) => void
|
|
ownerFilter: 'all' | 'mine'
|
|
currentUserId: string
|
|
onShared: () => void
|
|
}) {
|
|
const { groups, processes, settings, createGroup, isLoading, error, load } = useLibraryStore()
|
|
const { user } = useAuth()
|
|
const [search, setSearch] = useState('')
|
|
const [activeTags, setActiveTags] = useState<string[]>([])
|
|
|
|
const visibleProcesses = ownerFilter === 'mine'
|
|
? processes.filter((p) => p.ownerId === currentUserId)
|
|
: processes
|
|
|
|
const cloud = tagCloudData(visibleProcesses)
|
|
const unclassifiedProcesses = visibleProcesses.filter((p) => p.groupId == null)
|
|
|
|
const isSearching = search.trim().length > 0 || activeTags.length > 0
|
|
|
|
const filteredProcesses = isSearching
|
|
? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
|
|
: []
|
|
|
|
function toggleTag(tag: string) {
|
|
setActiveTags((prev) => prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag])
|
|
}
|
|
|
|
// Skeleton mientras carga y aún no hay datos (evita flash de empty state)
|
|
if (isLoading && groups.length === 0 && processes.length === 0) {
|
|
return (
|
|
<div className="space-y-5 animate-pulse">
|
|
<div className="h-4 w-32 bg-slate-100 rounded" />
|
|
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="h-20 rounded-xl bg-slate-100" />
|
|
))}
|
|
</div>
|
|
<div className="space-y-2">
|
|
{[1, 2].map((i) => (
|
|
<div key={i} className="h-14 rounded-xl bg-slate-100" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Error state: el load() falló o se colgó (timeout) — distinto de "biblioteca vacía"
|
|
if (error && 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-destructive/40" />
|
|
<p className="text-base font-medium">No se pudo cargar la biblioteca</p>
|
|
<p className="text-[13px] text-muted-foreground max-w-sm text-center">{error}</p>
|
|
<button
|
|
onClick={() => load()}
|
|
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' }}
|
|
>
|
|
Reintentar
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Empty state: sin grupos ni procesos (ya terminó de cargar)
|
|
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}
|
|
currentUserId={currentUserId}
|
|
onShared={onShared}
|
|
/>
|
|
)
|
|
})}
|
|
</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 = visibleProcesses.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, user!.id)}
|
|
/>
|
|
</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, currentUserId, onShared }: {
|
|
process: Process
|
|
showGroupChip?: boolean
|
|
groupName?: string
|
|
currentUserId: string
|
|
onShared: () => void
|
|
}) {
|
|
const { getLatestSimulation, deleteProcess } = 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}
|
|
onDelete={() => deleteProcess(process.id)}
|
|
currentUserId={currentUserId}
|
|
onShared={onShared}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// ─── Group View ───────────────────────────────────────────────────────────────
|
|
|
|
function GroupView({
|
|
groupId,
|
|
onBack,
|
|
currentUserId,
|
|
ownerFilter,
|
|
onShared,
|
|
}: {
|
|
groupId: string | null
|
|
onBack: () => void
|
|
currentUserId: string
|
|
ownerFilter: 'all' | 'mine'
|
|
onShared: () => 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 baseProcesses = ownerFilter === 'mine'
|
|
? processes.filter((p) => p.ownerId === currentUserId)
|
|
: processes
|
|
|
|
const groupProcesses = baseProcesses
|
|
.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}
|
|
currentUserId={currentUserId}
|
|
onShared={onShared}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── LibraryPage ──────────────────────────────────────────────────────────────
|
|
|
|
export function LibraryPage() {
|
|
const navigate = useNavigate()
|
|
const { load, processes, groups, settings } = useLibraryStore()
|
|
const { user } = useAuth()
|
|
const [view, setView] = useState<'home' | 'group'>('home')
|
|
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
|
|
const [transitioning, setTransitioning] = useState(false)
|
|
const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all')
|
|
const { message: toastMessage, show: showToast } = useToast()
|
|
|
|
useEffect(() => {
|
|
if (!user) return // no fetchear hasta tener usuario confirmado (evita race condition con sesión Supabase)
|
|
load()
|
|
}, [load, user])
|
|
|
|
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' })
|
|
}
|
|
}
|
|
|
|
const currentUserId = user!.id
|
|
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">
|
|
{/* Toggle Todos / Míos */}
|
|
<div className="flex rounded-lg border border-border overflow-hidden text-[12px]">
|
|
<button
|
|
className="px-3 py-1.5 font-medium transition-colors"
|
|
style={ownerFilter === 'all'
|
|
? { background: '#F59845', color: 'white' }
|
|
: { background: 'transparent', color: 'hsl(var(--muted-foreground))' }
|
|
}
|
|
onClick={() => setOwnerFilter('all')}
|
|
>
|
|
Todos
|
|
</button>
|
|
<button
|
|
className="px-3 py-1.5 font-medium transition-colors border-l border-border"
|
|
style={ownerFilter === 'mine'
|
|
? { background: '#F59845', color: 'white' }
|
|
: { background: 'transparent', color: 'hsl(var(--muted-foreground))' }
|
|
}
|
|
onClick={() => setOwnerFilter('mine')}
|
|
>
|
|
Míos
|
|
</button>
|
|
</div>
|
|
|
|
<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 — sin spinner bloqueante: skeleton dentro de HomeView */}
|
|
<div
|
|
className="transition-all duration-150"
|
|
style={{ opacity: transitioning ? 0 : 1, transform: transitioning ? 'translateY(6px)' : 'translateY(0)' }}
|
|
>
|
|
{view === 'home' ? (
|
|
<HomeView
|
|
onNavigateToGroup={navigateToGroup}
|
|
onImport={handleImport}
|
|
ownerFilter={ownerFilter}
|
|
currentUserId={currentUserId}
|
|
onShared={() => showToast('Proceso compartido con el equipo')}
|
|
/>
|
|
) : (
|
|
<GroupView
|
|
groupId={activeGroupId}
|
|
onBack={navigateHome}
|
|
currentUserId={currentUserId}
|
|
ownerFilter={ownerFilter}
|
|
onShared={() => showToast('Proceso compartido con el equipo')}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Toast message={toastMessage} />
|
|
</div>
|
|
)
|
|
}
|