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:
2026-08-31 09:41:22 -03:00
parent 97ef8a6ba8
commit 432cd55adb
12 changed files with 427 additions and 78 deletions

View File

@@ -3,10 +3,18 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsStri
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const;
export class IvrMenuOptionDto {
@IsIn(ALLOWED_IVR_DIGITS)
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()
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
destinationNumber!: string;

View File

@@ -49,6 +49,53 @@ function promptHostPath(tenantId: string, menuId: string): string {
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
* editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu`
@@ -133,22 +180,31 @@ export class IvrMenusController {
const prisma = getPrismaClient();
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;
try {
menu = await withTenantContext(prisma, tenantId, async (tx) => {
await assertNoIvrCycle(tx, tenantId, dto.context, createOptions);
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) => ({
data: createOptions.map((opt, i) => ({
tenantId,
ivrMenuId: created.id,
digit: opt.digit,
destinationType: opt.destinationType,
destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default",
label: opt.label,
positionX: opt.positionX,
positionY: opt.positionY,
destinationContext: opt.destinationContext,
label: dto.options[i].label,
positionX: dto.options[i].positionX,
positionY: dto.options[i].positionY,
})),
});
return created;
@@ -160,11 +216,6 @@ export class IvrMenusController {
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, {
@@ -219,7 +270,24 @@ export class IvrMenusController {
);
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) => {
if (dto.options) {
await assertNoIvrCycle(tx, tenantId, existing.context, options);
}
const updated = await tx.ivrMenu.update({
where: { id },
data: {
@@ -232,12 +300,13 @@ export class IvrMenusController {
if (dto.options) {
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
await tx.ivrMenuOption.createMany({
data: dto.options.map((opt) => ({
data: dto.options.map((opt, i) => ({
tenantId,
ivrMenuId: id,
digit: opt.digit,
destinationType: options[i].destinationType,
destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default",
destinationContext: options[i].destinationContext,
label: opt.label,
positionX: opt.positionX,
positionY: opt.positionY,
@@ -247,10 +316,6 @@ export class IvrMenusController {
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, {
@@ -342,6 +407,7 @@ export class IvrMenusController {
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));
@@ -380,6 +446,7 @@ export class IvrMenusController {
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session";
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 {
if (err instanceof ApiError) {
@@ -21,7 +21,9 @@ function extractErrorMessage(err: unknown): string {
export interface IvrMenuOptionInput {
digit: string;
destinationType?: IvrOptionDestinationType;
destinationNumber: string;
destinationContext?: string;
label?: string;
positionX?: number;
positionY?: number;

View File

@@ -19,14 +19,23 @@ import "@xyflow/react/dist/style.css";
import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
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 { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions";
interface OptionState {
key: string;
digit: string;
destinationNumber: string;
destinationType: IvrOptionDestinationType;
destinationValue: string;
label: string;
positionX: number | null;
positionY: number | null;
@@ -37,6 +46,8 @@ interface EntryNodeData extends Record<string, unknown> {}
interface OptionNodeData extends Record<string, unknown> {
option: OptionState;
extensions: Extension[];
queues: Queue[];
menus: IvrMenu[];
usedDigits: string[];
onChange: (key: string, patch: Partial<OptionState>) => void;
onRemove: (key: string) => void;
@@ -56,7 +67,7 @@ function EntryNode(_props: NodeProps<Node<EntryNodeData, "entry">>) {
}
function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
const { option, extensions, usedDigits, onChange, onRemove } = data;
const { option, extensions, queues, menus, usedDigits, onChange, onRemove } = data;
return (
<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} />
@@ -85,16 +96,40 @@ function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
</div>
<Select
className="nodrag"
value={option.destinationNumber}
onChange={(e) => onChange(option.key, { destinationNumber: e.target.value })}
value={option.destinationType}
onChange={(e) => onChange(option.key, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
>
<option value="">Selecione um ramal</option>
{extensions.map((ext) => (
<option key={ext.id} value={ext.number}>
{ext.number} {ext.name}
{IVR_OPTION_DESTINATION_TYPES.map((t) => (
<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}>
{ext.number} {ext.name}
</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>
<Input
className="nodrag"
value={option.label}
@@ -114,6 +149,8 @@ function layoutNodes(
options: OptionState[],
entryPosition: { x: number; y: number } | null,
extensions: Extension[],
queues: Queue[],
menus: IvrMenu[],
onChange: OptionNodeData["onChange"],
onRemove: OptionNodeData["onRemove"],
) {
@@ -129,7 +166,7 @@ function layoutNodes(
id: option.key,
type: "option",
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,
}));
const edges: Edge[] = options.map((option) => ({
@@ -155,18 +192,29 @@ function nextKey(): string {
* direto os campos (dígito/ramal/descrição); salvar recompila o
* 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[]>(
() =>
menu.options.map((o) => ({
key: o.id,
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 ?? "",
positionX: o.positionX,
positionY: o.positionY,
})),
[menu.options],
[menu.options, menus],
);
const initialEntryPosition = useMemo(
() => (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(
() => 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)
[options, extensions, onChange, onRemove],
[options, extensions, queues, menus, onChange, onRemove],
);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
@@ -212,7 +260,10 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
setSaved(false);
const usedDigits = new Set(options.map((o) => o.digit));
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() {
@@ -221,8 +272,8 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
setError("O menu precisa de pelo menos uma opção.");
return;
}
if (options.some((o) => !o.destinationNumber.trim())) {
setError("Toda opção precisa de um ramal de destino selecionado.");
if (options.some((o) => !o.destinationValue.trim())) {
setError("Toda opção precisa de um destino selecionado.");
return;
}
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.
const payload: IvrMenuOptionInput[] = options.map((o) => {
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 {
digit: o.digit,
destinationType: "IVR",
destinationNumber: IVR_ENTRY_DESTINATION,
destinationContext: target?.context ?? "default",
label: o.label.trim() || undefined,
...position,
};
}
return {
digit: o.digit,
destinationNumber: o.destinationNumber.trim(),
destinationType: o.destinationType,
destinationNumber: o.destinationValue.trim(),
label: o.label.trim() || undefined,
positionX: node?.position.x,
positionY: node?.position.y,
...position,
};
});
const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID);

View File

@@ -8,7 +8,15 @@ import { Button } from "@/components/ui/button";
import { Input, Select, FieldLabel } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill";
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 { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions";
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);
return (
@@ -47,7 +55,15 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
</Button>
</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>
<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>
</p>
<PromptControl menu={menu} />
<IvrFlowEditor menu={menu} extensions={extensions} />
<IvrFlowEditor
menu={menu}
extensions={extensions}
queues={queues}
menus={menus.filter((m) => m.id !== menu.id)}
/>
</li>
))}
</ul>
@@ -143,23 +164,28 @@ function PromptControl({ menu }: { menu: IvrMenu }) {
interface OptionRow {
digit: string;
destinationNumber: string;
destinationType: IvrOptionDestinationType;
destinationValue: string;
label: string;
}
function NewIvrMenuForm({
extensions,
queues,
menus,
existingContexts,
onDone,
}: {
extensions: Extension[];
queues: Queue[];
menus: IvrMenu[];
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 [options, setOptions] = useState<OptionRow[]>([{ digit: "1", destinationType: "EXTENSION", destinationValue: "", label: "" }]);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
@@ -172,7 +198,7 @@ function NewIvrMenuForm({
function addOption() {
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) {
@@ -190,8 +216,8 @@ function NewIvrMenuForm({
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.");
if (options.length === 0 || options.some((o) => !o.destinationValue.trim())) {
setError("Toda opção precisa de um dígito e um destino selecionado.");
return;
}
const digits = options.map((o) => o.digit);
@@ -200,11 +226,24 @@ function NewIvrMenuForm({
return;
}
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => ({
digit: o.digit,
destinationNumber: o.destinationNumber.trim(),
label: o.label.trim() || undefined,
}));
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => {
if (o.destinationType === "IVR") {
const target = menus.find((m) => m.id === o.destinationValue);
return {
digit: o.digit,
destinationType: "IVR",
destinationNumber: IVR_ENTRY_DESTINATION,
destinationContext: target?.context ?? "default",
label: o.label.trim() || undefined,
};
}
return {
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationValue.trim(),
label: o.label.trim() || undefined,
};
});
startTransition(async () => {
const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions });
@@ -247,7 +286,7 @@ function NewIvrMenuForm({
</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 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>
<FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel>
<Select
@@ -264,21 +303,52 @@ function NewIvrMenuForm({
</Select>
</div>
<div>
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Ramal de destino</FieldLabel>
<FieldLabel htmlFor={`ivr-opt-${i}-type`}>Tipo</FieldLabel>
<Select
id={`ivr-opt-${i}-dest`}
value={opt.destinationNumber}
onChange={(e) => updateOption(i, { destinationNumber: e.target.value })}
id={`ivr-opt-${i}-type`}
value={opt.destinationType}
onChange={(e) => updateOption(i, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
disabled={pending}
>
<option value="">Selecione um ramal</option>
{extensions.map((ext) => (
<option key={ext.id} value={ext.number}>
{ext.number} {ext.name}
{IVR_OPTION_DESTINATION_TYPES.map((t) => (
<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}>
{ext.number} {ext.name}
</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>
{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>
<FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel>
<Input

View File

@@ -1,14 +1,15 @@
import { requireSession } from "@/lib/session";
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 { IvrView } from "./ivr-view";
export default async function IvrPage() {
const session = await requireSession();
const [menus, extensions] = await Promise.all([
const [menus, extensions, queues] = await Promise.all([
apiFetch<IvrMenu[]>("/ivr-menus", 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} />;
}

View File

@@ -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). */
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 {
id: string;
digit: string;
destinationType: IvrOptionDestinationType;
destinationNumber: string;
destinationContext: string;
label: string | null;