feat(frontend): Relatórios (Filas/Agentes/Campanhas/IA) + PHASE 28 security review

Quatro páginas novas em /app/relatorios/*, uma rota por relatório (não abas de
uma página só, pra não quebrar o realce de "ativo" da sidebar quando vários
itens de menu apontam pro mesmo relatório) — reusa os endpoints de reports
que já existiam desde as fases de CDR/AI, nenhum backend novo.

Security quality gate (agente.md secao 224): revisão dedicada sobre todo o
diff desde origin/main (billing + frontend inteiro, 7 commits) não achou
nenhuma vulnerabilidade de alta confiança. Suítes de teste de isolamento
multi-tenant, autenticação/RBAC e rating engine reexecutadas do zero e
verdes. TODO.md documenta o escopo real ainda faltando (Agentes bloqueado
por falta de endpoint de listagem de usuários, Dialplan, Monitoramento em
tempo real, Gravações, IA CRUD, Relatórios > Chamadas/Consumo) em vez de
alegar a aplicação "finalizada".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-29 14:34:24 -03:00
parent de1e7ac49c
commit ca49504f0a
13 changed files with 528 additions and 4 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@
.env.*
!.env.example
FIRST_LOGIN.txt
ACESSO_TESTE.md
*.pem
*.key

73
TODO.md
View File

@@ -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.
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@@ -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<AgentReport[]>(`/reports/agents?from=${encodeURIComponent(from)}`, session.accessToken);
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Relatório de agentes</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Últimos 30 dias (agente.md secao 158). Sem seletor de período nesta primeira versão.
</p>
</div>
<AgentesReport rows={rows} />
</div>
);
}

View File

@@ -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<CampaignReport[]>("/reports/campaigns", session.accessToken);
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Relatório de campanhas</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Histórico completo (agente.md secao 160). &quot;Valor Telefonia&quot; e &quot;Valor IA&quot; ficam
indisponíveis até existir rating de uso ligado a este relatório.
</p>
</div>
<CampanhasReport rows={rows} />
</div>
);
}

View File

@@ -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<QueueReport[]>(`/reports/queues?from=${encodeURIComponent(from)}`, session.accessToken),
apiFetch<Queue[]>("/queues", session.accessToken),
]);
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Relatório de filas</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Últimos 30 dias (agente.md secao 159). Sem seletor de período nesta primeira versão.
</p>
</div>
<FilasReport rows={rows} names={Object.fromEntries(queues.map((q) => [q.id, q.name]))} />
</div>
);
}

View File

@@ -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<AIDashboardReport>(`/reports/ai-dashboard?from=${encodeURIComponent(from)}`, session.accessToken);
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Dashboard de IA</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Ú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.
</p>
</div>
<IAReport data={data} />
</div>
);
}

View File

@@ -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<string, string> }) {
return (
<Panel>
<PanelHeader title="Filas" description={`${rows.length} fila(s) com chamadas nos últimos 30 dias`} />
{rows.length === 0 ? (
<EmptyState title="Nenhuma chamada de fila no período" description="Volte aqui depois que a fila receber tráfego." />
) : (
<Table>
<THead>
<TR>
<TH>Fila</TH>
<TH>Recebidas</TH>
<TH>Atendidas</TH>
<TH>Abandonadas</TH>
<TH>TME</TH>
<TH>TMA</TH>
<TH>Service Level</TH>
<TH>Abandono</TH>
</TR>
</THead>
<TBody>
{rows.map((r) => (
<TR key={r.queueId}>
<TD className="font-medium text-foreground">{names[r.queueId] ?? r.queueId}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(r.received)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(r.answered)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(r.abandoned)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{dur(r.tme)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{dur(r.tma)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{pct(r.serviceLevel)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{pct(r.abandonRate)}</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
);
}
export function AgentesReport({ rows }: { rows: AgentReport[] }) {
return (
<Panel>
<PanelHeader title="Agentes" description={`${rows.length} agente(s) neste tenant`} />
{rows.length === 0 ? (
<EmptyState title="Nenhum agente cadastrado ainda" description="Provisione um agente pra ver o relatório aqui." />
) : (
<Table>
<THead>
<TR>
<TH>Agente</TH>
<TH>Logado</TH>
<TH>Disponível</TH>
<TH>Em chamada</TH>
<TH>Pausado</TH>
<TH>Atendidas</TH>
<TH>TMA</TH>
</TR>
</THead>
<TBody>
{rows.map((r) => (
<TR key={r.agentId}>
<TD className="font-medium text-foreground">{r.name}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{dur(r.loggedInSeconds)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{dur(r.availableSeconds)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">
{dur(r.reservedSeconds + r.ringingSeconds + r.inCallSeconds + r.wrapUpSeconds)}
</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{dur(r.pausedSeconds)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(r.callsAnswered)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{dur(r.tma)}</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
);
}
export function CampanhasReport({ rows }: { rows: CampaignReport[] }) {
return (
<Panel>
<PanelHeader title="Campanhas" description={`${rows.length} campanha(s) neste tenant`} />
{rows.length === 0 ? (
<EmptyState title="Nenhuma campanha cadastrada ainda" description="Crie uma campanha em Discador > Campanhas." />
) : (
<Table>
<THead>
<TR>
<TH>Campanha</TH>
<TH>Leads</TH>
<TH>Tentativas</TH>
<TH>Atendidas</TH>
<TH>Com agente</TH>
<TH>Taxa atend.</TH>
<TH>Contato</TH>
<TH>Abandono</TH>
<TH>TMA</TH>
</TR>
</THead>
<TBody>
{rows.map((r) => (
<TR key={r.campaignId}>
<TD className="font-medium text-foreground">{r.name}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(r.leads)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(r.attempts)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(r.answered)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(r.agentConnected)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{pct(r.answerRate)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{pct(r.contactRate)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{pct(r.abandonRate)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{dur(r.tma)}</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
);
}
export function IAReport({ data }: { data: AIDashboardReport }) {
return (
<div className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<InstrumentTile label="Chamadas analisadas" value={formatInt(data.callsAnalyzed)} />
<InstrumentTile
label="Score médio (análise)"
value={data.avgQualityScore}
pending="Sem análise de IA concluída nos últimos 30 dias"
/>
<InstrumentTile
label="Score médio (scorecard)"
value={data.avgScorecardScore}
pending="Sem avaliação de scorecard concluída nos últimos 30 dias"
/>
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<Panel>
<PanelHeader title="Sentimento" />
<div className="flex flex-wrap gap-2 p-5">
{Object.keys(data.sentimentBreakdown).length === 0 ? (
<p className="text-sm text-muted-foreground">Sem dados de sentimento no período.</p>
) : (
Object.entries(data.sentimentBreakdown).map(([sentiment, count]) => (
<Pill key={sentiment}>
{sentiment}: {formatInt(count)}
</Pill>
))
)}
</div>
</Panel>
<Panel>
<PanelHeader title="Alertas de compliance" description={`${data.complianceAlerts.length} chamada(s) sinalizada(s)`} />
{data.complianceAlerts.length === 0 ? (
<EmptyState title="Nenhum alerta no período" description="Nenhuma chamada analisada sinalizou risco de compliance." />
) : (
<ul className="divide-y divide-border">
{data.complianceAlerts.slice(0, 10).map((a) => (
<li key={a.callId} className="flex items-center gap-2 px-5 py-3 text-sm">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-status-red" aria-hidden />
<span className="font-mono text-xs text-muted-foreground">{a.callId}</span>
<span className="flex flex-wrap gap-1">
{a.flags.map((f) => (
<Pill key={f}>{f}</Pill>
))}
</span>
</li>
))}
</ul>
)}
</Panel>
<Panel>
<PanelHeader title="Principais assuntos" />
<TopicList items={data.topTopics} empty="Sem assuntos identificados no período." />
</Panel>
<Panel>
<PanelHeader title="Principais objeções" />
<TopicList items={data.topObjections} empty="Sem objeções identificadas no período." />
</Panel>
<Panel>
<PanelHeader title="Melhores agentes (score de IA)" />
<AgentRankingList items={data.topAgents} empty="Sem ranking ainda." />
</Panel>
<Panel>
<PanelHeader title="Agentes pra observar (score de IA)" />
<AgentRankingList items={data.bottomAgents} empty="Sem ranking ainda." />
</Panel>
</div>
</div>
);
}
function TopicList({ items, empty }: { items: { value: string; count: number }[]; empty: string }) {
if (items.length === 0) return <p className="px-5 py-4 text-sm text-muted-foreground">{empty}</p>;
return (
<ul className="divide-y divide-border">
{items.map((item) => (
<li key={item.value} className="flex items-center justify-between px-5 py-2.5 text-sm">
<span className="text-foreground">{item.value}</span>
<span className="font-mono tabular-nums text-muted-foreground">{formatInt(item.count)}</span>
</li>
))}
</ul>
);
}
function AgentRankingList({
items,
empty,
}: {
items: { agentId: string; name: string; avgScore: number; calls: number }[];
empty: string;
}) {
if (items.length === 0) return <p className="px-5 py-4 text-sm text-muted-foreground">{empty}</p>;
return (
<ul className="divide-y divide-border">
{items.map((a) => (
<li key={a.agentId} className="flex items-center justify-between px-5 py-2.5 text-sm">
<span className="text-foreground">{a.name}</span>
<span className="flex items-center gap-2">
<span className="font-mono tabular-nums text-muted-foreground">{a.avgScore.toFixed(1)}</span>
<span className="text-xs text-muted-foreground">({formatInt(a.calls)} chamadas)</span>
</span>
</li>
))}
</ul>
);
}

View File

@@ -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",

View File

@@ -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<string, number>;
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 }[];
}