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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user