diff --git a/TODO.md b/TODO.md index 42cf66f..6849e47 100644 --- a/TODO.md +++ b/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 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index db60ce3..f98fccc 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..7755f83 --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -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 })); + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..9477c08 --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { UsersController } from "./users.controller"; + +@Module({ + controllers: [UsersController], +}) +export class UsersModule {} diff --git a/apps/frontend/.impeccable/review/agentes-after-delete-desktop.png b/apps/frontend/.impeccable/review/agentes-after-delete-desktop.png new file mode 100644 index 0000000..6e865a6 Binary files /dev/null and b/apps/frontend/.impeccable/review/agentes-after-delete-desktop.png differ diff --git a/apps/frontend/.impeccable/review/agentes-empty-desktop.png b/apps/frontend/.impeccable/review/agentes-empty-desktop.png new file mode 100644 index 0000000..e5697e1 Binary files /dev/null and b/apps/frontend/.impeccable/review/agentes-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/agentes-form-desktop.png b/apps/frontend/.impeccable/review/agentes-form-desktop.png new file mode 100644 index 0000000..d27d065 Binary files /dev/null and b/apps/frontend/.impeccable/review/agentes-form-desktop.png differ diff --git a/apps/frontend/.impeccable/review/agentes-with-data-desktop.png b/apps/frontend/.impeccable/review/agentes-with-data-desktop.png new file mode 100644 index 0000000..b267be8 Binary files /dev/null and b/apps/frontend/.impeccable/review/agentes-with-data-desktop.png differ diff --git a/apps/frontend/.impeccable/review/dialplan-empty-desktop.png b/apps/frontend/.impeccable/review/dialplan-empty-desktop.png new file mode 100644 index 0000000..1107f19 Binary files /dev/null and b/apps/frontend/.impeccable/review/dialplan-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/dialplan-form-desktop.png b/apps/frontend/.impeccable/review/dialplan-form-desktop.png new file mode 100644 index 0000000..9eb2374 Binary files /dev/null and b/apps/frontend/.impeccable/review/dialplan-form-desktop.png differ diff --git a/apps/frontend/.impeccable/review/dialplan-version-active-desktop.png b/apps/frontend/.impeccable/review/dialplan-version-active-desktop.png new file mode 100644 index 0000000..554b1b9 Binary files /dev/null and b/apps/frontend/.impeccable/review/dialplan-version-active-desktop.png differ diff --git a/apps/frontend/.impeccable/review/dialplan-version-generated-desktop.png b/apps/frontend/.impeccable/review/dialplan-version-generated-desktop.png new file mode 100644 index 0000000..d3e6d2c Binary files /dev/null and b/apps/frontend/.impeccable/review/dialplan-version-generated-desktop.png differ diff --git a/apps/frontend/.impeccable/review/dialplan-with-data-desktop.png b/apps/frontend/.impeccable/review/dialplan-with-data-desktop.png new file mode 100644 index 0000000..7ed63a2 Binary files /dev/null and b/apps/frontend/.impeccable/review/dialplan-with-data-desktop.png differ diff --git a/apps/frontend/src/app/app/callcenter/agentes/actions.ts b/apps/frontend/src/app/app/callcenter/agentes/actions.ts new file mode 100644 index 0000000..a178d6e --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/agentes/actions.ts @@ -0,0 +1,50 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { 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("/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(`/agents/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/callcenter/agentes"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/callcenter/agentes/agentes-view.tsx b/apps/frontend/src/app/app/callcenter/agentes/agentes-view.tsx new file mode 100644 index 0000000..c85e81d --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/agentes/agentes-view.tsx @@ -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 {AGENT_STATE_LABELS[state] ?? state}; +} + +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 ( +
+
+
+

Agentes

+

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

+
+ +
+ + {availableUsers.length === 0 && !showForm && ( +

+ Todos os usuários deste tenant já são agentes, ou não há usuário nenhum além de quem está logado agora. +

+ )} + + {showForm && ( + setShowForm(false)} /> + )} + + + + {agents.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {agents.map((a) => ( + + + + + + + + + ))} + +
NomeUsuárioRamalEstadoCriado + Ações +
+ + + {a.name} + + {usersById[a.userId]?.email ?? a.userId}{a.extensionId ? extensionsById[a.extensionId]?.number ?? a.extensionId : "—"} + + {formatDate(a.createdAt)} + +
+ )} +
+
+ ); +} + +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(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 ( + +

+ Não há usuário disponível pra virar agente. Todo usuário deste tenant já é agente. +

+
+ ); + } + + return ( + +
+
+
+ Usuário + +
+
+ Nome do agente + setName(e.target.value)} placeholder="Ex.: Ana Souza" disabled={pending} /> +
+
+
+
+ Ramal (opcional) + +
+
+ Máx. não atendidas + setMaxNoAnswer(e.target.value)} disabled={pending} /> +
+
+ Pós-atendimento (s) + setWrapUpTime(e.target.value)} disabled={pending} /> +
+
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function DeleteAgentButton({ agentId, agentName }: { agentId: string; agentName: string }) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onClick() { + if (!confirming) { + setConfirming(true); + return; + } + setError(null); + startTransition(async () => { + const result = await deleteAgent(agentId); + if (!result.ok) { + setError(result.error); + setConfirming(false); + return; + } + router.refresh(); + }); + } + + return ( +
+ {error && {error}} + +
+ ); +} diff --git a/apps/frontend/src/app/app/callcenter/agentes/page.tsx b/apps/frontend/src/app/app/callcenter/agentes/page.tsx new file mode 100644 index 0000000..b8dc1b3 --- /dev/null +++ b/apps/frontend/src/app/app/callcenter/agentes/page.tsx @@ -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("/agents", session.accessToken), + apiFetch("/users", session.accessToken), + apiFetch("/extensions", session.accessToken), + ]); + + return ; +} diff --git a/apps/frontend/src/app/app/telefonia/dialplan/actions.ts b/apps/frontend/src/app/app/telefonia/dialplan/actions.ts new file mode 100644 index 0000000..d603e81 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/dialplan/actions.ts @@ -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("/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(`/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( + `/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(`/dialplan/versions/${id}/activate`, session.accessToken, { + method: "POST", + }); + revalidatePath("/app/telefonia/dialplan"); + return { ok: true, version }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/telefonia/dialplan/dialplan-view.tsx b/apps/frontend/src/app/app/telefonia/dialplan/dialplan-view.tsx new file mode 100644 index 0000000..b3125cd --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/dialplan/dialplan-view.tsx @@ -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 ( +
+
+
+

Dialplan

+

+ Editor estruturado do contexto {CONTEXT} (agente.md secao 43-44) — + cada regra tem uma condição e uma lista de ações executadas em ordem. Só depois de gerar{" "} + e ativar uma versão é que as regras valem pra chamadas reais. +

+
+ +
+ + {showForm && setShowForm(false)} />} + + + + {extensions.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {extensions.map((ext) => ( + + + + + + + + ))} + +
OrdemNomeCondiçãoAções + Remover +
{ext.order} + + + {ext.name} + + + {CONDITION_FIELD_LABELS[ext.conditionField] ?? ext.conditionField} + {" ~ "} + {ext.conditionExpr} + + + {ext.actions.map((a) => a.application + (a.data ? `(${a.data})` : "")).join(", ")} + + + +
+ )} +
+ + +
+ ); +} + +function NewExtensionForm({ onDone }: { onDone: () => void }) { + const [name, setName] = useState(""); + const [conditionField, setConditionField] = useState(ALLOWED_CONDITION_FIELDS[0]); + const [conditionExpr, setConditionExpr] = useState(""); + const [order, setOrder] = useState("10"); + const [continueOnFalse, setContinueOnFalse] = useState(false); + const [actions, setActions] = useState([{ application: ALLOWED_DIALPLAN_APPLICATIONS[0], data: "" }]); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function updateAction(index: number, patch: Partial) { + 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 ( + +
+
+
+ Nome + setName(e.target.value)} placeholder="Ex.: Discagem interna" disabled={pending} /> +
+
+ Ordem (menor avalia primeiro) + setOrder(e.target.value)} disabled={pending} /> +
+
+ +
+
+ Campo da condição + +
+
+ Expressão (regex) + setConditionExpr(e.target.value)} placeholder="^1[0-9]{3}$" className="font-mono" disabled={pending} /> +
+
+ +
+
+ Ações (executadas em ordem) + +
+
+ {actions.map((action, i) => ( +
+ + updateAction(i, { data: e.target.value })} + placeholder="argumento (opcional)" + className="flex-1 font-mono" + disabled={pending} + /> + +
+ ))} +
+
+ + + + {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function DeleteExtensionButton({ id, name }: { id: string; name: string }) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onClick() { + if (!confirming) { + setConfirming(true); + return; + } + setError(null); + startTransition(async () => { + const result = await deleteDialplanExtension(id); + if (!result.ok) { + setError(result.error); + setConfirming(false); + return; + } + router.refresh(); + }); + } + + return ( +
+ {error && {error}} + +
+ ); +} + +function VersionsPanel({ versions }: { versions: DialplanVersion[] }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(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 ( + +
+
+

Versões

+

+ Gerar cria um rascunho a partir das regras atuais; ativar uma versão antiga é o rollback (secao 44). +

+
+ +
+ {error && ( +

+ {error} +

+ )} + {versions.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {versions.map((v) => ( + + + + + + + + ))} + +
VersãoStatusGeradaAtivada + Ações +
v{v.version} + + {formatDate(v.createdAt)}{v.activatedAt ? formatDate(v.activatedAt) : "—"} + {v.status !== "ACTIVE" && ( + + )} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/app/app/telefonia/dialplan/page.tsx b/apps/frontend/src/app/app/telefonia/dialplan/page.tsx new file mode 100644 index 0000000..86a74b5 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/dialplan/page.tsx @@ -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(`/dialplan/extensions?context=${CONTEXT}`, session.accessToken), + apiFetch(`/dialplan/versions?context=${CONTEXT}`, session.accessToken), + ]); + + return ; +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index 024e2a0..bcae27c 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -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", + }, ], }, { diff --git a/apps/frontend/src/components/ui/status-badge.tsx b/apps/frontend/src/components/ui/status-badge.tsx index 3f1bb2a..0d3c4e2 100644 --- a/apps/frontend/src/components/ui/status-badge.tsx +++ b/apps/frontend/src/components/ui/status-badge.tsx @@ -26,6 +26,12 @@ const STATUS_CONFIG: Record = { diff --git a/apps/frontend/src/lib/callcenter-types.ts b/apps/frontend/src/lib/callcenter-types.ts index 576dbb4..5bcbcd2 100644 --- a/apps/frontend/src/lib/callcenter-types.ts +++ b/apps/frontend/src/lib/callcenter-types.ts @@ -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 = { + 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; +} diff --git a/apps/frontend/src/lib/dialplan-types.ts b/apps/frontend/src/lib/dialplan-types.ts new file mode 100644 index 0000000..3ef64e1 --- /dev/null +++ b/apps/frontend/src/lib/dialplan-types.ts @@ -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 `