diff --git a/.gitignore b/.gitignore index 7ee9fa0..d025f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .env.* !.env.example FIRST_LOGIN.txt +ACESSO_TESTE.md *.pem *.key diff --git a/TODO.md b/TODO.md index 6ac3532..42cf66f 100644 --- a/TODO.md +++ b/TODO.md @@ -905,8 +905,77 @@ Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71, light, mesma decisão de escopo das últimas telas simples) — reavaliar junto com a Sidebar quando "Agentes" ganhar tela própria -## PHASE 28+ — ver `agente.md` seções 140 em diante (resto do Frontend, -Security, Tests) +## PHASE 28 — Segurança, testes de regressão, Frontend: Relatórios +(agente.md secao 157-160, 119, 183-184, 202, 224) +- [x] **Security quality gate (secao 224)**: review dedicado (subagente + independente) sobre o diff completo desde `origin/main` (billing + orchestration + todo o frontend novo, 7 commits) — auth/sessão, + todos os controllers NestJS tocados, `permission.guard.ts`, RLS da + migration de billing, todas as Server Actions novas do frontend. + **Nenhum achado com confiança >= 7/10** (nem IDOR, nem bypass de + tenant, nem segredo vazado em resposta/log). Ver seção "Auditoria de + segurança" no relatório desta sessão (não persistida em arquivo — + resultado: limpo). +- [x] **Portas sensíveis (secao 184)** conferidas depois do reboot da VM: + Postgres (5432) e Redis (6379) só em `127.0.0.1`, Event Socket + (8021) não publicado no host, `apps/api` (3000) só em `127.0.0.1`. + Único listener em todas as interfaces é o `apps/frontend` (3001, dev + server) — necessário pra o usuário testar de fora da VM; token de + acesso nunca chega no browser (arquitetura desde a PHASE 23), então + isso não expõe a API real. +- [x] **Testes de regressão (secao 202)** re-executados do zero pós-reboot + pra confirmar que nada quebrou: `test:isolation` (RLS multi-tenant), + `test:auth` (login/RBAC/refresh rotation/logout), `packages/billing` + (RatingEngine, 17 casos) — todos verdes. +- [x] Frontend > Relatórios (secao 157-160, 119): 4 páginas novas — + `/app/relatorios/{filas,agentes,campanhas,ia}` — cada uma sua + própria rota (não abas de uma página só: 4 itens de menu apontando + pro mesmo `href` fariam a sidebar realçar os 4 ao mesmo tempo, ver + `components/shell/nav-list.tsx`). Reusa os endpoints que já existiam + desde a PHASE 17/21 (`GET /reports/{queues,agents,campaigns,ai- + dashboard}`), nenhum endpoint novo. Últimos 30 dias fixo, sem + seletor de período ainda (decisão deliberada, documentada na + própria tela). +- [x] Menu Tenant: "Relatórios > Agentes/Filas/Campanhas" e "IA > + Análises" saem de "em breve"; "Relatórios > Chamadas/Consumo" e o + resto do menu IA continuam pendentes. +- [x] Testado ponta a ponta contra a API real (tenant Acme, ainda sem + chamadas reais) — as 4 telas renderizam os estados vazios corretos + (honestos: `0` quando é contagem real, traço fantasma com + explicação quando é média sem dado), título/descrição da topbar + resolvidos corretamente por rota (sem a ambiguidade que teria dado + se as 4 dividissem um `href`). +- [ ] **Escopo real ainda faltando pra "aplicação finalizada" segundo o + agente.md completo** (236 seções) — não coberto nesta fase, listado + explicitamente em vez de fingir completo: + - Call Center > Agentes (frontend): bloqueado por uma lacuna de + backend, não só falta de tela — `POST /agents` exige um `userId` + de um usuário já existente do tenant, mas **não existe nenhum + endpoint pra listar/convidar usuários do tenant** ainda (só + criação de tenant/usuário via script, mesma lacuna já anotada na + PHASE 22 pra tenant/plano/assinatura). Precisa de um "Usuários" + (Administração) antes de Agentes fazer sentido na UI. + - Telefonia > Dialplan (editor estruturado, condition/actions + dinâmicos, generate/activate de versão) — backend pronto desde a + PHASE 10, zero UI. + - Monitoramento em tempo real (secao 54-55, 161) — WebSocket já + existe no backend (`RealtimeGateway`), zero cliente no frontend. + - Gravações (player de áudio, secao 121) e IA > Scorecards/Prompts/ + Configurações (CRUD, secao 114-118) — backend pronto, zero UI. + - Relatórios > Chamadas (lista filtrável) e > Consumo (dashboard de + quotas, secao 139) — sem tela ainda. + - Menu Platform: Clientes/Infraestrutura/Sistema inteiros "em + breve" (só Billing > Tarifas e o dashboard existem). + - "Itens sem permissão não aparecem" (secao 169) nunca foi aplicado + — lacuna conhecida desde a PHASE 24. + - Nenhuma bateria de teste automatizado de frontend (E2E/component) + existe — toda verificação até aqui foi manual via Puppeteer + + screenshot, sessão a sessão. + - `apps/api`/`apps/frontend` continuam sem supervisor de processo + (ver "Riscos conhecidos") — não sobrevivem a um reboot sozinhos. + - nginx / TLS / domínio público (PHASE 01, pendência original) + continuam sem existir — o acesso de teste é direto na porta 3001 + da VM, sem HTTPS. --- diff --git a/apps/frontend/.impeccable/review/relatorios-agentes-desktop.png b/apps/frontend/.impeccable/review/relatorios-agentes-desktop.png new file mode 100644 index 0000000..ea99010 Binary files /dev/null and b/apps/frontend/.impeccable/review/relatorios-agentes-desktop.png differ diff --git a/apps/frontend/.impeccable/review/relatorios-campanhas-desktop.png b/apps/frontend/.impeccable/review/relatorios-campanhas-desktop.png new file mode 100644 index 0000000..9950599 Binary files /dev/null and b/apps/frontend/.impeccable/review/relatorios-campanhas-desktop.png differ diff --git a/apps/frontend/.impeccable/review/relatorios-filas-desktop.png b/apps/frontend/.impeccable/review/relatorios-filas-desktop.png new file mode 100644 index 0000000..716c8d6 Binary files /dev/null and b/apps/frontend/.impeccable/review/relatorios-filas-desktop.png differ diff --git a/apps/frontend/.impeccable/review/relatorios-ia-desktop.png b/apps/frontend/.impeccable/review/relatorios-ia-desktop.png new file mode 100644 index 0000000..caf0f18 Binary files /dev/null and b/apps/frontend/.impeccable/review/relatorios-ia-desktop.png differ diff --git a/apps/frontend/src/app/app/relatorios/agentes/page.tsx b/apps/frontend/src/app/app/relatorios/agentes/page.tsx new file mode 100644 index 0000000..67ba104 --- /dev/null +++ b/apps/frontend/src/app/app/relatorios/agentes/page.tsx @@ -0,0 +1,28 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { AgentReport } from "@/lib/report-types"; +import { AgentesReport } from "../report-sections"; + +function last30Days(): string { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - 30); + return d.toISOString(); +} + +export default async function RelatorioAgentesPage() { + const session = await requireSession(); + const from = last30Days(); + const rows = await apiFetch(`/reports/agents?from=${encodeURIComponent(from)}`, session.accessToken); + + return ( +
+
+

Relatório de agentes

+

+ Últimos 30 dias (agente.md secao 158). Sem seletor de período nesta primeira versão. +

+
+ +
+ ); +} diff --git a/apps/frontend/src/app/app/relatorios/campanhas/page.tsx b/apps/frontend/src/app/app/relatorios/campanhas/page.tsx new file mode 100644 index 0000000..0c36d5f --- /dev/null +++ b/apps/frontend/src/app/app/relatorios/campanhas/page.tsx @@ -0,0 +1,22 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { CampaignReport } from "@/lib/report-types"; +import { CampanhasReport } from "../report-sections"; + +export default async function RelatorioCampanhasPage() { + const session = await requireSession(); + const rows = await apiFetch("/reports/campaigns", session.accessToken); + + return ( +
+
+

Relatório de campanhas

+

+ Histórico completo (agente.md secao 160). "Valor Telefonia" e "Valor IA" ficam + indisponíveis até existir rating de uso ligado a este relatório. +

+
+ +
+ ); +} diff --git a/apps/frontend/src/app/app/relatorios/filas/page.tsx b/apps/frontend/src/app/app/relatorios/filas/page.tsx new file mode 100644 index 0000000..40c3c9c --- /dev/null +++ b/apps/frontend/src/app/app/relatorios/filas/page.tsx @@ -0,0 +1,33 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { QueueReport } from "@/lib/report-types"; +import type { Queue } from "@/lib/callcenter-types"; +import { FilasReport } from "../report-sections"; + +function last30Days(): string { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - 30); + return d.toISOString(); +} + +export default async function RelatorioFilasPage() { + const session = await requireSession(); + const from = last30Days(); + + const [rows, queues] = await Promise.all([ + apiFetch(`/reports/queues?from=${encodeURIComponent(from)}`, session.accessToken), + apiFetch("/queues", session.accessToken), + ]); + + return ( +
+
+

Relatório de filas

+

+ Últimos 30 dias (agente.md secao 159). Sem seletor de período nesta primeira versão. +

+
+ [q.id, q.name]))} /> +
+ ); +} diff --git a/apps/frontend/src/app/app/relatorios/ia/page.tsx b/apps/frontend/src/app/app/relatorios/ia/page.tsx new file mode 100644 index 0000000..916bfde --- /dev/null +++ b/apps/frontend/src/app/app/relatorios/ia/page.tsx @@ -0,0 +1,29 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { AIDashboardReport } from "@/lib/report-types"; +import { IAReport } from "../report-sections"; + +function last30Days(): string { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - 30); + return d.toISOString(); +} + +export default async function RelatorioIAPage() { + const session = await requireSession(); + const from = last30Days(); + const data = await apiFetch(`/reports/ai-dashboard?from=${encodeURIComponent(from)}`, session.accessToken); + + return ( +
+
+

Dashboard de IA

+

+ Últimos 30 dias (agente.md secao 119). Depende de chamadas transcritas e analisadas pelo pipeline de IA — + fica honesto (traço, não zero) enquanto não houver análise concluída. +

+
+ +
+ ); +} diff --git a/apps/frontend/src/app/app/relatorios/report-sections.tsx b/apps/frontend/src/app/app/relatorios/report-sections.tsx new file mode 100644 index 0000000..83b7446 --- /dev/null +++ b/apps/frontend/src/app/app/relatorios/report-sections.tsx @@ -0,0 +1,259 @@ +import { AlertTriangle } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Pill } from "@/components/ui/pill"; +import { InstrumentTile } from "@/components/ui/instrument-tile"; +import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import { formatDuration, formatInt, formatPercent } from "@/lib/format"; +import type { AIDashboardReport, AgentReport, CampaignReport, QueueReport } from "@/lib/report-types"; + +/** Componentes de renderização puros, sem estado — compartilhados pelas 4 + * páginas de `/app/relatorios/*`. Cada rota é uma página própria (não uma + * aba de uma página só) pra não quebrar o realce de "ativo" da sidebar, + * que compara por `href` exato/prefixo (ver `components/shell/nav-list.tsx`) + * — 4 itens de menu apontando pro mesmo `href` ficariam todos "ativos" ao + * mesmo tempo. */ + +function pct(v: number | null): string { + return v == null ? "—" : formatPercent(v); +} + +function dur(v: number | null): string { + return v == null ? "—" : formatDuration(v); +} + +export function FilasReport({ rows, names }: { rows: QueueReport[]; names: Record }) { + return ( + + + {rows.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + + ))} + +
FilaRecebidasAtendidasAbandonadasTMETMAService LevelAbandono
{names[r.queueId] ?? r.queueId}{formatInt(r.received)}{formatInt(r.answered)}{formatInt(r.abandoned)}{dur(r.tme)}{dur(r.tma)}{pct(r.serviceLevel)}{pct(r.abandonRate)}
+ )} +
+ ); +} + +export function AgentesReport({ rows }: { rows: AgentReport[] }) { + return ( + + + {rows.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + ))} + +
AgenteLogadoDisponívelEm chamadaPausadoAtendidasTMA
{r.name}{dur(r.loggedInSeconds)}{dur(r.availableSeconds)} + {dur(r.reservedSeconds + r.ringingSeconds + r.inCallSeconds + r.wrapUpSeconds)} + {dur(r.pausedSeconds)}{formatInt(r.callsAnswered)}{dur(r.tma)}
+ )} +
+ ); +} + +export function CampanhasReport({ rows }: { rows: CampaignReport[] }) { + return ( + + + {rows.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + + + ))} + +
CampanhaLeadsTentativasAtendidasCom agenteTaxa atend.ContatoAbandonoTMA
{r.name}{formatInt(r.leads)}{formatInt(r.attempts)}{formatInt(r.answered)}{formatInt(r.agentConnected)}{pct(r.answerRate)}{pct(r.contactRate)}{pct(r.abandonRate)}{dur(r.tma)}
+ )} +
+ ); +} + +export function IAReport({ data }: { data: AIDashboardReport }) { + return ( +
+
+ + + +
+ +
+ + +
+ {Object.keys(data.sentimentBreakdown).length === 0 ? ( +

Sem dados de sentimento no período.

+ ) : ( + Object.entries(data.sentimentBreakdown).map(([sentiment, count]) => ( + + {sentiment}: {formatInt(count)} + + )) + )} +
+
+ + + + {data.complianceAlerts.length === 0 ? ( + + ) : ( +
    + {data.complianceAlerts.slice(0, 10).map((a) => ( +
  • + + {a.callId} + + {a.flags.map((f) => ( + {f} + ))} + +
  • + ))} +
+ )} +
+ + + + + + + + + + + + + + + + + + + + +
+
+ ); +} + +function TopicList({ items, empty }: { items: { value: string; count: number }[]; empty: string }) { + if (items.length === 0) return

{empty}

; + return ( +
    + {items.map((item) => ( +
  • + {item.value} + {formatInt(item.count)} +
  • + ))} +
+ ); +} + +function AgentRankingList({ + items, + empty, +}: { + items: { agentId: string; name: string; avgScore: number; calls: number }[]; + empty: string; +}) { + if (items.length === 0) return

{empty}

; + return ( +
    + {items.map((a) => ( +
  • + {a.name} + + {a.avgScore.toFixed(1)} + ({formatInt(a.calls)} chamadas) + +
  • + ))} +
+ ); +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index 1bbde27..024e2a0 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -95,12 +95,39 @@ export const TENANT_NAV: NavSection[] = [ { label: "IA", icon: Sparkles, - children: [{ label: "Análises" }, { label: "Scorecards" }, { label: "Prompts" }, { label: "Configurações" }], + children: [ + { + label: "Análises", + href: "/app/relatorios/ia", + description: "Dashboard de IA — score, sentimento, assuntos, compliance", + }, + { label: "Scorecards" }, + { label: "Prompts" }, + { label: "Configurações" }, + ], }, { label: "Relatórios", icon: BarChart3, - children: [{ label: "Chamadas" }, { label: "Agentes" }, { label: "Filas" }, { label: "Campanhas" }, { label: "Consumo" }], + children: [ + { label: "Chamadas" }, + { + label: "Agentes", + href: "/app/relatorios/agentes", + description: "Tempo logado/pausado/em chamada e TMA por agente", + }, + { + label: "Filas", + href: "/app/relatorios/filas", + description: "Recebidas/atendidas/abandonadas, TME/TMA, Service Level", + }, + { + label: "Campanhas", + href: "/app/relatorios/campanhas", + description: "Leads, tentativas, taxas de atendimento e contato", + }, + { label: "Consumo" }, + ], }, { label: "Administração", diff --git a/apps/frontend/src/lib/report-types.ts b/apps/frontend/src/lib/report-types.ts new file mode 100644 index 0000000..460bed3 --- /dev/null +++ b/apps/frontend/src/lib/report-types.ts @@ -0,0 +1,56 @@ +export interface QueueReport { + queueId: string; + received: number; + answered: number; + abandoned: number; + tme: number | null; + tma: number | null; + serviceLevel: number | null; + abandonRate: number | null; +} + +export interface AgentReport { + agentId: string; + name: string; + loggedInSeconds: number; + availableSeconds: number; + reservedSeconds: number; + ringingSeconds: number; + inCallSeconds: number; + wrapUpSeconds: number; + pausedSeconds: number; + callsAnswered: number; + tma: number | null; +} + +export interface CampaignReport { + campaignId: string; + name: string; + leads: number; + attempts: number; + answered: number; + agentConnected: number; + busy: number; + noAnswer: number; + failed: number; + callbacks: number; + answerRate: number | null; + contactRate: number | null; + abandonRate: number | null; + tme: number | null; + tma: number | null; + telefoniaValor: number | null; + iaValor: number | null; +} + +export interface AIDashboardReport { + callsAnalyzed: number; + avgQualityScore: number | null; + avgScorecardScore: number | null; + sentimentBreakdown: Record; + topTopics: { value: string; count: number }[]; + topObjections: { value: string; count: number }[]; + complianceAlerts: { callId: string; flags: string[] }[]; + topAgents: { agentId: string; name: string; avgScore: number; calls: number }[]; + bottomAgents: { agentId: string; name: string; avgScore: number; calls: number }[]; +}