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
This commit is contained in:
2026-08-29 16:10:45 -03:00
parent ca49504f0a
commit f1cadddc91
23 changed files with 1025 additions and 12 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View 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) };
}
}

View 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 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 são agentes, ou não 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 usuário disponível pra virar agente. Todo usuário deste tenant é 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>
);
}

View 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} />;
}

View 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) };
}
}

View 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. 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>
);
}

View 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} />;
}

View File

@@ -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",
},
],
},
{

View File

@@ -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> = {

View File

@@ -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;
}

View 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;
}