diff --git a/TODO.md b/TODO.md index 647527f..602985f 100644 --- a/TODO.md +++ b/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 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=` 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 diff --git a/apps/frontend/.impeccable/review/chamadas-filtered-desktop.png b/apps/frontend/.impeccable/review/chamadas-filtered-desktop.png new file mode 100644 index 0000000..6bd9b90 Binary files /dev/null and b/apps/frontend/.impeccable/review/chamadas-filtered-desktop.png differ diff --git a/apps/frontend/.impeccable/review/chamadas-filters-desktop.png b/apps/frontend/.impeccable/review/chamadas-filters-desktop.png new file mode 100644 index 0000000..8bfb5cb Binary files /dev/null and b/apps/frontend/.impeccable/review/chamadas-filters-desktop.png differ diff --git a/apps/frontend/src/app/app/relatorios/chamadas/chamadas-view.tsx b/apps/frontend/src/app/app/relatorios/chamadas/chamadas-view.tsx index a01dc73..34546b8 100644 --- a/apps/frontend/src/app/app/relatorios/chamadas/chamadas-view.tsx +++ b/apps/frontend/src/app/app/relatorios/chamadas/chamadas-view.tsx @@ -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; - agentNames: Record; - campaignNames: Record; - dispositionNames: Record; + 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) { + 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 (
-
-

Relatório de chamadas

-

- Ú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. -

+
+
+

Relatório de chamadas

+

+ Até 500 registros mais recentes do período (agente.md secao 157). Sem período informado, olha os + últimos 30 dias. +

+
+ {activeFilterCount > 0 && ( + + )}
+ +
+
+ De + setFrom(e.target.value)} onBlur={() => applyFilter({ from: from ? new Date(from).toISOString() : undefined })} /> +
+
+ Até + setTo(e.target.value)} onBlur={() => applyFilter({ to: to ? new Date(to).toISOString() : undefined })} /> +
+
+ Ramal + +
+
+ Agente + +
+
+ Fila + +
+
+ Campanha + +
+
+ Tronco + +
+
+ Disposição + +
+
+ Causa de encerramento + setHangupCause(e.target.value)} + onBlur={() => applyFilter({ hangupCause: hangupCause.trim() || undefined })} + placeholder="NORMAL_CLEARING…" + className="font-mono" + /> +
+
+ Telefone +
+ + setPhone(e.target.value)} + onBlur={() => applyFilter({ phone: phone.trim() || undefined })} + placeholder="Buscar telefone…" + className="pl-8" + /> +
+
+
+
+ -
-
- - setQuery(e.target.value)} placeholder="Buscar telefone…" className="pl-8" aria-label="Buscar telefone" /> -
-
- {rows.length === 0 ? ( + {calls.length === 0 ? ( 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." /> ) : ( @@ -81,7 +220,7 @@ export function ChamadasView({ - {rows.map((c) => { + {calls.map((c) => { const Icon = DIRECTION_ICON[c.direction]; return ( diff --git a/apps/frontend/src/app/app/relatorios/chamadas/page.tsx b/apps/frontend/src/app/app/relatorios/chamadas/page.tsx index ddda507..f2f467e 100644 --- a/apps/frontend/src/app/app/relatorios/chamadas/page.tsx +++ b/apps/frontend/src/app/app/relatorios/chamadas/page.tsx @@ -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(`/calls?from=${encodeURIComponent(from)}`, session.accessToken), +export default async function RelatorioChamadasPage({ searchParams }: { searchParams: Promise }) { + 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(`/calls?${query.toString()}`, session.accessToken), apiFetch("/queues", session.accessToken), apiFetch("/agents", session.accessToken), apiFetch("/campaigns", session.accessToken), apiFetch("/dispositions", session.accessToken), + apiFetch("/extensions", session.accessToken), + apiFetch("/trunks", session.accessToken), ]); return ( [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} /> ); } diff --git a/apps/frontend/src/lib/report-types.ts b/apps/frontend/src/lib/report-types.ts index ab5e313..b6e86ee 100644 --- a/apps/frontend/src/lib/report-types.ts +++ b/apps/frontend/src/lib/report-types.ts @@ -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;