feat: adicionar 5 novos painéis ao dashboard (MCPs, n8n, Paperclip, IA, Operações)

Expansão do dashboard de 3 para 8 páginas com dados reais do stack:
- MCPs: monitorização de 33 MCPs no gateway com ping e estado online/offline
- n8n: 14 workflows com último run, duração e falhas 24h
- Paperclip: 16 agentes operacionais, routines e issues (PostgreSQL)
- IA/Claude: visão das 3 camadas (189 skills, 72 agents, 39 MCPs, CARL)
- Operações: tickets Desk CRM por departamento + cobertura PROCs

16 ficheiros novos (3042 linhas), 3 existentes editados.
Nova dependência: pg (PostgreSQL client para Paperclip).
Audit: 0 vulnerabilidades (npm audit fix aplicado).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 20:58:48 +01:00
co-authored by Claude Opus 4.6
parent a4271fd06a
commit 12f688ff7c
23 changed files with 4123 additions and 21 deletions
+322
View File
@@ -0,0 +1,322 @@
/**
* Página Operações — Tickets e Cobertura de PROCs
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'
import {
Ticket,
AlertTriangle,
Clock,
FileText,
Building2,
RefreshCw,
} from 'lucide-react'
// ---------------------------------------------------------------------------
// Tipos
// ---------------------------------------------------------------------------
interface TicketsByDept {
dept: string
count: number
}
interface CoverageItem {
dept: string
procs: number
total_expected: number
pct: number
}
interface OperationsData {
tickets: {
open: number
high_priority: number
avg_response_hours: number
by_department: TicketsByDept[]
}
procedures: {
total: number
departments: number
coverage: CoverageItem[]
}
}
// ---------------------------------------------------------------------------
// Animações
// ---------------------------------------------------------------------------
const containerVariants = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
}
const itemVariants = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
}
// ---------------------------------------------------------------------------
// Sub-componentes
// ---------------------------------------------------------------------------
const StatCard = ({
label, value, icon: Icon, color, sub,
}: {
label: string
value: string
icon: React.ElementType
color: string
sub?: string
}) => (
<motion.div variants={itemVariants} className="glass-card p-6">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-zinc-400">{label}</span>
<div className={`w-10 h-10 rounded-xl ${color} flex items-center justify-center`}>
<Icon className="w-5 h-5 text-white" />
</div>
</div>
<div className="text-3xl font-bold text-white">{value}</div>
{sub && <div className="text-xs text-zinc-500 mt-1">{sub}</div>}
</motion.div>
)
const CustomTooltip = ({ active, payload, label }: {
active?: boolean
payload?: { color: string; name: string; value: number }[]
label?: string
}) => {
if (!active || !payload?.length) return null
return (
<div className="bg-zinc-900 border border-white/10 rounded-xl px-4 py-3 shadow-xl">
<p className="text-sm font-medium text-white mb-1">{label}</p>
{payload.map((p, i) => (
<p key={i} className="text-xs" style={{ color: p.color }}>
{p.name}: {p.value}
</p>
))}
</div>
)
}
// Barra de progresso para cobertura de PROCs
const ProgressBar = ({ pct }: { pct: number }) => {
const color =
pct >= 80 ? 'bg-emerald-500' :
pct >= 50 ? 'bg-amber-500' :
'bg-red-500'
return (
<div className="w-full bg-white/10 rounded-full h-2 overflow-hidden">
<div
className={`h-2 rounded-full transition-all duration-500 ${color}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Página principal
// ---------------------------------------------------------------------------
export default function Operations() {
const [data, setData] = useState<OperationsData | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const fetchData = useCallback(async () => {
setRefreshing(true)
try {
const response = await fetch('/api/operations')
if (!response.ok) throw new Error('Resposta inválida')
const json: OperationsData = await response.json()
setData(json)
} catch (err) {
console.error('[Operations] Erro ao carregar dados:', err)
} finally {
setLoading(false)
setRefreshing(false)
}
}, [])
useEffect(() => { fetchData() }, [fetchData])
// Estado de carregamento
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<Ticket className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
<p className="text-zinc-400">A carregar dados de operações...</p>
</motion.div>
</div>
)
}
if (!data) return null
// Cor do card de alta prioridade: vermelho se existirem tickets críticos
const priorityColor = data.tickets.high_priority > 0
? 'bg-red-500/20'
: 'bg-zinc-700/40'
return (
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
{/* Cabeçalho */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-white tracking-tight">Operações</h2>
<p className="text-xs text-zinc-500">Tickets de Suporte e Cobertura de Procedimentos</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={fetchData}
disabled={refreshing}
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
>
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
</motion.button>
</div>
<motion.div variants={containerVariants} initial="hidden" animate="show">
{/* Cards de resumo */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 mb-8">
<StatCard
label="Tickets Abertos"
value={String(data.tickets.open)}
icon={Ticket}
color="bg-blue-500/20"
sub="Status: Open, In Progress, Answered"
/>
<StatCard
label="Alta Prioridade"
value={String(data.tickets.high_priority)}
icon={AlertTriangle}
color={priorityColor}
sub={data.tickets.high_priority > 0 ? 'Requerem atenção imediata' : 'Sem tickets críticos'}
/>
<StatCard
label="Tempo Médio Resposta"
value={`${data.tickets.avg_response_hours}h`}
icon={Clock}
color="bg-amber-500/20"
sub="Média últimos 90 dias"
/>
</div>
{/* Gráfico de barras horizontal + Resumo de PROCs */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5 mb-8">
{/* Tickets por departamento (barra horizontal) */}
<motion.div variants={itemVariants} className="glass-card p-6 lg:col-span-2">
<h3 className="text-sm font-semibold text-white mb-6 uppercase tracking-wide flex items-center gap-2">
<Building2 className="w-4 h-4 text-brand-400" />
Tickets por Departamento
</h3>
<div className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data.tickets.by_department}
layout="vertical"
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" horizontal={false} />
<XAxis
type="number"
tick={{ fill: '#71717a', fontSize: 12 }}
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="dept"
tick={{ fill: '#a1a1aa', fontSize: 11 }}
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
width={140}
/>
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="count" name="Tickets" fill="#10b981" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</motion.div>
{/* Resumo de PROCs */}
<motion.div variants={itemVariants} className="glass-card p-6">
<h3 className="text-sm font-semibold text-white mb-5 uppercase tracking-wide flex items-center gap-2">
<FileText className="w-4 h-4 text-brand-400" />
Procedimentos
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between py-2 border-b border-white/10">
<span className="text-sm text-zinc-400">Total de PROCs</span>
<span className="text-xl font-bold text-white">{data.procedures.total}</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-white/10">
<span className="text-sm text-zinc-400">Departamentos cobertos</span>
<span className="text-xl font-bold text-white">{data.procedures.departments}</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-zinc-400">Cobertura global</span>
<span className="text-xl font-bold text-emerald-400">
{Math.round(
(data.procedures.coverage.reduce((s, d) => s + d.procs, 0) /
data.procedures.coverage.reduce((s, d) => s + d.total_expected, 0)) * 100
)}%
</span>
</div>
</div>
</motion.div>
</div>
{/* Tabela de cobertura por departamento */}
<motion.div variants={itemVariants} className="glass-card p-6">
<h3 className="text-sm font-semibold text-white mb-6 uppercase tracking-wide flex items-center gap-2">
<FileText className="w-4 h-4 text-brand-400" />
Cobertura de PROCs por Departamento
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10">
<th className="text-left py-2 pr-4 text-zinc-400 font-medium">Departamento</th>
<th className="text-center py-2 px-4 text-zinc-400 font-medium">PROCs</th>
<th className="text-center py-2 px-4 text-zinc-400 font-medium">Esperados</th>
<th className="text-left py-2 pl-4 text-zinc-400 font-medium w-48">Cobertura</th>
<th className="text-right py-2 pl-4 text-zinc-400 font-medium">%</th>
</tr>
</thead>
<tbody>
{data.procedures.coverage.map((item, i) => (
<tr
key={i}
className="border-b border-white/5 hover:bg-white/[0.02] transition-colors"
>
<td className="py-3 pr-4 text-white font-medium">{item.dept}</td>
<td className="py-3 px-4 text-center text-zinc-300 tabular-nums">{item.procs}</td>
<td className="py-3 px-4 text-center text-zinc-500 tabular-nums">{item.total_expected}</td>
<td className="py-3 pl-4 w-48">
<ProgressBar pct={item.pct} />
</td>
<td className="py-3 pl-4 text-right tabular-nums font-semibold"
style={{
color: item.pct >= 80 ? '#34d399' : item.pct >= 50 ? '#fbbf24' : '#f87171',
}}
>
{item.pct}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
</motion.div>
</div>
)
}