feat(frontend): Relatórios > Chamadas — filtros completos

O backend (CallsController.list) já aceitava todos os filtros da especificação
(from/to/extensionId/agentId/queueId/campaignId/trunkId/phone/hangupCause/
dispositionId) desde a fase CDR — só a tela nunca expunha nada além de busca por
telefone. Adicionados os 8 filtros restantes (período real, ramal/agente/fila/
campanha/tronco/disposição como Select, causa de encerramento como texto livre),
cada um refletido na querystring, mesmo padrão de filtro-via-URL já usado em Leads/
Callbacks/Assinaturas.

Testado ponta a ponta: selecionar um filtro de fila navega pra ?queueId=<uuid> e a
busca server-side já aplica o filtro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 09:04:58 -03:00
parent fcf334c7c0
commit 6f18b3a297
6 changed files with 232 additions and 44 deletions

16
TODO.md
View File

@@ -1684,6 +1684,22 @@ secao 96-103, 124, 169) + achado real de autorização em `/ai/models`
(confirmado por grep). O menu Tenant já tinha zerado essa lista na (confirmado por grep). O menu Tenant já tinha zerado essa lista na
PHASE 42 PHASE 42
## PHASE 47 — Relatórios > Chamadas: filtros completos (agente.md secao
157)
- [x] O backend (`CallsController.list`) já aceitava todos os filtros
(from/to/extensionId/agentId/queueId/campaignId/trunkId/phone/
hangupCause/dispositionId) desde a fase CDR — só a tela nunca
expunha o resto além de telefone. Adicionados os 8 campos restantes
(data de/até como filtro real de período, não só client-side; ramal/
agente/fila/campanha/tronco/disposição como Select; causa de
encerramento como texto livre), cada um refletido na querystring
(`?queueId=...`), mesmo padrão de filtro-via-URL já usado em Leads/
Callbacks/Assinaturas
- [x] Testado ponta a ponta: selecionar um filtro de fila navega pra
`?queueId=<uuid>` e a página server-side já busca com esse filtro
aplicado (0 chamadas — não existe tráfego real neste tenant ainda,
resultado honesto)
--- ---
## Riscos conhecidos ## Riscos conhecidos

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -1,13 +1,19 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useState } from "react";
import { ArrowDownLeft, ArrowRight, ArrowUpRight, Search } from "lucide-react"; import { useRouter } from "next/navigation";
import { ArrowDownLeft, ArrowRight, ArrowUpRight, Search, X } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel"; import { Panel, PanelHeader } from "@/components/ui/panel";
import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button";
import { Input, Select, FieldLabel } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill"; import { Pill } from "@/components/ui/pill";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import { formatDateTime, formatDuration } from "@/lib/format"; import { formatDateTime, formatDuration } from "@/lib/format";
import { CALL_DIRECTION_LABELS, type Call } from "@/lib/report-types"; import { CALL_DIRECTION_LABELS, type Call } from "@/lib/report-types";
import type { Agent, Disposition, Queue, Trunk } from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types";
import type { Campaign } from "@/lib/campaign-types";
import type { ChamadasFilters } from "./page";
function dur(v: number | null): string { function dur(v: number | null): string {
return v == null ? "—" : formatDuration(v); return v == null ? "—" : formatDuration(v);
@@ -21,48 +27,181 @@ const DIRECTION_ICON = {
export function ChamadasView({ export function ChamadasView({
calls, calls,
queueNames, filters,
agentNames, queues,
campaignNames, agents,
dispositionNames, campaigns,
dispositions,
extensions,
trunks,
}: { }: {
calls: Call[]; calls: Call[];
queueNames: Record<string, string>; filters: ChamadasFilters;
agentNames: Record<string, string>; queues: Queue[];
campaignNames: Record<string, string>; agents: Agent[];
dispositionNames: Record<string, string>; campaigns: Campaign[];
dispositions: Disposition[];
extensions: Extension[];
trunks: Trunk[];
}) { }) {
const [query, setQuery] = useState(""); const router = useRouter();
const [phone, setPhone] = useState(filters.phone ?? "");
const [from, setFrom] = useState(filters.from ? filters.from.slice(0, 10) : "");
const [to, setTo] = useState(filters.to ? filters.to.slice(0, 10) : "");
const [hangupCause, setHangupCause] = useState(filters.hangupCause ?? "");
const rows = useMemo(() => { const queueNames = Object.fromEntries(queues.map((q) => [q.id, q.name]));
if (!query.trim()) return calls; const agentNames = Object.fromEntries(agents.map((a) => [a.id, a.name]));
const q = query.trim(); const campaignNames = Object.fromEntries(campaigns.map((c) => [c.id, c.name]));
return calls.filter((c) => (c.callerNumber ?? "").includes(q) || (c.calledNumber ?? "").includes(q)); const dispositionNames = Object.fromEntries(dispositions.map((d) => [d.id, d.name]));
}, [calls, query]);
const activeFilterCount = Object.values(filters).filter(Boolean).length;
function applyFilter(patch: Partial<ChamadasFilters>) {
const next: ChamadasFilters = { ...filters, ...patch };
const params = new URLSearchParams();
for (const [key, value] of Object.entries(next)) {
if (value) params.set(key, value);
}
router.push(`/app/relatorios/chamadas${params.toString() ? `?${params.toString()}` : ""}`);
}
function clearAll() {
router.push("/app/relatorios/chamadas");
setPhone("");
setFrom("");
setTo("");
setHangupCause("");
}
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div> <div className="flex flex-wrap items-start justify-between gap-3">
<h1 className="text-lg font-semibold text-foreground">Relatório de chamadas</h1> <div>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground"> <h1 className="text-lg font-semibold text-foreground">Relatório de chamadas</h1>
Últimos 30 dias (agente.md secao 157), até 500 registros mais recentes. Sem seletor de período nem os <p className="mt-1 max-w-2xl text-sm text-muted-foreground">
outros filtros do backend (ramal/agente/fila/campanha/tronco/hangup cause) nesta primeira versão só Até 500 registros mais recentes do período (agente.md secao 157). Sem período informado, olha os
busca por telefone. últimos 30 dias.
</p> </p>
</div>
{activeFilterCount > 0 && (
<Button type="button" variant="outline" size="sm" onClick={clearAll}>
<X className="h-3.5 w-3.5" aria-hidden />
Limpar filtros ({activeFilterCount})
</Button>
)}
</div> </div>
<Panel className="p-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-4">
<div>
<FieldLabel htmlFor="cf-from">De</FieldLabel>
<Input id="cf-from" type="date" value={from} onChange={(e) => setFrom(e.target.value)} onBlur={() => applyFilter({ from: from ? new Date(from).toISOString() : undefined })} />
</div>
<div>
<FieldLabel htmlFor="cf-to">Até</FieldLabel>
<Input id="cf-to" type="date" value={to} onChange={(e) => setTo(e.target.value)} onBlur={() => applyFilter({ to: to ? new Date(to).toISOString() : undefined })} />
</div>
<div>
<FieldLabel htmlFor="cf-extension">Ramal</FieldLabel>
<Select id="cf-extension" value={filters.extensionId ?? ""} onChange={(e) => applyFilter({ extensionId: e.target.value || undefined })}>
<option value="">Todos</option>
{extensions.map((e) => (
<option key={e.id} value={e.id}>
{e.number} {e.name}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="cf-agent">Agente</FieldLabel>
<Select id="cf-agent" value={filters.agentId ?? ""} onChange={(e) => applyFilter({ agentId: e.target.value || undefined })}>
<option value="">Todos</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="cf-queue">Fila</FieldLabel>
<Select id="cf-queue" value={filters.queueId ?? ""} onChange={(e) => applyFilter({ queueId: e.target.value || undefined })}>
<option value="">Todas</option>
{queues.map((q) => (
<option key={q.id} value={q.id}>
{q.name}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="cf-campaign">Campanha</FieldLabel>
<Select id="cf-campaign" value={filters.campaignId ?? ""} onChange={(e) => applyFilter({ campaignId: e.target.value || undefined })}>
<option value="">Todas</option>
{campaigns.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="cf-trunk">Tronco</FieldLabel>
<Select id="cf-trunk" value={filters.trunkId ?? ""} onChange={(e) => applyFilter({ trunkId: e.target.value || undefined })}>
<option value="">Todos</option>
{trunks.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="cf-disposition">Disposição</FieldLabel>
<Select id="cf-disposition" value={filters.dispositionId ?? ""} onChange={(e) => applyFilter({ dispositionId: e.target.value || undefined })}>
<option value="">Todas</option>
{dispositions.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="cf-hangup">Causa de encerramento</FieldLabel>
<Input
id="cf-hangup"
value={hangupCause}
onChange={(e) => setHangupCause(e.target.value)}
onBlur={() => applyFilter({ hangupCause: hangupCause.trim() || undefined })}
placeholder="NORMAL_CLEARING…"
className="font-mono"
/>
</div>
<div>
<FieldLabel htmlFor="cf-phone">Telefone</FieldLabel>
<div className="relative">
<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
id="cf-phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
onBlur={() => applyFilter({ phone: phone.trim() || undefined })}
placeholder="Buscar telefone…"
className="pl-8"
/>
</div>
</div>
</div>
</Panel>
<Panel> <Panel>
<PanelHeader title="Chamadas" description={`${calls.length} chamada(s) no período`} /> <PanelHeader title="Chamadas" description={`${calls.length} chamada(s) no período`} />
<div className="border-b border-border px-5 py-3"> {calls.length === 0 ? (
<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 <EmptyState
title={calls.length > 0 ? "Nenhuma chamada bate com essa busca" : "Nenhuma chamada no período"} title="Nenhuma chamada bate com esses filtros"
description={calls.length > 0 ? "Tente outro telefone." : "Volte aqui depois que houver tráfego real."} description="Ajuste ou limpe os filtros acima, ou volte depois que houver tráfego real."
/> />
) : ( ) : (
<Table> <Table>
@@ -81,7 +220,7 @@ export function ChamadasView({
</TR> </TR>
</THead> </THead>
<TBody> <TBody>
{rows.map((c) => { {calls.map((c) => {
const Icon = DIRECTION_ICON[c.direction]; const Icon = DIRECTION_ICON[c.direction];
return ( return (
<TR key={c.id}> <TR key={c.id}>

View File

@@ -1,7 +1,8 @@
import { requireSession } from "@/lib/session"; import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import type { Call } from "@/lib/report-types"; import type { Call } from "@/lib/report-types";
import type { Queue, Agent, Disposition } from "@/lib/callcenter-types"; import type { Queue, Agent, Disposition, Trunk } from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types";
import type { Campaign } from "@/lib/campaign-types"; import type { Campaign } from "@/lib/campaign-types";
import { ChamadasView } from "./chamadas-view"; import { ChamadasView } from "./chamadas-view";
@@ -11,25 +12,55 @@ function last30Days(): string {
return d.toISOString(); return d.toISOString();
} }
export default async function RelatorioChamadasPage() { export interface ChamadasFilters {
const session = await requireSession(); from?: string;
const from = last30Days(); to?: string;
extensionId?: string;
agentId?: string;
queueId?: string;
campaignId?: string;
trunkId?: string;
phone?: string;
hangupCause?: string;
dispositionId?: string;
}
const [calls, queues, agents, campaigns, dispositions] = await Promise.all([ export default async function RelatorioChamadasPage({ searchParams }: { searchParams: Promise<ChamadasFilters> }) {
apiFetch<Call[]>(`/calls?from=${encodeURIComponent(from)}`, session.accessToken), const filters = await searchParams;
const session = await requireSession();
const query = new URLSearchParams();
query.set("from", filters.from || last30Days());
if (filters.to) query.set("to", filters.to);
if (filters.extensionId) query.set("extensionId", filters.extensionId);
if (filters.agentId) query.set("agentId", filters.agentId);
if (filters.queueId) query.set("queueId", filters.queueId);
if (filters.campaignId) query.set("campaignId", filters.campaignId);
if (filters.trunkId) query.set("trunkId", filters.trunkId);
if (filters.phone) query.set("phone", filters.phone);
if (filters.hangupCause) query.set("hangupCause", filters.hangupCause);
if (filters.dispositionId) query.set("dispositionId", filters.dispositionId);
const [calls, queues, agents, campaigns, dispositions, extensions, trunks] = await Promise.all([
apiFetch<Call[]>(`/calls?${query.toString()}`, session.accessToken),
apiFetch<Queue[]>("/queues", session.accessToken), apiFetch<Queue[]>("/queues", session.accessToken),
apiFetch<Agent[]>("/agents", session.accessToken), apiFetch<Agent[]>("/agents", session.accessToken),
apiFetch<Campaign[]>("/campaigns", session.accessToken), apiFetch<Campaign[]>("/campaigns", session.accessToken),
apiFetch<Disposition[]>("/dispositions", session.accessToken), apiFetch<Disposition[]>("/dispositions", session.accessToken),
apiFetch<Extension[]>("/extensions", session.accessToken),
apiFetch<Trunk[]>("/trunks", session.accessToken),
]); ]);
return ( return (
<ChamadasView <ChamadasView
calls={calls} calls={calls}
queueNames={Object.fromEntries(queues.map((q) => [q.id, q.name]))} filters={filters}
agentNames={Object.fromEntries(agents.map((a) => [a.id, a.name]))} queues={queues}
campaignNames={Object.fromEntries(campaigns.map((c) => [c.id, c.name]))} agents={agents}
dispositionNames={Object.fromEntries(dispositions.map((d) => [d.id, d.name]))} campaigns={campaigns}
dispositions={dispositions}
extensions={extensions}
trunks={trunks}
/> />
); );
} }

View File

@@ -51,6 +51,8 @@ export interface Call {
queueId: string | null; queueId: string | null;
agentId: string | null; agentId: string | null;
campaignId: string | null; campaignId: string | null;
extensionId: string | null;
trunkId: string | null;
hangupCause: string | null; hangupCause: string | null;
dispositionId: string | null; dispositionId: string | null;
waitTime: number | null; waitTime: number | null;