feat(frontend): Call Center > Filas/Pausas/Disposições, Telefonia > Troncos, Discador > Lista de Bloqueio
Cinco telas novas no app do tenant, todas contra endpoints de backend que já existiam (Queues/PauseReasons/Dispositions/Trunks/Suppression) — mesmo padrão de listagem+form inline+remoção com confirmação de 2 cliques usado em Ramais. StatusBadge ganhou o mapa de TrunkStatus. Corrigido bug real: PauseReasonsController.list() não filtrava enabled:true, então um motivo removido nunca sumia da lista. Testado ponta a ponta contra a API real do tenant Acme. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
55
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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/frontend/.impeccable/review/bloqueio-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/frontend/.impeccable/review/bloqueio-form-desktop.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
apps/frontend/.impeccable/review/bloqueio-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/frontend/.impeccable/review/disposicoes-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/frontend/.impeccable/review/disposicoes-form-desktop.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 76 KiB |
BIN
apps/frontend/.impeccable/review/filas-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/frontend/.impeccable/review/filas-form-desktop.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
apps/frontend/.impeccable/review/filas-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
apps/frontend/.impeccable/review/pausas-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/frontend/.impeccable/review/pausas-form-desktop.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
apps/frontend/.impeccable/review/pausas-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/frontend/.impeccable/review/troncos-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/frontend/.impeccable/review/troncos-form-desktop.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
apps/frontend/.impeccable/review/troncos-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
47
apps/frontend/src/app/app/callcenter/disposicoes/actions.ts
Normal file
@@ -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<Disposition>("/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<void>(`/dispositions/${id}`, session.accessToken, { method: "DELETE" });
|
||||
revalidatePath("/app/callcenter/disposicoes");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Disposições</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Motivos que o agente (ou supervisor) marca ao encerrar uma chamada (agente.md secao 89) — sem lista
|
||||
fixa, cada tenant define as suas.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Nova disposição"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && <NewDispositionForm onDone={() => setShowForm(false)} />}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Disposições cadastradas" description={`${dispositions.length} disposição(ões) neste tenant`} />
|
||||
{dispositions.length === 0 ? (
|
||||
<EmptyState title="Nenhuma disposição cadastrada ainda" description="Crie a primeira, ex.: Venda, Não Interessado." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Nome</TH>
|
||||
<TH>Código</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Ações</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{dispositions.map((d) => (
|
||||
<TR key={d.id}>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
<ClipboardCheck className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{d.name}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="font-mono text-muted-foreground">{d.code}</TD>
|
||||
<TD>
|
||||
<DeleteDispositionButton id={d.id} name={d.name} />
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewDispositionForm({ onDone }: { onDone: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Panel className="p-5">
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel htmlFor="d-name">Nome</FieldLabel>
|
||||
<Input id="d-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Venda" disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="d-code">Código</FieldLabel>
|
||||
<Input id="d-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="VENDA" className="font-mono uppercase" disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Criando…" : "Criar disposição"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteDispositionButton({ id, name }: { id: string; name: string }) {
|
||||
const router = useRouter();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirming ? "destructive" : "ghost"}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
disabled={pending}
|
||||
aria-label={confirming ? `Confirmar remoção de ${name}` : `Remover ${name}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{confirming ? "Confirmar" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
apps/frontend/src/app/app/callcenter/disposicoes/page.tsx
Normal file
@@ -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<Disposition[]>("/dispositions", session.accessToken);
|
||||
return <DisposicoesView dispositions={dispositions} />;
|
||||
}
|
||||
52
apps/frontend/src/app/app/callcenter/filas/actions.ts
Normal file
@@ -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<Queue>("/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<void>(`/queues/${id}`, session.accessToken, { method: "DELETE" });
|
||||
revalidatePath("/app/callcenter/filas");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
216
apps/frontend/src/app/app/callcenter/filas/filas-view.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Filas</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Nova fila"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && <NewQueueForm onDone={() => setShowForm(false)} />}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Filas cadastradas" description={`${queues.length} fila(s) neste tenant`} />
|
||||
{queues.length === 0 ? (
|
||||
<EmptyState title="Nenhuma fila cadastrada ainda" description="Crie a primeira fila deste tenant." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Nome</TH>
|
||||
<TH>Estratégia</TH>
|
||||
<TH>Espera máx.</TH>
|
||||
<TH>Descarta abandono após</TH>
|
||||
<TH>Gravação</TH>
|
||||
<TH>Criada</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Ações</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{queues.map((q) => (
|
||||
<TR key={q.id}>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
<ListTree className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{q.name}
|
||||
</span>
|
||||
{q.description && <span className="block pl-5 text-xs text-muted-foreground">{q.description}</span>}
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{QUEUE_STRATEGY_LABELS[q.strategy] ?? q.strategy}</TD>
|
||||
<TD className="font-mono tabular-nums text-muted-foreground">{q.maxWaitTime > 0 ? `${q.maxWaitTime}s` : "sem limite"}</TD>
|
||||
<TD className="font-mono tabular-nums text-muted-foreground">{q.discardAbandonedAfter}s</TD>
|
||||
<TD>
|
||||
<Pill tone={q.recordingEnabled ? "accent" : "neutral"}>{q.recordingEnabled ? "Habilitada" : "Desabilitada"}</Pill>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(q.createdAt)}</TD>
|
||||
<TD>
|
||||
<DeleteQueueButton queueId={q.id} queueName={q.name} />
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewQueueForm({ onDone }: { onDone: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [strategy, setStrategy] = useState<string>("LONGEST_IDLE_AGENT");
|
||||
const [maxWaitTime, setMaxWaitTime] = useState("120");
|
||||
const [discardAbandonedAfter, setDiscardAbandonedAfter] = useState("60");
|
||||
const [recordingEnabled, setRecordingEnabled] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Panel className="p-5">
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel htmlFor="q-name">Nome</FieldLabel>
|
||||
<Input id="q-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Suporte" disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="q-desc">Descrição (opcional)</FieldLabel>
|
||||
<Input id="q-desc" value={description} onChange={(e) => setDescription(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<FieldLabel htmlFor="q-strategy">Estratégia</FieldLabel>
|
||||
<Select id="q-strategy" value={strategy} onChange={(e) => setStrategy(e.target.value)} disabled={pending}>
|
||||
{QUEUE_STRATEGIES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{QUEUE_STRATEGY_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="q-wait">Espera máxima (s, 0 = sem limite)</FieldLabel>
|
||||
<Input id="q-wait" type="number" min={0} value={maxWaitTime} onChange={(e) => setMaxWaitTime(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="q-abandon">Descarta abandono após (s)</FieldLabel>
|
||||
<Input id="q-abandon" type="number" min={0} value={discardAbandonedAfter} onChange={(e) => setDiscardAbandonedAfter(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={recordingEnabled}
|
||||
onChange={(e) => setRecordingEnabled(e.target.checked)}
|
||||
disabled={pending}
|
||||
className="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
Gravar chamadas atendidas por esta fila
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Criando…" : "Criar fila"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteQueueButton({ queueId, queueName }: { queueId: string; queueName: string }) {
|
||||
const router = useRouter();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirming ? "destructive" : "ghost"}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
disabled={pending}
|
||||
aria-label={confirming ? `Confirmar remoção de ${queueName}` : `Remover ${queueName}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{confirming ? "Confirmar" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
apps/frontend/src/app/app/callcenter/filas/page.tsx
Normal file
@@ -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<Queue[]>("/queues", session.accessToken);
|
||||
return <FilasView queues={queues} />;
|
||||
}
|
||||
50
apps/frontend/src/app/app/callcenter/pausas/actions.ts
Normal file
@@ -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<PauseReason>("/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<void>(`/pause-reasons/${id}`, session.accessToken, { method: "DELETE" });
|
||||
revalidatePath("/app/callcenter/pausas");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
10
apps/frontend/src/app/app/callcenter/pausas/page.tsx
Normal file
@@ -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<PauseReason[]>("/pause-reasons", session.accessToken);
|
||||
return <PausasView reasons={reasons} />;
|
||||
}
|
||||
197
apps/frontend/src/app/app/callcenter/pausas/pausas-view.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Motivos de pausa</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Motivos que um agente pode escolher ao pausar (agente.md secao 47-49) — sem lista fixa, cada tenant
|
||||
define os seus.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Novo motivo"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && <NewPauseReasonForm onDone={() => setShowForm(false)} />}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Motivos cadastrados" description={`${reasons.length} motivo(s) neste tenant`} />
|
||||
{reasons.length === 0 ? (
|
||||
<EmptyState title="Nenhum motivo cadastrado ainda" description="Crie o primeiro motivo de pausa, ex.: Almoço, Banheiro." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Nome</TH>
|
||||
<TH>Código</TH>
|
||||
<TH>Duração máxima</TH>
|
||||
<TH>Remunerada</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Ações</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{reasons.map((r) => (
|
||||
<TR key={r.id}>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
<Coffee className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{r.name}
|
||||
</span>
|
||||
{r.description && <span className="block pl-5 text-xs text-muted-foreground">{r.description}</span>}
|
||||
</TD>
|
||||
<TD className="font-mono text-muted-foreground">{r.code}</TD>
|
||||
<TD className="font-mono tabular-nums text-muted-foreground">{r.maxDuration ? `${r.maxDuration}s` : "sem limite"}</TD>
|
||||
<TD>
|
||||
<Pill tone={r.paid ? "accent" : "neutral"}>{r.paid ? "Sim" : "Não"}</Pill>
|
||||
</TD>
|
||||
<TD>
|
||||
<DeletePauseReasonButton id={r.id} name={r.name} />
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<Panel className="p-5">
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<FieldLabel htmlFor="p-name">Nome</FieldLabel>
|
||||
<Input id="p-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Almoço" disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="p-code">Código</FieldLabel>
|
||||
<Input id="p-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="ALMOCO" className="font-mono uppercase" disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="p-max">Duração máxima (s, opcional)</FieldLabel>
|
||||
<Input id="p-max" type="number" min={0} value={maxDuration} onChange={(e) => setMaxDuration(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="p-desc">Descrição (opcional)</FieldLabel>
|
||||
<Input id="p-desc" value={description} onChange={(e) => setDescription(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={paid}
|
||||
onChange={(e) => setPaid(e.target.checked)}
|
||||
disabled={pending}
|
||||
className="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
Pausa remunerada
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Criando…" : "Criar motivo"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletePauseReasonButton({ id, name }: { id: string; name: string }) {
|
||||
const router = useRouter();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirming ? "destructive" : "ghost"}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
disabled={pending}
|
||||
aria-label={confirming ? `Confirmar remoção de ${name}` : `Remover ${name}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{confirming ? "Confirmar" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
apps/frontend/src/app/app/discador/bloqueio/actions.ts
Normal file
@@ -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<SuppressionEntry>("/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<void>(`/suppression/${id}`, session.accessToken, { method: "DELETE" });
|
||||
revalidatePath("/app/discador/bloqueio");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
177
apps/frontend/src/app/app/discador/bloqueio/bloqueio-view.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Lista de bloqueio</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Números que o discador nunca chama (agente.md secao 71) — leads importados com um telefone aqui entram
|
||||
como <strong>Não Ligar</strong> em vez de serem descartados.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Bloquear número"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && <NewSuppressionForm onDone={() => setShowForm(false)} />}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Números bloqueados" description={`${entries.length} número(s) neste tenant`} />
|
||||
<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 ? (
|
||||
<EmptyState
|
||||
title={entries.length > 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."}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Telefone</TH>
|
||||
<TH>Motivo</TH>
|
||||
<TH>Bloqueado em</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Ações</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((e) => (
|
||||
<TR key={e.id}>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-mono font-medium text-foreground">
|
||||
<Ban className="h-3.5 w-3.5 text-status-red" aria-hidden />
|
||||
{e.phoneNormalized}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{e.reason || "—"}</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(e.createdAt)}</TD>
|
||||
<TD>
|
||||
<DeleteSuppressionButton id={e.id} phone={e.phoneNormalized} />
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewSuppressionForm({ onDone }: { onDone: () => void }) {
|
||||
const [phone, setPhone] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Panel className="p-5">
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel htmlFor="s-phone">Telefone</FieldLabel>
|
||||
<Input id="s-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="11987654321" className="font-mono" disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="s-reason">Motivo (opcional)</FieldLabel>
|
||||
<Input id="s-reason" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Pedido do titular" disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Bloqueando…" : "Bloquear número"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteSuppressionButton({ id, phone }: { id: string; phone: string }) {
|
||||
const router = useRouter();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirming ? "destructive" : "ghost"}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
disabled={pending}
|
||||
aria-label={confirming ? `Confirmar desbloqueio de ${phone}` : `Desbloquear ${phone}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{confirming ? "Confirmar" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
apps/frontend/src/app/app/discador/bloqueio/page.tsx
Normal file
@@ -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<SuppressionEntry[]>("/suppression", session.accessToken);
|
||||
return <BloqueioView entries={entries} />;
|
||||
}
|
||||
52
apps/frontend/src/app/app/telefonia/troncos/actions.ts
Normal file
@@ -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<Trunk>("/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<void>(`/trunks/${id}`, session.accessToken, { method: "DELETE" });
|
||||
revalidatePath("/app/telefonia/troncos");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
10
apps/frontend/src/app/app/telefonia/troncos/page.tsx
Normal file
@@ -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<Trunk[]>("/trunks", session.accessToken);
|
||||
return <TroncosView trunks={trunks} />;
|
||||
}
|
||||
215
apps/frontend/src/app/app/telefonia/troncos/troncos-view.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Troncos</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Troncos SIP deste tenant (agente.md secao 41-42) — a senha de autenticação, quando informada, nunca é
|
||||
reexibida depois de criada.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Novo tronco"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && <NewTrunkForm onDone={() => setShowForm(false)} />}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Troncos cadastrados" description={`${trunks.length} tronco(s) neste tenant`} />
|
||||
{trunks.length === 0 ? (
|
||||
<EmptyState title="Nenhum tronco cadastrado ainda" description="Crie o primeiro tronco SIP deste tenant." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Nome</TH>
|
||||
<TH>Host</TH>
|
||||
<TH>Registro</TH>
|
||||
<TH>Transporte</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Criado</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Ações</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{trunks.map((t) => (
|
||||
<TR key={t.id}>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
<Router className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{t.name}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="font-mono text-muted-foreground">{t.host}</TD>
|
||||
<TD className="text-muted-foreground">{t.register ? `sim (${t.username ?? "sem usuário"})` : "não"}</TD>
|
||||
<TD className="font-mono text-muted-foreground">{t.transport}</TD>
|
||||
<TD>
|
||||
<StatusBadge status={t.status} />
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(t.createdAt)}</TD>
|
||||
<TD>
|
||||
<DeleteTrunkButton trunkId={t.id} trunkName={t.name} />
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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 (
|
||||
<Panel className="p-5">
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel htmlFor="t-name">Nome</FieldLabel>
|
||||
<Input id="t-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Tronco Principal" disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="t-host">Host</FieldLabel>
|
||||
<Input id="t-host" value={host} onChange={(e) => setHost(e.target.value)} placeholder="sip.provedor.com" disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={register}
|
||||
onChange={(e) => setRegister(e.target.checked)}
|
||||
disabled={pending}
|
||||
className="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
Este tronco precisa registrar (usuário/senha)
|
||||
</label>
|
||||
{register && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel htmlFor="t-username">Usuário</FieldLabel>
|
||||
<Input id="t-username" value={username} onChange={(e) => setUsername(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="t-password">Senha</FieldLabel>
|
||||
<Input id="t-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:w-48">
|
||||
<FieldLabel htmlFor="t-transport">Transporte</FieldLabel>
|
||||
<Select id="t-transport" value={transport} onChange={(e) => setTransport(e.target.value)} disabled={pending}>
|
||||
<option value="UDP">UDP</option>
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="TLS">TLS</option>
|
||||
</Select>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Criando…" : "Criar tronco"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteTrunkButton({ trunkId, trunkName }: { trunkId: string; trunkName: string }) {
|
||||
const router = useRouter();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirming ? "destructive" : "ghost"}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
disabled={pending}
|
||||
aria-label={confirming ? `Confirmar remoção de ${trunkName}` : `Remover ${trunkName}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{confirming ? "Confirmar" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -16,6 +16,16 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; shape: "dot"
|
||||
STOPPED: { label: "Parada", color: "status-red", shape: "dot" },
|
||||
COMPLETED: { label: "Concluída", color: "status-green-dark", shape: "dot" },
|
||||
ERROR: { label: "Erro", color: "status-red", shape: "square" },
|
||||
|
||||
// TrunkStatus (agente.md secao 41-42) — strings distintas das de
|
||||
// campanha, sem colisão no mesmo mapa.
|
||||
UP: { label: "Ativo", color: "status-green", shape: "dot" },
|
||||
REGISTERED: { label: "Registrado", color: "status-green", shape: "dot" },
|
||||
DOWN: { label: "Inativo", color: "status-red", shape: "dot" },
|
||||
TRYING: { label: "Conectando", color: "status-yellow", shape: "square" },
|
||||
FAILED: { label: "Falhou", color: "status-red", shape: "square" },
|
||||
UNREGISTERED: { label: "Não registrado", color: "status-gray", shape: "ring" },
|
||||
UNKNOWN: { label: "Desconhecido", color: "status-gray", shape: "ring" },
|
||||
};
|
||||
|
||||
const SHAPE_CLASS: Record<string, string> = {
|
||||
|
||||
74
apps/frontend/src/lib/callcenter-types.ts
Normal file
@@ -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<string, string> = {
|
||||
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;
|
||||
}
|
||||