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>
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
/**
|
|
* 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>
|
|
)
|
|
}
|