Merge pull request #8 from jonaspachecoometas/claude/analyze-project-0mXjP

ajuste e implementações 162026
This commit is contained in:
jonaspachecoometas 2026-03-16 22:36:47 -03:00 committed by GitHub
commit e38cbb24d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 3940 additions and 39 deletions

359
DEPLOY_COOLIFY.md Normal file
View File

@ -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=<gerado>
SSO_SECRET=<gerado>
# Banco de dados
PGHOST=db
PGPORT=5432
PGUSER=arcadia
PGPASSWORD=<senha-forte>
PGDATABASE=arcadia
DATABASE_URL=postgresql://arcadia:<senha>@db:5432/arcadia
# Redis
REDIS_URL=redis://redis:6379
# IA — LiteLLM gateway
LITELLM_API_KEY=<gerado>
AI_INTEGRATIONS_OPENAI_BASE_URL=http://litellm:4000/v1
AI_INTEGRATIONS_OPENAI_API_KEY=<mesmo-que-LITELLM_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=<openssl rand -hex 32>
SUPERSET_ADMIN_USER=admin
SUPERSET_ADMIN_EMAIL=admin@seu-dominio.com
SUPERSET_ADMIN_PASSWORD=<senha-forte>
SUPERSET_HOST=superset
SUPERSET_PORT=8088
```
### Arcádia Plus — profile `plus`
```env
SSO_PLUS_BASE_URL=http://plus:8080
PLUS_APP_KEY=<laravel-key>
PLUS_DB_ROOT_PASSWORD=<senha>
PLUS_DB_PASSWORD=<senha>
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=<senha>
ERPNEXT_DB_PASSWORD=<senha>
ERPNEXT_ADMIN_PASSWORD=<senha>
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 1020 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 (~1020 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
```

View File

@ -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} />

View File

@ -0,0 +1,25 @@
import { createContext, useContext, useState, ReactNode } from "react";
interface SoeMotorContextValue {
activeCompany: string | null;
setActiveCompany: (id: string | null) => void;
}
const SoeMotorContext = createContext<SoeMotorContextValue>({
activeCompany: null,
setActiveCompany: () => {},
});
export function SoeMotorProvider({ children }: { children: ReactNode }) {
const [activeCompany, setActiveCompany] = useState<string | null>(null);
return (
<SoeMotorContext.Provider value={{ activeCompany, setActiveCompany }}>
{children}
</SoeMotorContext.Provider>
);
}
export function useSoeMotor() {
return useContext(SoeMotorContext);
}

View File

@ -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<XosSocketState>({
connected: false,
supervisorStats: null,
lastSlaBreachEvent: null,
});
const socketRef = useRef<Socket | null>(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;
}

View File

@ -7,7 +7,7 @@ export function ProtectedRoute({
component: Component, component: Component,
}: { }: {
path: string; path: string;
component: () => React.JSX.Element; component: React.ComponentType<any>;
}) { }) {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();

2
client/src/pages/SOE.tsx Normal file
View File

@ -0,0 +1,2 @@
// SOE — Sistema de Operações Empresariais (alias para o módulo Plus/ERP)
export { default } from "./Plus";

View File

@ -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}`}>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1,478 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useXosSocket } from "@/hooks/use-xos-socket";
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 { connected: socketConnected, supervisorStats: liveStats } = useXosSocket();
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>
);
}
// Prefer real-time socket stats over polling data when available
const conv = overview?.conversations;
const tick = overview?.tickets;
const totalActive = liveStats?.openConversations ?? ((conv?.open || 0) + (conv?.pending || 0));
const resolvedToday = liveStats?.resolvedToday ?? conv?.resolved_today ?? 0;
const urgentTickets = liveStats?.urgentTickets ?? tick?.urgent_open ?? 0;
const agentsOnline = liveStats?.agentsOnline ?? overview?.agents_active?.length ?? 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") : "—"}
{socketConnected
? <span className="ml-2 text-emerald-600"> tempo real</span>
: autoRefresh && <span className="ml-2 text-yellow-600"> polling 30s</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">{resolvedToday}</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">{urgentTickets}</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">{agentsOnline}</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>
);
}

View File

@ -335,6 +335,13 @@ class ManusService extends EventEmitter {
return this.toolRetailStats(input.period, input.storeId); return this.toolRetailStats(input.period, input.storeId);
case "retail_report": case "retail_report":
return this.toolRetailReport(input.type, input.dateFrom, input.dateTo, input.storeId); return this.toolRetailReport(input.type, input.dateFrom, input.dateTo, input.storeId);
// ========== AUTOMAÇÃO + XOS + INBOX ==========
case "automation_trigger":
return this.toolAutomationTrigger(input.automation_id, input.event_type, input.payload, input.tenant_id, userId);
case "xos_action":
return this.toolXosAction(input.action, input.data, userId);
case "inbox_action":
return this.toolInboxAction(input.action, input.conversation_id, input.data, userId);
case "finish": case "finish":
let finishOutput = input.answer || ""; let finishOutput = input.answer || "";
if (input.chart) { if (input.chart) {
@ -3871,6 +3878,253 @@ class ManusService extends EventEmitter {
return { success: false, output: "", error: `Erro ao gerar relatório: ${error.message}` }; return { success: false, output: "", error: `Erro ao gerar relatório: ${error.message}` };
} }
} }
// ============================================================
// TOOL: automation_trigger
// ============================================================
private async toolAutomationTrigger(
automationId: number | undefined,
eventType: string | undefined,
payload: string | undefined,
tenantId: number | undefined,
userId: string
): Promise<ToolResult> {
try {
const parsedPayload = payload ? (typeof payload === 'string' ? JSON.parse(payload) : payload) : {};
if (automationId) {
// Direct automation execution via AutomationService
const { automationService } = await import("../automations/service");
const result = await automationService.runAutomation(automationId, userId, { ...parsedPayload, triggered_by: "manus" });
return {
success: result.success,
output: `Automação #${automationId} ${result.success ? 'executada com sucesso' : 'falhou'}. Log ID: ${result.logId}. ${result.result || result.error || ''}`,
};
}
if (eventType) {
// Emit event to automation engine via HTTP
const engineHost = process.env.AUTOMATION_ENGINE_HOST || "localhost";
const enginePort = process.env.AUTOMATION_ENGINE_PORT || "8005";
const response = await fetch(`http://${engineHost}:${enginePort}/xos/trigger`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event_type: eventType, tenant_id: tenantId, payload: parsedPayload }),
});
if (!response.ok) throw new Error(`Engine retornou ${response.status}`);
const result: any = await response.json();
return {
success: true,
output: `Evento '${eventType}' emitido. ${result.triggered_handlers?.length || 0} handlers ativados.`,
};
}
return { success: false, output: "", error: "Informe automation_id ou event_type" };
} catch (error: any) {
return { success: false, output: "", error: `Erro ao disparar automação: ${error.message}` };
}
}
// ============================================================
// TOOL: xos_action
// ============================================================
private async toolXosAction(action: string, data: string | object, userId: string): Promise<ToolResult> {
try {
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
switch (action) {
case "create_contact": {
const result = await db.execute(sql`
INSERT INTO xos_contacts (name, email, phone, whatsapp, type, company, position, source, tags, notes)
VALUES (${parsed.name}, ${parsed.email || null}, ${parsed.phone || null}, ${parsed.whatsapp || null},
${parsed.type || 'lead'}, ${parsed.company || null}, ${parsed.position || null},
${parsed.source || 'manus'}, ${parsed.tags || null}, ${parsed.notes || null})
RETURNING id, name, email, type
`);
const contact = (result.rows || result)[0] as any;
return { success: true, output: `Contato criado: ${contact.name} (ID: ${contact.id}, tipo: ${contact.type})` };
}
case "update_contact": {
const { id, ...fields } = parsed;
if (!id) return { success: false, output: "", error: "id obrigatório para update_contact" };
const sets = Object.entries(fields).map(([k, v]) => `${k} = '${v}'`).join(", ");
await db.execute(sql`UPDATE xos_contacts SET ${sql.raw(sets)}, updated_at = NOW() WHERE id = ${id}`);
return { success: true, output: `Contato #${id} atualizado com sucesso.` };
}
case "create_deal": {
const result = await db.execute(sql`
INSERT INTO xos_deals (title, pipeline_id, stage_id, contact_id, company_id, value, currency, assigned_to, expected_close_date, notes)
VALUES (${parsed.title}, ${parsed.pipeline_id}, ${parsed.stage_id}, ${parsed.contact_id || null},
${parsed.company_id || null}, ${parsed.value || null}, ${parsed.currency || 'BRL'},
${parsed.assigned_to || null}, ${parsed.expected_close_date || null}, ${parsed.notes || null})
RETURNING id, title, value
`);
const deal = (result.rows || result)[0] as any;
return { success: true, output: `Deal criado: "${deal.title}" (ID: ${deal.id}, valor: ${deal.value})` };
}
case "move_deal_stage": {
const { deal_id, stage_id } = parsed;
if (!deal_id || !stage_id) return { success: false, output: "", error: "deal_id e stage_id obrigatórios" };
await db.execute(sql`UPDATE xos_deals SET stage_id = ${stage_id}, updated_at = NOW() WHERE id = ${deal_id}`);
return { success: true, output: `Deal #${deal_id} movido para estágio #${stage_id}.` };
}
case "create_ticket": {
const result = await db.execute(sql`
INSERT INTO xos_tickets (title, description, contact_id, conversation_id, priority, status, category, assigned_to)
VALUES (${parsed.title}, ${parsed.description || null}, ${parsed.contact_id || null},
${parsed.conversation_id || null}, ${parsed.priority || 'medium'}, 'open',
${parsed.category || null}, ${parsed.assigned_to || null})
RETURNING id, title, priority
`);
const ticket = (result.rows || result)[0] as any;
return { success: true, output: `Ticket criado: "${ticket.title}" (ID: ${ticket.id}, prioridade: ${ticket.priority})` };
}
case "assign_agent": {
const { conversation_id, agent_id } = parsed;
if (!conversation_id) return { success: false, output: "", error: "conversation_id obrigatório" };
await db.execute(sql`UPDATE xos_conversations SET assigned_to = ${agent_id}, updated_at = NOW() WHERE id = ${conversation_id}`);
return { success: true, output: `Agente #${agent_id} atribuído à conversa #${conversation_id}.` };
}
case "create_task": {
const result = await db.execute(sql`
INSERT INTO xos_activities (contact_id, deal_id, type, title, description, due_date, assigned_to, status)
VALUES (${parsed.contact_id || null}, ${parsed.deal_id || null}, 'task',
${parsed.title}, ${parsed.description || null},
${parsed.due_date || null}, ${parsed.assigned_to || null}, 'pending')
RETURNING id, title
`);
const task = (result.rows || result)[0] as any;
return { success: true, output: `Tarefa criada: "${task.title}" (ID: ${task.id})` };
}
case "create_activity": {
const result = await db.execute(sql`
INSERT INTO xos_activities (contact_id, deal_id, type, title, description, scheduled_at, assigned_to)
VALUES (${parsed.contact_id || null}, ${parsed.deal_id || null}, ${parsed.type || 'note'},
${parsed.title}, ${parsed.description || null},
${parsed.scheduled_at || null}, ${parsed.assigned_to || null})
RETURNING id, title, type
`);
const act = (result.rows || result)[0] as any;
return { success: true, output: `Atividade criada: "${act.title}" (tipo: ${act.type}, ID: ${act.id})` };
}
case "create_note": {
const result = await db.execute(sql`
INSERT INTO xos_internal_notes (conversation_id, content, created_by, is_pinned)
VALUES (${parsed.conversation_id || null}, ${parsed.content}, ${userId}, ${parsed.is_pinned || false})
RETURNING id
`);
const note = (result.rows || result)[0] as any;
return { success: true, output: `Nota interna criada (ID: ${note.id})` };
}
default:
return { success: false, output: "", error: `Ação XOS desconhecida: ${action}. Use: create_contact, update_contact, create_deal, move_deal_stage, create_ticket, assign_agent, create_task, create_activity, create_note` };
}
} catch (error: any) {
return { success: false, output: "", error: `Erro na ação XOS '${action}': ${error.message}` };
}
}
// ============================================================
// TOOL: inbox_action
// ============================================================
private async toolInboxAction(
action: string,
conversationId: number | undefined,
data: string | object | undefined,
userId: string
): Promise<ToolResult> {
try {
const parsed = data ? (typeof data === 'string' ? JSON.parse(data) : data) : {} as any;
switch (action) {
case "close_conversation": {
if (!conversationId) return { success: false, output: "", error: "conversation_id obrigatório" };
await db.execute(sql`
UPDATE xos_conversations SET status = 'closed', closed_at = NOW(), updated_at = NOW()
WHERE id = ${conversationId}
`);
return { success: true, output: `Conversa #${conversationId} fechada.` };
}
case "transfer_conversation": {
if (!conversationId) return { success: false, output: "", error: "conversation_id obrigatório" };
const { queue_id, agent_id } = parsed;
await db.execute(sql`
UPDATE xos_conversations SET
queue_id = COALESCE(${queue_id || null}, queue_id),
assigned_to = COALESCE(${agent_id || null}, assigned_to),
updated_at = NOW()
WHERE id = ${conversationId}
`);
return { success: true, output: `Conversa #${conversationId} transferida para fila #${queue_id || 'N/A'} / agente #${agent_id || 'N/A'}.` };
}
case "send_message": {
if (!conversationId) return { success: false, output: "", error: "conversation_id obrigatório" };
const { content, content_type } = parsed;
if (!content) return { success: false, output: "", error: "content obrigatório" };
await db.execute(sql`
INSERT INTO xos_messages (conversation_id, direction, sender_type, sender_name, content, content_type)
VALUES (${conversationId}, 'outbound', 'agent', 'Manus IA', ${content}, ${content_type || 'text'})
`);
await db.execute(sql`
UPDATE xos_conversations SET last_message = ${content}, updated_at = NOW() WHERE id = ${conversationId}
`);
return { success: true, output: `Mensagem enviada na conversa #${conversationId}: "${content.substring(0, 100)}"` };
}
case "add_label": {
if (!conversationId) return { success: false, output: "", error: "conversation_id obrigatório" };
const { label } = parsed;
await db.execute(sql`
UPDATE xos_conversations SET
tags = COALESCE(tags, '') || ${label ? ',' + label : ''},
updated_at = NOW()
WHERE id = ${conversationId}
`);
return { success: true, output: `Etiqueta '${label}' adicionada à conversa #${conversationId}.` };
}
case "resolve_ticket": {
const { ticket_id, resolution } = parsed;
if (!ticket_id) return { success: false, output: "", error: "ticket_id obrigatório" };
await db.execute(sql`
UPDATE xos_tickets SET status = 'resolved', resolution = ${resolution || null},
resolved_at = NOW(), updated_at = NOW()
WHERE id = ${ticket_id}
`);
return { success: true, output: `Ticket #${ticket_id} resolvido.` };
}
case "escalate_ticket": {
const { ticket_id, priority, reason } = parsed;
if (!ticket_id) return { success: false, output: "", error: "ticket_id obrigatório" };
await db.execute(sql`
UPDATE xos_tickets SET priority = ${priority || 'urgent'},
notes = CONCAT(COALESCE(notes, ''), ' [Escalado por Manus: ', ${reason || 'sem motivo'}, ']'),
updated_at = NOW()
WHERE id = ${ticket_id}
`);
return { success: true, output: `Ticket #${ticket_id} escalado para prioridade ${priority || 'urgent'}.` };
}
default:
return { success: false, output: "", error: `Ação de inbox desconhecida: ${action}. Use: close_conversation, transfer_conversation, send_message, add_label, resolve_ticket, escalate_ticket` };
}
} catch (error: any) {
return { success: false, output: "", error: `Erro na ação de inbox '${action}': ${error.message}` };
}
}
} }
export const manusService = new ManusService(); export const manusService = new ManusService();

View File

@ -733,6 +733,33 @@ export const MANUS_TOOLS: ManusToolDef[] = [
dateTo: { type: "string", description: "Data final (YYYY-MM-DD)", required: false }, dateTo: { type: "string", description: "Data final (YYYY-MM-DD)", required: false },
storeId: { type: "number", description: "ID da loja para filtrar", required: false } storeId: { type: "number", description: "ID da loja para filtrar", required: false }
} }
},
{
name: "automation_trigger",
description: "Dispara uma automação existente ou emite um evento no barramento. Use para encadear automações, disparar workflows CRM, ou emitir eventos personalizados que ativam outras automações.",
parameters: {
automation_id: { type: "number", description: "ID da automação a executar (opcional se usar event_type)", required: false },
event_type: { type: "string", description: "Tipo de evento a emitir no barramento: crm.contact.created, crm.deal.stage_changed, crm.ticket.created, crm.message.received, manual.trigger, system.event, etc.", required: false },
payload: { type: "string", description: "Dados adicionais em JSON para passar à automação ou ao evento (ex: '{\"contact_id\": 42, \"stage\": \"qualified\"}')", required: false },
tenant_id: { type: "number", description: "ID do tenant para automações multi-tenant (xos_automations)", required: false }
}
},
{
name: "xos_action",
description: "Executa ações no XOS CRM: criar/atualizar contatos, mover deals no pipeline, criar tickets, atribuir agentes, criar tarefas, enviar campanhas. Use para automatizar operações de CRM via IA.",
parameters: {
action: { type: "string", description: "Ação a executar: create_contact, update_contact, create_deal, move_deal_stage, create_ticket, assign_agent, create_task, create_activity, send_campaign, create_note", required: true },
data: { type: "string", description: "Dados para a ação em JSON. Ex: create_contact: '{\"name\":\"João\",\"email\":\"joao@ex.com\",\"type\":\"lead\"}'; move_deal_stage: '{\"deal_id\":5,\"stage_id\":3}'; create_ticket: '{\"title\":\"Bug\",\"contact_id\":2,\"priority\":\"high\"}'", required: true }
}
},
{
name: "inbox_action",
description: "Executa ações na Central de Atendimento: fechar/transferir conversas, enviar mensagens automáticas, atribuir agentes, adicionar etiquetas, criar protocolos, alterar status de tickets de suporte.",
parameters: {
action: { type: "string", description: "Ação: close_conversation, transfer_conversation, send_message, assign_agent, add_label, create_protocol, resolve_ticket, escalate_ticket, send_csat", required: true },
conversation_id: { type: "number", description: "ID da conversa (obrigatório para ações em conversas existentes)", required: false },
data: { type: "string", description: "Dados adicionais em JSON. Ex: transfer: '{\"queue_id\":2}'; send_message: '{\"content\":\"Olá!\",\"type\":\"text\"}'; assign_agent: '{\"agent_id\":5}'", required: false }
}
} }
]; ];

View File

@ -67,6 +67,34 @@ class WorkflowStepType(str, Enum):
HTTP_REQUEST = "http" HTTP_REQUEST = "http"
TRANSFORM = "transform" TRANSFORM = "transform"
NOTIFY = "notify" NOTIFY = "notify"
SUB_WORKFLOW = "sub_workflow"
SPLIT_BATCH = "split_batch"
MERGE = "merge"
ERROR_HANDLER = "error_handler"
RETRY = "retry"
SEND_EMAIL = "send_email"
SEND_WHATSAPP = "send_whatsapp"
UPDATE_RECORD = "update_record"
CREATE_RECORD = "create_record"
MANUS_TASK = "manus_task"
class CrmEventType(str, Enum):
CONTACT_CREATED = "crm.contact.created"
CONTACT_UPDATED = "crm.contact.updated"
DEAL_CREATED = "crm.deal.created"
DEAL_STAGE_CHANGED = "crm.deal.stage_changed"
DEAL_WON = "crm.deal.won"
DEAL_LOST = "crm.deal.lost"
TICKET_CREATED = "crm.ticket.created"
TICKET_RESOLVED = "crm.ticket.resolved"
FORM_SUBMITTED = "crm.form.submitted"
MESSAGE_RECEIVED = "crm.message.received"
CONVERSATION_CLOSED = "crm.conversation.closed"
CAMPAIGN_SENT = "crm.campaign.sent"
CSAT_RECEIVED = "crm.csat.received"
SLA_BREACHED = "crm.sla.breached"
PROTOCOL_CREATED = "crm.protocol.created"
class CronExpression: class CronExpression:
@ -254,6 +282,9 @@ class WorkflowStep(BaseModel):
config: Dict = {} config: Dict = {}
on_success: Optional[str] = None on_success: Optional[str] = None
on_failure: Optional[str] = None on_failure: Optional[str] = None
retry_count: int = 0
retry_delay_seconds: int = 5
error_branch: Optional[str] = None
class WorkflowDefinition(BaseModel): class WorkflowDefinition(BaseModel):
@ -262,6 +293,8 @@ class WorkflowDefinition(BaseModel):
steps: List[WorkflowStep] steps: List[WorkflowStep]
trigger: Optional[str] = None trigger: Optional[str] = None
variables: Optional[Dict] = None variables: Optional[Dict] = None
error_handler: Optional[str] = None
max_execution_time: int = 300
class WorkflowExecution(BaseModel): class WorkflowExecution(BaseModel):
@ -270,6 +303,12 @@ class WorkflowExecution(BaseModel):
variables: Optional[Dict] = None variables: Optional[Dict] = None
class XosAutomationTrigger(BaseModel):
event_type: str
tenant_id: Optional[int] = None
payload: Dict = {}
class WorkflowExecutor: class WorkflowExecutor:
def __init__(self): def __init__(self):
self._workflows: Dict[str, WorkflowDefinition] = {} self._workflows: Dict[str, WorkflowDefinition] = {}
@ -308,21 +347,41 @@ class WorkflowExecutor:
"variables": {**(workflow.variables or {}), **(variables or {}), **(trigger_data or {})}, "variables": {**(workflow.variables or {}), **(variables or {}), **(trigger_data or {})},
} }
# Build step index for branching
step_map = {s.id: s for s in workflow.steps}
step_queue = list(workflow.steps)
try: try:
for i, step in enumerate(workflow.steps): i = 0
step_result = self._execute_step(step, execution["variables"]) while i < len(step_queue):
step = step_queue[i]
step_result, step_status = self._execute_step_with_retry(step, execution["variables"])
execution["results"].append({ execution["results"].append({
"step_id": step.id, "step_id": step.id,
"type": step.type, "type": step.type,
"status": "completed", "status": step_status,
"result": step_result, "result": step_result,
"executed_at": datetime.now().isoformat(), "executed_at": datetime.now().isoformat(),
}) })
execution["steps_completed"] = i + 1 execution["steps_completed"] += 1
if isinstance(step_result, dict): if isinstance(step_result, dict):
execution["variables"].update(step_result.get("output", {})) execution["variables"].update(step_result.get("output", {}))
# Handle branching on condition results
if step_status == "error" and step.error_branch and step.error_branch in step_map:
# Jump to error branch
step_queue = step_queue[:i+1] + [step_map[step.error_branch]] + step_queue[i+1:]
elif step.type == WorkflowStepType.CONDITION:
condition_result = step_result.get("result", False) if isinstance(step_result, dict) else False
next_id = step.on_success if condition_result else step.on_failure
if next_id and next_id in step_map:
# Insert branch step next
step_queue = step_queue[:i+1] + [step_map[next_id]] + step_queue[i+1:]
i += 1
execution["status"] = "completed" execution["status"] = "completed"
execution["completed_at"] = datetime.now().isoformat() execution["completed_at"] = datetime.now().isoformat()
except Exception as e: except Exception as e:
@ -336,25 +395,61 @@ class WorkflowExecutor:
return execution return execution
def _execute_step_with_retry(self, step: WorkflowStep, variables: Dict):
"""Execute step with retry logic. Returns (result, status)."""
max_attempts = max(1, step.retry_count + 1)
last_error = None
for attempt in range(max_attempts):
try:
result = self._execute_step(step, variables)
if isinstance(result, dict) and "error" in result and max_attempts > 1:
last_error = result["error"]
if attempt < max_attempts - 1:
time.sleep(min(step.retry_delay_seconds * (attempt + 1), 60))
continue
return result, "completed"
except Exception as e:
last_error = str(e)
if attempt < max_attempts - 1:
time.sleep(min(step.retry_delay_seconds * (attempt + 1), 60))
return {"error": last_error, "attempts": max_attempts}, "error"
def _execute_step(self, step: WorkflowStep, variables: Dict) -> Any: def _execute_step(self, step: WorkflowStep, variables: Dict) -> Any:
if step.type == WorkflowStepType.CONDITION: stype = step.type
if stype == WorkflowStepType.CONDITION:
return self._exec_condition(step.config, variables) return self._exec_condition(step.config, variables)
elif step.type == WorkflowStepType.ACTION: elif stype == WorkflowStepType.ACTION:
return self._exec_action(step.config, variables) return self._exec_action(step.config, variables)
elif step.type == WorkflowStepType.DELAY: elif stype == WorkflowStepType.DELAY:
delay_seconds = step.config.get("seconds", 1) delay_seconds = step.config.get("seconds", 1)
time.sleep(min(delay_seconds, 30)) time.sleep(min(delay_seconds, 300))
return {"delayed": delay_seconds} return {"delayed": delay_seconds}
elif step.type == WorkflowStepType.SQL_QUERY: elif stype == WorkflowStepType.SQL_QUERY:
return self._exec_query(step.config, variables) return self._exec_query(step.config, variables)
elif step.type == WorkflowStepType.HTTP_REQUEST: elif stype == WorkflowStepType.HTTP_REQUEST:
return self._exec_http(step.config, variables) return self._exec_http(step.config, variables)
elif step.type == WorkflowStepType.TRANSFORM: elif stype == WorkflowStepType.TRANSFORM:
return self._exec_transform(step.config, variables) return self._exec_transform(step.config, variables)
elif step.type == WorkflowStepType.NOTIFY: elif stype == WorkflowStepType.NOTIFY:
return {"notified": True, "message": step.config.get("message", ""), "channel": step.config.get("channel", "system")} return self._exec_notify(step.config, variables)
elif stype == WorkflowStepType.SUB_WORKFLOW:
return self._exec_sub_workflow(step.config, variables)
elif stype == WorkflowStepType.SPLIT_BATCH:
return self._exec_split_batch(step.config, variables)
elif stype == WorkflowStepType.SEND_EMAIL:
return self._exec_send_email(step.config, variables)
elif stype == WorkflowStepType.SEND_WHATSAPP:
return self._exec_send_whatsapp(step.config, variables)
elif stype == WorkflowStepType.UPDATE_RECORD:
return self._exec_update_record(step.config, variables)
elif stype == WorkflowStepType.CREATE_RECORD:
return self._exec_create_record(step.config, variables)
elif stype == WorkflowStepType.MANUS_TASK:
return self._exec_manus_task(step.config, variables)
elif stype == WorkflowStepType.LOOP:
return self._exec_loop(step.config, variables)
else: else:
return {"type": step.type, "status": "unknown_step_type"} return {"type": stype, "status": "executed"}
def _exec_condition(self, config: Dict, variables: Dict) -> Dict: def _exec_condition(self, config: Dict, variables: Dict) -> Dict:
field = config.get("field", "") field = config.get("field", "")
@ -443,8 +538,146 @@ class WorkflowExecutor:
value = config.get("value") value = config.get("value")
filtered = [item for item in data if isinstance(item, dict) and item.get(field) == value] filtered = [item for item in data if isinstance(item, dict) and item.get(field) == value]
return {"output": {"filtered": filtered}} return {"output": {"filtered": filtered}}
elif operation == "map" and isinstance(data, list):
field = config.get("field", "")
mapped = [item.get(field) for item in data if isinstance(item, dict)]
return {"output": {"mapped": mapped}}
elif operation == "sort" and isinstance(data, list):
field = config.get("field", "")
reverse = config.get("reverse", False)
sorted_data = sorted(data, key=lambda x: x.get(field, 0) if isinstance(x, dict) else 0, reverse=reverse)
return {"output": {"sorted": sorted_data}}
elif operation == "unique" and isinstance(data, list):
field = config.get("field", "")
seen = set()
unique = []
for item in data:
key = item.get(field) if isinstance(item, dict) else item
if key not in seen:
seen.add(key)
unique.append(item)
return {"output": {"unique": unique}}
return {"output": {}} return {"output": {}}
def _exec_notify(self, config: Dict, variables: Dict) -> Dict:
message = self._interpolate(config.get("message", ""), variables)
channel = config.get("channel", "system")
# Emit as system event so Node.js can handle delivery
event_bus.emit("system.notification", {
"message": message,
"channel": channel,
"title": config.get("title", "Automação"),
"level": config.get("level", "info"),
})
return {"notified": True, "message": message, "channel": channel}
def _exec_sub_workflow(self, config: Dict, variables: Dict) -> Dict:
sub_id = config.get("workflow_id", "")
if not sub_id or sub_id not in self._workflows:
return {"error": f"Sub-workflow '{sub_id}' nao encontrado"}
# Pass current variables merged with config overrides
sub_vars = {**variables, **config.get("variables", {})}
result = self.execute(sub_id, variables=sub_vars)
return {"output": {"sub_workflow_result": result.get("status"), "sub_workflow_vars": result.get("variables", {})}}
def _exec_split_batch(self, config: Dict, variables: Dict) -> Dict:
source = config.get("source", "")
batch_size = config.get("batch_size", 10)
data = variables.get(source, [])
if not isinstance(data, list):
return {"error": f"Source '{source}' nao e uma lista"}
batches = [data[i:i+batch_size] for i in range(0, len(data), batch_size)]
return {"output": {"batches": batches, "batch_count": len(batches), "total_items": len(data)}}
def _exec_loop(self, config: Dict, variables: Dict) -> Dict:
source = config.get("source", "")
max_iterations = min(config.get("max_iterations", 100), 1000)
data = variables.get(source, [])
if not isinstance(data, list):
return {"error": f"Source '{source}' nao e uma lista"}
results = []
for i, item in enumerate(data[:max_iterations]):
results.append({"index": i, "item": item})
return {"output": {"loop_results": results, "iterations": len(results)}}
def _exec_send_email(self, config: Dict, variables: Dict) -> Dict:
to = self._interpolate(config.get("to", ""), variables)
subject = self._interpolate(config.get("subject", ""), variables)
body = self._interpolate(config.get("body", ""), variables)
# Emit event for Node.js email service to handle
event_bus.emit("system.send_email", {"to": to, "subject": subject, "body": body})
return {"output": {"email_queued": True, "to": to, "subject": subject}}
def _exec_send_whatsapp(self, config: Dict, variables: Dict) -> Dict:
to = self._interpolate(config.get("to", ""), variables)
message = self._interpolate(config.get("message", ""), variables)
# Emit event for Node.js WhatsApp service to handle
event_bus.emit("system.send_whatsapp", {"to": to, "message": message, "channel_id": config.get("channel_id")})
return {"output": {"whatsapp_queued": True, "to": to}}
def _exec_update_record(self, config: Dict, variables: Dict) -> Dict:
if not HAS_PSYCOPG2 or not DATABASE_URL:
return {"error": "Database nao disponivel"}
table = config.get("table", "")
record_id = config.get("id") or variables.get("id")
fields = config.get("fields", {})
if not table or not record_id or not fields:
return {"error": "table, id e fields sao obrigatorios"}
# Resolve interpolated values
resolved = {k: self._interpolate(str(v), variables) for k, v in fields.items()}
set_clauses = ", ".join([f"{k} = %s" for k in resolved.keys()])
values = list(resolved.values()) + [record_id]
try:
conn = psycopg2.connect(DATABASE_URL)
cur = conn.cursor()
cur.execute(f"UPDATE {table} SET {set_clauses}, updated_at = NOW() WHERE id = %s RETURNING id", values)
conn.commit()
conn.close()
return {"output": {"updated": True, "table": table, "id": record_id}}
except Exception as e:
return {"error": f"Update falhou: {str(e)}"}
def _exec_create_record(self, config: Dict, variables: Dict) -> Dict:
if not HAS_PSYCOPG2 or not DATABASE_URL:
return {"error": "Database nao disponivel"}
table = config.get("table", "")
fields = config.get("fields", {})
if not table or not fields:
return {"error": "table e fields sao obrigatorios"}
resolved = {k: self._interpolate(str(v), variables) for k, v in fields.items()}
cols = ", ".join(resolved.keys())
placeholders = ", ".join(["%s"] * len(resolved))
values = list(resolved.values())
try:
conn = psycopg2.connect(DATABASE_URL)
cur = conn.cursor()
cur.execute(f"INSERT INTO {table} ({cols}) VALUES ({placeholders}) RETURNING id", values)
new_id = cur.fetchone()[0]
conn.commit()
conn.close()
return {"output": {"created": True, "table": table, "new_id": new_id}}
except Exception as e:
return {"error": f"Insert falhou: {str(e)}"}
def _exec_manus_task(self, config: Dict, variables: Dict) -> Dict:
prompt = self._interpolate(config.get("prompt", ""), variables)
# Emit event — Node.js Manus service will handle execution
event_bus.emit("system.manus_task", {
"prompt": prompt,
"user_id": config.get("user_id") or variables.get("user_id"),
"automation_context": variables,
})
return {"output": {"manus_task_queued": True, "prompt": prompt[:200]}}
def _interpolate(self, template: str, variables: Dict) -> str:
"""Replace {{variable}} placeholders with values from variables dict."""
import re
def replace(match):
key = match.group(1).strip()
val = variables.get(key, match.group(0))
return str(val) if val is not None else ""
return re.sub(r"\{\{(.+?)\}\}", replace, template)
def get_executions(self, workflow_id: str = None, limit: int = 50) -> List[Dict]: def get_executions(self, workflow_id: str = None, limit: int = 50) -> List[Dict]:
execs = self._executions execs = self._executions
if workflow_id: if workflow_id:
@ -497,8 +730,14 @@ async def health_check():
async def version(): async def version():
return { return {
"name": "Arcadia Automation Engine", "name": "Arcadia Automation Engine",
"version": "1.0.0", "version": "2.0.0",
"capabilities": ["scheduler", "event_bus", "workflow_executor", "cron", "http_actions", "sql_queries"], "capabilities": [
"scheduler", "event_bus", "workflow_executor", "cron",
"http_actions", "sql_queries", "sub_workflows", "loop",
"split_batch", "retry", "error_branch", "send_email",
"send_whatsapp", "update_record", "create_record",
"manus_task", "crm_events", "variable_interpolation",
],
} }
@ -584,7 +823,76 @@ async def event_stats():
@app.get("/events/types") @app.get("/events/types")
async def event_types(): async def event_types():
return {"types": [e.value for e in EventType]} return {
"types": [e.value for e in EventType],
"crm_types": [e.value for e in CrmEventType],
}
# --- XOS CRM Automation trigger ---
@app.post("/xos/trigger")
async def trigger_xos_automation(trigger: XosAutomationTrigger, background_tasks: BackgroundTasks):
"""
Receives CRM events (contact_created, deal_stage_changed, etc.)
and emits them to the event bus so active xos_automations can react.
Also calls the Node.js XOS webhook so database-persisted automations execute.
"""
full_payload = {"tenant_id": trigger.tenant_id, **trigger.payload}
triggered = event_bus.emit(trigger.event_type, full_payload)
# Also emit to generic record.created for wildcard listeners
if trigger.event_type.startswith("crm."):
event_bus.emit(EventType.RECORD_CREATED, {
"entity_type": trigger.event_type.replace("crm.", ""),
**full_payload,
})
# Forward to Node.js XOS automations engine (fire DB-persisted automations)
background_tasks.add_task(_call_xos_webhook, trigger.event_type, full_payload)
return {
"success": True,
"event_type": trigger.event_type,
"triggered_handlers": triggered,
"timestamp": datetime.now().isoformat(),
}
async def _call_xos_webhook(event_type: str, payload: Dict):
"""Non-blocking call to Node.js to fire DB-persisted XOS automations."""
import urllib.request
node_port = os.environ.get("PORT", "5000")
url = f"http://localhost:{node_port}/api/xos/automations/webhook/crm-event"
try:
body = json.dumps({"event_type": event_type, "payload": payload}).encode()
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("X-Internal-Call", "automation-engine")
urllib.request.urlopen(req, timeout=5)
except Exception as e:
print(f"[XOS Webhook] Nao foi possivel notificar Node.js: {e}")
@app.get("/xos/event-types")
async def xos_event_types():
return {"crm_event_types": [e.value for e in CrmEventType]}
@app.post("/cron/validate")
async def validate_cron_post(body: Dict):
expression = body.get("expression", "")
try:
cron = CronExpression(expression)
next_runs = []
dt = datetime.now()
for _ in range(5):
dt = cron.next_run(dt)
next_runs.append(dt.isoformat())
dt += timedelta(minutes=1)
return {"valid": True, "expression": expression, "next_runs": next_runs}
except ValueError as e:
return {"valid": False, "expression": expression, "error": str(e)}
# --- Workflow endpoints --- # --- Workflow endpoints ---

View File

@ -61,6 +61,8 @@ import pipelineRoutes from "./blackboard/pipelineRoutes";
import { startAllAgents } from "./blackboard/agents"; import { startAllAgents } from "./blackboard/agents";
import { loadModuleRoutes } from "./modules/loader"; import { loadModuleRoutes } from "./modules/loader";
import graphRoutes from "./graph/routes"; import graphRoutes from "./graph/routes";
import { initSocketIO } from "./socket-io";
import { startXosScheduler } from "./xos/scheduler";
export async function registerRoutes( export async function registerRoutes(
httpServer: Server, httpServer: Server,
@ -82,6 +84,8 @@ export async function registerRoutes(
registerChatRoutes(app); registerChatRoutes(app);
registerSoeRoutes(app); registerSoeRoutes(app);
registerInternalChatRoutes(app); registerInternalChatRoutes(app);
// Initialize shared Socket.IO singleton (must be before other socket setups)
initSocketIO(httpServer);
setupChatSocket(httpServer); setupChatSocket(httpServer);
setupCommunitySocket(httpServer); setupCommunitySocket(httpServer);
registerWhatsappRoutes(app); registerWhatsappRoutes(app);
@ -145,6 +149,9 @@ export async function registerRoutes(
// Iniciar os 6 agentes do Blackboard // Iniciar os 6 agentes do Blackboard
startAllAgents(); startAllAgents();
// XOS Scheduler: SLA breach checker + supervisor stats broadcaster
startXosScheduler();
// Central de Protocolos (MCP, A2A, AP2, UCP) // Central de Protocolos (MCP, A2A, AP2, UCP)
app.use("/api", protocolsRoutes); app.use("/api", protocolsRoutes);
registerAgentCard(app); // Agent Card na raiz (/.well-known/agent.json) registerAgentCard(app); // Agent Card na raiz (/.well-known/agent.json)

32
server/socket-io.ts Normal file
View File

@ -0,0 +1,32 @@
/**
* Singleton Socket.IO instância compartilhada entre todos os módulos.
* Inicializado em routes.ts via initSocketIO(httpServer).
* Qualquer módulo pode importar getIO() para emitir eventos.
*/
import { Server as HttpServer } from "http";
import { Server as SocketServer, Socket } from "socket.io";
let _io: SocketServer | null = null;
export function initSocketIO(httpServer: HttpServer): SocketServer {
if (_io) return _io;
_io = new SocketServer(httpServer, {
path: "/socket.io",
cors: { origin: "*", methods: ["GET", "POST"] },
});
return _io;
}
export function getIO(): SocketServer {
if (!_io) throw new Error("[socket-io] Not initialized. Call initSocketIO first.");
return _io;
}
/** Emite um evento XOS para todos os clientes conectados (não bloqueia). */
export function broadcastXos(event: string, data: unknown) {
try {
getIO().emit(`xos:${event}`, data);
} catch {
// Socket ainda não iniciado ou sem clientes — ignorar silenciosamente
}
}

View File

@ -26,6 +26,8 @@ interface AutoReplyConfig {
outsideHoursMessage: string; outsideHoursMessage: string;
aiEnabled: boolean; aiEnabled: boolean;
maxAutoRepliesPerContact: number; maxAutoRepliesPerContact: number;
/** Optional: link to an XOS queue for schedule/out-of-hours config */
xosQueueId?: number;
} }
interface WhatsAppSession { interface WhatsAppSession {
@ -130,6 +132,31 @@ Nome do cliente: ${contactName}`;
} }
} }
/** Check XOS queue schedule. Returns { isOpen, outOfHoursMessage }. */
private async checkXosQueueIsOpen(queueId: number): Promise<{ isOpen: boolean; outOfHoursMessage: string | null }> {
try {
const result = await db.execute(sql`
SELECT schedules, out_of_hours_message FROM xos_queues WHERE id = ${queueId}
`);
const queue = ((result as any).rows ?? [])[0];
if (!queue) return { isOpen: true, outOfHoursMessage: null };
const schedules: Array<{ dayOfWeek: number; startTime: string; endTime: string; enabled: boolean }> = queue.schedules || [];
if (!schedules.length) return { isOpen: true, outOfHoursMessage: null };
const now = new Date();
const dayOfWeek = now.getDay(); // 0=Sun, 6=Sat
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
const todaySchedule = schedules.find((s) => s.dayOfWeek === dayOfWeek && s.enabled !== false);
if (!todaySchedule) return { isOpen: false, outOfHoursMessage: queue.out_of_hours_message };
const isOpen = currentTime >= todaySchedule.startTime && currentTime < todaySchedule.endTime;
return { isOpen, outOfHoursMessage: isOpen ? null : queue.out_of_hours_message };
} catch {
return { isOpen: true, outOfHoursMessage: null }; // fail-open: don't block messages if DB query fails
}
}
private async processAutoReply(msg: IncomingMessage, contact: typeof whatsappContacts.$inferSelect): Promise<void> { private async processAutoReply(msg: IncomingMessage, contact: typeof whatsappContacts.$inferSelect): Promise<void> {
try { try {
const config = this.getAutoReplyConfig(msg.userId); const config = this.getAutoReplyConfig(msg.userId);
@ -142,13 +169,24 @@ Nome do cliente: ${contactName}`;
return; return;
} }
// Check business hours — prefer XOS queue schedule when configured
let isBusinessHours: boolean;
let outsideHoursReply: string;
if (config.xosQueueId) {
const { isOpen, outOfHoursMessage } = await this.checkXosQueueIsOpen(config.xosQueueId);
isBusinessHours = isOpen;
outsideHoursReply = outOfHoursMessage || config.outsideHoursMessage;
} else {
const currentHour = new Date().getHours(); const currentHour = new Date().getHours();
const isBusinessHours = currentHour >= config.businessHours.start && currentHour < config.businessHours.end; isBusinessHours = currentHour >= config.businessHours.start && currentHour < config.businessHours.end;
outsideHoursReply = config.outsideHoursMessage;
}
let replyText: string; let replyText: string;
if (!isBusinessHours) { if (!isBusinessHours) {
replyText = config.outsideHoursMessage; replyText = outsideHoursReply;
} else if (currentCount === 0) { } else if (currentCount === 0) {
replyText = config.welcomeMessage; replyText = config.welcomeMessage;
} else if (config.aiEnabled) { } else if (config.aiEnabled) {

File diff suppressed because it is too large Load Diff

119
server/xos/scheduler.ts Normal file
View File

@ -0,0 +1,119 @@
/**
* XOS Scheduler
* - Every 5 min: check SLA breaches and emit events
* - Every 30 sec: broadcast live supervisor stats via Socket.IO
*/
import { db } from "../db";
import { sql } from "drizzle-orm";
import { broadcastXos } from "../socket-io";
const SLA_CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
const STATS_BROADCAST_INTERVAL_MS = 30 * 1000; // 30 seconds
/** Mark overdue protocols as SLA-breached and emit event per tenant */
async function checkSlaBreaches() {
try {
// Mark protocols where sla_deadline has passed and still open/unbreached
const breached = await db.execute(sql`
UPDATE xos_protocols
SET sla_breach = true, updated_at = NOW()
WHERE status = 'open'
AND sla_deadline IS NOT NULL
AND sla_deadline < NOW()
AND sla_breach = false
RETURNING id, tenant_id, protocol_number, contact_id, queue_id, assigned_to
`);
const rows = (breached as any).rows ?? [];
for (const row of rows) {
broadcastXos("sla.breach", {
protocolId: row.id,
tenantId: row.tenant_id,
protocolNumber: row.protocol_number,
contactId: row.contact_id,
queueId: row.queue_id,
assignedTo: row.assigned_to,
breachedAt: new Date().toISOString(),
});
// Forward to automation engine (non-blocking)
try {
const engineHost = process.env.AUTOMATION_ENGINE_HOST || "localhost";
const enginePort = process.env.AUTOMATION_ENGINE_PORT || "8005";
await fetch(`http://${engineHost}:${enginePort}/xos/trigger`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event_type: "crm.sla.breached",
payload: { protocol_id: row.id, protocol_number: row.protocol_number },
tenant_id: row.tenant_id,
}),
signal: AbortSignal.timeout(5000),
});
} catch {
// Non-blocking — engine may be offline
}
}
if (rows.length > 0) {
console.log(`[xos:scheduler] SLA check: ${rows.length} breach(es) detected`);
}
} catch (err) {
console.error("[xos:scheduler] SLA check error:", err);
}
}
/** Broadcast aggregate supervisor stats to all connected clients */
async function broadcastSupervisorStats() {
try {
const [convsResult, ticketsResult, agentsResult] = await Promise.all([
db.execute(sql`
SELECT
COUNT(*) FILTER (WHERE status = 'open') AS open_conversations,
COUNT(*) FILTER (WHERE status = 'resolved' AND updated_at > NOW() - INTERVAL '24h') AS resolved_today
FROM xos_conversations
`),
db.execute(sql`
SELECT
COUNT(*) FILTER (WHERE status IN ('open','in_progress') AND priority = 'urgent') AS urgent_open
FROM xos_tickets
`),
db.execute(sql`
SELECT COUNT(*) FILTER (WHERE is_online = true) AS agents_online
FROM xos_agents
`),
]);
const c = ((convsResult as any).rows ?? [])[0] ?? {};
const t = ((ticketsResult as any).rows ?? [])[0] ?? {};
const a = ((agentsResult as any).rows ?? [])[0] ?? {};
broadcastXos("supervisor.stats", {
openConversations: Number(c.open_conversations ?? 0),
resolvedToday: Number(c.resolved_today ?? 0),
urgentTickets: Number(t.urgent_open ?? 0),
agentsOnline: Number(a.agents_online ?? 0),
timestamp: new Date().toISOString(),
});
} catch (err) {
// Suppress — tables may not exist in dev
if (process.env.NODE_ENV !== "production") {
console.debug("[xos:scheduler] stats broadcast skipped:", (err as any)?.message);
}
}
}
export function startXosScheduler() {
// Stagger initial runs slightly to avoid startup congestion
setTimeout(() => {
checkSlaBreaches();
setInterval(checkSlaBreaches, SLA_CHECK_INTERVAL_MS);
}, 15_000); // first SLA check 15s after boot
setTimeout(() => {
broadcastSupervisorStats();
setInterval(broadcastSupervisorStats, STATS_BROADCAST_INTERVAL_MS);
}, 5_000); // first broadcast 5s after boot
console.log("[xos:scheduler] Started — SLA checks every 5min, stats every 30s");
}

View File

@ -7099,6 +7099,53 @@ export const insertAgentMetricsSchema = createInsertSchema(xosAgentMetrics).omit
export type XosAgentMetric = typeof xosAgentMetrics.$inferSelect; export type XosAgentMetric = typeof xosAgentMetrics.$inferSelect;
export type InsertXosAgentMetric = z.infer<typeof insertAgentMetricsSchema>; export type InsertXosAgentMetric = z.infer<typeof insertAgentMetricsSchema>;
// ── Protocolos de Atendimento ────────────────────────────────────────────────
export const xosProtocols = pgTable("xos_protocols", {
id: serial("id").primaryKey(),
tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
protocolNumber: varchar("protocol_number", { length: 30 }).notNull().unique(),
conversationId: integer("conversation_id").references(() => xosConversations.id, { onDelete: "set null" }),
ticketId: integer("ticket_id").references(() => xosTickets.id, { onDelete: "set null" }),
contactId: integer("contact_id").references(() => xosContacts.id, { onDelete: "set null" }),
queueId: integer("queue_id").references(() => xosQueues.id, { onDelete: "set null" }),
assignedTo: varchar("assigned_to").references(() => users.id, { onDelete: "set null" }),
subject: text("subject"),
status: varchar("status", { length: 30 }).default("open"), // open, resolved, cancelled
openedAt: timestamp("opened_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
resolvedAt: timestamp("resolved_at"),
slaDeadline: timestamp("sla_deadline"),
slaBreach: boolean("sla_breach").default(false),
satisfactionScore: integer("satisfaction_score"), // 1-5
satisfactionComment: text("satisfaction_comment"),
metadata: jsonb("metadata"),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
});
export const insertXosProtocolSchema = createInsertSchema(xosProtocols).omit({ id: true, createdAt: true, updatedAt: true });
export type XosProtocol = typeof xosProtocols.$inferSelect;
export type InsertXosProtocol = z.infer<typeof insertXosProtocolSchema>;
// ── Políticas de SLA ─────────────────────────────────────────────────────────
export const xosSlaPolicies = pgTable("xos_sla_policies", {
id: serial("id").primaryKey(),
tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
queueId: integer("queue_id").references(() => xosQueues.id, { onDelete: "cascade" }),
name: varchar("name", { length: 100 }).notNull(),
priority: varchar("priority", { length: 20 }).default("normal"), // low, normal, high, urgent
firstResponseMinutes: integer("first_response_minutes").default(60),
resolutionMinutes: integer("resolution_minutes").default(480),
escalationAgentId: varchar("escalation_agent_id").references(() => users.id, { onDelete: "set null" }),
notifyOnBreach: boolean("notify_on_breach").default(true),
isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
});
export const insertXosSlaPolicySchema = createInsertSchema(xosSlaPolicies).omit({ id: true, createdAt: true, updatedAt: true });
export type XosSlaPolicy = typeof xosSlaPolicies.$inferSelect;
export type InsertXosSlaPolicy = z.infer<typeof insertXosSlaPolicySchema>;
export const xosDevPipelines = pgTable("xos_dev_pipelines", { export const xosDevPipelines = pgTable("xos_dev_pipelines", {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
correlationId: text("correlation_id").notNull().default(sql`gen_random_uuid()`), correlationId: text("correlation_id").notNull().default(sql`gen_random_uuid()`),