diff --git a/DEPLOY_COOLIFY.md b/DEPLOY_COOLIFY.md new file mode 100644 index 0000000..9410139 --- /dev/null +++ b/DEPLOY_COOLIFY.md @@ -0,0 +1,359 @@ +# Deploy no Coolify — Arcádia Suite + +> Guia completo para subir o Arcádia Suite no Coolify com Docker Compose. + +--- + +## Índice + +1. [Pré-requisitos](#pré-requisitos) +2. [Arquitetura de Serviços](#arquitetura-de-serviços) +3. [Configuração no Coolify](#configuração-no-coolify) +4. [Variáveis de Ambiente](#variáveis-de-ambiente) +5. [Serviços Opcionais (Profiles)](#serviços-opcionais-profiles) +6. [Sequência de Inicialização](#sequência-de-inicialização) +7. [Traefik e HTTPS](#traefik-e-https) +8. [Volumes e Persistência](#volumes-e-persistência) +9. [Stack de IA (LiteLLM + Ollama)](#stack-de-ia-litellm--ollama) +10. [Pós-deploy](#pós-deploy) +11. [Checklist Final](#checklist-final) + +--- + +## Pré-requisitos + +- Coolify instalado e acessível (v4+) +- Docker Engine 24+ e Docker Compose v2+ no servidor +- Domínio apontando para o IP do servidor (`A record`) +- Mínimo recomendado: **4 vCPU, 8 GB RAM, 50 GB SSD** +- Para IA local (Ollama): **16 GB RAM, GPU opcional** + +--- + +## Arquitetura de Serviços + +``` +Internet → Traefik (Coolify) → app:5000 → arcadia-internal + → open-webui:8080 (opcional) + +arcadia-internal: + db (PostgreSQL 16 + pgvector) :5432 + redis :6379 + app (Node.js/Express) :5000 + embeddings (Python FastAPI) :8001 + fisco (Python FastAPI) :8002 + contabil (Python FastAPI) :8003 + bi (Python FastAPI) :8004 + automation (Python FastAPI) :8005 + litellm (gateway LLM) :4000 ← profile: ai + ollama (modelos locais) :11434 ← profile: ai + superset (BI) :8088 ← profile: bi + plus (Laravel/ERP) :8080 ← profile: plus + erpnext :8090 ← profile: erpnext +``` + +--- + +## Configuração no Coolify + +### 1. Criar novo projeto + +No painel Coolify: **New Project → Docker Compose**. + +### 2. Apontar para o repositório + +- **Source:** Git (GitHub/GitLab/Gitea) +- **Branch:** `main` (ou sua branch de produção) +- **Docker Compose file:** `docker-compose.prod.yml` + +### 3. Definir o domínio + +Em **Domains**, adicione `seu-dominio.com`. O Coolify irá automaticamente: +- Criar as rotas no Traefik +- Emitir certificado Let's Encrypt + +--- + +## Variáveis de Ambiente + +Configure todas no painel **Environment Variables** do Coolify (nunca no `.env` do repositório). + +### Obrigatórias + +```env +# Aplicação +NODE_ENV=production +PORT=5000 +DOMAIN=seu-dominio.com +DOCKER_MODE=true + +# Segredos — gerar com: openssl rand -hex 32 +SESSION_SECRET= +SSO_SECRET= + +# Banco de dados +PGHOST=db +PGPORT=5432 +PGUSER=arcadia +PGPASSWORD= +PGDATABASE=arcadia +DATABASE_URL=postgresql://arcadia:@db:5432/arcadia + +# Redis +REDIS_URL=redis://redis:6379 + +# IA — LiteLLM gateway +LITELLM_API_KEY= +AI_INTEGRATIONS_OPENAI_BASE_URL=http://litellm:4000/v1 +AI_INTEGRATIONS_OPENAI_API_KEY= +OLLAMA_BASE_URL=http://ollama:11434 + +# Microserviços Python +PYTHON_SERVICE_URL=http://embeddings:8001 +FISCO_PYTHON_URL=http://fisco:8002 +CONTABIL_PYTHON_URL=http://contabil:8003 +BI_PYTHON_URL=http://bi:8004 +AUTOMATION_PYTHON_URL=http://automation:8005 +``` + +### LLMs externos (opcionais — soberania: deixar em branco) + +```env +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GROQ_API_KEY= +LLMFIT_BASE_URL= # habilitar quando LLMFit estiver disponível +``` + +### Superset — profile `bi` + +```env +SUPERSET_SECRET_KEY= +SUPERSET_ADMIN_USER=admin +SUPERSET_ADMIN_EMAIL=admin@seu-dominio.com +SUPERSET_ADMIN_PASSWORD= +SUPERSET_HOST=superset +SUPERSET_PORT=8088 +``` + +### Arcádia Plus — profile `plus` + +```env +SSO_PLUS_BASE_URL=http://plus:8080 +PLUS_APP_KEY= +PLUS_DB_ROOT_PASSWORD= +PLUS_DB_PASSWORD= +PLUS_DB_DATABASE=arcadia_plus +PLUS_DB_USER=plus +``` + +### ERPNext — profile `erpnext` + +```env +ERPNEXT_PROXY_ENABLED=true +ERPNEXT_CONTAINER_HOST=erpnext +ERPNEXT_DB_ROOT_PASSWORD= +ERPNEXT_DB_PASSWORD= +ERPNEXT_ADMIN_PASSWORD= +ERPNEXT_URL=http://erpnext:8090 +``` + +--- + +## Serviços Opcionais (Profiles) + +O `docker-compose.prod.yml` usa profiles para habilitar serviços sob demanda. +No Coolify, adicione a variável de ambiente `COMPOSE_PROFILES`: + +| O que habilitar | `COMPOSE_PROFILES` | +|---|---| +| Somente core | *(vazio)* | +| IA local (Ollama + LiteLLM + WebUI) | `ai` | +| Superset BI | `bi` | +| Arcádia Plus (Laravel ERP) | `plus` | +| ERPNext | `erpnext` | +| Tudo | `ai,bi,plus,erpnext` | + +> **Atenção:** Ollama com modelos grandes (llama3.3, qwen2.5) pode consumir 10–20 GB de disco. Certifique-se de ter espaço no volume `ollama_models`. + +--- + +## Sequência de Inicialização + +Os `depends_on` garantem a ordem correta automaticamente: + +``` +1. db → healthcheck: pg_isready (até 10 tentativas) +2. redis → healthcheck: redis-cli ping +3. embeddings, fisco, contabil, bi, automation → aguardam db healthy +4. litellm, ollama → aguardam redis (se profile ai) +5. app → aguarda db healthy + redis started +6. open-webui, superset, plus, erpnext → aguardam serviços base +``` + +Se um serviço falhar ao iniciar, cheque os logs: + +```bash +# No servidor +docker compose -f docker-compose.prod.yml logs app --tail 50 +docker compose -f docker-compose.prod.yml logs db --tail 50 +``` + +--- + +## Traefik e HTTPS + +O Coolify gerencia o Traefik. As labels já estão no `docker-compose.prod.yml`: + +```yaml +# Serviço principal +labels: + - "traefik.enable=true" + - "traefik.http.routers.arcadia.rule=Host(`${DOMAIN}`)" + - "traefik.http.routers.arcadia.tls=true" + - "traefik.http.routers.arcadia.tls.certresolver=letsencrypt" + - "traefik.http.services.arcadia.loadbalancer.server.port=5000" + +# Open WebUI (profile ai) — subdomínio ai.seu-dominio.com +labels: + - "traefik.http.routers.webui.rule=Host(`ai.${DOMAIN}`)" + - "traefik.http.routers.webui.tls=true" + - "traefik.http.routers.webui.tls.certresolver=letsencrypt" + - "traefik.http.services.webui.loadbalancer.server.port=8080" +``` + +Certifique-se de que o DNS do subdomínio `ai.seu-dominio.com` também aponta para o servidor antes de habilitar o profile `ai`. + +--- + +## Volumes e Persistência + +| Volume | Conteúdo | Criticidade | +|---|---|---| +| `pgdata` | Banco de dados PostgreSQL | **CRÍTICO — fazer backup** | +| `redis_data` | Sessões e cache | Alta | +| `ollama_models` | Modelos LLM baixados (~10–20 GB) | Média (redownload possível) | +| `open_webui_data` | Histórico do WebUI | Baixa | +| `superset_home` | Dashboards Superset | Média | +| `plus_db` | Banco MySQL do Plus | Alta | +| `plus_storage` | Arquivos do Plus | Alta | +| `erpnext_sites` | Sites ERPNext | **CRÍTICO** | +| `erpnext_logs` | Logs ERPNext | Baixa | + +### Backup do PostgreSQL + +```bash +# Backup +docker exec arcadia-db pg_dump -U arcadia arcadia | gzip > backup-$(date +%Y%m%d).sql.gz + +# Restore +gunzip < backup-20240101.sql.gz | docker exec -i arcadia-db psql -U arcadia arcadia +``` + +Configure backups automáticos no Coolify em **Project → Backups**. + +--- + +## Stack de IA (LiteLLM + Ollama) + +### Três tiers configurados em `docker/litellm-config.yaml` + +``` +TIER 1 — LLMFit (fine-tuned, soberano) → slot pronto, habilitar via LLMFIT_BASE_URL +TIER 2 — Ollama (local, padrão) → llama3.3, qwen2.5-coder, nomic-embed-text +TIER 3 — Externos (opt-in) → OpenAI, Anthropic, Groq (apenas se API key definida) +``` + +### Baixar modelos Ollama após o primeiro deploy + +```bash +docker exec arcadia-ollama ollama pull llama3.3 +docker exec arcadia-ollama ollama pull qwen2.5-coder:7b +docker exec arcadia-ollama ollama pull nomic-embed-text +``` + +### Habilitar LLMFit (quando disponível) + +1. Defina `LLMFIT_BASE_URL=http://seu-llmfit:porta` +2. Descomente o bloco TIER 1 em `docker/litellm-config.yaml` +3. Redeploy do serviço `litellm` + +--- + +## Pós-deploy + +### Rodar migrations do banco + +```bash +# Via Coolify → Run Command, ou no servidor: +docker exec arcadia-app npm run db:push +``` + +### Verificar saúde dos serviços + +```bash +docker compose -f docker-compose.prod.yml ps +``` + +Todos devem estar `healthy` ou `running`. Se algum ficar em `restarting`, verifique os logs. + +### Verificar conectividade IA + +```bash +curl -H "Authorization: Bearer $LITELLM_API_KEY" \ + http://localhost:4000/v1/models +``` + +--- + +## Checklist Final + +### Antes do deploy + +- [ ] Domínio DNS apontando para o servidor (`A record`) +- [ ] Todas as variáveis obrigatórias definidas no Coolify +- [ ] `SESSION_SECRET` e `SSO_SECRET` gerados com `openssl rand -hex 32` +- [ ] Senhas do banco definidas (nunca reutilizar senhas de dev) +- [ ] `DOCKER_MODE=true` definido + +### No Coolify + +- [ ] Projeto criado apontando para `docker-compose.prod.yml` +- [ ] Domínio configurado no Coolify +- [ ] HTTPS ativo (Let's Encrypt) +- [ ] Volumes persistentes configurados (especialmente `pgdata`) +- [ ] `COMPOSE_PROFILES` definido conforme os serviços desejados + +### Após o deploy + +- [ ] `npm run db:push` executado (migrations) +- [ ] Login funcional no `https://seu-dominio.com` +- [ ] Healthchecks verdes no painel Coolify +- [ ] Modelos Ollama baixados (se profile `ai` ativo) +- [ ] Backup automático configurado para `pgdata` +- [ ] Monitoramento/alertas configurados (Coolify suporta webhooks) + +--- + +## Comandos de Referência Rápida + +```bash +# Subir apenas o core +docker compose -f docker-compose.prod.yml up -d + +# Subir com IA +docker compose -f docker-compose.prod.yml --profile ai up -d + +# Subir tudo +docker compose -f docker-compose.prod.yml --profile ai --profile bi --profile plus up -d + +# Ver logs em tempo real +docker compose -f docker-compose.prod.yml logs -f app + +# Reiniciar um serviço específico +docker compose -f docker-compose.prod.yml restart app + +# Atualizar para nova versão (após git pull) +docker compose -f docker-compose.prod.yml pull +docker compose -f docker-compose.prod.yml up -d --no-deps app +``` 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/contexts/SoeMotorContext.tsx b/client/src/contexts/SoeMotorContext.tsx new file mode 100644 index 0000000..52d06f4 --- /dev/null +++ b/client/src/contexts/SoeMotorContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext, useState, ReactNode } from "react"; + +interface SoeMotorContextValue { + activeCompany: string | null; + setActiveCompany: (id: string | null) => void; +} + +const SoeMotorContext = createContext({ + activeCompany: null, + setActiveCompany: () => {}, +}); + +export function SoeMotorProvider({ children }: { children: ReactNode }) { + const [activeCompany, setActiveCompany] = useState(null); + + return ( + + {children} + + ); +} + +export function useSoeMotor() { + return useContext(SoeMotorContext); +} diff --git a/client/src/hooks/use-xos-socket.ts b/client/src/hooks/use-xos-socket.ts new file mode 100644 index 0000000..ae10986 --- /dev/null +++ b/client/src/hooks/use-xos-socket.ts @@ -0,0 +1,103 @@ +/** + * useXosSocket — subscribes to XOS real-time events from Socket.IO. + * Usage: + * const { supervisorStats, lastSlaBreachEvent } = useXosSocket(); + */ +import { useEffect, useRef, useState, useCallback } from "react"; +import { io, Socket } from "socket.io-client"; + +export interface SupervisorStats { + openConversations: number; + resolvedToday: number; + urgentTickets: number; + agentsOnline: number; + timestamp: string; +} + +export interface SlaBreachEvent { + protocolId: number; + protocolNumber: string; + tenantId: number; + queueId: number | null; + assignedTo: number | null; + breachedAt: string; +} + +interface XosSocketState { + connected: boolean; + supervisorStats: SupervisorStats | null; + lastSlaBreachEvent: SlaBreachEvent | null; +} + +let _socket: Socket | null = null; +let _refCount = 0; + +function getSocket(): Socket { + if (!_socket) { + _socket = io(window.location.origin, { + path: "/socket.io", + transports: ["websocket", "polling"], + reconnectionAttempts: 10, + reconnectionDelay: 2000, + }); + } + return _socket; +} + +export function useXosSocket() { + const [state, setState] = useState({ + connected: false, + supervisorStats: null, + lastSlaBreachEvent: null, + }); + const socketRef = useRef(null); + + const handleStats = useCallback((data: SupervisorStats) => { + setState((s) => ({ ...s, supervisorStats: data })); + }, []); + + const handleSlaBreach = useCallback((data: SlaBreachEvent) => { + setState((s) => ({ ...s, lastSlaBreachEvent: data })); + }, []); + + const handleConnect = useCallback(() => { + setState((s) => ({ ...s, connected: true })); + }, []); + + const handleDisconnect = useCallback(() => { + setState((s) => ({ ...s, connected: false })); + }, []); + + useEffect(() => { + const socket = getSocket(); + socketRef.current = socket; + _refCount++; + + socket.on("connect", handleConnect); + socket.on("disconnect", handleDisconnect); + socket.on("xos:supervisor.stats", handleStats); + socket.on("xos:sla.breach", handleSlaBreach); + + // Sync initial connection state + if (socket.connected) { + setState((s) => ({ ...s, connected: true })); + } + + return () => { + socket.off("connect", handleConnect); + socket.off("disconnect", handleDisconnect); + socket.off("xos:supervisor.stats", handleStats); + socket.off("xos:sla.breach", handleSlaBreach); + + _refCount--; + // Keep socket alive as long as any component uses it + if (_refCount <= 0 && _socket) { + _socket.disconnect(); + _socket = null; + _refCount = 0; + } + }; + }, [handleConnect, handleDisconnect, handleStats, handleSlaBreach]); + + return state; +} diff --git a/client/src/lib/protected-route.tsx b/client/src/lib/protected-route.tsx index 1bc483f..3e91a82 100644 --- a/client/src/lib/protected-route.tsx +++ b/client/src/lib/protected-route.tsx @@ -7,7 +7,7 @@ export function ProtectedRoute({ component: Component, }: { path: string; - component: () => React.JSX.Element; + component: React.ComponentType; }) { const { user, isLoading } = useAuth(); diff --git a/client/src/pages/SOE.tsx b/client/src/pages/SOE.tsx new file mode 100644 index 0000000..83a5b4f --- /dev/null +++ b/client/src/pages/SOE.tsx @@ -0,0 +1,2 @@ +// SOE — Sistema de Operações Empresariais (alias para o módulo Plus/ERP) +export { default } from "./Plus"; 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]}

+
+
+ +