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:
16
TODO.md
16
TODO.md
@@ -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
|
||||||
|
|||||||
BIN
apps/frontend/.impeccable/review/chamadas-filtered-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/chamadas-filtered-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
apps/frontend/.impeccable/review/chamadas-filters-desktop.png
Normal file
BIN
apps/frontend/.impeccable/review/chamadas-filters-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -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 — só
|
ú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}>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user