/** * Página Paperclip — Painel de agentes, routines e issues * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ import { useState, useEffect, useCallback } from 'react' import { motion } from 'framer-motion' import { Bot, Users, Calendar, CheckCircle2, AlertTriangle, Clock, RefreshCw, Activity, Zap, } from 'lucide-react' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface PaperclipAgent { id: string name: string role: string status: 'active' | 'idle' | 'error' | 'archived' last_heartbeat: string | null last_run: string | null total_runs: number } interface PaperclipRoutine { id: string name: string cron: string active: boolean last_run: string | null last_status: 'success' | 'error' | null next_run: string | null } interface PaperclipDashboard { agents: { total: number active: number idle: number error: number list: PaperclipAgent[] } routines: { total: number active: number list: PaperclipRoutine[] } issues: { open: number in_progress: number closed_7d: number } } // --------------------------------------------------------------------------- // Animation variants (consistente com Monitor.tsx) // --------------------------------------------------------------------------- const containerVariants = { hidden: { opacity: 0 }, show: { opacity: 1, transition: { staggerChildren: 0.06 }, }, } const itemVariants = { hidden: { opacity: 0, y: 20 }, show: { opacity: 1, y: 0, transition: { type: 'spring' as const, stiffness: 300, damping: 30 }, }, } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Ordem de prioridade de roles para agrupamento hierárquico. * Roles desconhecidos ficam no fim. */ const ROLE_ORDER: Record = { Board: 0, CEO: 1, 'C-Level': 2, COO: 2, CTO: 2, CFO: 2, CMO: 2, Director: 3, Specialist: 4, } function roleOrder(role: string): number { return ROLE_ORDER[role] ?? 99 } /** Agrupa a lista de agentes por role, ordenando roles hierarchicamente. */ function groupAgentsByRole(agents: PaperclipAgent[]): Record { const groups: Record = {} for (const agent of agents) { if (!groups[agent.role]) groups[agent.role] = [] groups[agent.role].push(agent) } return groups } /** Ordena as keys de um objecto de grupos pelo ranking hierárquico. */ function sortedRoleKeys(groups: Record): string[] { return Object.keys(groups).sort((a, b) => roleOrder(a) - roleOrder(b)) } /** Formata uma string ISO ou null para data/hora legível PT. */ function formatDate(iso: string | null): string { if (!iso) return '—' try { return new Date(iso).toLocaleString('pt-PT', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', }) } catch { return iso } } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- /** Dot colorido de estado (verde/amarelo/vermelho). */ const StatusDot = ({ status }: { status: string }) => { const colour = status === 'active' ? 'bg-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.5)]' : status === 'idle' ? 'bg-amber-400 shadow-[0_0_10px_rgba(245,158,11,0.5)]' : status === 'error' ? 'bg-red-400 shadow-[0_0_10px_rgba(239,68,68,0.5)] animate-pulse' : 'bg-zinc-600' return } /** Badge de role do agente. */ const RoleBadge = ({ role }: { role: string }) => { const styles: Record = { Board: 'bg-violet-500/20 text-violet-300 border border-violet-500/30', CEO: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30', 'C-Level': 'bg-blue-500/20 text-blue-300 border border-blue-500/30', COO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30', CTO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30', CFO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30', CMO: 'bg-blue-500/20 text-blue-300 border border-blue-500/30', Director: 'bg-sky-500/20 text-sky-300 border border-sky-500/30', Specialist: 'bg-teal-500/20 text-teal-300 border border-teal-500/30', } const cls = styles[role] ?? 'bg-zinc-700/40 text-zinc-400 border border-zinc-600/30' return ( {role} ) } /** Card de estatística no topo. */ const StatCard = ({ label, value, icon: Icon, accent, }: { label: string value: number icon: React.ElementType accent: string }) => (

{value}

{label}

) /** Card individual de agente. */ const AgentCard = ({ agent }: { agent: PaperclipAgent }) => { const statusLabel: Record = { active: 'Activo', idle: 'Inactivo', error: 'Erro', archived: 'Arquivado', } const statusColour: Record = { active: 'text-emerald-400', idle: 'text-amber-400', error: 'text-red-400', archived: 'text-zinc-500', } return ( {/* Cabeçalho: ícone + nome + status dot */}
{agent.name}
{/* Role badge + estado */}
{statusLabel[agent.status] ?? agent.status}
{/* Último heartbeat */}
{agent.last_heartbeat ? formatDate(agent.last_heartbeat) : 'Sem heartbeat'}
) } /** Secção de agentes agrupados por role. */ const AgentsSection = ({ agents }: { agents: PaperclipAgent[] }) => { const groups = groupAgentsByRole(agents) const roleKeys = sortedRoleKeys(groups) if (agents.length === 0) { return (

Sem agentes disponíveis

) } return ( <> {roleKeys.map(role => ( {/* Separador de grupo */}

{role}

{groups[role].length}
{groups[role].map(agent => ( ))}
))} ) } /** Tabela de routines. */ const RoutinesTable = ({ routines }: { routines: PaperclipRoutine[] }) => { if (routines.length === 0) { return (

Sem routines disponíveis

) } return (

Routines

{routines.length} routines
{routines.map((routine, idx) => ( ))}
Nome Cron Activa Último run Estado Próximo run
{routine.name} {routine.cron} {routine.active ? ( Sim ) : ( Não )} {formatDate(routine.last_run)} {routine.last_status === 'success' ? ( ) : routine.last_status === 'error' ? ( ) : ( )} {formatDate(routine.next_run)}
) } /** Contador de issues (open / in-progress / closed). */ const IssuesBar = ({ issues }: { issues: PaperclipDashboard['issues'] }) => (

Issues

{issues.open}

Abertas

{issues.in_progress}

Em progresso

{issues.closed_7d}

Fechadas (7d)

) // --------------------------------------------------------------------------- // Página principal // --------------------------------------------------------------------------- export default function Paperclip() { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [lastUpdated, setLastUpdated] = useState(null) const fetchData = useCallback(async () => { setLoading(true) setError(null) try { const res = await fetch('/api/paperclip') if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body?.message ?? `HTTP ${res.status}`) } const json: PaperclipDashboard = await res.json() setData(json) setLastUpdated(new Date()) } catch (err) { setError((err as Error).message) } finally { setLoading(false) } }, []) useEffect(() => { fetchData() }, [fetchData]) return (
{/* Cabeçalho */}

Paperclip

Orquestrador autónomo — agentes, routines e issues

{lastUpdated && ( Actualizado às {lastUpdated.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' })} )}
{/* Estado de erro */} {error && ( Erro ao carregar dados: {error} )} {/* Estado de carregamento inicial */} {loading && !data && ( {[...Array(4)].map((_, i) => (
))} )} {/* Conteúdo principal */} {data && ( {/* Stats cards */}
{/* Issues */} {/* Agentes agrupados por role */}

Agentes

({data.agents.total} total)
{/* Routines */}
)}
) }