feat(frontend): Relatórios > Chamadas + Gravações (proxy de áudio autenticado)

Relatórios > Chamadas: lista das últimas 500 chamadas dos últimos 30 dias,
nomes de fila/agente/campanha/disposição resolvidos client-side, busca por
telefone. Só o filtro de telefone nesta primeira versão.

Gravações: lista + player + download. Achado de arquitetura resolvido
antes de codar: <audio src>/<a download> não mandam Authorization Bearer
(só cookie), e a API nunca expõe o storage por URL direta — criado um
proxy autenticado (Route Handler /api/recordings/[id]/audio) que lê o
cookie de sessão, chama a API real com o access token do lado do servidor,
e reencaminha o stream com Content-Disposition: inline (a API manda
attachment). Mesmo princípio de apiFetch: token nunca chega em JS legível.

Testado ponta a ponta contra a API real (estados vazios honestos, proxy
confirmado 401 sem sessão). Smoke test de regressão nas 15 telas
anteriores do tenant + platform, todas 200.

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 16:20:01 -03:00
parent f1cadddc91
commit 4d112fcea0
12 changed files with 366 additions and 2 deletions

39
TODO.md
View File

@@ -1015,6 +1015,45 @@ Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71,
decisões deliberadas de escopo, mesmas simplificações já existentes decisões deliberadas de escopo, mesmas simplificações já existentes
no backend desde a PHASE 10 no backend desde a PHASE 10
## PHASE 30 — Frontend: Relatórios > Chamadas, Gravações
(agente.md secao 90-94, 121, 157)
- [x] Relatórios > Chamadas (`/app/relatorios/chamadas`): lista das
últimas 500 chamadas dos últimos 30 dias, nomes de fila/agente/
campanha/disposição resolvidos client-side (mesmo padrão de
Campanhas/Bloqueio), busca por telefone. Só o filtro de telefone
desta primeira versão — o backend já aceita ramal/agente/fila/
campanha/tronco/hangup cause/disposição, sem UI pra eles ainda.
- [x] Gravações (`/app/gravacoes`): lista + player de áudio + download.
**Achado de arquitetura, resolvido antes de codar**: `<audio
src>`/`<a download>` do browser não mandam `Authorization: Bearer
...` (só cookie), e a API deliberadamente nunca expõe o storage por
URL direta (secao 121) — criado um proxy autenticado
(`/api/recordings/[id]/audio`, Route Handler) que lê o cookie de
sessão, refaz a chamada real pra `apps/api` com o access token do
lado do servidor, e reencaminha o stream; troca
`Content-Disposition: attachment` (da API) por `inline` (pro player
tocar em vez de forçar download) — quem quiser baixar usa o
atributo `download` do `<a>`, mesma origem, ignora o header do
servidor. Mesmo princípio de `apiFetch`: token nunca chega em JS
legível no browser.
- [x] Testado ponta a ponta contra a API real (tenant Acme): as duas
telas renderizam estado vazio honesto (nenhuma chamada/gravação
real neste tenant ainda — precisa de uma campanha rodando de
verdade); proxy de áudio confirmado devolvendo 401 sem cookie de
sessão. Smoke test de regressão nas 15 telas anteriores do tenant +
platform, todas 200.
- [ ] **Nunca exercitado**: o proxy de áudio contra uma gravação real
(nenhuma foi gerada nesta sessão — precisa de uma campanha com
`recordingEnabled=true` rodando via `PredictiveDialerEngine` de
verdade, como na PHASE 18, não reproduzido aqui por tempo).
Comportamento de `<audio>`/download nunca visto num browser de
verdade com um WAV real tocando.
- [ ] Relatórios > Chamadas: sem os outros filtros (ramal/agente/fila/
campanha/tronco/hangup cause/disposição) nem seletor de período —
só telefone
- [ ] Relatórios > Consumo (dashboard de quotas, secao 139) — ainda "em
breve"
--- ---
## Riscos conhecidos ## Riscos conhecidos

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { API_BASE_URL } from "@/lib/api";
import { getSession } from "@/lib/session";
/**
* Proxy autenticado pro áudio da gravação (agente.md secao 121: player,
* nunca uma URL direta pro storage). O elemento `<audio>`/link de
* download do browser não manda `Authorization: Bearer ...` — só cookie
* — então isso reencaminha a chamada real pra `apps/api` com o access
* token do lado do servidor (mesmo princípio de `apiFetch`, token nunca
* chega em JS legível). Content-Disposition vira `inline` aqui (a API
* manda `attachment`) pra o player tocar em vez de forçar download; quem
* quiser baixar usa o atributo `download` do `<a>` (mesma origem, ignora
* o header do servidor).
*/
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
if (!session) return new NextResponse(null, { status: 401 });
const { id } = await params;
const upstream = await fetch(`${API_BASE_URL}/recordings/${id}/audio`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: "no-store",
});
if (!upstream.ok || !upstream.body) {
return new NextResponse(null, { status: upstream.status });
}
const headers = new Headers();
const contentType = upstream.headers.get("content-type");
if (contentType) headers.set("Content-Type", contentType);
headers.set("Content-Disposition", "inline");
return new NextResponse(upstream.body, { status: 200, headers });
}

View File

@@ -0,0 +1,79 @@
"use client";
import { Download, Mic } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import { formatBytes, formatDateTime, formatDuration } from "@/lib/format";
import type { Recording } from "@/lib/recording-types";
export function GravacoesView({ recordings }: { recordings: Recording[] }) {
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Gravações</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Chamadas gravadas deste tenant (agente.md secao 90-94, 121) o áudio nunca sai por uma URL direta pro
armazenamento, sempre passa autenticado por aqui.
</p>
</div>
<Panel>
<PanelHeader title="Gravações disponíveis" description={`${recordings.length} gravação(ões)`} />
{recordings.length === 0 ? (
<EmptyState title="Nenhuma gravação disponível ainda" description="Ative gravação numa fila ou campanha pra ver algo aqui." />
) : (
<Table>
<THead>
<TR>
<TH>Gravada em</TH>
<TH>Duração</TH>
<TH>Canais</TH>
<TH>Tamanho</TH>
<TH>Retenção até</TH>
<TH>Reproduzir</TH>
<TH>
<span className="sr-only">Baixar</span>
</TH>
</TR>
</THead>
<TBody>
{recordings.map((r) => {
const size = r.sizeBytes != null ? formatBytes(r.sizeBytes) : null;
const src = `/api/recordings/${r.id}/audio`;
return (
<TR key={r.id}>
<TD>
<span className="flex items-center gap-2 font-medium text-foreground">
<Mic className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
{formatDateTime(r.recordedAt)}
</span>
</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{r.durationSeconds != null ? formatDuration(r.durationSeconds) : "—"}</TD>
<TD className="text-muted-foreground">{r.channels}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{size ? `${size.value} ${size.unit}` : "—"}</TD>
<TD className="text-muted-foreground">{r.retentionUntil ? formatDateTime(r.retentionUntil) : "sem limite"}</TD>
<TD>
<audio controls preload="none" src={src} className="h-8 max-w-[220px]">
Seu navegador não suporta áudio embutido.
</audio>
</TD>
<TD>
<a
href={src}
download={`${r.callId}.${r.format}`}
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
aria-label={`Baixar gravação de ${formatDateTime(r.recordedAt)}`}
>
<Download className="h-3.5 w-3.5" aria-hidden />
</a>
</TD>
</TR>
);
})}
</TBody>
</Table>
)}
</Panel>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { Recording } from "@/lib/recording-types";
import { GravacoesView } from "./gravacoes-view";
export default async function GravacoesPage() {
const session = await requireSession();
const recordings = await apiFetch<Recording[]>("/recordings", session.accessToken);
return <GravacoesView recordings={recordings} />;
}

View File

@@ -0,0 +1,112 @@
"use client";
import { useMemo, useState } from "react";
import { ArrowDownLeft, ArrowRight, ArrowUpRight, Search } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Input } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import { formatDateTime, formatDuration } from "@/lib/format";
import { CALL_DIRECTION_LABELS, type Call } from "@/lib/report-types";
function dur(v: number | null): string {
return v == null ? "—" : formatDuration(v);
}
const DIRECTION_ICON = {
INBOUND: ArrowDownLeft,
OUTBOUND: ArrowUpRight,
INTERNAL: ArrowRight,
};
export function ChamadasView({
calls,
queueNames,
agentNames,
campaignNames,
dispositionNames,
}: {
calls: Call[];
queueNames: Record<string, string>;
agentNames: Record<string, string>;
campaignNames: Record<string, string>;
dispositionNames: Record<string, string>;
}) {
const [query, setQuery] = useState("");
const rows = useMemo(() => {
if (!query.trim()) return calls;
const q = query.trim();
return calls.filter((c) => (c.callerNumber ?? "").includes(q) || (c.calledNumber ?? "").includes(q));
}, [calls, query]);
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Relatório de chamadas</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Últimos 30 dias (agente.md secao 157), até 500 registros mais recentes. Sem seletor de período nem os
outros filtros do backend (ramal/agente/fila/campanha/tronco/hangup cause) nesta primeira versão
busca por telefone.
</p>
</div>
<Panel>
<PanelHeader title="Chamadas" description={`${calls.length} chamada(s) no período`} />
<div className="border-b border-border px-5 py-3">
<div className="relative w-full max-w-xs">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Buscar telefone…" className="pl-8" aria-label="Buscar telefone" />
</div>
</div>
{rows.length === 0 ? (
<EmptyState
title={calls.length > 0 ? "Nenhuma chamada bate com essa busca" : "Nenhuma chamada no período"}
description={calls.length > 0 ? "Tente outro telefone." : "Volte aqui depois que houver tráfego real."}
/>
) : (
<Table>
<THead>
<TR>
<TH>Quando</TH>
<TH>Direção</TH>
<TH>De</TH>
<TH>Para</TH>
<TH>Fila</TH>
<TH>Agente</TH>
<TH>Campanha</TH>
<TH>Duração</TH>
<TH>Encerramento</TH>
<TH>Disposição</TH>
</TR>
</THead>
<TBody>
{rows.map((c) => {
const Icon = DIRECTION_ICON[c.direction];
return (
<TR key={c.id}>
<TD className="text-muted-foreground">{formatDateTime(c.createdAt)}</TD>
<TD>
<span className="flex items-center gap-1.5 text-muted-foreground">
<Icon className="h-3.5 w-3.5" aria-hidden />
{CALL_DIRECTION_LABELS[c.direction]}
</span>
</TD>
<TD className="font-mono text-muted-foreground">{c.callerNumber ?? "—"}</TD>
<TD className="font-mono text-muted-foreground">{c.calledNumber ?? "—"}</TD>
<TD className="text-muted-foreground">{c.queueId ? queueNames[c.queueId] ?? c.queueId : "—"}</TD>
<TD className="text-muted-foreground">{c.agentId ? agentNames[c.agentId] ?? c.agentId : "—"}</TD>
<TD className="text-muted-foreground">{c.campaignId ? campaignNames[c.campaignId] ?? c.campaignId : "—"}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{dur(c.talkTime ?? c.durationSeconds)}</TD>
<TD className="text-muted-foreground">{c.hangupCause ?? (c.endAt ? "—" : "Em andamento")}</TD>
<TD>{c.dispositionId ? <Pill>{dispositionNames[c.dispositionId] ?? c.dispositionId}</Pill> : "—"}</TD>
</TR>
);
})}
</TBody>
</Table>
)}
</Panel>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { Call } from "@/lib/report-types";
import type { Queue, Agent, Disposition } from "@/lib/callcenter-types";
import type { Campaign } from "@/lib/campaign-types";
import { ChamadasView } from "./chamadas-view";
function last30Days(): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() - 30);
return d.toISOString();
}
export default async function RelatorioChamadasPage() {
const session = await requireSession();
const from = last30Days();
const [calls, queues, agents, campaigns, dispositions] = await Promise.all([
apiFetch<Call[]>(`/calls?from=${encodeURIComponent(from)}`, session.accessToken),
apiFetch<Queue[]>("/queues", session.accessToken),
apiFetch<Agent[]>("/agents", session.accessToken),
apiFetch<Campaign[]>("/campaigns", session.accessToken),
apiFetch<Disposition[]>("/dispositions", session.accessToken),
]);
return (
<ChamadasView
calls={calls}
queueNames={Object.fromEntries(queues.map((q) => [q.id, q.name]))}
agentNames={Object.fromEntries(agents.map((a) => [a.id, a.name]))}
campaignNames={Object.fromEntries(campaigns.map((c) => [c.id, c.name]))}
dispositionNames={Object.fromEntries(dispositions.map((d) => [d.id, d.name]))}
/>
);
}

View File

@@ -99,7 +99,12 @@ export const TENANT_NAV: NavSection[] = [
icon: Radar, icon: Radar,
children: [{ label: "Campanhas" }, { label: "Filas" }, { label: "Agentes" }, { label: "Ramais" }, { label: "Troncos" }], children: [{ label: "Campanhas" }, { label: "Filas" }, { label: "Agentes" }, { label: "Ramais" }, { label: "Troncos" }],
}, },
{ label: "Gravações", icon: Mic }, {
label: "Gravações",
icon: Mic,
href: "/app/gravacoes",
description: "Chamadas gravadas deste tenant — player autenticado, sem URL direta pro storage",
},
{ {
label: "IA", label: "IA",
icon: Sparkles, icon: Sparkles,
@@ -118,7 +123,11 @@ export const TENANT_NAV: NavSection[] = [
label: "Relatórios", label: "Relatórios",
icon: BarChart3, icon: BarChart3,
children: [ children: [
{ label: "Chamadas" }, {
label: "Chamadas",
href: "/app/relatorios/chamadas",
description: "Lista de chamadas — direção, fila, agente, duração, encerramento",
},
{ {
label: "Agentes", label: "Agentes",
href: "/app/relatorios/agentes", href: "/app/relatorios/agentes",

View File

@@ -37,6 +37,16 @@ export function formatDate(iso: string): string {
return new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short", year: "numeric" }).format(new Date(iso)); return new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short", year: "numeric" }).format(new Date(iso));
} }
export function formatDateTime(iso: string): string {
return new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(iso));
}
/** agente.md secao 128 — catálogo de preço, mesma nomenclatura do /** agente.md secao 128 — catálogo de preço, mesma nomenclatura do
* `PriceItemType` do backend (packages/database/prisma/schema.prisma). */ * `PriceItemType` do backend (packages/database/prisma/schema.prisma). */
export const PRICE_ITEM_TYPE_LABELS: Record<string, string> = { export const PRICE_ITEM_TYPE_LABELS: Record<string, string> = {

View File

@@ -0,0 +1,11 @@
export interface Recording {
id: string;
callId: string;
format: string;
durationSeconds: number | null;
channels: number;
sizeBytes: number | null;
recordedAt: string;
retentionUntil: string | null;
status: string;
}

View File

@@ -43,6 +43,29 @@ export interface CampaignReport {
iaValor: number | null; iaValor: number | null;
} }
export interface Call {
id: string;
direction: "INBOUND" | "OUTBOUND" | "INTERNAL";
callerNumber: string | null;
calledNumber: string | null;
queueId: string | null;
agentId: string | null;
campaignId: string | null;
hangupCause: string | null;
dispositionId: string | null;
waitTime: number | null;
talkTime: number | null;
durationSeconds: number | null;
createdAt: string;
endAt: string | null;
}
export const CALL_DIRECTION_LABELS: Record<string, string> = {
INBOUND: "Entrada",
OUTBOUND: "Saída",
INTERNAL: "Interna",
};
export interface AIDashboardReport { export interface AIDashboardReport {
callsAnalyzed: number; callsAnalyzed: number;
avgQualityScore: number | null; avgQualityScore: number | null;