feat: Call Center > Agentes + Telefonia > Dialplan (novo endpoint GET /users)
Fecha a lacuna de backend que travava Agentes: POST /agents exigia um userId de um usuário já existente do tenant, mas não havia nenhum endpoint pra listar usuários. Novo GET /users (gated por users.manage, não agents.manage — listar identidade de login é administração de usuário) via TenantMembership, mesmo padrão de RLS já usado em listUserTenants. Duas telas novas: Call Center > Agentes (criar/listar/remover, seletor de usuário só mostra quem ainda não é agente) e Telefonia > Dialplan (editor estruturado do contexto default — regras com condição/ações dinâmicas, painel de Versões com gerar/ativar, reativar versão antiga = rollback). Testado ponta a ponta contra a API real do tenant Acme, incluindo o ciclo completo de dialplan (criar regra -> gerar v1 -> ativar -> badge "Ativa"). Smoke test de regressão nas 13 telas anteriores do tenant + platform. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
58
TODO.md
@@ -948,16 +948,6 @@ Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71,
|
||||
- [ ] **Escopo real ainda faltando pra "aplicação finalizada" segundo o
|
||||
agente.md completo** (236 seções) — não coberto nesta fase, listado
|
||||
explicitamente em vez de fingir completo:
|
||||
- Call Center > Agentes (frontend): bloqueado por uma lacuna de
|
||||
backend, não só falta de tela — `POST /agents` exige um `userId`
|
||||
de um usuário já existente do tenant, mas **não existe nenhum
|
||||
endpoint pra listar/convidar usuários do tenant** ainda (só
|
||||
criação de tenant/usuário via script, mesma lacuna já anotada na
|
||||
PHASE 22 pra tenant/plano/assinatura). Precisa de um "Usuários"
|
||||
(Administração) antes de Agentes fazer sentido na UI.
|
||||
- Telefonia > Dialplan (editor estruturado, condition/actions
|
||||
dinâmicos, generate/activate de versão) — backend pronto desde a
|
||||
PHASE 10, zero UI.
|
||||
- Monitoramento em tempo real (secao 54-55, 161) — WebSocket já
|
||||
existe no backend (`RealtimeGateway`), zero cliente no frontend.
|
||||
- Gravações (player de áudio, secao 121) e IA > Scorecards/Prompts/
|
||||
@@ -977,6 +967,54 @@ Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71,
|
||||
continuam sem existir — o acesso de teste é direto na porta 3001
|
||||
da VM, sem HTTPS.
|
||||
|
||||
## PHASE 29 — Frontend: Call Center > Agentes, Telefonia > Dialplan
|
||||
(agente.md secao 45, 43-44)
|
||||
- [x] **Lacuna de backend fechada primeiro**: `GET /users` (novo,
|
||||
`apps/api/src/users/`) — lista os usuários do tenant ativo via
|
||||
`TenantMembership` (join, mesma tabela/RLS já usada em
|
||||
`listUserTenants`), gated por `users.manage` (não `agents.manage`
|
||||
de propósito: listar identidade de login é administração de
|
||||
usuário, não de call center — um supervisor com `agents.manage` mas
|
||||
sem `users.manage` gerencia agentes já provisionados, mas não lista
|
||||
usuários pra criar um novo). Só o necessário pra desbloquear o
|
||||
seletor de Agentes; convite/criação de usuário continua só via
|
||||
script.
|
||||
- [x] Call Center > Agentes (`/app/callcenter/agentes`): criar (usuário +
|
||||
nome + ramal opcional + máx. não-atendidas + pós-atendimento),
|
||||
listar (usuário/e-mail, ramal, estado), remover (soft delete). O
|
||||
seletor de usuário só mostra quem ainda não é agente (filtro
|
||||
client-side, backend segue sendo a autoridade); ramal segue a mesma
|
||||
lógica. Estado do agente (`AgentState`) tem badge própria — **não**
|
||||
reusa `StatusBadge`: `PAUSED` já existe nesse mapa global pro
|
||||
status de campanha ("Pausada"), reaproveitar mostraria o texto
|
||||
errado pra um agente em pausa.
|
||||
- [x] Telefonia > Dialplan (`/app/telefonia/dialplan`): editor estruturado
|
||||
do contexto `default` (fixo nesta primeira versão — todo o sistema
|
||||
só usou um contexto até aqui) — criar regra (nome, campo/expressão
|
||||
da condição, lista dinâmica de ações com a mesma allowlist do
|
||||
backend, ordem, continuar-se-falhar), listar em ordem de avaliação,
|
||||
remover. Painel de Versões: gerar rascunho a partir das regras
|
||||
atuais, ativar (reativar uma versão antiga é o rollback, sem
|
||||
endpoint separado — mesmo texto já usado no backend). `ACTIVE`/
|
||||
`SUPERSEDED` novos no mapa de `StatusBadge`; `DRAFT` reusa o rótulo
|
||||
que já existia pra campanha (mesmo conceito, sem conflito).
|
||||
- [x] Testado ponta a ponta contra a API real (tenant Acme): Agentes —
|
||||
criar agente ligado ao próprio usuário logado (único usuário do
|
||||
tenant neste estágio) → aparece na lista com estado `Offline` →
|
||||
remover → volta vazio, botão "Novo agente" reabilitado. Dialplan —
|
||||
criar regra `destination_number ~ ^1[0-9]{3}$` com ação
|
||||
`bridge(${destination_number})` → gerar v1 (DRAFT) → ativar → badge
|
||||
"Ativa" confirmado. Smoke test de regressão nas 13 telas anteriores
|
||||
do tenant + platform, todas 200 sem quebrar nada.
|
||||
- [ ] Sem paginação/uniqueness real em `Agent.userId`/`extensionId` — o
|
||||
"só quem não é agente ainda" é conveniência de UI, não constraint de
|
||||
banco (mesma classe de lacuna sistêmica da PHASE 12, `@@unique` +
|
||||
soft delete)
|
||||
- [ ] Dialplan: só 1 contexto (`default`), sem editor de `antiActions`,
|
||||
sem reordenar por drag-and-drop (edita `order` como número) — todas
|
||||
decisões deliberadas de escopo, mesmas simplificações já existentes
|
||||
no backend desde a PHASE 10
|
||||
|
||||
---
|
||||
|
||||
## Riscos conhecidos
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TrunksModule } from "./trunks/trunks.module";
|
||||
import { DialplanModule } from "./dialplan/dialplan.module";
|
||||
import { QueuesModule } from "./queues/queues.module";
|
||||
import { AgentsModule } from "./agents/agents.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { PauseReasonsModule } from "./pause-reasons/pause-reasons.module";
|
||||
import { RealtimeModule } from "./realtime/realtime.module";
|
||||
import { CampaignsModule } from "./campaigns/campaigns.module";
|
||||
@@ -29,6 +30,7 @@ import { BillingModule } from "./billing/billing.module";
|
||||
DialplanModule,
|
||||
QueuesModule,
|
||||
AgentsModule,
|
||||
UsersModule,
|
||||
PauseReasonsModule,
|
||||
RealtimeModule,
|
||||
CampaignsModule,
|
||||
|
||||
41
apps/api/src/users/users.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import type { AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
/**
|
||||
* Usuários do tenant ativo — hoje só o list existe, e só pra alimentar o
|
||||
* seletor de "Call Center > Agentes" (provisionar agente precisa de um
|
||||
* `userId` já existente, secao 45). Convite/criação de usuário continua
|
||||
* só via script (mesma lacuna documentada na PHASE 22 pra tenant/plano).
|
||||
* Gated por `users.manage` (não `agents.manage`) porque listar identidade
|
||||
* de login de outras pessoas é uma ação de administração de usuários, não
|
||||
* de call center — um supervisor com `agents.manage` mas sem
|
||||
* `users.manage` gerencia agentes já provisionados, mas não lista
|
||||
* usuários pra provisionar um novo.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("users")
|
||||
export class UsersController {
|
||||
@RequirePermission("users.manage")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const memberships = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.tenantMembership.findMany({
|
||||
where: { tenantId },
|
||||
include: { user: { select: { id: true, email: true, name: true, status: true, deletedAt: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
);
|
||||
|
||||
return memberships
|
||||
.filter((m) => m.user.deletedAt == null)
|
||||
.map((m) => ({ id: m.user.id, email: m.user.email, name: m.user.name, status: m.user.status }));
|
||||
}
|
||||
}
|
||||
7
apps/api/src/users/users.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UsersController } from "./users.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/frontend/.impeccable/review/agentes-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/frontend/.impeccable/review/agentes-form-desktop.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/frontend/.impeccable/review/agentes-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/frontend/.impeccable/review/dialplan-empty-desktop.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
apps/frontend/.impeccable/review/dialplan-form-desktop.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 104 KiB |
BIN
apps/frontend/.impeccable/review/dialplan-with-data-desktop.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
50
apps/frontend/src/app/app/callcenter/agentes/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 { Agent } 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 CreateAgentInput {
|
||||
userId: string;
|
||||
extensionId?: string;
|
||||
name: string;
|
||||
maxNoAnswer?: number;
|
||||
wrapUpTime?: number;
|
||||
}
|
||||
|
||||
export async function createAgent(input: CreateAgentInput): Promise<{ ok: true; agent: Agent } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const agent = await apiFetch<Agent>("/agents", session.accessToken, { method: "POST", body: JSON.stringify(input) });
|
||||
revalidatePath("/app/callcenter/agentes");
|
||||
return { ok: true, agent };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAgent(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
await apiFetch<void>(`/agents/${id}`, session.accessToken, { method: "DELETE" });
|
||||
revalidatePath("/app/callcenter/agentes");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
264
apps/frontend/src/app/app/callcenter/agentes/agentes-view.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Headset, 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 { AGENT_STATE_LABELS, type Agent, type TenantUser } from "@/lib/callcenter-types";
|
||||
import type { Extension } from "@/lib/extension-types";
|
||||
import { createAgent, deleteAgent } from "./actions";
|
||||
|
||||
/** Estado do agente não usa `StatusBadge` (agente.md secao 172) de
|
||||
* propósito: `PAUSED` já existe no mapa global desse componente pro
|
||||
* status de campanha ("Pausada"), e reaproveitar o mesmo mapa mostraria o
|
||||
* texto errado (agente em pausa não é "campanha pausada"). Badge próprio,
|
||||
* pequeno, só pra este estado. */
|
||||
function AgentStateBadge({ state }: { state: string }) {
|
||||
const tone = state === "AVAILABLE" ? "accent" : "neutral";
|
||||
return <Pill tone={tone}>{AGENT_STATE_LABELS[state] ?? state}</Pill>;
|
||||
}
|
||||
|
||||
export function AgentesView({
|
||||
agents,
|
||||
users,
|
||||
extensions,
|
||||
}: {
|
||||
agents: Agent[];
|
||||
users: TenantUser[];
|
||||
extensions: Extension[];
|
||||
}) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const usersById = useMemo(() => Object.fromEntries(users.map((u) => [u.id, u])), [users]);
|
||||
const extensionsById = useMemo(() => Object.fromEntries(extensions.map((e) => [e.id, e])), [extensions]);
|
||||
|
||||
const assignedUserIds = new Set(agents.map((a) => a.userId));
|
||||
const assignedExtensionIds = new Set(agents.map((a) => a.extensionId).filter((id): id is string => id != null));
|
||||
const availableUsers = users.filter((u) => !assignedUserIds.has(u.id));
|
||||
const availableExtensions = extensions.filter((e) => !assignedExtensionIds.has(e.id));
|
||||
|
||||
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">Agentes</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Identidade de call center (agente.md secao 45) — liga um usuário já existente a um agente e,
|
||||
opcionalmente, a um ramal. Não cria login novo: escolha um usuário deste tenant que ainda não é agente.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)} disabled={availableUsers.length === 0 && !showForm}>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Novo agente"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{availableUsers.length === 0 && !showForm && (
|
||||
<p className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
Todos os usuários deste tenant já são agentes, ou não há usuário nenhum além de quem está logado agora.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<NewAgentForm availableUsers={availableUsers} availableExtensions={availableExtensions} onDone={() => setShowForm(false)} />
|
||||
)}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Agentes cadastrados" description={`${agents.length} agente(s) neste tenant`} />
|
||||
{agents.length === 0 ? (
|
||||
<EmptyState title="Nenhum agente cadastrado ainda" description="Crie o primeiro agente deste tenant." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Nome</TH>
|
||||
<TH>Usuário</TH>
|
||||
<TH>Ramal</TH>
|
||||
<TH>Estado</TH>
|
||||
<TH>Criado</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Ações</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{agents.map((a) => (
|
||||
<TR key={a.id}>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
<Headset className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{a.name}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{usersById[a.userId]?.email ?? a.userId}</TD>
|
||||
<TD className="font-mono text-muted-foreground">{a.extensionId ? extensionsById[a.extensionId]?.number ?? a.extensionId : "—"}</TD>
|
||||
<TD>
|
||||
<AgentStateBadge state={a.state} />
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(a.createdAt)}</TD>
|
||||
<TD>
|
||||
<DeleteAgentButton agentId={a.id} agentName={a.name} />
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewAgentForm({
|
||||
availableUsers,
|
||||
availableExtensions,
|
||||
onDone,
|
||||
}: {
|
||||
availableUsers: TenantUser[];
|
||||
availableExtensions: Extension[];
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [userId, setUserId] = useState(availableUsers[0]?.id ?? "");
|
||||
const [extensionId, setExtensionId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [maxNoAnswer, setMaxNoAnswer] = useState("3");
|
||||
const [wrapUpTime, setWrapUpTime] = useState("10");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!userId) {
|
||||
setError("Escolha um usuário.");
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
setError("Dê um nome ao agente.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await createAgent({
|
||||
userId,
|
||||
extensionId: extensionId || undefined,
|
||||
name: name.trim(),
|
||||
maxNoAnswer: Number(maxNoAnswer) || undefined,
|
||||
wrapUpTime: Number(wrapUpTime) || undefined,
|
||||
});
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onDone();
|
||||
});
|
||||
}
|
||||
|
||||
if (availableUsers.length === 0) {
|
||||
return (
|
||||
<Panel className="p-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Não há usuário disponível pra virar agente. Todo usuário deste tenant já é agente.
|
||||
</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
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="a-user">Usuário</FieldLabel>
|
||||
<Select id="a-user" value={userId} onChange={(e) => setUserId(e.target.value)} disabled={pending}>
|
||||
{availableUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="a-name">Nome do agente</FieldLabel>
|
||||
<Input id="a-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Ana Souza" disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<FieldLabel htmlFor="a-extension">Ramal (opcional)</FieldLabel>
|
||||
<Select id="a-extension" value={extensionId} onChange={(e) => setExtensionId(e.target.value)} disabled={pending}>
|
||||
<option value="">Sem ramal</option>
|
||||
{availableExtensions.map((ext) => (
|
||||
<option key={ext.id} value={ext.id}>
|
||||
{ext.number} — {ext.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="a-noanswer">Máx. não atendidas</FieldLabel>
|
||||
<Input id="a-noanswer" type="number" min={0} max={20} value={maxNoAnswer} onChange={(e) => setMaxNoAnswer(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="a-wrapup">Pós-atendimento (s)</FieldLabel>
|
||||
<Input id="a-wrapup" type="number" min={0} value={wrapUpTime} onChange={(e) => setWrapUpTime(e.target.value)} 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 agente"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAgentButton({ agentId, agentName }: { agentId: string; agentName: 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 deleteAgent(agentId);
|
||||
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 ${agentName}` : `Remover ${agentName}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{confirming ? "Confirmar" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/frontend/src/app/app/callcenter/agentes/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { Agent, TenantUser } from "@/lib/callcenter-types";
|
||||
import type { Extension } from "@/lib/extension-types";
|
||||
import { AgentesView } from "./agentes-view";
|
||||
|
||||
export default async function AgentesPage() {
|
||||
const session = await requireSession();
|
||||
|
||||
const [agents, users, extensions] = await Promise.all([
|
||||
apiFetch<Agent[]>("/agents", session.accessToken),
|
||||
apiFetch<TenantUser[]>("/users", session.accessToken),
|
||||
apiFetch<Extension[]>("/extensions", session.accessToken),
|
||||
]);
|
||||
|
||||
return <AgentesView agents={agents} users={users} extensions={extensions} />;
|
||||
}
|
||||
89
apps/frontend/src/app/app/telefonia/dialplan/actions.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import type { DialplanAction, DialplanExtension, DialplanVersion } from "@/lib/dialplan-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 CreateDialplanExtensionInput {
|
||||
context: string;
|
||||
name: string;
|
||||
conditionField: string;
|
||||
conditionExpr: string;
|
||||
actions: DialplanAction[];
|
||||
continueOnFalse?: boolean;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export async function createDialplanExtension(
|
||||
input: CreateDialplanExtensionInput,
|
||||
): Promise<{ ok: true; extension: DialplanExtension } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const extension = await apiFetch<DialplanExtension>("/dialplan/extensions", session.accessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
revalidatePath("/app/telefonia/dialplan");
|
||||
return { ok: true, extension };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDialplanExtension(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
await apiFetch<void>(`/dialplan/extensions/${id}`, session.accessToken, { method: "DELETE" });
|
||||
revalidatePath("/app/telefonia/dialplan");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateDialplanVersion(
|
||||
context: string,
|
||||
): Promise<{ ok: true; version: DialplanVersion } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const version = await apiFetch<DialplanVersion>(
|
||||
`/dialplan/versions/generate?context=${encodeURIComponent(context)}`,
|
||||
session.accessToken,
|
||||
{ method: "POST" },
|
||||
);
|
||||
revalidatePath("/app/telefonia/dialplan");
|
||||
return { ok: true, version };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateDialplanVersion(
|
||||
id: string,
|
||||
): Promise<{ ok: true; version: DialplanVersion } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const version = await apiFetch<DialplanVersion>(`/dialplan/versions/${id}/activate`, session.accessToken, {
|
||||
method: "POST",
|
||||
});
|
||||
revalidatePath("/app/telefonia/dialplan");
|
||||
return { ok: true, version };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
377
apps/frontend/src/app/app/telefonia/dialplan/dialplan-view.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { GitBranch, 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 { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
ALLOWED_CONDITION_FIELDS,
|
||||
ALLOWED_DIALPLAN_APPLICATIONS,
|
||||
CONDITION_FIELD_LABELS,
|
||||
type DialplanAction,
|
||||
type DialplanExtension,
|
||||
type DialplanVersion,
|
||||
} from "@/lib/dialplan-types";
|
||||
import { activateDialplanVersion, createDialplanExtension, deleteDialplanExtension, generateDialplanVersion } from "./actions";
|
||||
|
||||
const CONTEXT = "default";
|
||||
|
||||
export function DialplanView({ extensions, versions }: { extensions: DialplanExtension[]; versions: DialplanVersion[] }) {
|
||||
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">Dialplan</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Editor estruturado do contexto <code className="font-mono">{CONTEXT}</code> (agente.md secao 43-44) —
|
||||
cada regra tem uma condição e uma lista de ações executadas em ordem. Só depois de <strong>gerar</strong>{" "}
|
||||
e <strong>ativar</strong> uma versão é que as regras valem pra chamadas reais.
|
||||
</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 regra"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && <NewExtensionForm onDone={() => setShowForm(false)} />}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader
|
||||
title="Regras cadastradas"
|
||||
description={`${extensions.length} regra(s) no contexto ${CONTEXT}, em ordem de avaliação`}
|
||||
/>
|
||||
{extensions.length === 0 ? (
|
||||
<EmptyState title="Nenhuma regra cadastrada ainda" description="Crie a primeira regra deste contexto." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Ordem</TH>
|
||||
<TH>Nome</TH>
|
||||
<TH>Condição</TH>
|
||||
<TH>Ações</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Remover</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{extensions.map((ext) => (
|
||||
<TR key={ext.id}>
|
||||
<TD className="font-mono tabular-nums text-muted-foreground">{ext.order}</TD>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{ext.name}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">
|
||||
<span className="font-mono text-xs">{CONDITION_FIELD_LABELS[ext.conditionField] ?? ext.conditionField}</span>
|
||||
{" ~ "}
|
||||
<span className="font-mono text-xs">{ext.conditionExpr}</span>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">
|
||||
<span className="font-mono text-xs">
|
||||
{ext.actions.map((a) => a.application + (a.data ? `(${a.data})` : "")).join(", ")}
|
||||
</span>
|
||||
</TD>
|
||||
<TD>
|
||||
<DeleteExtensionButton id={ext.id} name={ext.name} />
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<VersionsPanel versions={versions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewExtensionForm({ onDone }: { onDone: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [conditionField, setConditionField] = useState<string>(ALLOWED_CONDITION_FIELDS[0]);
|
||||
const [conditionExpr, setConditionExpr] = useState("");
|
||||
const [order, setOrder] = useState("10");
|
||||
const [continueOnFalse, setContinueOnFalse] = useState(false);
|
||||
const [actions, setActions] = useState<DialplanAction[]>([{ application: ALLOWED_DIALPLAN_APPLICATIONS[0], data: "" }]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function updateAction(index: number, patch: Partial<DialplanAction>) {
|
||||
setActions((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)));
|
||||
}
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!name.trim() || !conditionExpr.trim()) {
|
||||
setError("Nome e expressão da condição são obrigatórios.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await createDialplanExtension({
|
||||
context: CONTEXT,
|
||||
name: name.trim(),
|
||||
conditionField,
|
||||
conditionExpr: conditionExpr.trim(),
|
||||
actions: actions
|
||||
.filter((a) => a.application)
|
||||
.map((a) => ({ application: a.application, data: a.data?.trim() || undefined })),
|
||||
continueOnFalse,
|
||||
order: Number(order) || 0,
|
||||
});
|
||||
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="dp-name">Nome</FieldLabel>
|
||||
<Input id="dp-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Discagem interna" disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="dp-order">Ordem (menor avalia primeiro)</FieldLabel>
|
||||
<Input id="dp-order" type="number" value={order} onChange={(e) => setOrder(e.target.value)} disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel htmlFor="dp-field">Campo da condição</FieldLabel>
|
||||
<Select id="dp-field" value={conditionField} onChange={(e) => setConditionField(e.target.value)} disabled={pending}>
|
||||
{ALLOWED_CONDITION_FIELDS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{CONDITION_FIELD_LABELS[f]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="dp-expr">Expressão (regex)</FieldLabel>
|
||||
<Input id="dp-expr" value={conditionExpr} onChange={(e) => setConditionExpr(e.target.value)} placeholder="^1[0-9]{3}$" className="font-mono" disabled={pending} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<FieldLabel htmlFor="dp-action-0">Ações (executadas em ordem)</FieldLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setActions((prev) => [...prev, { application: ALLOWED_DIALPLAN_APPLICATIONS[0], data: "" }])}
|
||||
disabled={pending || actions.length >= 20}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden />
|
||||
Adicionar ação
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{actions.map((action, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Select
|
||||
id={`dp-action-${i}`}
|
||||
value={action.application}
|
||||
onChange={(e) => updateAction(i, { application: e.target.value })}
|
||||
disabled={pending}
|
||||
className="w-48"
|
||||
>
|
||||
{ALLOWED_DIALPLAN_APPLICATIONS.map((app) => (
|
||||
<option key={app} value={app}>
|
||||
{app}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
value={action.data ?? ""}
|
||||
onChange={(e) => updateAction(i, { data: e.target.value })}
|
||||
placeholder="argumento (opcional)"
|
||||
className="flex-1 font-mono"
|
||||
disabled={pending}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setActions((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
disabled={pending || actions.length <= 1}
|
||||
aria-label={`Remover ação ${i + 1}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={continueOnFalse}
|
||||
onChange={(e) => setContinueOnFalse(e.target.checked)}
|
||||
disabled={pending}
|
||||
className="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
Continuar avaliando as próximas regras mesmo se a condição falhar
|
||||
</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 regra"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteExtensionButton({ 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 deleteDialplanExtension(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>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionsPanel({ versions }: { versions: DialplanVersion[] }) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function onGenerate() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await generateDialplanVersion(CONTEXT);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function onActivate(id: string) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await activateDialplanVersion(id);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">Versões</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Gerar cria um rascunho a partir das regras atuais; ativar uma versão antiga é o rollback (secao 44).
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={onGenerate} disabled={pending}>
|
||||
{pending ? "Gerando…" : "Gerar nova versão"}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="mx-5 mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{versions.length === 0 ? (
|
||||
<EmptyState title="Nenhuma versão gerada ainda" description="Gere a primeira versão a partir das regras cadastradas acima." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Versão</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Gerada</TH>
|
||||
<TH>Ativada</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Ações</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{versions.map((v) => (
|
||||
<TR key={v.id}>
|
||||
<TD className="font-mono tabular-nums text-foreground">v{v.version}</TD>
|
||||
<TD>
|
||||
<StatusBadge status={v.status} />
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(v.createdAt)}</TD>
|
||||
<TD className="text-muted-foreground">{v.activatedAt ? formatDate(v.activatedAt) : "—"}</TD>
|
||||
<TD>
|
||||
{v.status !== "ACTIVE" && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onActivate(v.id)} disabled={pending}>
|
||||
Ativar
|
||||
</Button>
|
||||
)}
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
17
apps/frontend/src/app/app/telefonia/dialplan/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { DialplanExtension, DialplanVersion } from "@/lib/dialplan-types";
|
||||
import { DialplanView } from "./dialplan-view";
|
||||
|
||||
const CONTEXT = "default";
|
||||
|
||||
export default async function DialplanPage() {
|
||||
const session = await requireSession();
|
||||
|
||||
const [extensions, versions] = await Promise.all([
|
||||
apiFetch<DialplanExtension[]>(`/dialplan/extensions?context=${CONTEXT}`, session.accessToken),
|
||||
apiFetch<DialplanVersion[]>(`/dialplan/versions?context=${CONTEXT}`, session.accessToken),
|
||||
]);
|
||||
|
||||
return <DialplanView extensions={extensions} versions={versions} />;
|
||||
}
|
||||
@@ -51,7 +51,11 @@ export const TENANT_NAV: NavSection[] = [
|
||||
label: "Call Center",
|
||||
icon: Headset,
|
||||
children: [
|
||||
{ label: "Agentes" },
|
||||
{
|
||||
label: "Agentes",
|
||||
href: "/app/callcenter/agentes",
|
||||
description: "Identidade de call center — liga um usuário a um agente e a um ramal",
|
||||
},
|
||||
{
|
||||
label: "Filas",
|
||||
href: "/app/callcenter/filas",
|
||||
@@ -83,7 +87,11 @@ export const TENANT_NAV: NavSection[] = [
|
||||
href: "/app/telefonia/troncos",
|
||||
description: "Troncos SIP deste tenant",
|
||||
},
|
||||
{ label: "Dialplan" },
|
||||
{
|
||||
label: "Dialplan",
|
||||
href: "/app/telefonia/dialplan",
|
||||
description: "Editor de regras do contexto default — condição, ações, versões",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,6 +26,12 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; shape: "dot"
|
||||
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" },
|
||||
|
||||
// DialplanVersionStatus (agente.md secao 44) — DRAFT reusa o rótulo já
|
||||
// existente pra campanha ("Rascunho", mesmo conceito); ACTIVE/SUPERSEDED
|
||||
// são novos, sem colisão.
|
||||
ACTIVE: { label: "Ativa", color: "status-green", shape: "dot" },
|
||||
SUPERSEDED: { label: "Substituída", color: "status-gray", shape: "ring" },
|
||||
};
|
||||
|
||||
const SHAPE_CLASS: Record<string, string> = {
|
||||
|
||||
@@ -72,3 +72,33 @@ export interface SuppressionEntry {
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
userId: string;
|
||||
extensionId: string | null;
|
||||
name: string;
|
||||
maxNoAnswer: number;
|
||||
wrapUpTime: number;
|
||||
state: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const AGENT_STATE_LABELS: Record<string, string> = {
|
||||
OFFLINE: "Offline",
|
||||
LOGGED_IN: "Logado",
|
||||
AVAILABLE: "Disponível",
|
||||
RESERVED: "Reservado",
|
||||
RINGING: "Chamando",
|
||||
IN_CALL: "Em chamada",
|
||||
WRAP_UP: "Pós-atendimento",
|
||||
PAUSED: "Em pausa",
|
||||
};
|
||||
|
||||
export interface TenantUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
67
apps/frontend/src/lib/dialplan-types.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/** Duplicado do backend só pra UI (mesmo padrão de QUEUE_STRATEGIES em
|
||||
* callcenter-types.ts) — o frontend não importa pacotes do backend, fala
|
||||
* só HTTP. `apps/api` continua sendo a autoridade: a allowlist aqui é só
|
||||
* pra montar o `<select>`, o servidor rejeita qualquer coisa fora dela
|
||||
* (agente.md secao 180). */
|
||||
export const ALLOWED_DIALPLAN_APPLICATIONS = [
|
||||
"answer",
|
||||
"pre_answer",
|
||||
"bridge",
|
||||
"hangup",
|
||||
"park",
|
||||
"playback",
|
||||
"ring_ready",
|
||||
"respond",
|
||||
"set",
|
||||
"export",
|
||||
"transfer",
|
||||
"sleep",
|
||||
"record_session",
|
||||
] as const;
|
||||
|
||||
export const ALLOWED_CONDITION_FIELDS = [
|
||||
"destination_number",
|
||||
"caller_id_number",
|
||||
"caller_id_name",
|
||||
"context",
|
||||
"network_addr",
|
||||
"source",
|
||||
] as const;
|
||||
|
||||
export const CONDITION_FIELD_LABELS: Record<string, string> = {
|
||||
destination_number: "Número discado",
|
||||
caller_id_number: "Número do chamador",
|
||||
caller_id_name: "Nome do chamador",
|
||||
context: "Contexto",
|
||||
network_addr: "Endereço de rede",
|
||||
source: "Origem",
|
||||
};
|
||||
|
||||
export interface DialplanAction {
|
||||
application: string;
|
||||
data?: string;
|
||||
}
|
||||
|
||||
export interface DialplanExtension {
|
||||
id: string;
|
||||
context: string;
|
||||
name: string;
|
||||
conditionField: string;
|
||||
conditionExpr: string;
|
||||
actions: DialplanAction[];
|
||||
antiActions: DialplanAction[] | null;
|
||||
continueOnFalse: boolean;
|
||||
order: number;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DialplanVersion {
|
||||
id: string;
|
||||
context: string;
|
||||
version: number;
|
||||
status: "DRAFT" | "ACTIVE" | "SUPERSEDED";
|
||||
createdAt: string;
|
||||
activatedAt: string | null;
|
||||
createdByUserId: string;
|
||||
}
|
||||