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

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 }[];
}