feat(ivr): dropdown de destino (fila/outro IVR) numa opção de menu, com bloqueio de ciclo (PHASE 67)
Mesmo gap que Rotas de Entrada já tinha resolvido na PHASE 62: uma opção dentro de um menu de IVR só sabia apontar pra ramal (bridge fixo). Novo IvrMenuOption.destinationType (EXTENSION/QUEUE/IVR) decide a action de cada branch em buildIvrDialplanExtensions, mesmo padrão de buildInboundRouteXml — QUEUE vira answer+callcenter, IVR vira transfer pro IVR_ENTRY_DESTINATION do menu alvo (efetivamente um sub-menu). Sem CALL_GROUP aqui de propósito: diferente de InboundRoute (resolvido a cada chamada), o dialplan de um IVR é compilado uma vez ao salvar — "quem está no grupo agora" ficaria desatualizado até a próxima edição. "Outro IVR" cria a primeira forma de um menu apontar pra outro (uma InboundRoute nunca é ela mesma um menu, nunca formava ciclo antes). assertNoIvrCycle monta o grafo com todos os menus do tenant antes de compilar e rejeita qualquer save que criaria um ciclo, em create e update. Os dois editores de IVR (form clássico e o editor visual de nós) ganharam o mesmo par de dropdowns "Tipo"/"Destino" já usado em Rotas de Entrada. Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR reais criados na hora: menu com opção Fila e menu com opção Outro IVR apontando pro primeiro, os dois persistindo certo depois de reload completo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
39
TODO.md
39
TODO.md
@@ -2417,6 +2417,45 @@ já em produção] que depois vai vir via sip comum ao b2bcall")
|
|||||||
`sip-user`/`sip-domain`/`sip-server` corretos e a senha presente
|
`sip-user`/`sip-domain`/`sip-server` corretos e a senha presente
|
||||||
(não vazia) assim que o agente com ramal vinculado abre `/app`.
|
(não vazia) assim que o agente com ramal vinculado abre `/app`.
|
||||||
|
|
||||||
|
## PHASE 67 — Dropdown de destino (fila/outro IVR) numa opção de IVR
|
||||||
|
(pedido do usuário: "dentro do IVR tem que dar a opção de eu colocar como
|
||||||
|
destino não apenas os ramais mas tb as filas e outra IVR, como feito na
|
||||||
|
rota de entrada")
|
||||||
|
- [x] Mesmo gap que existia em Rotas de Entrada até a PHASE 62:
|
||||||
|
`IvrMenuOption` só tinha `destinationNumber` (sempre interpretado
|
||||||
|
como ramal), sem tipo nenhum. Novo enum próprio
|
||||||
|
`IvrOptionDestinationType` (EXTENSION/QUEUE/IVR) — SEM
|
||||||
|
`CALL_GROUP` de propósito: diferente de `InboundRoute` (resolvido
|
||||||
|
por chamada, sempre fresco), o dialplan de um IVR é compilado UMA
|
||||||
|
VEZ ao salvar o menu — "quem está no grupo agora" ficaria
|
||||||
|
desatualizado até a próxima edição, comportamento silenciosamente
|
||||||
|
stale que ninguém pediu.
|
||||||
|
- [x] `buildIvrDialplanExtensions` (packages/telephony) — cada branch de
|
||||||
|
dígito decide a action pelo `destinationType`, mesmo padrão de
|
||||||
|
`buildInboundRouteXml`: EXTENSION continua `bridge` direto;
|
||||||
|
QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro
|
||||||
|
`IVR_ENTRY_DESTINATION` do contexto do menu alvo — reaproveita a
|
||||||
|
MESMA entrada que uma `InboundRoute` usa pra entrar naquele menu.
|
||||||
|
- [x] "Outro IVR" cria um grafo entre menus que nunca existia antes (uma
|
||||||
|
`InboundRoute` não é ela mesma um menu, nunca podia formar ciclo).
|
||||||
|
`assertNoIvrCycle` (novo, `ivr-menus.controller.ts`) monta o grafo
|
||||||
|
com TODOS os menus do tenant antes de compilar e rejeita
|
||||||
|
(409) qualquer combinação que criaria um ciclo — A→B→A ou até
|
||||||
|
A→A — checado tanto em `create` quanto `update`.
|
||||||
|
- [x] Frontend: os dois editores de IVR (form clássico `ivr-view.tsx` e
|
||||||
|
o editor visual `ivr-flow-editor.tsx`, PHASE 60/61) ganharam o
|
||||||
|
mesmo dropdown "Tipo" + "Destino" condicional já usado em Rotas de
|
||||||
|
Entrada — precisou buscar `queues` na `page.tsx` do IVR (só
|
||||||
|
`extensions` era buscado antes) e passar a lista de OUTROS menus
|
||||||
|
(excluindo o próprio, de propósito, pra reduzir a chance de
|
||||||
|
self-reference direto na UI — o backend segue sendo quem
|
||||||
|
garante isso de verdade).
|
||||||
|
- [x] Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR
|
||||||
|
reais criados na hora: Menu A com opção tipo Fila (aponta pra
|
||||||
|
"Fila QA"), Menu B com opção tipo Outro IVR (aponta pro Menu A) —
|
||||||
|
os dois persistiram com o tipo certo, sobrevivendo a um reload
|
||||||
|
completo da página.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Riscos conhecidos
|
## Riscos conhecidos
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsStri
|
|||||||
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
|
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
|
||||||
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
|
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
|
||||||
|
|
||||||
|
const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const;
|
||||||
|
|
||||||
export class IvrMenuOptionDto {
|
export class IvrMenuOptionDto {
|
||||||
@IsIn(ALLOWED_IVR_DIGITS)
|
@IsIn(ALLOWED_IVR_DIGITS)
|
||||||
digit!: string;
|
digit!: string;
|
||||||
|
|
||||||
|
// PHASE 67 — decide como destinationNumber/destinationContext são
|
||||||
|
// interpretados (ver comentário em IvrMenuOption no schema).
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(IVR_OPTION_DESTINATION_TYPES)
|
||||||
|
destinationType?: (typeof IVR_OPTION_DESTINATION_TYPES)[number];
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
|
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
|
||||||
destinationNumber!: string;
|
destinationNumber!: string;
|
||||||
|
|||||||
@@ -49,6 +49,53 @@ function promptHostPath(tenantId: string, menuId: string): string {
|
|||||||
return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`);
|
return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHASE 67 — destino "Outro IVR" cria um grafo entre menus (algo que
|
||||||
|
* InboundRoute->IVR nunca tinha, já que uma rota de entrada não é ela
|
||||||
|
* mesma um menu). Sem essa checagem, dois menus apontando um pro outro
|
||||||
|
* (ou um menu apontando pra si mesmo) compilam sem erro nenhum e só
|
||||||
|
* travam a experiência do CHAMADOR depois (fica indo de um prompt pro
|
||||||
|
* outro pra sempre) — pega isso antes de compilar, não depois de alguém
|
||||||
|
* reclamar. Monta o grafo com TODOS os menus do tenant (não só os que já
|
||||||
|
* existiam antes desta operação), substituindo as arestas do menu sendo
|
||||||
|
* salvo pelas opções novas — cobre tanto create quanto update.
|
||||||
|
*/
|
||||||
|
async function assertNoIvrCycle(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
tenantId: string,
|
||||||
|
menuContext: string,
|
||||||
|
newOptions: IvrMenuOptionInput[],
|
||||||
|
): Promise<void> {
|
||||||
|
const menus = await tx.ivrMenu.findMany({
|
||||||
|
where: { tenantId, deletedAt: null },
|
||||||
|
select: { context: true, options: { select: { destinationType: true, destinationContext: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges = new Map<string, string[]>();
|
||||||
|
for (const m of menus) {
|
||||||
|
edges.set(
|
||||||
|
m.context,
|
||||||
|
m.options.filter((o) => o.destinationType === "IVR").map((o) => o.destinationContext),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
edges.set(
|
||||||
|
menuContext,
|
||||||
|
newOptions.filter((o) => o.destinationType === "IVR").map((o) => o.destinationContext),
|
||||||
|
);
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const stack = [...(edges.get(menuContext) ?? [])];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const next = stack.pop()!;
|
||||||
|
if (next === menuContext) {
|
||||||
|
throw new ConflictException(`Esse destino criaria um ciclo entre menus de IVR (via "${next}")`);
|
||||||
|
}
|
||||||
|
if (visited.has(next)) continue;
|
||||||
|
visited.add(next);
|
||||||
|
stack.push(...(edges.get(next) ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tela de autoria de IVR (PHASE 58, docs/INBOUND_ROUTES.md) — por cima do
|
* 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`
|
* editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu`
|
||||||
@@ -133,22 +180,31 @@ export class IvrMenusController {
|
|||||||
const prisma = getPrismaClient();
|
const prisma = getPrismaClient();
|
||||||
const tenantId = user.tenantId!;
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const createOptions: IvrMenuOptionInput[] = dto.options.map((o) => ({
|
||||||
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType ?? "EXTENSION",
|
||||||
|
destinationNumber: o.destinationNumber,
|
||||||
|
destinationContext: o.destinationContext ?? "default",
|
||||||
|
}));
|
||||||
|
|
||||||
let menu;
|
let menu;
|
||||||
try {
|
try {
|
||||||
menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
await assertNoIvrCycle(tx, tenantId, dto.context, createOptions);
|
||||||
const created = await tx.ivrMenu.create({
|
const created = await tx.ivrMenu.create({
|
||||||
data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting },
|
data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting },
|
||||||
});
|
});
|
||||||
await tx.ivrMenuOption.createMany({
|
await tx.ivrMenuOption.createMany({
|
||||||
data: dto.options.map((opt) => ({
|
data: createOptions.map((opt, i) => ({
|
||||||
tenantId,
|
tenantId,
|
||||||
ivrMenuId: created.id,
|
ivrMenuId: created.id,
|
||||||
digit: opt.digit,
|
digit: opt.digit,
|
||||||
|
destinationType: opt.destinationType,
|
||||||
destinationNumber: opt.destinationNumber,
|
destinationNumber: opt.destinationNumber,
|
||||||
destinationContext: opt.destinationContext ?? "default",
|
destinationContext: opt.destinationContext,
|
||||||
label: opt.label,
|
label: dto.options[i].label,
|
||||||
positionX: opt.positionX,
|
positionX: dto.options[i].positionX,
|
||||||
positionY: opt.positionY,
|
positionY: dto.options[i].positionY,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
return created;
|
return created;
|
||||||
@@ -160,11 +216,6 @@ export class IvrMenusController {
|
|||||||
throw err;
|
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 compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, createOptions);
|
||||||
|
|
||||||
await recordAuditEvent(prisma, {
|
await recordAuditEvent(prisma, {
|
||||||
@@ -219,7 +270,24 @@ export class IvrMenusController {
|
|||||||
);
|
);
|
||||||
if (!existing) throw new NotFoundException();
|
if (!existing) throw new NotFoundException();
|
||||||
|
|
||||||
|
const options: IvrMenuOptionInput[] = dto.options
|
||||||
|
? dto.options.map((o) => ({
|
||||||
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType ?? "EXTENSION",
|
||||||
|
destinationNumber: o.destinationNumber,
|
||||||
|
destinationContext: o.destinationContext ?? "default",
|
||||||
|
}))
|
||||||
|
: existing.options.map((o) => ({
|
||||||
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType,
|
||||||
|
destinationNumber: o.destinationNumber,
|
||||||
|
destinationContext: o.destinationContext,
|
||||||
|
}));
|
||||||
|
|
||||||
const menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
const menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
if (dto.options) {
|
||||||
|
await assertNoIvrCycle(tx, tenantId, existing.context, options);
|
||||||
|
}
|
||||||
const updated = await tx.ivrMenu.update({
|
const updated = await tx.ivrMenu.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -232,12 +300,13 @@ export class IvrMenusController {
|
|||||||
if (dto.options) {
|
if (dto.options) {
|
||||||
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
|
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
|
||||||
await tx.ivrMenuOption.createMany({
|
await tx.ivrMenuOption.createMany({
|
||||||
data: dto.options.map((opt) => ({
|
data: dto.options.map((opt, i) => ({
|
||||||
tenantId,
|
tenantId,
|
||||||
ivrMenuId: id,
|
ivrMenuId: id,
|
||||||
digit: opt.digit,
|
digit: opt.digit,
|
||||||
|
destinationType: options[i].destinationType,
|
||||||
destinationNumber: opt.destinationNumber,
|
destinationNumber: opt.destinationNumber,
|
||||||
destinationContext: opt.destinationContext ?? "default",
|
destinationContext: options[i].destinationContext,
|
||||||
label: opt.label,
|
label: opt.label,
|
||||||
positionX: opt.positionX,
|
positionX: opt.positionX,
|
||||||
positionY: opt.positionY,
|
positionY: opt.positionY,
|
||||||
@@ -247,10 +316,6 @@ export class IvrMenusController {
|
|||||||
return updated;
|
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 compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, options);
|
||||||
|
|
||||||
await recordAuditEvent(prisma, {
|
await recordAuditEvent(prisma, {
|
||||||
@@ -342,6 +407,7 @@ export class IvrMenusController {
|
|||||||
|
|
||||||
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
||||||
digit: o.digit,
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType,
|
||||||
destinationNumber: o.destinationNumber,
|
destinationNumber: o.destinationNumber,
|
||||||
destinationContext: o.destinationContext,
|
destinationContext: o.destinationContext,
|
||||||
}));
|
}));
|
||||||
@@ -380,6 +446,7 @@ export class IvrMenusController {
|
|||||||
|
|
||||||
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
||||||
digit: o.digit,
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType,
|
||||||
destinationNumber: o.destinationNumber,
|
destinationNumber: o.destinationNumber,
|
||||||
destinationContext: o.destinationContext,
|
destinationContext: o.destinationContext,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requireSession } from "@/lib/session";
|
import { requireSession } from "@/lib/session";
|
||||||
import { apiFetch, ApiError, API_BASE_URL } from "@/lib/api";
|
import { apiFetch, ApiError, API_BASE_URL } from "@/lib/api";
|
||||||
import type { IvrMenu } from "@/lib/callcenter-types";
|
import type { IvrMenu, IvrOptionDestinationType } from "@/lib/callcenter-types";
|
||||||
|
|
||||||
function extractErrorMessage(err: unknown): string {
|
function extractErrorMessage(err: unknown): string {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -21,7 +21,9 @@ function extractErrorMessage(err: unknown): string {
|
|||||||
|
|
||||||
export interface IvrMenuOptionInput {
|
export interface IvrMenuOptionInput {
|
||||||
digit: string;
|
digit: string;
|
||||||
|
destinationType?: IvrOptionDestinationType;
|
||||||
destinationNumber: string;
|
destinationNumber: string;
|
||||||
|
destinationContext?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
positionX?: number;
|
positionX?: number;
|
||||||
positionY?: number;
|
positionY?: number;
|
||||||
|
|||||||
@@ -19,14 +19,23 @@ import "@xyflow/react/dist/style.css";
|
|||||||
import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react";
|
import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input, Select } from "@/components/ui/input";
|
import { Input, Select } from "@/components/ui/input";
|
||||||
import { ALLOWED_IVR_DIGITS, type IvrMenu } from "@/lib/callcenter-types";
|
import {
|
||||||
|
ALLOWED_IVR_DIGITS,
|
||||||
|
IVR_ENTRY_DESTINATION,
|
||||||
|
IVR_OPTION_DESTINATION_TYPES,
|
||||||
|
IVR_OPTION_DESTINATION_TYPE_LABELS,
|
||||||
|
type IvrMenu,
|
||||||
|
type IvrOptionDestinationType,
|
||||||
|
type Queue,
|
||||||
|
} from "@/lib/callcenter-types";
|
||||||
import type { Extension } from "@/lib/extension-types";
|
import type { Extension } from "@/lib/extension-types";
|
||||||
import { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions";
|
import { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions";
|
||||||
|
|
||||||
interface OptionState {
|
interface OptionState {
|
||||||
key: string;
|
key: string;
|
||||||
digit: string;
|
digit: string;
|
||||||
destinationNumber: string;
|
destinationType: IvrOptionDestinationType;
|
||||||
|
destinationValue: string;
|
||||||
label: string;
|
label: string;
|
||||||
positionX: number | null;
|
positionX: number | null;
|
||||||
positionY: number | null;
|
positionY: number | null;
|
||||||
@@ -37,6 +46,8 @@ interface EntryNodeData extends Record<string, unknown> {}
|
|||||||
interface OptionNodeData extends Record<string, unknown> {
|
interface OptionNodeData extends Record<string, unknown> {
|
||||||
option: OptionState;
|
option: OptionState;
|
||||||
extensions: Extension[];
|
extensions: Extension[];
|
||||||
|
queues: Queue[];
|
||||||
|
menus: IvrMenu[];
|
||||||
usedDigits: string[];
|
usedDigits: string[];
|
||||||
onChange: (key: string, patch: Partial<OptionState>) => void;
|
onChange: (key: string, patch: Partial<OptionState>) => void;
|
||||||
onRemove: (key: string) => void;
|
onRemove: (key: string) => void;
|
||||||
@@ -56,7 +67,7 @@ function EntryNode(_props: NodeProps<Node<EntryNodeData, "entry">>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
|
function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
|
||||||
const { option, extensions, usedDigits, onChange, onRemove } = data;
|
const { option, extensions, queues, menus, usedDigits, onChange, onRemove } = data;
|
||||||
return (
|
return (
|
||||||
<div className="w-[230px] space-y-2 rounded-lg border border-border bg-surface px-3 py-3 shadow-panel">
|
<div className="w-[230px] space-y-2 rounded-lg border border-border bg-surface px-3 py-3 shadow-panel">
|
||||||
<Handle type="target" position={Position.Top} />
|
<Handle type="target" position={Position.Top} />
|
||||||
@@ -85,15 +96,39 @@ function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
|
|||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
className="nodrag"
|
className="nodrag"
|
||||||
value={option.destinationNumber}
|
value={option.destinationType}
|
||||||
onChange={(e) => onChange(option.key, { destinationNumber: e.target.value })}
|
onChange={(e) => onChange(option.key, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
|
||||||
>
|
>
|
||||||
<option value="">Selecione um ramal…</option>
|
{IVR_OPTION_DESTINATION_TYPES.map((t) => (
|
||||||
{extensions.map((ext) => (
|
<option key={t} value={t}>
|
||||||
|
{IVR_OPTION_DESTINATION_TYPE_LABELS[t]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
className="nodrag"
|
||||||
|
value={option.destinationValue}
|
||||||
|
onChange={(e) => onChange(option.key, { destinationValue: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Selecione…</option>
|
||||||
|
{option.destinationType === "EXTENSION" &&
|
||||||
|
extensions.map((ext) => (
|
||||||
<option key={ext.id} value={ext.number}>
|
<option key={ext.id} value={ext.number}>
|
||||||
{ext.number} — {ext.name}
|
{ext.number} — {ext.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
{option.destinationType === "QUEUE" &&
|
||||||
|
queues.map((q) => (
|
||||||
|
<option key={q.id} value={q.id}>
|
||||||
|
{q.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{option.destinationType === "IVR" &&
|
||||||
|
menus.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
className="nodrag"
|
className="nodrag"
|
||||||
@@ -114,6 +149,8 @@ function layoutNodes(
|
|||||||
options: OptionState[],
|
options: OptionState[],
|
||||||
entryPosition: { x: number; y: number } | null,
|
entryPosition: { x: number; y: number } | null,
|
||||||
extensions: Extension[],
|
extensions: Extension[],
|
||||||
|
queues: Queue[],
|
||||||
|
menus: IvrMenu[],
|
||||||
onChange: OptionNodeData["onChange"],
|
onChange: OptionNodeData["onChange"],
|
||||||
onRemove: OptionNodeData["onRemove"],
|
onRemove: OptionNodeData["onRemove"],
|
||||||
) {
|
) {
|
||||||
@@ -129,7 +166,7 @@ function layoutNodes(
|
|||||||
id: option.key,
|
id: option.key,
|
||||||
type: "option",
|
type: "option",
|
||||||
position: option.positionX != null && option.positionY != null ? { x: option.positionX, y: option.positionY } : { x: i * COLUMN_WIDTH, y: 160 },
|
position: option.positionX != null && option.positionY != null ? { x: option.positionX, y: option.positionY } : { x: i * COLUMN_WIDTH, y: 160 },
|
||||||
data: { option, extensions, usedDigits, onChange, onRemove },
|
data: { option, extensions, queues, menus, usedDigits, onChange, onRemove },
|
||||||
draggable: true,
|
draggable: true,
|
||||||
}));
|
}));
|
||||||
const edges: Edge[] = options.map((option) => ({
|
const edges: Edge[] = options.map((option) => ({
|
||||||
@@ -155,18 +192,29 @@ function nextKey(): string {
|
|||||||
* direto os campos (dígito/ramal/descrição); salvar recompila o
|
* direto os campos (dígito/ramal/descrição); salvar recompila o
|
||||||
* dialplan no backend exatamente como o formulário antigo fazia.
|
* dialplan no backend exatamente como o formulário antigo fazia.
|
||||||
*/
|
*/
|
||||||
export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions: Extension[] }) {
|
export function IvrFlowEditor({
|
||||||
|
menu,
|
||||||
|
extensions,
|
||||||
|
queues,
|
||||||
|
menus,
|
||||||
|
}: {
|
||||||
|
menu: IvrMenu;
|
||||||
|
extensions: Extension[];
|
||||||
|
queues: Queue[];
|
||||||
|
menus: IvrMenu[];
|
||||||
|
}) {
|
||||||
const initialOptions = useMemo<OptionState[]>(
|
const initialOptions = useMemo<OptionState[]>(
|
||||||
() =>
|
() =>
|
||||||
menu.options.map((o) => ({
|
menu.options.map((o) => ({
|
||||||
key: o.id,
|
key: o.id,
|
||||||
digit: o.digit,
|
digit: o.digit,
|
||||||
destinationNumber: o.destinationNumber,
|
destinationType: o.destinationType,
|
||||||
|
destinationValue: o.destinationType === "IVR" ? (menus.find((m) => m.context === o.destinationContext)?.id ?? "") : o.destinationNumber,
|
||||||
label: o.label ?? "",
|
label: o.label ?? "",
|
||||||
positionX: o.positionX,
|
positionX: o.positionX,
|
||||||
positionY: o.positionY,
|
positionY: o.positionY,
|
||||||
})),
|
})),
|
||||||
[menu.options],
|
[menu.options, menus],
|
||||||
);
|
);
|
||||||
const initialEntryPosition = useMemo(
|
const initialEntryPosition = useMemo(
|
||||||
() => (menu.entryPositionX != null && menu.entryPositionY != null ? { x: menu.entryPositionX, y: menu.entryPositionY } : null),
|
() => (menu.entryPositionX != null && menu.entryPositionY != null ? { x: menu.entryPositionX, y: menu.entryPositionY } : null),
|
||||||
@@ -188,9 +236,9 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||||
() => layoutNodes(options, initialEntryPosition, extensions, onChange, onRemove),
|
() => layoutNodes(options, initialEntryPosition, extensions, queues, menus, onChange, onRemove),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialEntryPosition só serve pro layout inicial (posição arrastada depois vive em `nodes`, nunca recomputada daqui)
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialEntryPosition só serve pro layout inicial (posição arrastada depois vive em `nodes`, nunca recomputada daqui)
|
||||||
[options, extensions, onChange, onRemove],
|
[options, extensions, queues, menus, onChange, onRemove],
|
||||||
);
|
);
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||||
@@ -212,7 +260,10 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
|||||||
setSaved(false);
|
setSaved(false);
|
||||||
const usedDigits = new Set(options.map((o) => o.digit));
|
const usedDigits = new Set(options.map((o) => o.digit));
|
||||||
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
|
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
|
||||||
setOptions((prev) => [...prev, { key: nextKey(), digit: nextDigit, destinationNumber: "", label: "", positionX: null, positionY: null }]);
|
setOptions((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ key: nextKey(), digit: nextDigit, destinationType: "EXTENSION", destinationValue: "", label: "", positionX: null, positionY: null },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSave() {
|
function onSave() {
|
||||||
@@ -221,8 +272,8 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
|||||||
setError("O menu precisa de pelo menos uma opção.");
|
setError("O menu precisa de pelo menos uma opção.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.some((o) => !o.destinationNumber.trim())) {
|
if (options.some((o) => !o.destinationValue.trim())) {
|
||||||
setError("Toda opção precisa de um ramal de destino selecionado.");
|
setError("Toda opção precisa de um destino selecionado.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const digits = options.map((o) => o.digit);
|
const digits = options.map((o) => o.digit);
|
||||||
@@ -236,12 +287,24 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
|||||||
// pelo react-flow é o estado de nós, nunca `data.option` de volta.
|
// pelo react-flow é o estado de nós, nunca `data.option` de volta.
|
||||||
const payload: IvrMenuOptionInput[] = options.map((o) => {
|
const payload: IvrMenuOptionInput[] = options.map((o) => {
|
||||||
const node = nodes.find((n) => n.id === o.key);
|
const node = nodes.find((n) => n.id === o.key);
|
||||||
|
const position = { positionX: node?.position.x, positionY: node?.position.y };
|
||||||
|
if (o.destinationType === "IVR") {
|
||||||
|
const target = menus.find((m) => m.id === o.destinationValue);
|
||||||
return {
|
return {
|
||||||
digit: o.digit,
|
digit: o.digit,
|
||||||
destinationNumber: o.destinationNumber.trim(),
|
destinationType: "IVR",
|
||||||
|
destinationNumber: IVR_ENTRY_DESTINATION,
|
||||||
|
destinationContext: target?.context ?? "default",
|
||||||
label: o.label.trim() || undefined,
|
label: o.label.trim() || undefined,
|
||||||
positionX: node?.position.x,
|
...position,
|
||||||
positionY: node?.position.y,
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType,
|
||||||
|
destinationNumber: o.destinationValue.trim(),
|
||||||
|
label: o.label.trim() || undefined,
|
||||||
|
...position,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID);
|
const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID);
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||||
import { Pill } from "@/components/ui/pill";
|
import { Pill } from "@/components/ui/pill";
|
||||||
import { EmptyState } from "@/components/ui/table";
|
import { EmptyState } from "@/components/ui/table";
|
||||||
import { ALLOWED_IVR_DIGITS, IVR_ENTRY_DESTINATION, type IvrMenu } from "@/lib/callcenter-types";
|
import {
|
||||||
|
ALLOWED_IVR_DIGITS,
|
||||||
|
IVR_ENTRY_DESTINATION,
|
||||||
|
IVR_OPTION_DESTINATION_TYPES,
|
||||||
|
IVR_OPTION_DESTINATION_TYPE_LABELS,
|
||||||
|
type IvrMenu,
|
||||||
|
type IvrOptionDestinationType,
|
||||||
|
type Queue,
|
||||||
|
} from "@/lib/callcenter-types";
|
||||||
import type { Extension } from "@/lib/extension-types";
|
import type { Extension } from "@/lib/extension-types";
|
||||||
import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions";
|
import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions";
|
||||||
import { IvrFlowEditor } from "./ivr-flow-editor";
|
import { IvrFlowEditor } from "./ivr-flow-editor";
|
||||||
@@ -27,7 +35,7 @@ function slugifyContext(name: string): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: Extension[] }) {
|
export function IvrView({ menus, extensions, queues }: { menus: IvrMenu[]; extensions: Extension[]; queues: Queue[] }) {
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +55,15 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && <NewIvrMenuForm extensions={extensions} existingContexts={menus.map((m) => m.context)} onDone={() => setShowForm(false)} />}
|
{showForm && (
|
||||||
|
<NewIvrMenuForm
|
||||||
|
extensions={extensions}
|
||||||
|
queues={queues}
|
||||||
|
menus={menus}
|
||||||
|
existingContexts={menus.map((m) => m.context)}
|
||||||
|
onDone={() => setShowForm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader title="Menus cadastrados" description={`${menus.length} menu(s) neste tenant`} />
|
<PanelHeader title="Menus cadastrados" description={`${menus.length} menu(s) neste tenant`} />
|
||||||
@@ -70,7 +86,12 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
|
|||||||
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
|
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
|
||||||
</p>
|
</p>
|
||||||
<PromptControl menu={menu} />
|
<PromptControl menu={menu} />
|
||||||
<IvrFlowEditor menu={menu} extensions={extensions} />
|
<IvrFlowEditor
|
||||||
|
menu={menu}
|
||||||
|
extensions={extensions}
|
||||||
|
queues={queues}
|
||||||
|
menus={menus.filter((m) => m.id !== menu.id)}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -143,23 +164,28 @@ function PromptControl({ menu }: { menu: IvrMenu }) {
|
|||||||
|
|
||||||
interface OptionRow {
|
interface OptionRow {
|
||||||
digit: string;
|
digit: string;
|
||||||
destinationNumber: string;
|
destinationType: IvrOptionDestinationType;
|
||||||
|
destinationValue: string;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewIvrMenuForm({
|
function NewIvrMenuForm({
|
||||||
extensions,
|
extensions,
|
||||||
|
queues,
|
||||||
|
menus,
|
||||||
existingContexts,
|
existingContexts,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
extensions: Extension[];
|
extensions: Extension[];
|
||||||
|
queues: Queue[];
|
||||||
|
menus: IvrMenu[];
|
||||||
existingContexts: string[];
|
existingContexts: string[];
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [context, setContext] = useState("");
|
const [context, setContext] = useState("");
|
||||||
const [contextTouched, setContextTouched] = useState(false);
|
const [contextTouched, setContextTouched] = useState(false);
|
||||||
const [options, setOptions] = useState<OptionRow[]>([{ digit: "1", destinationNumber: "", label: "" }]);
|
const [options, setOptions] = useState<OptionRow[]>([{ digit: "1", destinationType: "EXTENSION", destinationValue: "", label: "" }]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -172,7 +198,7 @@ function NewIvrMenuForm({
|
|||||||
|
|
||||||
function addOption() {
|
function addOption() {
|
||||||
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
|
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
|
||||||
setOptions((prev) => [...prev, { digit: nextDigit, destinationNumber: "", label: "" }]);
|
setOptions((prev) => [...prev, { digit: nextDigit, destinationType: "EXTENSION", destinationValue: "", label: "" }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeOption(index: number) {
|
function removeOption(index: number) {
|
||||||
@@ -190,8 +216,8 @@ function NewIvrMenuForm({
|
|||||||
setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`);
|
setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.length === 0 || options.some((o) => !o.destinationNumber.trim())) {
|
if (options.length === 0 || options.some((o) => !o.destinationValue.trim())) {
|
||||||
setError("Toda opção precisa de um dígito e um ramal de destino.");
|
setError("Toda opção precisa de um dígito e um destino selecionado.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const digits = options.map((o) => o.digit);
|
const digits = options.map((o) => o.digit);
|
||||||
@@ -200,11 +226,24 @@ function NewIvrMenuForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => ({
|
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => {
|
||||||
|
if (o.destinationType === "IVR") {
|
||||||
|
const target = menus.find((m) => m.id === o.destinationValue);
|
||||||
|
return {
|
||||||
digit: o.digit,
|
digit: o.digit,
|
||||||
destinationNumber: o.destinationNumber.trim(),
|
destinationType: "IVR",
|
||||||
|
destinationNumber: IVR_ENTRY_DESTINATION,
|
||||||
|
destinationContext: target?.context ?? "default",
|
||||||
label: o.label.trim() || undefined,
|
label: o.label.trim() || undefined,
|
||||||
}));
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType,
|
||||||
|
destinationNumber: o.destinationValue.trim(),
|
||||||
|
label: o.label.trim() || undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions });
|
const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions });
|
||||||
@@ -247,7 +286,7 @@ function NewIvrMenuForm({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{options.map((opt, i) => (
|
{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 key={i} className="grid grid-cols-1 gap-3 rounded-md border border-border p-3 sm:grid-cols-[5rem_7rem_1fr_1fr_auto]">
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel>
|
<FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -264,20 +303,51 @@ function NewIvrMenuForm({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Ramal de destino</FieldLabel>
|
<FieldLabel htmlFor={`ivr-opt-${i}-type`}>Tipo</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
id={`ivr-opt-${i}-dest`}
|
id={`ivr-opt-${i}-type`}
|
||||||
value={opt.destinationNumber}
|
value={opt.destinationType}
|
||||||
onChange={(e) => updateOption(i, { destinationNumber: e.target.value })}
|
onChange={(e) => updateOption(i, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
>
|
>
|
||||||
<option value="">Selecione um ramal…</option>
|
{IVR_OPTION_DESTINATION_TYPES.map((t) => (
|
||||||
{extensions.map((ext) => (
|
<option key={t} value={t}>
|
||||||
|
{IVR_OPTION_DESTINATION_TYPE_LABELS[t]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Destino</FieldLabel>
|
||||||
|
<Select
|
||||||
|
id={`ivr-opt-${i}-dest`}
|
||||||
|
value={opt.destinationValue}
|
||||||
|
onChange={(e) => updateOption(i, { destinationValue: e.target.value })}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<option value="">Selecione…</option>
|
||||||
|
{opt.destinationType === "EXTENSION" &&
|
||||||
|
extensions.map((ext) => (
|
||||||
<option key={ext.id} value={ext.number}>
|
<option key={ext.id} value={ext.number}>
|
||||||
{ext.number} — {ext.name}
|
{ext.number} — {ext.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
{opt.destinationType === "QUEUE" &&
|
||||||
|
queues.map((q) => (
|
||||||
|
<option key={q.id} value={q.id}>
|
||||||
|
{q.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{opt.destinationType === "IVR" &&
|
||||||
|
menus.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
{opt.destinationType === "IVR" && menus.length === 0 && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Crie outro menu de IVR primeiro pra poder apontar pra ele.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel>
|
<FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { requireSession } from "@/lib/session";
|
import { requireSession } from "@/lib/session";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import type { IvrMenu } from "@/lib/callcenter-types";
|
import type { IvrMenu, Queue } from "@/lib/callcenter-types";
|
||||||
import type { Extension } from "@/lib/extension-types";
|
import type { Extension } from "@/lib/extension-types";
|
||||||
import { IvrView } from "./ivr-view";
|
import { IvrView } from "./ivr-view";
|
||||||
|
|
||||||
export default async function IvrPage() {
|
export default async function IvrPage() {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
const [menus, extensions] = await Promise.all([
|
const [menus, extensions, queues] = await Promise.all([
|
||||||
apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken),
|
apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken),
|
||||||
apiFetch<Extension[]>("/extensions", session.accessToken),
|
apiFetch<Extension[]>("/extensions", session.accessToken),
|
||||||
|
apiFetch<Queue[]>("/queues", session.accessToken),
|
||||||
]);
|
]);
|
||||||
return <IvrView menus={menus} extensions={extensions} />;
|
return <IvrView menus={menus} extensions={extensions} queues={queues} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,9 +74,19 @@ export const ALLOWED_IVR_DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9",
|
|||||||
* aponta pra um IvrMenu usa isso como destinationNumber (packages/telephony). */
|
* aponta pra um IvrMenu usa isso como destinationNumber (packages/telephony). */
|
||||||
export const IVR_ENTRY_DESTINATION = "ivr_entry";
|
export const IVR_ENTRY_DESTINATION = "ivr_entry";
|
||||||
|
|
||||||
|
export const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const;
|
||||||
|
export type IvrOptionDestinationType = (typeof IVR_OPTION_DESTINATION_TYPES)[number];
|
||||||
|
|
||||||
|
export const IVR_OPTION_DESTINATION_TYPE_LABELS: Record<IvrOptionDestinationType, string> = {
|
||||||
|
EXTENSION: "Ramal",
|
||||||
|
QUEUE: "Fila",
|
||||||
|
IVR: "Outro IVR",
|
||||||
|
};
|
||||||
|
|
||||||
export interface IvrMenuOption {
|
export interface IvrMenuOption {
|
||||||
id: string;
|
id: string;
|
||||||
digit: string;
|
digit: string;
|
||||||
|
destinationType: IvrOptionDestinationType;
|
||||||
destinationNumber: string;
|
destinationNumber: string;
|
||||||
destinationContext: string;
|
destinationContext: string;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
|
|||||||
@@ -255,13 +255,48 @@ ramais) + um segundo dropdown listando as opções reais do tenant pra
|
|||||||
cada tipo (ramais cadastrados, menus de IVR, filas, ou os valores
|
cada tipo (ramais cadastrados, menus de IVR, filas, ou os valores
|
||||||
distintos de `callGroup` já usados em algum ramal).
|
distintos de `callGroup` já usados em algum ramal).
|
||||||
|
|
||||||
|
## Dropdown de destino numa opção de IVR: ramal, fila ou outro IVR (PHASE 67)
|
||||||
|
|
||||||
|
Mesmo gap da seção acima, um nível abaixo: até aqui, uma OPÇÃO dentro de
|
||||||
|
um menu de IVR só sabia apontar pra ramal (`bridge` fixo). Rota de
|
||||||
|
entrada já suportava fila/grupo pra ENTRAR num menu, mas depois de
|
||||||
|
entrar, cada dígito só discava um ramal.
|
||||||
|
|
||||||
|
`IvrMenuOption.destinationType` (novo enum próprio,
|
||||||
|
`IvrOptionDestinationType`: `EXTENSION`/`QUEUE`/`IVR`) decide a action de
|
||||||
|
cada branch em `buildIvrDialplanExtensions` (packages/telephony), mesmo
|
||||||
|
padrão do `InboundRoute` acima:
|
||||||
|
|
||||||
|
- **EXTENSION**: inalterado — `bridge` direto pro ramal.
|
||||||
|
- **QUEUE**: `answer` + `callcenter data="<queueId>@<domain>"`.
|
||||||
|
- **IVR**: `transfer` pro `IVR_ENTRY_DESTINATION` do contexto do MENU
|
||||||
|
ALVO — reaproveita a mesma entrada que uma `InboundRoute` usa pra
|
||||||
|
entrar num IVR, então um menu vira efetivamente um sub-menu de outro.
|
||||||
|
|
||||||
|
**Sem `CALL_GROUP` aqui, de propósito** (diferente de `InboundRoute`,
|
||||||
|
que tem os 4 tipos): o dialplan de um IVR é compilado UMA VEZ, ao salvar
|
||||||
|
o menu (`compileAndActivateIvrDialplan`) — não resolvido a cada chamada
|
||||||
|
como a resolução de `InboundRoute`. Um destino `CALL_GROUP` ficaria
|
||||||
|
"quem estava no grupo no momento em que o menu foi salvo por último",
|
||||||
|
uma defasagem silenciosa que ninguém pediu — melhor não oferecer do que
|
||||||
|
oferecer errado.
|
||||||
|
|
||||||
|
**Ciclos**: "Outro IVR" cria a primeira forma de um menu apontar pra
|
||||||
|
outro (uma `InboundRoute` nunca é ela mesma um menu, então nunca podia
|
||||||
|
formar ciclo). `assertNoIvrCycle` (`ivr-menus.controller.ts`) monta o
|
||||||
|
grafo com todos os menus do tenant antes de compilar e rejeita (409)
|
||||||
|
qualquer save que criaria um ciclo (A→B→A, ou até A apontando pra si
|
||||||
|
mesmo) — checado tanto em `create` quanto `update`.
|
||||||
|
|
||||||
|
Os dois editores de IVR (form clássico e o editor visual de nós, PHASE
|
||||||
|
60/61) ganharam o mesmo par de dropdowns "Tipo"/"Destino" já usado em
|
||||||
|
Rotas de Entrada. Testado ponta a ponta: menu A com opção tipo Fila,
|
||||||
|
menu B com opção tipo Outro IVR apontando pro menu A — os dois
|
||||||
|
persistiram certo depois de um reload completo da página.
|
||||||
|
|
||||||
## O que falta
|
## O que falta
|
||||||
|
|
||||||
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
|
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
|
||||||
- Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem
|
|
||||||
destino "fila" — só ramal, dentro do contexto `default`. Rota de
|
|
||||||
entrada já suporta fila/grupo, mas um MENU de IVR ainda só bridge pra
|
|
||||||
ramal.
|
|
||||||
- Grupo de ramais só é alcançável por Rota de Entrada — não existe (e
|
- Grupo de ramais só é alcançável por Rota de Entrada — não existe (e
|
||||||
não foi pedido) um jeito de discar um grupo de dentro do próprio
|
não foi pedido) um jeito de discar um grupo de dentro do próprio
|
||||||
dialplan "default" via feature code.
|
dialplan "default" via feature code.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- PHASE 67: dropdown de destino (ramal/fila/outro IVR) numa opção de IVR,
|
||||||
|
-- mesmo gap que existia em Rotas de Entrada até a PHASE 62.
|
||||||
|
CREATE TYPE "ivr_option_destination_type" AS ENUM ('EXTENSION', 'QUEUE', 'IVR');
|
||||||
|
|
||||||
|
ALTER TABLE "ivr_menu_options" ADD COLUMN "destination_type" "ivr_option_destination_type" NOT NULL DEFAULT 'EXTENSION';
|
||||||
@@ -545,6 +545,14 @@ model IvrMenu {
|
|||||||
@@map("ivr_menus")
|
@@map("ivr_menus")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum IvrOptionDestinationType {
|
||||||
|
EXTENSION
|
||||||
|
QUEUE
|
||||||
|
IVR
|
||||||
|
|
||||||
|
@@map("ivr_option_destination_type")
|
||||||
|
}
|
||||||
|
|
||||||
model IvrMenuOption {
|
model IvrMenuOption {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
tenantId String @map("tenant_id") @db.Uuid
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
@@ -552,6 +560,25 @@ model IvrMenuOption {
|
|||||||
|
|
||||||
digit String // "0".."9", "*" ou "#" — validado na API, é o que vira o regex da extension de branching
|
digit String // "0".."9", "*" ou "#" — validado na API, é o que vira o regex da extension de branching
|
||||||
|
|
||||||
|
// PHASE 67 — achado real reportado pelo usuário: o destino de uma opção
|
||||||
|
// de IVR só podia ser ramal, sem dropdown de fila/outro menu, igual o
|
||||||
|
// gap que existia em Rotas de Entrada até a PHASE 62. Mesmo `destination
|
||||||
|
// Type` decide como `buildIvrDialplanExtensions` (packages/telephony)
|
||||||
|
// interpreta os 2 campos abaixo:
|
||||||
|
// EXTENSION -> destinationNumber é o número do ramal (bridge, igual
|
||||||
|
// sempre foi); destinationContext não usado
|
||||||
|
// QUEUE -> destinationNumber é o Queue.id (answer + callcenter,
|
||||||
|
// mesmo padrão de InboundRoute); destinationContext não usado
|
||||||
|
// IVR -> destinationNumber vira sempre IVR_ENTRY_DESTINATION;
|
||||||
|
// destinationContext é o IvrMenu.context do menu alvo
|
||||||
|
// (transfer pro próprio contexto, igual InboundRoute
|
||||||
|
// encaminha pra um IVR). Sem CALL_GROUP aqui de propósito:
|
||||||
|
// diferente de InboundRoute, o dialplan de um IVR é
|
||||||
|
// compilado uma vez ao salvar (não resolvido por chamada),
|
||||||
|
// então "quem está no grupo agora" ficaria desatualizado
|
||||||
|
// até a próxima edição — não pedido pelo usuário, não
|
||||||
|
// implementado.
|
||||||
|
destinationType IvrOptionDestinationType @default(EXTENSION) @map("destination_type")
|
||||||
destinationNumber String @map("destination_number") // ramal real dentro do contexto abaixo
|
destinationNumber String @map("destination_number") // ramal real dentro do contexto abaixo
|
||||||
destinationContext String @default("default") @map("destination_context")
|
destinationContext String @default("default") @map("destination_context")
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function escapeRegexLiteral(digit: string): string {
|
|||||||
|
|
||||||
export interface IvrMenuOptionInput {
|
export interface IvrMenuOptionInput {
|
||||||
digit: string;
|
digit: string;
|
||||||
|
destinationType: "EXTENSION" | "QUEUE" | "IVR";
|
||||||
destinationNumber: string;
|
destinationNumber: string;
|
||||||
destinationContext: string;
|
destinationContext: string;
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,14 @@ export interface IvrMenuOptionInput {
|
|||||||
* por uma extension nunca é enxergada por OUTRA extension na mesma
|
* por uma extension nunca é enxergada por OUTRA extension na mesma
|
||||||
* passada; só um `transfer` (nova consulta de dialplan) resolve isso —
|
* passada; só um `transfer` (nova consulta de dialplan) resolve isso —
|
||||||
* e uma extension por dígito, casando por `destination_number` normal.
|
* e uma extension por dígito, casando por `destination_number` normal.
|
||||||
|
*
|
||||||
|
* PHASE 67 (dropdown de destino real: ramal/fila/outro IVR) — cada branch
|
||||||
|
* decide a action pelo `destinationType`, mesmo padrão de
|
||||||
|
* `buildInboundRouteXml`: EXTENSION continua `bridge` direto pro ramal;
|
||||||
|
* QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro
|
||||||
|
* `IVR_ENTRY_DESTINATION` do contexto alvo (reaproveita a MESMA entrada
|
||||||
|
* que qualquer chamada de fora usa pra entrar naquele menu). Sem
|
||||||
|
* CALL_GROUP aqui — motivo no comentário do schema (`IvrMenuOption`).
|
||||||
*/
|
*/
|
||||||
export function buildIvrDialplanExtensions(
|
export function buildIvrDialplanExtensions(
|
||||||
menu: { context: string; greeting?: string | null },
|
menu: { context: string; greeting?: string | null },
|
||||||
@@ -61,14 +70,27 @@ export function buildIvrDialplanExtensions(
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const branches: DialplanExtensionInput[] = options.map((opt, i) => ({
|
const branches: DialplanExtensionInput[] = options.map((opt, i) => {
|
||||||
|
let actions: DialplanExtensionInput["actions"];
|
||||||
|
if (opt.destinationType === "QUEUE") {
|
||||||
|
actions = [
|
||||||
|
{ application: "answer" },
|
||||||
|
{ application: "callcenter", data: `${opt.destinationNumber}@\${domain_name}` },
|
||||||
|
];
|
||||||
|
} else if (opt.destinationType === "IVR") {
|
||||||
|
actions = [{ application: "transfer", data: `${IVR_ENTRY_DESTINATION} XML ${opt.destinationContext}` }];
|
||||||
|
} else {
|
||||||
|
actions = [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }];
|
||||||
|
}
|
||||||
|
return {
|
||||||
name: `IVR opção ${opt.digit}`,
|
name: `IVR opção ${opt.digit}`,
|
||||||
conditionField: "destination_number",
|
conditionField: "destination_number",
|
||||||
conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`,
|
conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`,
|
||||||
continueOnFalse: false,
|
continueOnFalse: false,
|
||||||
order: 10 + i,
|
order: 10 + i,
|
||||||
actions: [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }],
|
actions,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return [entry, ...branches];
|
return [entry, ...branches];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user