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 className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h1 className="text-lg font-semibold text-foreground">Relatório de chamadas</h1> <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"> <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 Até 500 registros mais recentes do período (agente.md secao 157). Sem período informado, olha os
outros filtros do backend (ramal/agente/fila/campanha/tronco/hangup cause) nesta primeira versão últimos 30 dias.
busca por telefone.
</p> </p>
</div> </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>
<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;