From c77b41402971e2a10dcb66e7cb3bff554e3537d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 00:12:03 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Fase=203=20=E2=80=94=20UI=20React:=20Su?= =?UTF-8?q?pervisor=20Monitor,=20Relat=C3=B3rios,=20Protocolos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.tsx | 6 + client/src/pages/XosCentral.tsx | 12 +- client/src/pages/XosProtocols.tsx | 506 ++++++++++++++++++++++++++++ client/src/pages/XosReports.tsx | 514 +++++++++++++++++++++++++++++ client/src/pages/XosSupervisor.tsx | 468 ++++++++++++++++++++++++++ 5 files changed, 1502 insertions(+), 4 deletions(-) create mode 100644 client/src/pages/XosProtocols.tsx create mode 100644 client/src/pages/XosReports.tsx create mode 100644 client/src/pages/XosSupervisor.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index f4a16aa..57a89c0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -67,6 +67,9 @@ const XosAutomations = lazy(() => import("@/pages/XosAutomations")); const XosSites = lazy(() => import("@/pages/XosSites")); const XosGovernance = lazy(() => import("@/pages/XosGovernance")); 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() { @@ -135,6 +138,9 @@ function Router() { + + + diff --git a/client/src/pages/XosCentral.tsx b/client/src/pages/XosCentral.tsx index c2db7f4..7fe72b0 100644 --- a/client/src/pages/XosCentral.tsx +++ b/client/src/pages/XosCentral.tsx @@ -2,10 +2,11 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Link } from "wouter"; import { BrowserFrame } from "@/components/Browser/BrowserFrame"; -import { - Users, Building2, TrendingUp, MessageSquare, Ticket, Zap, LayoutGrid, +import { + Users, Building2, TrendingUp, MessageSquare, Ticket, Zap, LayoutGrid, 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"; import { Button } from "@/components/ui/button"; 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: "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: "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) => { @@ -264,7 +268,7 @@ export default function XosCentral() { {/* Modules Grid */}

Módulos XOS

-
+
{modules.map((mod) => ( diff --git a/client/src/pages/XosProtocols.tsx b/client/src/pages/XosProtocols.tsx new file mode 100644 index 0000000..3f49b07 --- /dev/null +++ b/client/src/pages/XosProtocols.tsx @@ -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(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({ + 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 ( + +
+ {/* Header */} +
+
+
+
+ + + +
+ +
+
+

Protocolos

+

Rastreamento de atendimentos

+
+
+ +
+
+
+ +
+ {/* Stats */} +
+ + + +
+

Abertos

+

{open}

+
+
+
+ + + +
+

Resolvidos

+

{resolved}

+
+
+
+ 0 ? "bg-gradient-to-br from-red-500 to-red-600" : "bg-gradient-to-br from-slate-500 to-slate-600"}`}> + + +
+

SLA Violado

+

{breached}

+
+
+
+
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ + {/* List */} + {isLoading ? ( +
+
+
+ ) : protocols.length === 0 ? ( + + + +

Nenhum protocolo encontrado

+ +
+
+ ) : ( +
+ {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 ( + setSelectedProtocol(protocol)} + > + +
+
+ +
+
+
+ + {statusCfg.label} + {breached && SLA Violado} + {protocol.satisfaction_score && ( + + + {protocol.satisfaction_score}/5 + + )} +
+

{protocol.subject || "Sem assunto"}

+
+ {protocol.contact_name && ( + + {protocol.contact_name} + + )} + {protocol.queue_name && ( + {protocol.queue_name} + )} + {new Date(protocol.opened_at).toLocaleDateString("pt-BR")} +
+
+
+ {slaLeft && ( +

+ + {slaLeft} restante +

+ )} + {breached && protocol.status === "open" && ( +

SLA vencido

+ )} + +
+
+
+
+ ); + })} +
+ )} +
+ + {/* Protocol detail dialog */} + {selectedProtocol && ( + setSelectedProtocol(null)}> + + + + + Protocolo #{selectedProtocol.protocol_number} + + +
+
+
+

Status

+ + {getStatusConfig(selectedProtocol.status).label} + +
+
+

Abertura

+

{new Date(selectedProtocol.opened_at).toLocaleString("pt-BR")}

+
+ {selectedProtocol.contact_name && ( +
+

Contato

+

{selectedProtocol.contact_name}

+
+ )} + {selectedProtocol.queue_name && ( +
+

Fila

+

{selectedProtocol.queue_name}

+
+ )} + {selectedProtocol.sla_deadline && ( +
+

SLA Deadline

+

+ {new Date(selectedProtocol.sla_deadline).toLocaleString("pt-BR")} + {isSlaBreached(selectedProtocol) && " (violado)"} +

+
+ )} + {selectedProtocol.satisfaction_score && ( +
+

CSAT

+
+ {"★".repeat(selectedProtocol.satisfaction_score)} + {selectedProtocol.satisfaction_score}/5 +
+
+ )} +
+ {selectedProtocol.subject && ( +
+

Assunto

+

{selectedProtocol.subject}

+
+ )} +
+ + {selectedProtocol.status === "open" && ( + <> + + + + )} + + +
+
+ )} + + {/* New Protocol Dialog */} + + + + Novo Protocolo + +
+
+ + setNewForm(f => ({ ...f, subject: e.target.value }))} + /> +
+
+
+ + setNewForm(f => ({ ...f, contactId: e.target.value }))} + /> +
+
+ + setNewForm(f => ({ ...f, slaMinutes: e.target.value }))} + /> +
+
+
+ + + + +
+
+ + {/* CSAT Dialog */} + !o && setCsatDialog({ open: false, protocol: null })}> + + + Registrar Avaliação (CSAT) + +
+
+ +
+ {[1, 2, 3, 4, 5].map(n => ( + + ))} +
+

{["", "Muito insatisfeito", "Insatisfeito", "Neutro", "Satisfeito", "Muito satisfeito"][csatScore]}

+
+
+ +