feat: Fase 3 — UI React: Supervisor Monitor, Relatórios, Protocolos
3 novas páginas XOS + rotas + navegação central: XosSupervisor (/xos/supervisor): - KPI cards em tempo real: em atendimento, resolvidos hoje, tickets urgentes, agentes ativos - Tab Filas: ocupação, aguardando, TMA, agentes por fila com Progress bar - Tab Agentes: tabela com conversas ativas, resolvidos hoje, CSAT médio, TMA, filas - Tab Ao Vivo: lista de conversas abertas com nome, canal, fila, agente, tempo de espera - Auto-refresh a cada 30s, filtro por fila, indicador de horário de atualização - Conversas > 30min destacadas em vermelho XosReports (/xos/reports): - Seletor de período (7d / 30d / 90d / 1 ano) - Tab Visão Geral: KPI de conversas, tickets, receita ganha, novos contatos + cards de detalhe com TMA, canais, pipeline com taxa de conversão - Tab CSAT: nota média, taxa de satisfação, distribuição de notas com barras visuais - Tab SLA: compliance rate com badge colorido, violações, tickets por prioridade - Tab Agentes: tabela com conversas, resolução, CSAT, TMA, tickets XosProtocols (/xos/protocols): - KPI: abertos, resolvidos, SLA violados - Lista com número do protocolo (clicável para copiar), status badge, SLA restante - Protocolos com SLA violado destacados em vermelho - Dialog de detalhes: todos os campos, ações (Resolver, Registrar CSAT) - Dialog de CSAT: stars interativas 1-5 + comentário - Dialog de criação: assunto, ID do contato, SLA em minutos - Busca e filtro por status App.tsx: - 3 novas rotas: /xos/supervisor, /xos/reports, /xos/protocols XosCentral.tsx: - 3 novos módulos no grid: Supervisor, Relatórios, Protocolos - Grid expandido de 6 para 9 colunas https://claude.ai/code/session_01DinH3VcgbAv1d9MqnNxzdb
This commit is contained in:
parent
8f05f8b021
commit
5aaf94df0e
|
|
@ -67,6 +67,9 @@ const XosAutomations = lazy(() => import("@/pages/XosAutomations"));
|
||||||
const XosSites = lazy(() => import("@/pages/XosSites"));
|
const XosSites = lazy(() => import("@/pages/XosSites"));
|
||||||
const XosGovernance = lazy(() => import("@/pages/XosGovernance"));
|
const XosGovernance = lazy(() => import("@/pages/XosGovernance"));
|
||||||
const XosPipeline = lazy(() => import("@/pages/XosPipeline"));
|
const XosPipeline = lazy(() => import("@/pages/XosPipeline"));
|
||||||
|
const XosSupervisor = lazy(() => import("@/pages/XosSupervisor"));
|
||||||
|
const XosReports = lazy(() => import("@/pages/XosReports"));
|
||||||
|
const XosProtocols = lazy(() => import("@/pages/XosProtocols"));
|
||||||
|
|
||||||
|
|
||||||
function LoadingFallback() {
|
function LoadingFallback() {
|
||||||
|
|
@ -135,6 +138,9 @@ function Router() {
|
||||||
<ProtectedRoute path="/xos/sites" component={XosSites} />
|
<ProtectedRoute path="/xos/sites" component={XosSites} />
|
||||||
<ProtectedRoute path="/xos/governance" component={XosGovernance} />
|
<ProtectedRoute path="/xos/governance" component={XosGovernance} />
|
||||||
<ProtectedRoute path="/xos/pipeline" component={XosPipeline} />
|
<ProtectedRoute path="/xos/pipeline" component={XosPipeline} />
|
||||||
|
<ProtectedRoute path="/xos/supervisor" component={XosSupervisor} />
|
||||||
|
<ProtectedRoute path="/xos/reports" component={XosReports} />
|
||||||
|
<ProtectedRoute path="/xos/protocols" component={XosProtocols} />
|
||||||
|
|
||||||
<ProtectedRoute path="/doctype-builder" component={DocTypeBuilder} />
|
<ProtectedRoute path="/doctype-builder" component={DocTypeBuilder} />
|
||||||
<ProtectedRoute path="/page-builder" component={PageBuilder} />
|
<ProtectedRoute path="/page-builder" component={PageBuilder} />
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
import {
|
import {
|
||||||
Users, Building2, TrendingUp, MessageSquare, Ticket, Zap, LayoutGrid,
|
Users, Building2, TrendingUp, MessageSquare, Ticket, Zap, LayoutGrid,
|
||||||
ChevronRight, PlusCircle, Filter, Search, Bell, Calendar, Target,
|
ChevronRight, PlusCircle, Filter, Search, Bell, Calendar, Target,
|
||||||
BarChart3, DollarSign, Clock, AlertCircle, Phone, Mail, ArrowUpRight
|
BarChart3, DollarSign, Clock, AlertCircle, Phone, Mail, ArrowUpRight,
|
||||||
|
Activity, Hash, Shield
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
|
@ -97,6 +98,9 @@ export default function XosCentral() {
|
||||||
{ id: "automations", name: "Automações", icon: Zap, href: "/xos/automations", color: "bg-violet-100 text-violet-600", description: "Workflows automáticos" },
|
{ id: "automations", name: "Automações", icon: Zap, href: "/xos/automations", color: "bg-violet-100 text-violet-600", description: "Workflows automáticos" },
|
||||||
{ id: "campaigns", name: "Campanhas", icon: Target, href: "/xos/campaigns", color: "bg-pink-100 text-pink-600", description: "Marketing automation" },
|
{ id: "campaigns", name: "Campanhas", icon: Target, href: "/xos/campaigns", color: "bg-pink-100 text-pink-600", description: "Marketing automation" },
|
||||||
{ id: "sites", name: "Sites", icon: LayoutGrid, href: "/xos/sites", color: "bg-cyan-100 text-cyan-600", description: "Site builder" },
|
{ id: "sites", name: "Sites", icon: LayoutGrid, href: "/xos/sites", color: "bg-cyan-100 text-cyan-600", description: "Site builder" },
|
||||||
|
{ id: "supervisor", name: "Supervisor", icon: Activity, href: "/xos/supervisor", color: "bg-indigo-100 text-indigo-600", description: "Monitor em tempo real" },
|
||||||
|
{ id: "reports", name: "Relatórios", icon: BarChart3, href: "/xos/reports", color: "bg-emerald-100 text-emerald-600", description: "CSAT, SLA e KPIs" },
|
||||||
|
{ id: "protocols", name: "Protocolos", icon: Hash, href: "/xos/protocols", color: "bg-teal-100 text-teal-600", description: "Rastreamento de atendimentos" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
|
|
@ -264,7 +268,7 @@ export default function XosCentral() {
|
||||||
{/* Modules Grid */}
|
{/* Modules Grid */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Módulos XOS</h2>
|
<h2 className="text-lg font-semibold text-slate-800 mb-4">Módulos XOS</h2>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-9 gap-4">
|
||||||
{modules.map((mod) => (
|
{modules.map((mod) => (
|
||||||
<Link key={mod.id} href={mod.href}>
|
<Link key={mod.id} href={mod.href}>
|
||||||
<Card className="hover:shadow-lg transition-all cursor-pointer group" data-testid={`card-module-${mod.id}`}>
|
<Card className="hover:shadow-lg transition-all cursor-pointer group" data-testid={`card-module-${mod.id}`}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,506 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
|
import {
|
||||||
|
Hash, Search, Filter, ArrowLeft, CheckCircle2, Clock, AlertTriangle,
|
||||||
|
Plus, Star, Shield, Copy, ChevronRight, User, Inbox
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface Protocol {
|
||||||
|
id: number;
|
||||||
|
protocol_number: string;
|
||||||
|
status: "open" | "resolved" | "cancelled";
|
||||||
|
subject: string | null;
|
||||||
|
contact_name: string | null;
|
||||||
|
queue_name: string | null;
|
||||||
|
assigned_to: string | null;
|
||||||
|
sla_deadline: string | null;
|
||||||
|
sla_breach: boolean;
|
||||||
|
satisfaction_score: number | null;
|
||||||
|
opened_at: string;
|
||||||
|
resolved_at: string | null;
|
||||||
|
conversation_id: number | null;
|
||||||
|
ticket_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function XosProtocols() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [selectedProtocol, setSelectedProtocol] = useState<Protocol | null>(null);
|
||||||
|
const [showNewDialog, setShowNewDialog] = useState(false);
|
||||||
|
const [newForm, setNewForm] = useState({ subject: "", contactId: "", queueId: "", slaMinutes: "" });
|
||||||
|
const [csatDialog, setCsatDialog] = useState<{ open: boolean; protocol: Protocol | null }>({ open: false, protocol: null });
|
||||||
|
const [csatScore, setCsatScore] = useState(5);
|
||||||
|
const [csatComment, setCsatComment] = useState("");
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: protocols = [], isLoading } = useQuery<Protocol[]>({
|
||||||
|
queryKey: ["/api/xos/protocols", search, statusFilter],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
if (statusFilter !== "all") params.set("status", statusFilter);
|
||||||
|
const res = await fetch(`/api/xos/protocols?${params}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createProtocol = useMutation({
|
||||||
|
mutationFn: async (data: typeof newForm) => {
|
||||||
|
const res = await fetch("/api/xos/protocols", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
subject: data.subject || null,
|
||||||
|
contactId: data.contactId ? parseInt(data.contactId) : null,
|
||||||
|
queueId: data.queueId ? parseInt(data.queueId) : null,
|
||||||
|
slaMinutes: data.slaMinutes ? parseInt(data.slaMinutes) : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Falha ao criar protocolo");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: (prot) => {
|
||||||
|
toast({ title: "Protocolo criado", description: `Número: ${prot.protocol_number}` });
|
||||||
|
setShowNewDialog(false);
|
||||||
|
setNewForm({ subject: "", contactId: "", queueId: "", slaMinutes: "" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/xos/protocols"] });
|
||||||
|
},
|
||||||
|
onError: () => toast({ title: "Erro ao criar protocolo", variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateProtocol = useMutation({
|
||||||
|
mutationFn: async ({ id, status }: { id: number; status: string }) => {
|
||||||
|
const res = await fetch(`/api/xos/protocols/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Protocolo atualizado" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/xos/protocols"] });
|
||||||
|
setSelectedProtocol(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitCsat = useMutation({
|
||||||
|
mutationFn: async ({ protocol, score, comment }: { protocol: Protocol; score: number; comment: string }) => {
|
||||||
|
const endpoint = protocol.conversation_id
|
||||||
|
? `/api/xos/conversations/${protocol.conversation_id}/csat`
|
||||||
|
: protocol.ticket_id
|
||||||
|
? `/api/xos/tickets/${protocol.ticket_id}/csat`
|
||||||
|
: null;
|
||||||
|
if (!endpoint) throw new Error("Sem conversa ou ticket vinculado");
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ score, comment }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Falha ao registrar CSAT");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Avaliação registrada!", description: "CSAT salvo com sucesso." });
|
||||||
|
setCsatDialog({ open: false, protocol: null });
|
||||||
|
setCsatScore(5);
|
||||||
|
setCsatComment("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/xos/protocols"] });
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusConfig = (status: string) => ({
|
||||||
|
open: { label: "Aberto", color: "bg-blue-100 text-blue-700", icon: Clock },
|
||||||
|
resolved: { label: "Resolvido", color: "bg-emerald-100 text-emerald-700", icon: CheckCircle2 },
|
||||||
|
cancelled: { label: "Cancelado", color: "bg-slate-100 text-slate-600", icon: AlertTriangle },
|
||||||
|
}[status] || { label: status, color: "bg-slate-100 text-slate-600", icon: Clock });
|
||||||
|
|
||||||
|
const isSlaBreached = (p: Protocol) => {
|
||||||
|
if (p.sla_breach) return true;
|
||||||
|
if (p.status !== "open" || !p.sla_deadline) return false;
|
||||||
|
return new Date(p.sla_deadline) < new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSlaTimeLeft = (deadline: string) => {
|
||||||
|
const diff = new Date(deadline).getTime() - Date.now();
|
||||||
|
if (diff < 0) return null;
|
||||||
|
const h = Math.floor(diff / 3600000);
|
||||||
|
const m = Math.floor((diff % 3600000) / 60000);
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyProtocol = (number: string) => {
|
||||||
|
navigator.clipboard.writeText(number);
|
||||||
|
toast({ title: "Copiado!", description: number });
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = protocols.filter(p => p.status === "open").length;
|
||||||
|
const resolved = protocols.filter(p => p.status === "resolved").length;
|
||||||
|
const breached = protocols.filter(p => isSlaBreached(p)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserFrame>
|
||||||
|
<div className="min-h-screen bg-slate-50">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/xos">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div className="bg-gradient-to-br from-teal-600 to-cyan-700 p-2 rounded-xl">
|
||||||
|
<Hash className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-800">Protocolos</h1>
|
||||||
|
<p className="text-xs text-slate-500">Rastreamento de atendimentos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowNewDialog(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Novo Protocolo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Card className="bg-gradient-to-br from-blue-500 to-blue-600 text-white">
|
||||||
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
|
<Clock className="h-8 w-8 text-blue-200" />
|
||||||
|
<div>
|
||||||
|
<p className="text-blue-100 text-sm">Abertos</p>
|
||||||
|
<p className="text-2xl font-bold">{open}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-emerald-500 to-emerald-600 text-white">
|
||||||
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
|
<CheckCircle2 className="h-8 w-8 text-emerald-200" />
|
||||||
|
<div>
|
||||||
|
<p className="text-emerald-100 text-sm">Resolvidos</p>
|
||||||
|
<p className="text-2xl font-bold">{resolved}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className={`text-white ${breached > 0 ? "bg-gradient-to-br from-red-500 to-red-600" : "bg-gradient-to-br from-slate-500 to-slate-600"}`}>
|
||||||
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
|
<AlertTriangle className="h-8 w-8 opacity-70" />
|
||||||
|
<div>
|
||||||
|
<p className="opacity-80 text-sm">SLA Violado</p>
|
||||||
|
<p className="text-2xl font-bold">{breached}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar por número ou assunto..."
|
||||||
|
className="pl-10"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todos</SelectItem>
|
||||||
|
<SelectItem value="open">Abertos</SelectItem>
|
||||||
|
<SelectItem value="resolved">Resolvidos</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelados</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : protocols.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-16 text-center">
|
||||||
|
<Inbox className="h-12 w-12 mx-auto mb-3 text-slate-300" />
|
||||||
|
<p className="text-slate-500">Nenhum protocolo encontrado</p>
|
||||||
|
<Button className="mt-4" onClick={() => setShowNewDialog(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" /> Criar Protocolo
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{protocols.map(protocol => {
|
||||||
|
const statusCfg = getStatusConfig(protocol.status);
|
||||||
|
const StatusIcon = statusCfg.icon;
|
||||||
|
const breached = isSlaBreached(protocol);
|
||||||
|
const slaLeft = protocol.sla_deadline && protocol.status === "open" ? getSlaTimeLeft(protocol.sla_deadline) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={protocol.id}
|
||||||
|
className={`hover:shadow-md transition-all cursor-pointer ${breached ? "border-red-300 bg-red-50" : ""}`}
|
||||||
|
onClick={() => setSelectedProtocol(protocol)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<StatusIcon className={`h-5 w-5 ${protocol.status === "resolved" ? "text-emerald-500" : protocol.status === "cancelled" ? "text-slate-400" : "text-blue-500"}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
className="font-mono font-semibold text-slate-800 hover:text-blue-600 flex items-center gap-1"
|
||||||
|
onClick={e => { e.stopPropagation(); copyProtocol(protocol.protocol_number); }}
|
||||||
|
>
|
||||||
|
#{protocol.protocol_number}
|
||||||
|
<Copy className="h-3 w-3 opacity-50" />
|
||||||
|
</button>
|
||||||
|
<Badge className={statusCfg.color}>{statusCfg.label}</Badge>
|
||||||
|
{breached && <Badge className="bg-red-100 text-red-700">SLA Violado</Badge>}
|
||||||
|
{protocol.satisfaction_score && (
|
||||||
|
<Badge className="bg-yellow-100 text-yellow-700">
|
||||||
|
<Star className="h-3 w-3 mr-1 fill-current" />
|
||||||
|
{protocol.satisfaction_score}/5
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 mt-1 truncate">{protocol.subject || "Sem assunto"}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
|
||||||
|
{protocol.contact_name && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User className="h-3 w-3" /> {protocol.contact_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{protocol.queue_name && (
|
||||||
|
<span>{protocol.queue_name}</span>
|
||||||
|
)}
|
||||||
|
<span>{new Date(protocol.opened_at).toLocaleDateString("pt-BR")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 text-right">
|
||||||
|
{slaLeft && (
|
||||||
|
<p className="text-xs text-emerald-600 font-medium flex items-center gap-1 justify-end">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
{slaLeft} restante
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{breached && protocol.status === "open" && (
|
||||||
|
<p className="text-xs text-red-600 font-medium">SLA vencido</p>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="h-4 w-4 text-slate-400 mt-1 ml-auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Protocol detail dialog */}
|
||||||
|
{selectedProtocol && (
|
||||||
|
<Dialog open onOpenChange={() => setSelectedProtocol(null)}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Hash className="h-5 w-5 text-teal-600" />
|
||||||
|
Protocolo #{selectedProtocol.protocol_number}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Status</p>
|
||||||
|
<Badge className={getStatusConfig(selectedProtocol.status).color}>
|
||||||
|
{getStatusConfig(selectedProtocol.status).label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Abertura</p>
|
||||||
|
<p className="text-sm font-medium">{new Date(selectedProtocol.opened_at).toLocaleString("pt-BR")}</p>
|
||||||
|
</div>
|
||||||
|
{selectedProtocol.contact_name && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Contato</p>
|
||||||
|
<p className="text-sm font-medium">{selectedProtocol.contact_name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedProtocol.queue_name && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">Fila</p>
|
||||||
|
<p className="text-sm font-medium">{selectedProtocol.queue_name}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedProtocol.sla_deadline && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">SLA Deadline</p>
|
||||||
|
<p className={`text-sm font-medium ${isSlaBreached(selectedProtocol) ? "text-red-600" : "text-emerald-600"}`}>
|
||||||
|
{new Date(selectedProtocol.sla_deadline).toLocaleString("pt-BR")}
|
||||||
|
{isSlaBreached(selectedProtocol) && " (violado)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedProtocol.satisfaction_score && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">CSAT</p>
|
||||||
|
<div className="flex items-center gap-1 text-yellow-600">
|
||||||
|
{"★".repeat(selectedProtocol.satisfaction_score)}
|
||||||
|
<span className="text-sm ml-1">{selectedProtocol.satisfaction_score}/5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedProtocol.subject && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500 mb-1">Assunto</p>
|
||||||
|
<p className="text-sm bg-slate-50 p-2 rounded">{selectedProtocol.subject}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex flex-wrap gap-2">
|
||||||
|
{selectedProtocol.status === "open" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCsatDialog({ open: true, protocol: selectedProtocol })}
|
||||||
|
>
|
||||||
|
<Star className="h-4 w-4 mr-2" /> Registrar CSAT
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateProtocol.mutate({ id: selectedProtocol.id, status: "resolved" })}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-2" /> Resolver
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelectedProtocol(null)}>Fechar</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New Protocol Dialog */}
|
||||||
|
<Dialog open={showNewDialog} onOpenChange={setShowNewDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Novo Protocolo</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="subject">Assunto</Label>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
placeholder="Descreva o motivo do atendimento"
|
||||||
|
value={newForm.subject}
|
||||||
|
onChange={e => setNewForm(f => ({ ...f, subject: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="contactId">ID do Contato</Label>
|
||||||
|
<Input
|
||||||
|
id="contactId"
|
||||||
|
type="number"
|
||||||
|
placeholder="Ex: 42"
|
||||||
|
value={newForm.contactId}
|
||||||
|
onChange={e => setNewForm(f => ({ ...f, contactId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="slaMinutes">SLA (minutos)</Label>
|
||||||
|
<Input
|
||||||
|
id="slaMinutes"
|
||||||
|
type="number"
|
||||||
|
placeholder="Ex: 480"
|
||||||
|
value={newForm.slaMinutes}
|
||||||
|
onChange={e => setNewForm(f => ({ ...f, slaMinutes: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setShowNewDialog(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={() => createProtocol.mutate(newForm)} disabled={createProtocol.isPending}>
|
||||||
|
{createProtocol.isPending ? "Criando..." : "Criar Protocolo"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* CSAT Dialog */}
|
||||||
|
<Dialog open={csatDialog.open} onOpenChange={o => !o && setCsatDialog({ open: false, protocol: null })}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Registrar Avaliação (CSAT)</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Nota (1 a 5)</Label>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
{[1, 2, 3, 4, 5].map(n => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setCsatScore(n)}
|
||||||
|
className={`text-2xl transition-transform hover:scale-110 ${n <= csatScore ? "text-yellow-400" : "text-slate-300"}`}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">{["", "Muito insatisfeito", "Insatisfeito", "Neutro", "Satisfeito", "Muito satisfeito"][csatScore]}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="csatComment">Comentário (opcional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="csatComment"
|
||||||
|
placeholder="O que poderia melhorar?"
|
||||||
|
value={csatComment}
|
||||||
|
onChange={e => setCsatComment(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setCsatDialog({ open: false, protocol: null })}>Cancelar</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => csatDialog.protocol && submitCsat.mutate({ protocol: csatDialog.protocol, score: csatScore, comment: csatComment })}
|
||||||
|
disabled={submitCsat.isPending}
|
||||||
|
>
|
||||||
|
{submitCsat.isPending ? "Salvando..." : "Salvar Avaliação"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,514 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
|
import {
|
||||||
|
BarChart3, Star, Shield, Users, MessageSquare, Ticket, TrendingUp,
|
||||||
|
ArrowLeft, DollarSign, CheckCircle2, AlertTriangle, Clock, Download
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
|
interface OverviewReport {
|
||||||
|
period: string;
|
||||||
|
conversations: { total: number; resolved: number; open: number; avg_csat: number; avg_handle_minutes: number; channels_used: number };
|
||||||
|
tickets: { total: number; resolved: number; open: number; sla_breaches: number; avg_csat: number };
|
||||||
|
contacts: { total: number; leads: number; customers: number; new_in_period: number };
|
||||||
|
deals: { total: number; won: number; lost: number; won_value: number; win_rate_pct: number };
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentReport {
|
||||||
|
agent_id: string;
|
||||||
|
agent_name: string;
|
||||||
|
conversations_handled: number;
|
||||||
|
resolved: number;
|
||||||
|
avg_csat: number;
|
||||||
|
avg_handle_minutes: number;
|
||||||
|
tickets_handled: number;
|
||||||
|
tickets_resolved: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlaReport {
|
||||||
|
period: string;
|
||||||
|
protocols: { total: number; breached: number; compliant: number; compliance_rate_pct: number };
|
||||||
|
tickets_by_priority: Array<{ priority: string; total: number; breached: number; avg_resolution_minutes: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CsatSummary {
|
||||||
|
period: string;
|
||||||
|
total_responses: number;
|
||||||
|
avg_score: number;
|
||||||
|
satisfaction_rate_pct: number;
|
||||||
|
distribution: { 5: number; 4: number; 3: number; "1-2": number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function XosReports() {
|
||||||
|
const [period, setPeriod] = useState("30d");
|
||||||
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
|
||||||
|
const { data: overview, isLoading } = useQuery<OverviewReport>({
|
||||||
|
queryKey: ["/api/xos/reports/overview", period],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/xos/reports/overview?period=${period}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: agentReport = [] } = useQuery<AgentReport[]>({
|
||||||
|
queryKey: ["/api/xos/reports/agents", period],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/xos/reports/agents?period=${period}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: slaReport } = useQuery<SlaReport>({
|
||||||
|
queryKey: ["/api/xos/reports/sla", period],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/xos/reports/sla?period=${period}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: csatSummary } = useQuery<CsatSummary>({
|
||||||
|
queryKey: ["/api/xos/csat/summary", period],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/xos/csat/summary?period=${period}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatCurrency = (v: number) =>
|
||||||
|
new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(v || 0);
|
||||||
|
|
||||||
|
const periodLabel: Record<string, string> = { "7d": "7 dias", "30d": "30 dias", "90d": "90 dias", "1y": "1 ano" };
|
||||||
|
|
||||||
|
const getPriorityColor = (p: string) => ({
|
||||||
|
low: "text-slate-500", normal: "text-blue-600", high: "text-orange-600", urgent: "text-red-600"
|
||||||
|
}[p] || "text-slate-600");
|
||||||
|
|
||||||
|
const getPriorityBadge = (p: string) => ({
|
||||||
|
low: "bg-slate-100 text-slate-700", normal: "bg-blue-100 text-blue-700",
|
||||||
|
high: "bg-orange-100 text-orange-700", urgent: "bg-red-100 text-red-700"
|
||||||
|
}[p] || "bg-gray-100 text-gray-700");
|
||||||
|
|
||||||
|
const csatBar = (label: string, count: number, total: number, color: string) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-slate-600 w-8">{label}</span>
|
||||||
|
<Progress value={total > 0 ? (count / total) * 100 : 0} className={`flex-1 h-3 ${color}`} />
|
||||||
|
<span className="text-sm font-medium text-slate-700 w-8 text-right">{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<BrowserFrame>
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conv = overview?.conversations;
|
||||||
|
const tick = overview?.tickets;
|
||||||
|
const contacts = overview?.contacts;
|
||||||
|
const deals = overview?.deals;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserFrame>
|
||||||
|
<div className="min-h-screen bg-slate-50">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/xos">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div className="bg-gradient-to-br from-emerald-600 to-teal-700 p-2 rounded-xl">
|
||||||
|
<BarChart3 className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-800">Relatórios XOS</h1>
|
||||||
|
<p className="text-xs text-slate-500">Análise de atendimento e CRM</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={period} onValueChange={setPeriod}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7d">Últimos 7 dias</SelectItem>
|
||||||
|
<SelectItem value="30d">Últimos 30 dias</SelectItem>
|
||||||
|
<SelectItem value="90d">Últimos 90 dias</SelectItem>
|
||||||
|
<SelectItem value="1y">Último ano</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Visão Geral</TabsTrigger>
|
||||||
|
<TabsTrigger value="csat">CSAT</TabsTrigger>
|
||||||
|
<TabsTrigger value="sla">SLA</TabsTrigger>
|
||||||
|
<TabsTrigger value="agents">Agentes</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
<TabsContent value="overview" className="space-y-6">
|
||||||
|
<p className="text-sm text-slate-500">Período: {periodLabel[period]}</p>
|
||||||
|
|
||||||
|
{/* Top KPI row */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="bg-gradient-to-br from-blue-500 to-blue-600 text-white">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-blue-100 text-sm">Conversas</p>
|
||||||
|
<p className="text-3xl font-bold">{conv?.total || 0}</p>
|
||||||
|
</div>
|
||||||
|
<MessageSquare className="h-10 w-10 text-blue-200" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-blue-100">
|
||||||
|
{conv?.resolved || 0} resolvidas ({conv?.total ? Math.round((conv.resolved / conv.total) * 100) : 0}%)
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-br from-orange-500 to-orange-600 text-white">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-orange-100 text-sm">Tickets</p>
|
||||||
|
<p className="text-3xl font-bold">{tick?.total || 0}</p>
|
||||||
|
</div>
|
||||||
|
<Ticket className="h-10 w-10 text-orange-200" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-orange-100">
|
||||||
|
{tick?.sla_breaches || 0} SLA violados
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-br from-emerald-500 to-emerald-600 text-white">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-emerald-100 text-sm">Receita Ganha</p>
|
||||||
|
<p className="text-2xl font-bold">{formatCurrency(deals?.won_value || 0)}</p>
|
||||||
|
</div>
|
||||||
|
<DollarSign className="h-10 w-10 text-emerald-200" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-emerald-100">
|
||||||
|
{deals?.won || 0} deals • {deals?.win_rate_pct || 0}% taxa
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-br from-violet-500 to-violet-600 text-white">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-violet-100 text-sm">Novos Contatos</p>
|
||||||
|
<p className="text-3xl font-bold">{contacts?.new_in_period || 0}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="h-10 w-10 text-violet-200" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-violet-100">
|
||||||
|
{contacts?.leads || 0} leads • {contacts?.customers || 0} clientes
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-4 w-4 text-blue-500" />
|
||||||
|
Atendimento
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-slate-800">{conv?.avg_handle_minutes || 0}<span className="text-sm font-normal text-slate-500">min</span></p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Tempo Médio de Atendimento</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-slate-800">{conv?.channels_used || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Canais Utilizados</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-emerald-700">{conv?.resolved || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Resolvidas</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-blue-700">{conv?.open || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Em Aberto</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{conv?.avg_csat ? (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-yellow-50 rounded-lg">
|
||||||
|
<Star className="h-5 w-5 text-yellow-500 fill-yellow-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-800">{Number(conv.avg_csat).toFixed(1)} / 5</p>
|
||||||
|
<p className="text-xs text-slate-500">CSAT Médio das Conversas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||||
|
Pipeline de Vendas
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-emerald-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-emerald-700">{deals?.won || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Ganhos</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-red-700">{deals?.lost || 0}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Perdidos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-600">Taxa de conversão</span>
|
||||||
|
<span className="font-semibold">{deals?.win_rate_pct || 0}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={deals?.win_rate_pct || 0} className="h-3" />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-slate-50 rounded-lg">
|
||||||
|
<p className="text-xs text-slate-500">Valor Total Ganho</p>
|
||||||
|
<p className="text-xl font-bold text-emerald-600">{formatCurrency(deals?.won_value || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* CSAT */}
|
||||||
|
<TabsContent value="csat" className="space-y-6">
|
||||||
|
{csatSummary ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card className="bg-gradient-to-br from-yellow-400 to-orange-500 text-white">
|
||||||
|
<CardContent className="p-5 text-center">
|
||||||
|
<Star className="h-10 w-10 mx-auto mb-2 fill-white" />
|
||||||
|
<p className="text-4xl font-bold">{Number(csatSummary.avg_score).toFixed(1)}</p>
|
||||||
|
<p className="text-yellow-100 text-sm mt-1">Nota Média / 5</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5 text-center">
|
||||||
|
<CheckCircle2 className="h-10 w-10 mx-auto mb-2 text-emerald-500" />
|
||||||
|
<p className="text-4xl font-bold text-emerald-600">{csatSummary.satisfaction_rate_pct}%</p>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">Taxa de Satisfação (4-5 ★)</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5 text-center">
|
||||||
|
<Users className="h-10 w-10 mx-auto mb-2 text-blue-500" />
|
||||||
|
<p className="text-4xl font-bold text-blue-600">{csatSummary.total_responses}</p>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">Total de Avaliações</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Distribuição de Notas</CardTitle>
|
||||||
|
<CardDescription>Período: {periodLabel[period]}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{csatBar("⭐⭐⭐⭐⭐", csatSummary.distribution[5], csatSummary.total_responses, "[&>div]:bg-emerald-500")}
|
||||||
|
{csatBar("⭐⭐⭐⭐", csatSummary.distribution[4], csatSummary.total_responses, "[&>div]:bg-green-400")}
|
||||||
|
{csatBar("⭐⭐⭐", csatSummary.distribution[3], csatSummary.total_responses, "[&>div]:bg-yellow-400")}
|
||||||
|
{csatBar("⭐⭐", csatSummary.distribution["1-2"], csatSummary.total_responses, "[&>div]:bg-red-400")}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-16 text-center">
|
||||||
|
<Star className="h-12 w-12 mx-auto mb-3 text-slate-300" />
|
||||||
|
<p className="text-slate-500">Nenhuma avaliação no período</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* SLA */}
|
||||||
|
<TabsContent value="sla" className="space-y-6">
|
||||||
|
{slaReport && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card className={`${slaReport.protocols.compliance_rate_pct >= 90 ? "bg-gradient-to-br from-emerald-500 to-emerald-600" : slaReport.protocols.compliance_rate_pct >= 70 ? "bg-gradient-to-br from-yellow-500 to-yellow-600" : "bg-gradient-to-br from-red-500 to-red-600"} text-white`}>
|
||||||
|
<CardContent className="p-5 text-center">
|
||||||
|
<Shield className="h-10 w-10 mx-auto mb-2 opacity-80" />
|
||||||
|
<p className="text-4xl font-bold">{slaReport.protocols.compliance_rate_pct}%</p>
|
||||||
|
<p className="text-sm opacity-80 mt-1">Compliance de SLA</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5 text-center">
|
||||||
|
<CheckCircle2 className="h-10 w-10 mx-auto mb-2 text-emerald-500" />
|
||||||
|
<p className="text-4xl font-bold text-emerald-600">{slaReport.protocols.compliant}</p>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">Protocolos dentro do SLA</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5 text-center">
|
||||||
|
<AlertTriangle className="h-10 w-10 mx-auto mb-2 text-red-500" />
|
||||||
|
<p className="text-4xl font-bold text-red-600">{slaReport.protocols.breached}</p>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">Violações de SLA</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tickets por Prioridade</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{slaReport.tickets_by_priority.length === 0 ? (
|
||||||
|
<p className="text-center text-slate-500 py-8">Nenhum ticket no período</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50 border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-sm font-medium text-slate-600">Prioridade</th>
|
||||||
|
<th className="text-center p-3 text-sm font-medium text-slate-600">Total</th>
|
||||||
|
<th className="text-center p-3 text-sm font-medium text-slate-600">Violações</th>
|
||||||
|
<th className="text-center p-3 text-sm font-medium text-slate-600">Tempo Médio Res.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{slaReport.tickets_by_priority.map(row => (
|
||||||
|
<tr key={row.priority} className="hover:bg-slate-50">
|
||||||
|
<td className="p-3">
|
||||||
|
<Badge className={getPriorityBadge(row.priority)}>{row.priority}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center font-medium">{row.total}</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<span className={row.breached > 0 ? "text-red-600 font-semibold" : "text-emerald-600"}>
|
||||||
|
{row.breached}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center text-slate-600">
|
||||||
|
{row.avg_resolution_minutes ? (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{Number(row.avg_resolution_minutes).toFixed(0)}min
|
||||||
|
</div>
|
||||||
|
) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Agents */}
|
||||||
|
<TabsContent value="agents">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Performance por Agente</CardTitle>
|
||||||
|
<CardDescription>Período: {periodLabel[period]}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50 border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-4 text-sm font-medium text-slate-600">Agente</th>
|
||||||
|
<th className="text-center p-4 text-sm font-medium text-slate-600">Conversas</th>
|
||||||
|
<th className="text-center p-4 text-sm font-medium text-slate-600">Resolvidas</th>
|
||||||
|
<th className="text-center p-4 text-sm font-medium text-slate-600">CSAT</th>
|
||||||
|
<th className="text-center p-4 text-sm font-medium text-slate-600">TMA</th>
|
||||||
|
<th className="text-center p-4 text-sm font-medium text-slate-600">Tickets</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{agentReport.map(agent => {
|
||||||
|
const resolutionRate = agent.conversations_handled > 0
|
||||||
|
? Math.round((agent.resolved / agent.conversations_handled) * 100)
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<tr key={agent.agent_id} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="p-4 font-medium text-slate-800">{agent.agent_name}</td>
|
||||||
|
<td className="p-4 text-center">{agent.conversations_handled}</td>
|
||||||
|
<td className="p-4 text-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="font-medium">{agent.resolved}</span>
|
||||||
|
<span className="text-xs text-slate-500">{resolutionRate}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-center">
|
||||||
|
{agent.avg_csat ? (
|
||||||
|
<div className="flex items-center justify-center gap-1 text-yellow-600 font-semibold">
|
||||||
|
<Star className="h-3 w-3 fill-current" />
|
||||||
|
{Number(agent.avg_csat).toFixed(1)}
|
||||||
|
</div>
|
||||||
|
) : <span className="text-slate-400">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-center text-slate-600">
|
||||||
|
{agent.avg_handle_minutes ? `${Number(agent.avg_handle_minutes).toFixed(0)}min` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-center">
|
||||||
|
<span className="text-sm">{agent.tickets_handled} / {agent.tickets_resolved}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{agentReport.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center py-12 text-slate-500">
|
||||||
|
Nenhum dado de agente no período
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,468 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
|
import {
|
||||||
|
Users, MessageSquare, Ticket, Clock, AlertTriangle, CheckCircle2,
|
||||||
|
TrendingUp, ChevronRight, RefreshCw, Activity, UserCheck, PhoneOff,
|
||||||
|
BarChart3, Inbox, Star, ArrowLeft, Circle
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
|
interface SupervisorOverview {
|
||||||
|
conversations: {
|
||||||
|
open: number;
|
||||||
|
pending: number;
|
||||||
|
resolved_today: number;
|
||||||
|
unassigned: number;
|
||||||
|
avg_handle_time_minutes: number;
|
||||||
|
};
|
||||||
|
tickets: {
|
||||||
|
open: number;
|
||||||
|
resolved_today: number;
|
||||||
|
urgent_open: number;
|
||||||
|
sla_breached: number;
|
||||||
|
};
|
||||||
|
agents_active: Array<{ agent_id: string; active_conversations: number }>;
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueueStat {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
active_conversations: number;
|
||||||
|
waiting: number;
|
||||||
|
total_agents: number;
|
||||||
|
avg_first_response_minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentStat {
|
||||||
|
agent_id: string;
|
||||||
|
agent_name: string;
|
||||||
|
avatar: string | null;
|
||||||
|
active_conversations: number;
|
||||||
|
resolved_today: number;
|
||||||
|
avg_csat: number;
|
||||||
|
avg_handle_time_minutes: number;
|
||||||
|
queues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiveConversation {
|
||||||
|
id: number;
|
||||||
|
contact_name: string;
|
||||||
|
contact_phone: string;
|
||||||
|
queue_name: string;
|
||||||
|
queue_color: string;
|
||||||
|
channel: string;
|
||||||
|
status: string;
|
||||||
|
assigned_to: string | null;
|
||||||
|
age_minutes: number;
|
||||||
|
last_message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function XosSupervisor() {
|
||||||
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
const [queueFilter, setQueueFilter] = useState("all");
|
||||||
|
const [autoRefresh] = useState(true);
|
||||||
|
|
||||||
|
const refetchInterval = autoRefresh ? 30000 : false;
|
||||||
|
|
||||||
|
const { data: overview, isLoading: loadingOverview, refetch: refetchOverview } = useQuery<SupervisorOverview>({
|
||||||
|
queryKey: ["/api/xos/supervisor/overview"],
|
||||||
|
refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: queues = [] } = useQuery<QueueStat[]>({
|
||||||
|
queryKey: ["/api/xos/supervisor/queues"],
|
||||||
|
refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: agents = [] } = useQuery<AgentStat[]>({
|
||||||
|
queryKey: ["/api/xos/supervisor/agents", queueFilter],
|
||||||
|
queryFn: async () => {
|
||||||
|
const url = queueFilter === "all"
|
||||||
|
? "/api/xos/supervisor/agents"
|
||||||
|
: `/api/xos/supervisor/agents?queueId=${queueFilter}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: liveConvs = [] } = useQuery<LiveConversation[]>({
|
||||||
|
queryKey: ["/api/xos/supervisor/conversations/live", queueFilter],
|
||||||
|
queryFn: async () => {
|
||||||
|
const url = queueFilter === "all"
|
||||||
|
? "/api/xos/supervisor/conversations/live"
|
||||||
|
: `/api/xos/supervisor/conversations/live?queueId=${queueFilter}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getQueueColor = (color: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
blue: "bg-blue-500", green: "bg-green-500", red: "bg-red-500",
|
||||||
|
yellow: "bg-yellow-500", purple: "bg-purple-500", orange: "bg-orange-500",
|
||||||
|
cyan: "bg-cyan-500", pink: "bg-pink-500",
|
||||||
|
};
|
||||||
|
return map[color] || "bg-slate-500";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAgentCsatColor = (score: number) => {
|
||||||
|
if (!score) return "text-slate-400";
|
||||||
|
if (score >= 4.5) return "text-emerald-600";
|
||||||
|
if (score >= 3.5) return "text-yellow-600";
|
||||||
|
return "text-red-600";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAgeColor = (minutes: number) => {
|
||||||
|
if (minutes < 10) return "text-emerald-600";
|
||||||
|
if (minutes < 30) return "text-yellow-600";
|
||||||
|
return "text-red-600";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingOverview) {
|
||||||
|
return (
|
||||||
|
<BrowserFrame>
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto" />
|
||||||
|
<p className="mt-4 text-slate-500">Carregando dados do supervisor...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conv = overview?.conversations;
|
||||||
|
const tick = overview?.tickets;
|
||||||
|
const totalActive = (conv?.open || 0) + (conv?.pending || 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserFrame>
|
||||||
|
<div className="min-h-screen bg-slate-50">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/xos">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div className="bg-gradient-to-br from-indigo-600 to-purple-700 p-2 rounded-xl">
|
||||||
|
<Activity className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-800">Monitor de Supervisor</h1>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Atualizado às {overview ? new Date(overview.generated_at).toLocaleTimeString("pt-BR") : "—"}
|
||||||
|
{autoRefresh && <span className="ml-2 text-emerald-600">• ao vivo</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={queueFilter} onValueChange={setQueueFilter}>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue placeholder="Todas as filas" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todas as filas</SelectItem>
|
||||||
|
{queues.map(q => (
|
||||||
|
<SelectItem key={q.id} value={String(q.id)}>{q.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="icon" onClick={() => refetchOverview()}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="bg-gradient-to-br from-blue-500 to-blue-600 text-white">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-blue-100 text-sm">Em Atendimento</p>
|
||||||
|
<p className="text-3xl font-bold">{totalActive}</p>
|
||||||
|
</div>
|
||||||
|
<MessageSquare className="h-10 w-10 text-blue-200" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-sm text-blue-100">
|
||||||
|
<span>{conv?.unassigned || 0} sem agente</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{conv?.pending || 0} pendentes</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-br from-emerald-500 to-emerald-600 text-white">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-emerald-100 text-sm">Resolvidos Hoje</p>
|
||||||
|
<p className="text-3xl font-bold">{conv?.resolved_today || 0}</p>
|
||||||
|
</div>
|
||||||
|
<CheckCircle2 className="h-10 w-10 text-emerald-200" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-emerald-100">
|
||||||
|
TMA: {conv?.avg_handle_time_minutes || 0}min
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className={`text-white ${(tick?.sla_breached || 0) > 0 ? "bg-gradient-to-br from-red-500 to-red-600" : "bg-gradient-to-br from-orange-500 to-orange-600"}`}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-orange-100 text-sm">Tickets Urgentes</p>
|
||||||
|
<p className="text-3xl font-bold">{tick?.urgent_open || 0}</p>
|
||||||
|
</div>
|
||||||
|
<Ticket className="h-10 w-10 text-orange-200" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-sm text-orange-100">
|
||||||
|
{(tick?.sla_breached || 0) > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-red-200">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
{tick?.sla_breached} SLA violado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(tick?.sla_breached || 0) === 0 && <span>SLA OK</span>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-br from-violet-500 to-violet-600 text-white">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-violet-100 text-sm">Agentes Ativos</p>
|
||||||
|
<p className="text-3xl font-bold">{overview?.agents_active?.length || 0}</p>
|
||||||
|
</div>
|
||||||
|
<Users className="h-10 w-10 text-violet-200" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-violet-100">
|
||||||
|
{agents.length} no total
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Filas</TabsTrigger>
|
||||||
|
<TabsTrigger value="agents">Agentes</TabsTrigger>
|
||||||
|
<TabsTrigger value="live">Ao Vivo ({liveConvs.length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Queues Tab */}
|
||||||
|
<TabsContent value="overview" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{queues.map(queue => {
|
||||||
|
const occupancy = queue.total_agents > 0
|
||||||
|
? Math.min(100, Math.round((queue.active_conversations / queue.total_agents) * 100))
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<Card key={queue.id} className="hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className={`w-3 h-3 rounded-full ${getQueueColor(queue.color)}`} />
|
||||||
|
<h3 className="font-semibold text-slate-800">{queue.name}</h3>
|
||||||
|
<Badge variant="outline" className="ml-auto text-xs">
|
||||||
|
{queue.total_agents} agentes
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{queue.active_conversations}</p>
|
||||||
|
<p className="text-xs text-slate-500">ativos</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={`text-2xl font-bold ${queue.waiting > 0 ? "text-orange-600" : "text-slate-400"}`}>
|
||||||
|
{queue.waiting}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">aguardando</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-slate-600">
|
||||||
|
{queue.avg_first_response_minutes ? `${queue.avg_first_response_minutes}m` : "—"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">TMP</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-slate-500">
|
||||||
|
<span>Ocupação</span>
|
||||||
|
<span>{occupancy}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={occupancy} className="h-2" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{queues.length === 0 && (
|
||||||
|
<div className="col-span-3 text-center py-12 text-slate-500">
|
||||||
|
<Inbox className="h-12 w-12 mx-auto mb-3 text-slate-300" />
|
||||||
|
<p>Nenhuma fila configurada</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Agents Tab */}
|
||||||
|
<TabsContent value="agents">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50 border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-4 text-sm font-medium text-slate-600">Agente</th>
|
||||||
|
<th className="text-center p-4 text-sm font-medium text-slate-600">Em Atend.</th>
|
||||||
|
<th className="text-center p-4 text-sm font-medium text-slate-600">Resolvidos Hoje</th>
|
||||||
|
<th className="text-center p-4 text-sm font-medium text-slate-600">CSAT Médio</th>
|
||||||
|
<th className="text-center p-4 text-sm font-medium text-slate-600">TMA (min)</th>
|
||||||
|
<th className="text-left p-4 text-sm font-medium text-slate-600">Filas</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{agents.map(agent => (
|
||||||
|
<tr key={agent.agent_id} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="h-9 w-9">
|
||||||
|
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-indigo-500 text-white text-sm">
|
||||||
|
{(agent.agent_name || "?").slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-800">{agent.agent_name}</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Circle className={`h-2 w-2 fill-current ${agent.active_conversations > 0 ? "text-emerald-500" : "text-slate-300"}`} />
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{agent.active_conversations > 0 ? "online" : "disponível"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-center">
|
||||||
|
<span className={`text-xl font-bold ${agent.active_conversations > 5 ? "text-red-600" : agent.active_conversations > 2 ? "text-yellow-600" : "text-slate-700"}`}>
|
||||||
|
{agent.active_conversations}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-center">
|
||||||
|
<span className="text-lg font-semibold text-emerald-600">{agent.resolved_today || 0}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-center">
|
||||||
|
<div className={`flex items-center justify-center gap-1 font-semibold ${getAgentCsatColor(agent.avg_csat)}`}>
|
||||||
|
{agent.avg_csat ? (
|
||||||
|
<>
|
||||||
|
<Star className="h-3 w-3 fill-current" />
|
||||||
|
{Number(agent.avg_csat).toFixed(1)}
|
||||||
|
</>
|
||||||
|
) : "—"}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-center text-slate-600">
|
||||||
|
{agent.avg_handle_time_minutes ? `${agent.avg_handle_time_minutes}m` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(agent.queues || []).filter(Boolean).map((q, i) => (
|
||||||
|
<Badge key={i} variant="outline" className="text-xs">{q}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{agents.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center py-12 text-slate-500">
|
||||||
|
<UserCheck className="h-12 w-12 mx-auto mb-3 text-slate-300" />
|
||||||
|
Nenhum agente encontrado
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Live Conversations Tab */}
|
||||||
|
<TabsContent value="live">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{liveConvs.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-16 text-center">
|
||||||
|
<MessageSquare className="h-12 w-12 mx-auto mb-3 text-slate-300" />
|
||||||
|
<p className="text-slate-500">Nenhuma conversa em aberto</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{liveConvs.map(conv => (
|
||||||
|
<Card key={conv.id} className={`hover:shadow-md transition-shadow ${Number(conv.age_minutes) > 30 ? "border-red-200 bg-red-50" : ""}`}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar className="h-10 w-10 flex-shrink-0">
|
||||||
|
<AvatarFallback className="bg-gradient-to-br from-slate-400 to-slate-600 text-white">
|
||||||
|
{(conv.contact_name || "?").slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-slate-800">{conv.contact_name || "Desconhecido"}</p>
|
||||||
|
<Badge variant="outline" className="text-xs">{conv.channel}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 truncate">{conv.last_message || "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 flex-shrink-0">
|
||||||
|
{conv.queue_name && (
|
||||||
|
<Badge variant="secondary" className="text-xs">{conv.queue_name}</Badge>
|
||||||
|
)}
|
||||||
|
{conv.assigned_to ? (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-500">
|
||||||
|
<UserCheck className="h-3 w-3" />
|
||||||
|
<span className="hidden sm:block">{conv.assigned_to}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-orange-500">
|
||||||
|
<PhoneOff className="h-3 w-3" />
|
||||||
|
<span>Sem agente</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`flex items-center gap-1 text-sm font-medium ${getAgeColor(Number(conv.age_minutes))}`}>
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
{Number(conv.age_minutes).toFixed(0)}m
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue