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:
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowDownLeft, ArrowRight, ArrowUpRight, Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowDownLeft, ArrowRight, ArrowUpRight, Search, X } from "lucide-react";
|
||||
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 { 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";
|
||||
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 {
|
||||
return v == null ? "—" : formatDuration(v);
|
||||
@@ -21,48 +27,181 @@ const DIRECTION_ICON = {
|
||||
|
||||
export function ChamadasView({
|
||||
calls,
|
||||
queueNames,
|
||||
agentNames,
|
||||
campaignNames,
|
||||
dispositionNames,
|
||||
filters,
|
||||
queues,
|
||||
agents,
|
||||
campaigns,
|
||||
dispositions,
|
||||
extensions,
|
||||
trunks,
|
||||
}: {
|
||||
calls: Call[];
|
||||
queueNames: Record<string, string>;
|
||||
agentNames: Record<string, string>;
|
||||
campaignNames: Record<string, string>;
|
||||
dispositionNames: Record<string, string>;
|
||||
filters: ChamadasFilters;
|
||||
queues: Queue[];
|
||||
agents: Agent[];
|
||||
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(() => {
|
||||
if (!query.trim()) return calls;
|
||||
const q = query.trim();
|
||||
return calls.filter((c) => (c.callerNumber ?? "").includes(q) || (c.calledNumber ?? "").includes(q));
|
||||
}, [calls, query]);
|
||||
const queueNames = Object.fromEntries(queues.map((q) => [q.id, q.name]));
|
||||
const agentNames = Object.fromEntries(agents.map((a) => [a.id, a.name]));
|
||||
const campaignNames = Object.fromEntries(campaigns.map((c) => [c.id, c.name]));
|
||||
const dispositionNames = Object.fromEntries(dispositions.map((d) => [d.id, d.name]));
|
||||
|
||||
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 (
|
||||
<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 className="flex flex-wrap items-start justify-between gap-3">
|
||||
<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">
|
||||
Até 500 registros mais recentes do período (agente.md secao 157). Sem período informado, olha os
|
||||
últimos 30 dias.
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<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 ? (
|
||||
{calls.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."}
|
||||
title="Nenhuma chamada bate com esses filtros"
|
||||
description="Ajuste ou limpe os filtros acima, ou volte depois que houver tráfego real."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
@@ -81,7 +220,7 @@ export function ChamadasView({
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((c) => {
|
||||
{calls.map((c) => {
|
||||
const Icon = DIRECTION_ICON[c.direction];
|
||||
return (
|
||||
<TR key={c.id}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { Queue, Agent, Disposition, Trunk } from "@/lib/callcenter-types";
|
||||
import type { Extension } from "@/lib/extension-types";
|
||||
import type { Campaign } from "@/lib/campaign-types";
|
||||
import { ChamadasView } from "./chamadas-view";
|
||||
|
||||
@@ -11,25 +12,55 @@ function last30Days(): string {
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export default async function RelatorioChamadasPage() {
|
||||
const session = await requireSession();
|
||||
const from = last30Days();
|
||||
export interface ChamadasFilters {
|
||||
from?: string;
|
||||
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([
|
||||
apiFetch<Call[]>(`/calls?from=${encodeURIComponent(from)}`, session.accessToken),
|
||||
export default async function RelatorioChamadasPage({ searchParams }: { searchParams: Promise<ChamadasFilters> }) {
|
||||
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<Agent[]>("/agents", session.accessToken),
|
||||
apiFetch<Campaign[]>("/campaigns", session.accessToken),
|
||||
apiFetch<Disposition[]>("/dispositions", session.accessToken),
|
||||
apiFetch<Extension[]>("/extensions", session.accessToken),
|
||||
apiFetch<Trunk[]>("/trunks", 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]))}
|
||||
filters={filters}
|
||||
queues={queues}
|
||||
agents={agents}
|
||||
campaigns={campaigns}
|
||||
dispositions={dispositions}
|
||||
extensions={extensions}
|
||||
trunks={trunks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface Call {
|
||||
queueId: string | null;
|
||||
agentId: string | null;
|
||||
campaignId: string | null;
|
||||
extensionId: string | null;
|
||||
trunkId: string | null;
|
||||
hangupCause: string | null;
|
||||
dispositionId: string | null;
|
||||
waitTime: number | null;
|
||||
|
||||
Reference in New Issue
Block a user