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:
36
apps/frontend/src/app/api/recordings/[id]/audio/route.ts
Normal file
36
apps/frontend/src/app/api/recordings/[id]/audio/route.ts
Normal 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 });
|
||||
}
|
||||
79
apps/frontend/src/app/app/gravacoes/gravacoes-view.tsx
Normal file
79
apps/frontend/src/app/app/gravacoes/gravacoes-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/frontend/src/app/app/gravacoes/page.tsx
Normal file
10
apps/frontend/src/app/app/gravacoes/page.tsx
Normal 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} />;
|
||||
}
|
||||
112
apps/frontend/src/app/app/relatorios/chamadas/chamadas-view.tsx
Normal file
112
apps/frontend/src/app/app/relatorios/chamadas/chamadas-view.tsx
Normal 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 — só
|
||||
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>
|
||||
);
|
||||
}
|
||||
35
apps/frontend/src/app/app/relatorios/chamadas/page.tsx
Normal file
35
apps/frontend/src/app/app/relatorios/chamadas/page.tsx
Normal 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]))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,12 @@ export const TENANT_NAV: NavSection[] = [
|
||||
icon: Radar,
|
||||
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",
|
||||
icon: Sparkles,
|
||||
@@ -118,7 +123,11 @@ export const TENANT_NAV: NavSection[] = [
|
||||
label: "Relatórios",
|
||||
icon: BarChart3,
|
||||
children: [
|
||||
{ label: "Chamadas" },
|
||||
{
|
||||
label: "Chamadas",
|
||||
href: "/app/relatorios/chamadas",
|
||||
description: "Lista de chamadas — direção, fila, agente, duração, encerramento",
|
||||
},
|
||||
{
|
||||
label: "Agentes",
|
||||
href: "/app/relatorios/agentes",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
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
|
||||
* `PriceItemType` do backend (packages/database/prisma/schema.prisma). */
|
||||
export const PRICE_ITEM_TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
11
apps/frontend/src/lib/recording-types.ts
Normal file
11
apps/frontend/src/lib/recording-types.ts
Normal 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;
|
||||
}
|
||||
@@ -43,6 +43,29 @@ export interface CampaignReport {
|
||||
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 {
|
||||
callsAnalyzed: number;
|
||||
avgQualityScore: number | null;
|
||||
|
||||
Reference in New Issue
Block a user