diff --git a/TODO.md b/TODO.md index a55b822..6ac3532 100644 --- a/TODO.md +++ b/TODO.md @@ -866,7 +866,46 @@ app do Tenant + Dashboard (agente.md secao 162, 169) (secao 161) — `stats`/`callsInFlight` só atualizam ao recarregar a página, não em tempo real -## PHASE 27+ — ver `agente.md` seções 140 em diante (resto do Frontend, +## PHASE 27 — Frontend: Call Center > Filas/Pausas/Disposições, Telefonia > +Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71, +89, 169) +- [x] 5 telas novas, mesmo padrão de listagem+form inline+remoção com + confirmação de 2 cliques já usado em Ramais/Tarifas: `/app/callcenter/ + filas`, `/app/callcenter/pausas`, `/app/callcenter/disposicoes`, + `/app/telefonia/troncos`, `/app/discador/bloqueio` — todas contra + endpoints que já existiam desde as fases de backend (Queues/ + PauseReasons/Dispositions/Trunks/Suppression), nenhum endpoint novo + precisou ser criado +- [x] `lib/callcenter-types.ts` (novo): tipos `Queue`/`Trunk`/`PauseReason`/ + `Disposition`/`SuppressionEntry` compartilhados entre as 5 telas +- [x] `StatusBadge` ganhou o mapa de `TrunkStatus` (UP/REGISTERED/DOWN/ + TRYING/FAILED/UNREGISTERED/UNKNOWN) — mesmo componente usado por + Campanhas, sem colisão de chave com `CampaignStatus` +- [x] `nav-data.ts`: as 5 rotas saem de "em breve" pra link real; resta só + Agentes (Call Center), Dialplan (Telefonia), Leads/Importações/ + Callbacks (Discador) "em breve" no menu Tenant +- [x] **Bug real, achado testando a tela de Pausas**: + `PauseReasonsController.list()` era o único endpoint do arquivo sem + filtro `enabled: true` — um motivo removido (soft delete) nunca + sumia da listagem. Corrigido. +- [x] Testado ponta a ponta contra a API real (tenant Acme) depois de + reerguer `apps/api` e `apps/frontend` do zero pós-reboot da VM (ver + "Riscos conhecidos" — os dois rodam direto no host, fora do + docker-compose, e não voltam sozinhos): criar fila/pausa/disposição/ + tronco/bloqueio de número → aparece na lista com os dados certos → + remover com confirmação de 2 cliques → lista volta ao estado vazio. + Screenshots em `apps/frontend/.impeccable/review/`. +- [ ] Editar campos existentes (só create/list/delete em todas as 5, + mesma limitação já documentada em Ramais) — nenhum backend novo tem + PATCH/PUT ainda +- [ ] Tronco: `Trunk.status` continua sem atualização automática em + produção (lacuna já documentada na PHASE 09/TRUNKS.md) — a tela só + exibe o `StatusBadge`, não força refresh nem faz polling +- [ ] Dark mode e mobile não foram capturados nesta fase (só desktop + light, mesma decisão de escopo das últimas telas simples) — + reavaliar junto com a Sidebar quando "Agentes" ganhar tela própria + +## PHASE 28+ — ver `agente.md` seções 140 em diante (resto do Frontend, Security, Tests) --- @@ -878,3 +917,17 @@ Security, Tests) simultâneos — reavaliar quando chegarmos lá. - **Disco (26GB livre)**: build do FreeSWITCH + imagens Docker + gravações vão consumir espaço rápido. Monitorar com `df -h`. +- **RAM da VM aumentada pra 8GB em 2026-08-29** — resolve a preocupação acima, mas + ainda não tem swap generoso nem monitoramento de pico durante um build de frontend + + todos os workers rodando junto; reavaliar se o Next.js dev server crescer muito. +- **`apps/api` e `apps/frontend` rodam direto no host** (`pnpm dev`), fora do + docker-compose — só Postgres/Redis/FreeSWITCH/fs-config/fs-events/predictive-dialer/ + ai-worker são containers. Depois de qualquer reboot da VM (como o desta sessão, pra + aplicar a RAM nova) os containers voltam sozinhos (restart policy do compose), mas + os dois processos de host **não voltam** — precisam ser religados manualmente: + `apps/api` precisa de `set -a && source .env && set +a` antes (não lê `.env` + sozinho, ao contrário dos containers) e deve subir **antes** do frontend, porque os + dois usam a porta 3000 por padrão (`API_PORT` não setado, `next dev` sem `-p`) — o + segundo a subir cai sozinho pra 3001. `B2BCALL_API_URL` do frontend + (`apps/frontend/.env.local`) assume que a API ficou com a 3000, então a ordem + importa. Sem um processo supervisor (pm2/systemd) isso vai se repetir a cada reboot. diff --git a/apps/api/src/pause-reasons/pause-reasons.controller.ts b/apps/api/src/pause-reasons/pause-reasons.controller.ts index 8cbc8da..ea3c90e 100644 --- a/apps/api/src/pause-reasons/pause-reasons.controller.ts +++ b/apps/api/src/pause-reasons/pause-reasons.controller.ts @@ -60,7 +60,12 @@ export class PauseReasonsController { async list(@CurrentUser() user: AccessTokenClaims) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; - return withTenantContext(prisma, tenantId, (tx) => tx.pauseReason.findMany({ orderBy: { name: "asc" } })); + // Bug real, achado testando o frontend: sem o filtro `enabled: true` + // aqui, um motivo "removido" (soft delete, ver `remove` abaixo) nunca + // sumia da lista — único endpoint deste arquivo sem esse filtro. + return withTenantContext(prisma, tenantId, (tx) => + tx.pauseReason.findMany({ where: { enabled: true }, orderBy: { name: "asc" } }), + ); } @RequirePermission("agents.manage") diff --git a/apps/frontend/.impeccable/review/bloqueio-after-delete-desktop.png b/apps/frontend/.impeccable/review/bloqueio-after-delete-desktop.png new file mode 100644 index 0000000..a9822bb Binary files /dev/null and b/apps/frontend/.impeccable/review/bloqueio-after-delete-desktop.png differ diff --git a/apps/frontend/.impeccable/review/bloqueio-empty-desktop.png b/apps/frontend/.impeccable/review/bloqueio-empty-desktop.png new file mode 100644 index 0000000..8e614dc Binary files /dev/null and b/apps/frontend/.impeccable/review/bloqueio-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/bloqueio-form-desktop.png b/apps/frontend/.impeccable/review/bloqueio-form-desktop.png new file mode 100644 index 0000000..8387d69 Binary files /dev/null and b/apps/frontend/.impeccable/review/bloqueio-form-desktop.png differ diff --git a/apps/frontend/.impeccable/review/bloqueio-with-data-desktop.png b/apps/frontend/.impeccable/review/bloqueio-with-data-desktop.png new file mode 100644 index 0000000..faccd48 Binary files /dev/null and b/apps/frontend/.impeccable/review/bloqueio-with-data-desktop.png differ diff --git a/apps/frontend/.impeccable/review/disposicoes-after-delete-desktop.png b/apps/frontend/.impeccable/review/disposicoes-after-delete-desktop.png new file mode 100644 index 0000000..1512ea2 Binary files /dev/null and b/apps/frontend/.impeccable/review/disposicoes-after-delete-desktop.png differ diff --git a/apps/frontend/.impeccable/review/disposicoes-empty-desktop.png b/apps/frontend/.impeccable/review/disposicoes-empty-desktop.png new file mode 100644 index 0000000..44113bc Binary files /dev/null and b/apps/frontend/.impeccable/review/disposicoes-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/disposicoes-form-desktop.png b/apps/frontend/.impeccable/review/disposicoes-form-desktop.png new file mode 100644 index 0000000..c0c929a Binary files /dev/null and b/apps/frontend/.impeccable/review/disposicoes-form-desktop.png differ diff --git a/apps/frontend/.impeccable/review/disposicoes-with-data-desktop.png b/apps/frontend/.impeccable/review/disposicoes-with-data-desktop.png new file mode 100644 index 0000000..e5063b3 Binary files /dev/null and b/apps/frontend/.impeccable/review/disposicoes-with-data-desktop.png differ diff --git a/apps/frontend/.impeccable/review/filas-empty-desktop.png b/apps/frontend/.impeccable/review/filas-empty-desktop.png new file mode 100644 index 0000000..76742dc Binary files /dev/null and b/apps/frontend/.impeccable/review/filas-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/filas-form-desktop.png b/apps/frontend/.impeccable/review/filas-form-desktop.png new file mode 100644 index 0000000..0cff15c Binary files /dev/null and b/apps/frontend/.impeccable/review/filas-form-desktop.png differ diff --git a/apps/frontend/.impeccable/review/filas-with-data-desktop.png b/apps/frontend/.impeccable/review/filas-with-data-desktop.png new file mode 100644 index 0000000..6e74f91 Binary files /dev/null and b/apps/frontend/.impeccable/review/filas-with-data-desktop.png differ diff --git a/apps/frontend/.impeccable/review/pausas-empty-desktop.png b/apps/frontend/.impeccable/review/pausas-empty-desktop.png new file mode 100644 index 0000000..0fc2731 Binary files /dev/null and b/apps/frontend/.impeccable/review/pausas-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/pausas-form-desktop.png b/apps/frontend/.impeccable/review/pausas-form-desktop.png new file mode 100644 index 0000000..a1e5109 Binary files /dev/null and b/apps/frontend/.impeccable/review/pausas-form-desktop.png differ diff --git a/apps/frontend/.impeccable/review/pausas-with-data-desktop.png b/apps/frontend/.impeccable/review/pausas-with-data-desktop.png new file mode 100644 index 0000000..2487fd7 Binary files /dev/null and b/apps/frontend/.impeccable/review/pausas-with-data-desktop.png differ diff --git a/apps/frontend/.impeccable/review/troncos-empty-desktop.png b/apps/frontend/.impeccable/review/troncos-empty-desktop.png new file mode 100644 index 0000000..2589d84 Binary files /dev/null and b/apps/frontend/.impeccable/review/troncos-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/troncos-form-desktop.png b/apps/frontend/.impeccable/review/troncos-form-desktop.png new file mode 100644 index 0000000..f253730 Binary files /dev/null and b/apps/frontend/.impeccable/review/troncos-form-desktop.png differ diff --git a/apps/frontend/.impeccable/review/troncos-with-data-desktop.png b/apps/frontend/.impeccable/review/troncos-with-data-desktop.png new file mode 100644 index 0000000..348b0c9 Binary files /dev/null and b/apps/frontend/.impeccable/review/troncos-with-data-desktop.png differ diff --git a/apps/frontend/src/app/app/callcenter/disposicoes/actions.ts b/apps/frontend/src/app/app/callcenter/disposicoes/actions.ts new file mode 100644 index 0000000..6fc7ea1 --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/disposicoes/actions.ts @@ -0,0 +1,47 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { Disposition } from "@/lib/callcenter-types"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export interface CreateDispositionInput { + name: string; + code: string; +} + +export async function createDisposition(input: CreateDispositionInput): Promise<{ ok: true; disposition: Disposition } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const disposition = await apiFetch("/dispositions", session.accessToken, { method: "POST", body: JSON.stringify(input) }); + revalidatePath("/app/callcenter/disposicoes"); + return { ok: true, disposition }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export async function deleteDisposition(id: string): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/dispositions/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/callcenter/disposicoes"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/callcenter/disposicoes/disposicoes-view.tsx b/apps/frontend/src/app/app/callcenter/disposicoes/disposicoes-view.tsx new file mode 100644 index 0000000..440f428 --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/disposicoes/disposicoes-view.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { ClipboardCheck, Plus, Trash2, X } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input, FieldLabel } from "@/components/ui/input"; +import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import type { Disposition } from "@/lib/callcenter-types"; +import { createDisposition, deleteDisposition } from "./actions"; + +export function DisposicoesView({ dispositions }: { dispositions: Disposition[] }) { + const [showForm, setShowForm] = useState(false); + + return ( +
+
+
+

Disposições

+

+ Motivos que o agente (ou supervisor) marca ao encerrar uma chamada (agente.md secao 89) — sem lista + fixa, cada tenant define as suas. +

+
+ +
+ + {showForm && setShowForm(false)} />} + + + + {dispositions.length === 0 ? ( + + ) : ( + + + + + + + + + + {dispositions.map((d) => ( + + + + + + ))} + +
NomeCódigo + Ações +
+ + + {d.name} + + {d.code} + +
+ )} +
+
+ ); +} + +function NewDispositionForm({ onDone }: { onDone: () => void }) { + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!name.trim() || !code.trim()) { + setError("Nome e código são obrigatórios."); + return; + } + startTransition(async () => { + const result = await createDisposition({ name: name.trim(), code: code.trim().toUpperCase() }); + if (!result.ok) { + setError(result.error); + return; + } + onDone(); + }); + } + + return ( + +
+
+
+ Nome + setName(e.target.value)} placeholder="Ex.: Venda" disabled={pending} /> +
+
+ Código + setCode(e.target.value)} placeholder="VENDA" className="font-mono uppercase" disabled={pending} /> +
+
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function DeleteDispositionButton({ id, name }: { id: string; name: string }) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onClick() { + if (!confirming) { + setConfirming(true); + return; + } + setError(null); + startTransition(async () => { + const result = await deleteDisposition(id); + if (!result.ok) { + setError(result.error); + setConfirming(false); + return; + } + router.refresh(); + }); + } + + return ( +
+ {error && {error}} + +
+ ); +} diff --git a/apps/frontend/src/app/app/callcenter/disposicoes/page.tsx b/apps/frontend/src/app/app/callcenter/disposicoes/page.tsx new file mode 100644 index 0000000..35a3725 --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/disposicoes/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { Disposition } from "@/lib/callcenter-types"; +import { DisposicoesView } from "./disposicoes-view"; + +export default async function DisposicoesPage() { + const session = await requireSession(); + const dispositions = await apiFetch("/dispositions", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/app/callcenter/filas/actions.ts b/apps/frontend/src/app/app/callcenter/filas/actions.ts new file mode 100644 index 0000000..a5698b5 --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/filas/actions.ts @@ -0,0 +1,52 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { Queue } from "@/lib/callcenter-types"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export interface CreateQueueInput { + name: string; + description?: string; + strategy?: string; + maxWaitTime?: number; + maxWaitTimeWithNoAgent?: number; + discardAbandonedAfter?: number; + recordingEnabled?: boolean; +} + +export async function createQueue(input: CreateQueueInput): Promise<{ ok: true; queue: Queue } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const queue = await apiFetch("/queues", session.accessToken, { method: "POST", body: JSON.stringify(input) }); + revalidatePath("/app/callcenter/filas"); + return { ok: true, queue }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export async function deleteQueue(id: string): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/queues/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/callcenter/filas"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/callcenter/filas/filas-view.tsx b/apps/frontend/src/app/app/callcenter/filas/filas-view.tsx new file mode 100644 index 0000000..c6a94ca --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/filas/filas-view.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { ListTree, Plus, Trash2, X } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +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 { formatDate } from "@/lib/format"; +import { QUEUE_STRATEGIES, QUEUE_STRATEGY_LABELS, type Queue } from "@/lib/callcenter-types"; +import { createQueue, deleteQueue } from "./actions"; + +export function FilasView({ queues }: { queues: Queue[] }) { + const [showForm, setShowForm] = useState(false); + + return ( +
+
+
+

Filas

+

+ Filas de atendimento deste tenant (agente.md secao 50-51) — campanhas e ramais entregam chamadas pra uma + fila, que distribui pros agentes segundo a estratégia escolhida. +

+
+ +
+ + {showForm && setShowForm(false)} />} + + + + {queues.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + {queues.map((q) => ( + + + + + + + + + + ))} + +
NomeEstratégiaEspera máx.Descarta abandono apósGravaçãoCriada + Ações +
+ + + {q.name} + + {q.description && {q.description}} + {QUEUE_STRATEGY_LABELS[q.strategy] ?? q.strategy}{q.maxWaitTime > 0 ? `${q.maxWaitTime}s` : "sem limite"}{q.discardAbandonedAfter}s + {q.recordingEnabled ? "Habilitada" : "Desabilitada"} + {formatDate(q.createdAt)} + +
+ )} +
+
+ ); +} + +function NewQueueForm({ onDone }: { onDone: () => void }) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [strategy, setStrategy] = useState("LONGEST_IDLE_AGENT"); + const [maxWaitTime, setMaxWaitTime] = useState("120"); + const [discardAbandonedAfter, setDiscardAbandonedAfter] = useState("60"); + const [recordingEnabled, setRecordingEnabled] = useState(false); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!name.trim()) { + setError("Dê um nome à fila."); + return; + } + startTransition(async () => { + const result = await createQueue({ + name: name.trim(), + description: description.trim() || undefined, + strategy, + maxWaitTime: Number(maxWaitTime) || undefined, + discardAbandonedAfter: Number(discardAbandonedAfter) || undefined, + recordingEnabled, + }); + if (!result.ok) { + setError(result.error); + return; + } + onDone(); + }); + } + + return ( + +
+
+
+ Nome + setName(e.target.value)} placeholder="Ex.: Suporte" disabled={pending} /> +
+
+ Descrição (opcional) + setDescription(e.target.value)} disabled={pending} /> +
+
+
+
+ Estratégia + +
+
+ Espera máxima (s, 0 = sem limite) + setMaxWaitTime(e.target.value)} disabled={pending} /> +
+
+ Descarta abandono após (s) + setDiscardAbandonedAfter(e.target.value)} disabled={pending} /> +
+
+ + {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function DeleteQueueButton({ queueId, queueName }: { queueId: string; queueName: string }) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onClick() { + if (!confirming) { + setConfirming(true); + return; + } + setError(null); + startTransition(async () => { + const result = await deleteQueue(queueId); + if (!result.ok) { + setError(result.error); + setConfirming(false); + return; + } + router.refresh(); + }); + } + + return ( +
+ {error && {error}} + +
+ ); +} diff --git a/apps/frontend/src/app/app/callcenter/filas/page.tsx b/apps/frontend/src/app/app/callcenter/filas/page.tsx new file mode 100644 index 0000000..8bf4578 --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/filas/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { Queue } from "@/lib/callcenter-types"; +import { FilasView } from "./filas-view"; + +export default async function FilasPage() { + const session = await requireSession(); + const queues = await apiFetch("/queues", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/app/callcenter/pausas/actions.ts b/apps/frontend/src/app/app/callcenter/pausas/actions.ts new file mode 100644 index 0000000..d852a7c --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/pausas/actions.ts @@ -0,0 +1,50 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { PauseReason } from "@/lib/callcenter-types"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export interface CreatePauseReasonInput { + name: string; + code: string; + description?: string; + maxDuration?: number; + paid?: boolean; +} + +export async function createPauseReason(input: CreatePauseReasonInput): Promise<{ ok: true; reason: PauseReason } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const reason = await apiFetch("/pause-reasons", session.accessToken, { method: "POST", body: JSON.stringify(input) }); + revalidatePath("/app/callcenter/pausas"); + return { ok: true, reason }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export async function deletePauseReason(id: string): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/pause-reasons/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/callcenter/pausas"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/callcenter/pausas/page.tsx b/apps/frontend/src/app/app/callcenter/pausas/page.tsx new file mode 100644 index 0000000..377fd34 --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/pausas/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { PauseReason } from "@/lib/callcenter-types"; +import { PausasView } from "./pausas-view"; + +export default async function PausasPage() { + const session = await requireSession(); + const reasons = await apiFetch("/pause-reasons", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/app/callcenter/pausas/pausas-view.tsx b/apps/frontend/src/app/app/callcenter/pausas/pausas-view.tsx new file mode 100644 index 0000000..3a92a31 --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/pausas/pausas-view.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Coffee, Plus, Trash2, X } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input, FieldLabel } from "@/components/ui/input"; +import { Pill } from "@/components/ui/pill"; +import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import type { PauseReason } from "@/lib/callcenter-types"; +import { createPauseReason, deletePauseReason } from "./actions"; + +export function PausasView({ reasons }: { reasons: PauseReason[] }) { + const [showForm, setShowForm] = useState(false); + + return ( +
+
+
+

Motivos de pausa

+

+ Motivos que um agente pode escolher ao pausar (agente.md secao 47-49) — sem lista fixa, cada tenant + define os seus. +

+
+ +
+ + {showForm && setShowForm(false)} />} + + + + {reasons.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {reasons.map((r) => ( + + + + + + + + ))} + +
NomeCódigoDuração máximaRemunerada + Ações +
+ + + {r.name} + + {r.description && {r.description}} + {r.code}{r.maxDuration ? `${r.maxDuration}s` : "sem limite"} + {r.paid ? "Sim" : "Não"} + + +
+ )} +
+
+ ); +} + +function NewPauseReasonForm({ onDone }: { onDone: () => void }) { + const [name, setName] = useState(""); + const [code, setCode] = useState(""); + const [description, setDescription] = useState(""); + const [maxDuration, setMaxDuration] = useState(""); + const [paid, setPaid] = useState(true); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!name.trim() || !code.trim()) { + setError("Nome e código são obrigatórios."); + return; + } + startTransition(async () => { + const result = await createPauseReason({ + name: name.trim(), + code: code.trim().toUpperCase(), + description: description.trim() || undefined, + maxDuration: maxDuration ? Number(maxDuration) : undefined, + paid, + }); + if (!result.ok) { + setError(result.error); + return; + } + onDone(); + }); + } + + return ( + +
+
+
+ Nome + setName(e.target.value)} placeholder="Ex.: Almoço" disabled={pending} /> +
+
+ Código + setCode(e.target.value)} placeholder="ALMOCO" className="font-mono uppercase" disabled={pending} /> +
+
+ Duração máxima (s, opcional) + setMaxDuration(e.target.value)} disabled={pending} /> +
+
+
+ Descrição (opcional) + setDescription(e.target.value)} disabled={pending} /> +
+ + {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function DeletePauseReasonButton({ id, name }: { id: string; name: string }) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onClick() { + if (!confirming) { + setConfirming(true); + return; + } + setError(null); + startTransition(async () => { + const result = await deletePauseReason(id); + if (!result.ok) { + setError(result.error); + setConfirming(false); + return; + } + router.refresh(); + }); + } + + return ( +
+ {error && {error}} + +
+ ); +} diff --git a/apps/frontend/src/app/app/discador/bloqueio/actions.ts b/apps/frontend/src/app/app/discador/bloqueio/actions.ts new file mode 100644 index 0000000..c11db68 --- /dev/null +++ b/apps/frontend/src/app/app/discador/bloqueio/actions.ts @@ -0,0 +1,47 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { SuppressionEntry } from "@/lib/callcenter-types"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export interface CreateSuppressionInput { + phone: string; + reason?: string; +} + +export async function createSuppressionEntry(input: CreateSuppressionInput): Promise<{ ok: true; entry: SuppressionEntry } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const entry = await apiFetch("/suppression", session.accessToken, { method: "POST", body: JSON.stringify(input) }); + revalidatePath("/app/discador/bloqueio"); + return { ok: true, entry }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export async function deleteSuppressionEntry(id: string): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/suppression/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/discador/bloqueio"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/discador/bloqueio/bloqueio-view.tsx b/apps/frontend/src/app/app/discador/bloqueio/bloqueio-view.tsx new file mode 100644 index 0000000..38e0344 --- /dev/null +++ b/apps/frontend/src/app/app/discador/bloqueio/bloqueio-view.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Ban, Plus, Search, Trash2, X } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input, FieldLabel } from "@/components/ui/input"; +import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import { formatDate } from "@/lib/format"; +import type { SuppressionEntry } from "@/lib/callcenter-types"; +import { createSuppressionEntry, deleteSuppressionEntry } from "./actions"; + +export function BloqueioView({ entries }: { entries: SuppressionEntry[] }) { + const [showForm, setShowForm] = useState(false); + const [query, setQuery] = useState(""); + + const rows = useMemo(() => entries.filter((e) => e.phoneNormalized.includes(query)), [entries, query]); + + return ( +
+
+
+

Lista de bloqueio

+

+ Números que o discador nunca chama (agente.md secao 71) — leads importados com um telefone aqui entram + como Não Ligar em vez de serem descartados. +

+
+ +
+ + {showForm && setShowForm(false)} />} + + + +
+
+ + setQuery(e.target.value)} placeholder="Buscar telefone…" className="pl-8" aria-label="Buscar telefone" /> +
+
+ {rows.length === 0 ? ( + 0 ? "Nenhum número bate com essa busca" : "Nenhum número bloqueado ainda"} + description={entries.length > 0 ? "Tente outro termo." : "Bloqueie o primeiro número deste tenant."} + /> + ) : ( + + + + + + + + + + + {rows.map((e) => ( + + + + + + + ))} + +
TelefoneMotivoBloqueado em + Ações +
+ + + {e.phoneNormalized} + + {e.reason || "—"}{formatDate(e.createdAt)} + +
+ )} +
+
+ ); +} + +function NewSuppressionForm({ onDone }: { onDone: () => void }) { + const [phone, setPhone] = useState(""); + const [reason, setReason] = useState(""); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!phone.trim()) { + setError("Informe um telefone."); + return; + } + startTransition(async () => { + const result = await createSuppressionEntry({ phone: phone.trim(), reason: reason.trim() || undefined }); + if (!result.ok) { + setError(result.error); + return; + } + onDone(); + }); + } + + return ( + +
+
+
+ Telefone + setPhone(e.target.value)} placeholder="11987654321" className="font-mono" disabled={pending} /> +
+
+ Motivo (opcional) + setReason(e.target.value)} placeholder="Pedido do titular" disabled={pending} /> +
+
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function DeleteSuppressionButton({ id, phone }: { id: string; phone: string }) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onClick() { + if (!confirming) { + setConfirming(true); + return; + } + setError(null); + startTransition(async () => { + const result = await deleteSuppressionEntry(id); + if (!result.ok) { + setError(result.error); + setConfirming(false); + return; + } + router.refresh(); + }); + } + + return ( +
+ {error && {error}} + +
+ ); +} diff --git a/apps/frontend/src/app/app/discador/bloqueio/page.tsx b/apps/frontend/src/app/app/discador/bloqueio/page.tsx new file mode 100644 index 0000000..ef63f4a --- /dev/null +++ b/apps/frontend/src/app/app/discador/bloqueio/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { SuppressionEntry } from "@/lib/callcenter-types"; +import { BloqueioView } from "./bloqueio-view"; + +export default async function BloqueioPage() { + const session = await requireSession(); + const entries = await apiFetch("/suppression", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/app/telefonia/troncos/actions.ts b/apps/frontend/src/app/app/telefonia/troncos/actions.ts new file mode 100644 index 0000000..e83dfc6 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/troncos/actions.ts @@ -0,0 +1,52 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { Trunk } from "@/lib/callcenter-types"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export interface CreateTrunkInput { + name: string; + description?: string; + host: string; + register?: boolean; + username?: string; + password?: string; + transport?: string; +} + +export async function createTrunk(input: CreateTrunkInput): Promise<{ ok: true; trunk: Trunk } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const trunk = await apiFetch("/trunks", session.accessToken, { method: "POST", body: JSON.stringify(input) }); + revalidatePath("/app/telefonia/troncos"); + return { ok: true, trunk }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export async function deleteTrunk(id: string): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/trunks/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/telefonia/troncos"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/telefonia/troncos/page.tsx b/apps/frontend/src/app/app/telefonia/troncos/page.tsx new file mode 100644 index 0000000..6d44ecc --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/troncos/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { Trunk } from "@/lib/callcenter-types"; +import { TroncosView } from "./troncos-view"; + +export default async function TroncosPage() { + const session = await requireSession(); + const trunks = await apiFetch("/trunks", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/app/telefonia/troncos/troncos-view.tsx b/apps/frontend/src/app/app/telefonia/troncos/troncos-view.tsx new file mode 100644 index 0000000..3c7f844 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/troncos/troncos-view.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, Router, Trash2, X } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input, Select, FieldLabel } from "@/components/ui/input"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import { formatDate } from "@/lib/format"; +import type { Trunk } from "@/lib/callcenter-types"; +import { createTrunk, deleteTrunk } from "./actions"; + +export function TroncosView({ trunks }: { trunks: Trunk[] }) { + const [showForm, setShowForm] = useState(false); + + return ( +
+
+
+

Troncos

+

+ Troncos SIP deste tenant (agente.md secao 41-42) — a senha de autenticação, quando informada, nunca é + reexibida depois de criada. +

+
+ +
+ + {showForm && setShowForm(false)} />} + + + + {trunks.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + {trunks.map((t) => ( + + + + + + + + + + ))} + +
NomeHostRegistroTransporteStatusCriado + Ações +
+ + + {t.name} + + {t.host}{t.register ? `sim (${t.username ?? "sem usuário"})` : "não"}{t.transport} + + {formatDate(t.createdAt)} + +
+ )} +
+
+ ); +} + +function NewTrunkForm({ onDone }: { onDone: () => void }) { + const [name, setName] = useState(""); + const [host, setHost] = useState(""); + const [register, setRegister] = useState(false); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [transport, setTransport] = useState("UDP"); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!name.trim() || !host.trim()) { + setError("Nome e host são obrigatórios."); + return; + } + startTransition(async () => { + const result = await createTrunk({ + name: name.trim(), + host: host.trim(), + register, + username: username.trim() || undefined, + password: password || undefined, + transport, + }); + if (!result.ok) { + setError(result.error); + return; + } + onDone(); + }); + } + + return ( + +
+
+
+ Nome + setName(e.target.value)} placeholder="Ex.: Tronco Principal" disabled={pending} /> +
+
+ Host + setHost(e.target.value)} placeholder="sip.provedor.com" disabled={pending} /> +
+
+ + {register && ( +
+
+ Usuário + setUsername(e.target.value)} disabled={pending} /> +
+
+ Senha + setPassword(e.target.value)} disabled={pending} /> +
+
+ )} +
+ Transporte + +
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function DeleteTrunkButton({ trunkId, trunkName }: { trunkId: string; trunkName: string }) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onClick() { + if (!confirming) { + setConfirming(true); + return; + } + setError(null); + startTransition(async () => { + const result = await deleteTrunk(trunkId); + if (!result.ok) { + setError(result.error); + setConfirming(false); + return; + } + router.refresh(); + }); + } + + return ( +
+ {error && {error}} + +
+ ); +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index 0648e52..1bbde27 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -11,14 +11,16 @@ import { } from "lucide-react"; import type { NavSection } from "../shell/nav-types"; -/** IA fixa do menu Tenant (agente.md secao 169) — "Dashboard" (PHASE 23), - * "Telefonia > Ramais" (PHASE 25) e "Discador > Campanhas" (PHASE 26) têm - * página construída até agora, o resto existe pra provar a arquitetura - * completa (Product Principle #5 em PRODUCT.md), renderizado como - * indisponível em vez de virar link morto. "Itens sem permissão não - * aparecem" (secao 169) ainda não é aplicado aqui — este primeiro corte - * mostra a IA inteira pra qualquer usuário tenant autenticado; filtrar por - * permission real fica pra quando mais telas existirem pra testar contra. */ +/** IA fixa do menu Tenant (agente.md secao 169). Páginas construídas até + * agora: Dashboard (PHASE 23), Telefonia > Ramais (PHASE 25), Discador > + * Campanhas (PHASE 26), Call Center > Filas/Pausas/Disposições, Telefonia + * > Troncos e Discador > Lista de Bloqueio (PHASE 27) — o resto existe pra + * provar a arquitetura completa (Product Principle #5 em PRODUCT.md), + * renderizado como indisponível em vez de virar link morto. "Itens sem + * permissão não aparecem" (secao 169) ainda não é aplicado aqui — este + * primeiro corte mostra a IA inteira pra qualquer usuário tenant + * autenticado; filtrar por permission real fica pra quando mais telas + * existirem pra testar contra. */ export const TENANT_NAV: NavSection[] = [ { label: "Dashboard", @@ -38,13 +40,34 @@ export const TENANT_NAV: NavSection[] = [ { label: "Leads" }, { label: "Importações" }, { label: "Callbacks" }, - { label: "Lista de Bloqueio" }, + { + label: "Lista de Bloqueio", + href: "/app/discador/bloqueio", + description: "Números que o discador nunca chama", + }, ], }, { label: "Call Center", icon: Headset, - children: [{ label: "Agentes" }, { label: "Filas" }, { label: "Pausas" }, { label: "Disposições" }], + children: [ + { label: "Agentes" }, + { + label: "Filas", + href: "/app/callcenter/filas", + description: "Filas de atendimento deste tenant", + }, + { + label: "Pausas", + href: "/app/callcenter/pausas", + description: "Motivos de pausa que o agente pode escolher", + }, + { + label: "Disposições", + href: "/app/callcenter/disposicoes", + description: "Motivos de encerramento de chamada", + }, + ], }, { label: "Telefonia", @@ -55,7 +78,11 @@ export const TENANT_NAV: NavSection[] = [ href: "/app/telefonia/ramais", description: "Ramais SIP deste tenant — números, senha, caller ID", }, - { label: "Troncos" }, + { + label: "Troncos", + href: "/app/telefonia/troncos", + description: "Troncos SIP deste tenant", + }, { label: "Dialplan" }, ], }, diff --git a/apps/frontend/src/components/ui/status-badge.tsx b/apps/frontend/src/components/ui/status-badge.tsx index a5208a2..3f1bb2a 100644 --- a/apps/frontend/src/components/ui/status-badge.tsx +++ b/apps/frontend/src/components/ui/status-badge.tsx @@ -16,6 +16,16 @@ const STATUS_CONFIG: Record = { diff --git a/apps/frontend/src/lib/callcenter-types.ts b/apps/frontend/src/lib/callcenter-types.ts new file mode 100644 index 0000000..576dbb4 --- /dev/null +++ b/apps/frontend/src/lib/callcenter-types.ts @@ -0,0 +1,74 @@ +export interface Queue { + id: string; + name: string; + description: string | null; + strategy: string; + maxWaitTime: number; + maxWaitTimeWithNoAgent: number; + discardAbandonedAfter: number; + recordingEnabled: boolean; + enabled: boolean; + createdAt: string; +} + +export const QUEUE_STRATEGIES = [ + "LONGEST_IDLE_AGENT", + "ROUND_ROBIN", + "TOP_DOWN", + "AGENT_WITH_LEAST_TALK_TIME", + "AGENT_WITH_FEWEST_CALLS", + "SEQUENTIALLY_BY_AGENT_ORDER", + "RING_ALL", + "RING_PROGRESSIVELY", +] as const; + +export const QUEUE_STRATEGY_LABELS: Record = { + LONGEST_IDLE_AGENT: "Agente ocioso há mais tempo", + ROUND_ROBIN: "Round robin", + TOP_DOWN: "Ordem fixa (top-down)", + AGENT_WITH_LEAST_TALK_TIME: "Menor tempo em chamada", + AGENT_WITH_FEWEST_CALLS: "Menos chamadas atendidas", + SEQUENTIALLY_BY_AGENT_ORDER: "Sequencial por ordem do agente", + RING_ALL: "Toca todos ao mesmo tempo", + RING_PROGRESSIVELY: "Toca progressivamente", +}; + +export interface Trunk { + id: string; + name: string; + description: string | null; + host: string; + register: boolean; + username: string | null; + transport: string; + dtmfMode: string; + status: string; + enabled: boolean; + createdAt: string; +} + +export interface PauseReason { + id: string; + name: string; + code: string; + description: string | null; + maxDuration: number | null; + paid: boolean; + enabled: boolean; + createdAt: string; +} + +export interface Disposition { + id: string; + name: string; + code: string; + enabled: boolean; + createdAt: string; +} + +export interface SuppressionEntry { + id: string; + phoneNormalized: string; + reason: string | null; + createdAt: string; +}