feat(ivr): tela de autoria de menu de IVR no frontend
Pedido do usuário: "constrói a tela de IVR no frontend". Até aqui um
menu de IVR só existia se alguém escrevesse as regras à mão no editor
genérico de dialplan (o que eu fiz manualmente pra testar na PHASE 56) —
sem UI nenhuma pra isso.
`IvrMenu`/`IvrMenuOption` (RLS real): nome + contexto (derivado do nome)
+ opções (dígito → ramal + rótulo opcional). Nenhuma tabela nova pro
dialplan em si — `IvrMenusController` compila o menu inteiro em
`DialplanExtension`/`DialplanVersion` do contexto do menu
(`buildIvrDialplanExtensions`, packages/telephony) usando as MESMAS 2
formas de `<extension>` já testadas com DTMF real na PHASE 56 (entrada
com `play_and_get_digits` + `transfer` usando o dígito coletado como
novo destination_number, uma extension por dígito) — e já gera + ativa
a versão nova automaticamente, o mesmo generate+activate manual que o
editor de dialplan faz, só que embutido no create/update do menu.
`greeting` (o prompt do menu) é texto livre do tenant, então passa pela
MESMA proteção anti-RCE já aplicada em `data` de dialplan
(`IsSafeDialplanData`) — nunca pode virar `${system(...)}`.
Tela "Telefonia > IVR": lista de menus com as opções de cada um, criação
com nome/contexto (auto-gerado do nome, editável) + linhas dinâmicas de
opção (dígito + select de ramal já cadastrado + rótulo), remoção com
confirmação de 2 cliques. Mostra o contexto/destino fixo (`ivr_entry`)
que uma Rota de Entrada precisa usar pra apontar pro menu.
Testado ponta a ponta criando um menu DE VERDADE pela tela/API (não só
lendo o XML manualmente escrito antes): softphone externo discou um DID
apontado pro menu recém-criado, atendeu, tocou o prompt, colheu o dígito
com DTMF real (`uuid_recv_dtmf`) e bridged com o ramal certo — confirma
que o compilador produz XML funcionalmente idêntico ao testado
manualmente. Falta pipeline de upload/TTS de áudio, sub-menus e destino
"fila" — ver docs/INBOUND_ROUTES.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
@@ -4,6 +4,7 @@ import { AuthModule } from "./auth/auth.module";
|
||||
import { ExtensionsModule } from "./extensions/extensions.module";
|
||||
import { TrunksModule } from "./trunks/trunks.module";
|
||||
import { InboundRoutesModule } from "./inbound-routes/inbound-routes.module";
|
||||
import { IvrModule } from "./ivr/ivr.module";
|
||||
import { DialplanModule } from "./dialplan/dialplan.module";
|
||||
import { QueuesModule } from "./queues/queues.module";
|
||||
import { AgentsModule } from "./agents/agents.module";
|
||||
@@ -31,6 +32,7 @@ import { PlansModule } from "./plans/plans.module";
|
||||
ExtensionsModule,
|
||||
TrunksModule,
|
||||
InboundRoutesModule,
|
||||
IvrModule,
|
||||
DialplanModule,
|
||||
QueuesModule,
|
||||
AgentsModule,
|
||||
|
||||
69
apps/api/src/ivr/dto/create-ivr-menu.dto.ts
Normal file
69
apps/api/src/ivr/dto/create-ivr-menu.dto.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Type } from "class-transformer";
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
|
||||
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
|
||||
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
|
||||
|
||||
export class IvrMenuOptionDto {
|
||||
@IsIn(ALLOWED_IVR_DIGITS)
|
||||
digit!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
|
||||
destinationNumber!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
destinationContext?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export class CreateIvrMenuDto {
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
// Vira o `context` do dialplan compilado — derivado do nome no
|
||||
// frontend (slug), mas validado aqui como qualquer outro context.
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9-]{1,60}$/, { message: "context deve ser minúsculo, com letras/números/hífen (1 a 60 caracteres)" })
|
||||
context!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
@IsSafeDialplanData({ message: "greeting usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
|
||||
greeting?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(12)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => IvrMenuOptionDto)
|
||||
options!: IvrMenuOptionDto[];
|
||||
}
|
||||
|
||||
export class UpdateIvrMenuDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
@IsSafeDialplanData({ message: "greeting usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
|
||||
greeting?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(12)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => IvrMenuOptionDto)
|
||||
options?: IvrMenuOptionDto[];
|
||||
}
|
||||
259
apps/api/src/ivr/ivr-menus.controller.ts
Normal file
259
apps/api/src/ivr/ivr-menus.controller.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
ConflictException,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { XMLValidator } from "fast-xml-parser";
|
||||
import { getPrismaClient, withTenantContext, Prisma } from "@b2bcall/database";
|
||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { buildDialplanXml, buildIvrDialplanExtensions, type IvrMenuOptionInput } from "@b2bcall/telephony";
|
||||
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";
|
||||
import { CreateIvrMenuDto, UpdateIvrMenuDto } from "./dto/create-ivr-menu.dto";
|
||||
|
||||
/**
|
||||
* Tela de autoria de IVR (PHASE 58, docs/INBOUND_ROUTES.md) — por cima do
|
||||
* editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu`
|
||||
* recompila e reativa uma versão nova do contexto correspondente, o
|
||||
* mesmo fluxo generate+activate que o editor manual faz, só que
|
||||
* automático. Uma `InboundRoute` aponta pra cá com
|
||||
* `destinationContext = IvrMenu.context` e
|
||||
* `destinationNumber = IVR_ENTRY_DESTINATION` ("ivr_entry").
|
||||
*/
|
||||
async function compileAndActivateIvrDialplan(
|
||||
prisma: ReturnType<typeof getPrismaClient>,
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
menu: { context: string; greeting: string | null },
|
||||
options: IvrMenuOptionInput[],
|
||||
): Promise<void> {
|
||||
// Substitui as linhas compiladas anteriores desse contexto — nunca
|
||||
// acumula lixo de compilações antigas (mesmo padrão de "editar" já
|
||||
// usado em reset-password/reveal-password: nunca reexpor/reaproveitar
|
||||
// o estado velho, sempre um recorte limpo do estado atual).
|
||||
await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.dialplanExtension.updateMany({
|
||||
where: { tenantId, context: menu.context, deletedAt: null },
|
||||
data: { deletedAt: new Date(), enabled: false },
|
||||
}),
|
||||
);
|
||||
|
||||
const compiled = buildIvrDialplanExtensions(menu, options);
|
||||
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||
for (const ext of compiled) {
|
||||
await tx.dialplanExtension.create({
|
||||
data: {
|
||||
tenantId,
|
||||
context: menu.context,
|
||||
name: ext.name,
|
||||
conditionField: ext.conditionField,
|
||||
conditionExpr: ext.conditionExpr,
|
||||
actions: ext.actions as unknown as Prisma.InputJsonValue,
|
||||
continueOnFalse: ext.continueOnFalse,
|
||||
order: ext.order,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const xml = buildDialplanXml(menu.context, compiled);
|
||||
const validation = XMLValidator.validate(xml);
|
||||
if (validation !== true) {
|
||||
throw new BadRequestException(`XML gerado invalido: ${validation.err.msg}`);
|
||||
}
|
||||
|
||||
const last = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.dialplanVersion.findFirst({ where: { tenantId, context: menu.context }, orderBy: { version: "desc" } }),
|
||||
);
|
||||
const nextVersion = (last?.version ?? 0) + 1;
|
||||
|
||||
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||
await tx.dialplanVersion.updateMany({
|
||||
where: { tenantId, context: menu.context, status: "ACTIVE" },
|
||||
data: { status: "SUPERSEDED" },
|
||||
});
|
||||
await tx.dialplanVersion.create({
|
||||
data: {
|
||||
tenantId,
|
||||
context: menu.context,
|
||||
version: nextVersion,
|
||||
generatedXml: xml,
|
||||
status: "ACTIVE",
|
||||
createdByUserId: userId,
|
||||
activatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("ivr-menus")
|
||||
export class IvrMenusController {
|
||||
@RequirePermission("ivr.manage")
|
||||
@Post()
|
||||
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateIvrMenuDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
let menu;
|
||||
try {
|
||||
menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||
const created = await tx.ivrMenu.create({
|
||||
data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting },
|
||||
});
|
||||
await tx.ivrMenuOption.createMany({
|
||||
data: dto.options.map((opt) => ({
|
||||
tenantId,
|
||||
ivrMenuId: created.id,
|
||||
digit: opt.digit,
|
||||
destinationNumber: opt.destinationNumber,
|
||||
destinationContext: opt.destinationContext ?? "default",
|
||||
label: opt.label,
|
||||
})),
|
||||
});
|
||||
return created;
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||
throw new ConflictException("Já existe um menu de IVR com esse contexto neste tenant");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const createOptions: IvrMenuOptionInput[] = dto.options.map((o) => ({
|
||||
digit: o.digit,
|
||||
destinationNumber: o.destinationNumber,
|
||||
destinationContext: o.destinationContext ?? "default",
|
||||
}));
|
||||
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, createOptions);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "IVR_MENU_CREATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ivr_menu",
|
||||
entityId: menu.id,
|
||||
after: { name: menu.name, context: menu.context, optionCount: dto.options.length },
|
||||
});
|
||||
|
||||
return this.get(user, menu.id);
|
||||
}
|
||||
|
||||
@RequirePermission("ivr.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
return withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.ivrMenu.findMany({
|
||||
where: { deletedAt: null },
|
||||
include: { options: { orderBy: { digit: "asc" } } },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission("ivr.view")
|
||||
@Get(":id")
|
||||
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const menu = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.ivrMenu.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: { options: { orderBy: { digit: "asc" } } },
|
||||
}),
|
||||
);
|
||||
if (!menu) throw new NotFoundException();
|
||||
return menu;
|
||||
}
|
||||
|
||||
@RequirePermission("ivr.manage")
|
||||
@Patch(":id")
|
||||
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateIvrMenuDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const existing = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
|
||||
);
|
||||
if (!existing) throw new NotFoundException();
|
||||
|
||||
const menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||
const updated = await tx.ivrMenu.update({
|
||||
where: { id },
|
||||
data: { ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.greeting !== undefined ? { greeting: dto.greeting } : {}) },
|
||||
});
|
||||
if (dto.options) {
|
||||
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
|
||||
await tx.ivrMenuOption.createMany({
|
||||
data: dto.options.map((opt) => ({
|
||||
tenantId,
|
||||
ivrMenuId: id,
|
||||
digit: opt.digit,
|
||||
destinationNumber: opt.destinationNumber,
|
||||
destinationContext: opt.destinationContext ?? "default",
|
||||
label: opt.label,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
const options: IvrMenuOptionInput[] = dto.options
|
||||
? dto.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext ?? "default" }))
|
||||
: existing.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext }));
|
||||
|
||||
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, options);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "IVR_MENU_UPDATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ivr_menu",
|
||||
entityId: id,
|
||||
after: { name: menu.name, greeting: menu.greeting, optionCount: options.length },
|
||||
});
|
||||
|
||||
return this.get(user, id);
|
||||
}
|
||||
|
||||
@RequirePermission("ivr.manage")
|
||||
@Delete(":id")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const menu = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.findFirst({ where: { id, deletedAt: null } }));
|
||||
if (!menu) throw new NotFoundException();
|
||||
|
||||
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||
await tx.ivrMenu.update({ where: { id }, data: { deletedAt: new Date(), enabled: false } });
|
||||
await tx.dialplanExtension.updateMany({
|
||||
where: { tenantId, context: menu.context, deletedAt: null },
|
||||
data: { deletedAt: new Date(), enabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "IVR_MENU_DELETE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "ivr_menu",
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
apps/api/src/ivr/ivr.module.ts
Normal file
7
apps/api/src/ivr/ivr.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { IvrMenusController } from "./ivr-menus.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [IvrMenusController],
|
||||
})
|
||||
export class IvrModule {}
|
||||
55
apps/frontend/src/app/app/telefonia/ivr/actions.ts
Normal file
55
apps/frontend/src/app/app/telefonia/ivr/actions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import type { IvrMenu } 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 IvrMenuOptionInput {
|
||||
digit: string;
|
||||
destinationNumber: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface CreateIvrMenuInput {
|
||||
name: string;
|
||||
context: string;
|
||||
greeting?: string;
|
||||
options: IvrMenuOptionInput[];
|
||||
}
|
||||
|
||||
export async function createIvrMenu(input: CreateIvrMenuInput): Promise<{ ok: true; menu: IvrMenu } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const menu = await apiFetch<IvrMenu>("/ivr-menus", session.accessToken, { method: "POST", body: JSON.stringify(input) });
|
||||
revalidatePath("/app/telefonia/ivr");
|
||||
return { ok: true, menu };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteIvrMenu(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
await apiFetch<void>(`/ivr-menus/${id}`, session.accessToken, { method: "DELETE" });
|
||||
revalidatePath("/app/telefonia/ivr");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
314
apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx
Normal file
314
apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ListTree, Plus, Trash2, X } from "lucide-react";
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||
import { ALLOWED_IVR_DIGITS, IVR_ENTRY_DESTINATION, type IvrMenu } from "@/lib/callcenter-types";
|
||||
import type { Extension } from "@/lib/extension-types";
|
||||
import { createIvrMenu, deleteIvrMenu, type IvrMenuOptionInput } from "./actions";
|
||||
|
||||
function slugifyContext(name: string): string {
|
||||
return (
|
||||
"ivr-" +
|
||||
name
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
);
|
||||
}
|
||||
|
||||
export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: Extension[] }) {
|
||||
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">IVR</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Menus de atendimento automático — a pessoa liga, ouve um prompt e escolhe um dígito, que cai num ramal
|
||||
deste tenant. Pra receber ligações por esse menu, aponte uma rota de entrada (Telefonia > Rotas de
|
||||
Entrada) pro contexto e destino mostrados abaixo de cada menu.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Novo menu"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && <NewIvrMenuForm extensions={extensions} existingContexts={menus.map((m) => m.context)} onDone={() => setShowForm(false)} />}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Menus cadastrados" description={`${menus.length} menu(s) neste tenant`} />
|
||||
{menus.length === 0 ? (
|
||||
<EmptyState title="Nenhum menu de IVR cadastrado ainda" description="Crie o primeiro menu deste tenant." />
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{menus.map((menu) => (
|
||||
<li key={menu.id} className="space-y-3 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2 font-medium text-foreground">
|
||||
<ListTree className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{menu.name}
|
||||
<Pill tone={menu.enabled ? "accent" : "neutral"}>{menu.enabled ? "Ativo" : "Desativado"}</Pill>
|
||||
</span>
|
||||
<DeleteIvrMenuButton menuId={menu.id} menuName={menu.name} />
|
||||
</div>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
Rota de entrada: contexto <span className="text-foreground">{menu.context}</span> · destino{" "}
|
||||
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
|
||||
</p>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>Dígito</TH>
|
||||
<TH>Destino</TH>
|
||||
<TH>Descrição</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{menu.options.map((opt) => (
|
||||
<TR key={opt.id}>
|
||||
<TD className="font-mono font-medium text-foreground">{opt.digit}</TD>
|
||||
<TD className="font-mono text-muted-foreground">{opt.destinationNumber}</TD>
|
||||
<TD className="text-muted-foreground">{opt.label ?? "—"}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionRow {
|
||||
digit: string;
|
||||
destinationNumber: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function NewIvrMenuForm({
|
||||
extensions,
|
||||
existingContexts,
|
||||
onDone,
|
||||
}: {
|
||||
extensions: Extension[];
|
||||
existingContexts: string[];
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [context, setContext] = useState("");
|
||||
const [contextTouched, setContextTouched] = useState(false);
|
||||
const [options, setOptions] = useState<OptionRow[]>([{ digit: "1", destinationNumber: "", label: "" }]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const effectiveContext = contextTouched ? context : slugifyContext(name);
|
||||
const usedDigits = useMemo(() => new Set(options.map((o) => o.digit)), [options]);
|
||||
|
||||
function updateOption(index: number, patch: Partial<OptionRow>) {
|
||||
setOptions((prev) => prev.map((o, i) => (i === index ? { ...o, ...patch } : o)));
|
||||
}
|
||||
|
||||
function addOption() {
|
||||
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
|
||||
setOptions((prev) => [...prev, { digit: nextDigit, destinationNumber: "", label: "" }]);
|
||||
}
|
||||
|
||||
function removeOption(index: number) {
|
||||
setOptions((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!name.trim() || !effectiveContext.trim()) {
|
||||
setError("Nome é obrigatório.");
|
||||
return;
|
||||
}
|
||||
if (existingContexts.includes(effectiveContext)) {
|
||||
setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`);
|
||||
return;
|
||||
}
|
||||
if (options.length === 0 || options.some((o) => !o.destinationNumber.trim())) {
|
||||
setError("Toda opção precisa de um dígito e um ramal de destino.");
|
||||
return;
|
||||
}
|
||||
const digits = options.map((o) => o.digit);
|
||||
if (new Set(digits).size !== digits.length) {
|
||||
setError("Não pode repetir o mesmo dígito em duas opções.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => ({
|
||||
digit: o.digit,
|
||||
destinationNumber: o.destinationNumber.trim(),
|
||||
label: o.label.trim() || undefined,
|
||||
}));
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions });
|
||||
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="ivr-name">Nome do menu</FieldLabel>
|
||||
<Input id="ivr-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Vendas" disabled={pending} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="ivr-context">Contexto (gerado do nome, editável)</FieldLabel>
|
||||
<Input
|
||||
id="ivr-context"
|
||||
value={effectiveContext}
|
||||
onChange={(e) => {
|
||||
setContextTouched(true);
|
||||
setContext(e.target.value);
|
||||
}}
|
||||
placeholder="ivr-vendas"
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel htmlFor="ivr-opt-0">Opções do menu</FieldLabel>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addOption} disabled={pending || options.length >= 12}>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden /> Adicionar opção
|
||||
</Button>
|
||||
</div>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} className="grid grid-cols-1 gap-3 rounded-md border border-border p-3 sm:grid-cols-[6rem_1fr_1fr_auto]">
|
||||
<div>
|
||||
<FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel>
|
||||
<Select
|
||||
id={`ivr-opt-${i}-digit`}
|
||||
value={opt.digit}
|
||||
onChange={(e) => updateOption(i, { digit: e.target.value })}
|
||||
disabled={pending}
|
||||
>
|
||||
{ALLOWED_IVR_DIGITS.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Ramal de destino</FieldLabel>
|
||||
<Select
|
||||
id={`ivr-opt-${i}-dest`}
|
||||
value={opt.destinationNumber}
|
||||
onChange={(e) => updateOption(i, { destinationNumber: e.target.value })}
|
||||
disabled={pending}
|
||||
>
|
||||
<option value="">Selecione um ramal…</option>
|
||||
{extensions.map((ext) => (
|
||||
<option key={ext.id} value={ext.number}>
|
||||
{ext.number} — {ext.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel>
|
||||
<Input
|
||||
id={`ivr-opt-${i}-label`}
|
||||
value={opt.label}
|
||||
onChange={(e) => updateOption(i, { label: e.target.value })}
|
||||
placeholder="Ex.: Vendas"
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeOption(i)}
|
||||
disabled={pending || options.length <= 1}
|
||||
aria-label={`Remover opção ${opt.digit}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</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 menu"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteIvrMenuButton({ menuId, menuName }: { menuId: string; menuName: 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 deleteIvrMenu(menuId);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center 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 ${menuName}` : `Remover ${menuName}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{confirming ? "Confirmar" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
apps/frontend/src/app/app/telefonia/ivr/page.tsx
Normal file
14
apps/frontend/src/app/app/telefonia/ivr/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { IvrMenu } from "@/lib/callcenter-types";
|
||||
import type { Extension } from "@/lib/extension-types";
|
||||
import { IvrView } from "./ivr-view";
|
||||
|
||||
export default async function IvrPage() {
|
||||
const session = await requireSession();
|
||||
const [menus, extensions] = await Promise.all([
|
||||
apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken),
|
||||
apiFetch<Extension[]>("/extensions", session.accessToken),
|
||||
]);
|
||||
return <IvrView menus={menus} extensions={extensions} />;
|
||||
}
|
||||
@@ -105,6 +105,12 @@ export const TENANT_NAV: NavSection[] = [
|
||||
description: "Números (DID) recebidos por tronco — pra qual ramal/fila/IVR cada um cai",
|
||||
permission: "inbound_routes.view",
|
||||
},
|
||||
{
|
||||
label: "IVR",
|
||||
href: "/app/telefonia/ivr",
|
||||
description: "Menus de atendimento automático — prompt + dígito escolhido leva a um ramal",
|
||||
permission: "ivr.view",
|
||||
},
|
||||
{
|
||||
label: "Dialplan",
|
||||
href: "/app/telefonia/dialplan",
|
||||
|
||||
@@ -57,6 +57,30 @@ export interface InboundRoute {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const ALLOWED_IVR_DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "*", "#"] as const;
|
||||
|
||||
/** destination_number fixo pra entrar num menu — toda InboundRoute que
|
||||
* aponta pra um IvrMenu usa isso como destinationNumber (packages/telephony). */
|
||||
export const IVR_ENTRY_DESTINATION = "ivr_entry";
|
||||
|
||||
export interface IvrMenuOption {
|
||||
id: string;
|
||||
digit: string;
|
||||
destinationNumber: string;
|
||||
destinationContext: string;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface IvrMenu {
|
||||
id: string;
|
||||
name: string;
|
||||
context: string;
|
||||
greeting: string | null;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
options: IvrMenuOption[];
|
||||
}
|
||||
|
||||
export interface PauseReason {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user