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:
BIN
apps/frontend/.impeccable/review/relatorios-agentes-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/relatorios-agentes-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/frontend/.impeccable/review/relatorios-filas-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/relatorios-filas-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/frontend/.impeccable/review/relatorios-ia-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/relatorios-ia-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
28
apps/frontend/src/app/app/relatorios/agentes/page.tsx
Normal file
28
apps/frontend/src/app/app/relatorios/agentes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/frontend/src/app/app/relatorios/campanhas/page.tsx
Normal file
22
apps/frontend/src/app/app/relatorios/campanhas/page.tsx
Normal 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). "Valor Telefonia" e "Valor IA" ficam
|
||||
indisponíveis até existir rating de uso ligado a este relatório.
|
||||
</p>
|
||||
</div>
|
||||
<CampanhasReport rows={rows} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
apps/frontend/src/app/app/relatorios/filas/page.tsx
Normal file
33
apps/frontend/src/app/app/relatorios/filas/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/frontend/src/app/app/relatorios/ia/page.tsx
Normal file
29
apps/frontend/src/app/app/relatorios/ia/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
259
apps/frontend/src/app/app/relatorios/report-sections.tsx
Normal file
259
apps/frontend/src/app/app/relatorios/report-sections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
56
apps/frontend/src/lib/report-types.ts
Normal file
56
apps/frontend/src/lib/report-types.ts
Normal 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 }[];
|
||||
}
|
||||
Reference in New Issue
Block a user