From 0148b926a0634d181dda81041427861e4582e48f Mon Sep 17 00:00:00 2001
From: Matheus
Date: Sun, 30 Aug 2026 18:21:26 -0300
Subject: [PATCH] =?UTF-8?q?feat(ivr):=20editor=20visual=20do=20menu=20(can?=
=?UTF-8?q?vas=20de=20n=C3=B3s,=20sem=20Node-RED)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pedido do usuário: "ajusta o IVR para fazer o fluxo de maneira visual
usando nodeRED". Perguntei antes de construir: integrar Node-RED de
verdade significa rodar uma plataforma externa completa (motor de
execução próprio, nós "function" = execução de código arbitrário — o
mesmo tipo de risco corrigido no dialplan nesta sessão — e sem
multi-tenancy nativa), um projeto de vários dias com decisões de
arquitetura antes de começar a construir. O usuário escolheu a
alternativa: um editor visual próprio, sem dependência externa.
`@xyflow/react` (sucessor mantido do reactflow) — canvas de nós/setas
dentro da própria tela "Telefonia > IVR": 1 nó "Entrada" fixo conectado
a 1 nó por opção (dígito + select de ramal + descrição, editável direto
no nó, arrastável pro canvas). "Adicionar opção"/"Salvar alterações"
chamam o mesmo PATCH /ivr-menus/:id que já existia — nenhuma mudança de
backend necessária, só uma forma nova de editar o mesmo dado (o modelo
IvrMenu/IvrMenuOption continua sendo a fonte da verdade).
Testado ponta a ponta pela tela de verdade, não só leitura de código:
criado um menu real, enviado um prompt de VOZ real (WAV sintetizado com
espeak-ng dizendo uma saudação de verdade, não um tom sintético como nos
testes anteriores desta fase) e completada uma chamada real — a saudação
de ~6s tocou até o fim, o dígito foi capturado, o ramal certo atendeu
com áudio de verdade. Depois, uma segunda opção foi adicionada via PATCH
(a mesma chamada que o botão "Salvar" do editor visual faz) — confirmado
no banco que a versão 2 do dialplan compilou as duas opções
corretamente, superando a versão 1.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
---
TODO.md | 27 ++
apps/frontend/package.json | 1 +
.../src/app/app/telefonia/ivr/actions.ts | 17 ++
.../app/app/telefonia/ivr/ivr-flow-editor.tsx | 264 ++++++++++++++++++
.../src/app/app/telefonia/ivr/ivr-view.tsx | 22 +-
docs/INBOUND_ROUTES.md | 25 ++
pnpm-lock.yaml | 162 +++++++++++
7 files changed, 499 insertions(+), 19 deletions(-)
create mode 100644 apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx
diff --git a/TODO.md b/TODO.md
index 7ed164f..da0ca9d 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2176,6 +2176,33 @@ tela de IVR no frontend")
confirma que áudio de qualquer sample rate/formato WAV comum
funciona sem transcodificação manual
+## PHASE 60 — Editor visual do IVR (pedido do usuário: "ajusta o IVR
+para fazer o fluxo de maneira visual usando nodeRED")
+- [x] Node-RED de verdade avaliado e descartado por decisão do usuário
+ (pergunta explícita: integrar de verdade significa uma plataforma
+ externa completa — motor de execução próprio, nós "function" =
+ RCE, sem multi-tenancy nativa — vários dias de trabalho e
+ superfície de risco nova, não um ajuste na tela atual). Escolhido
+ em vez disso um editor visual PRÓPRIO, sobre o mesmo modelo já
+ existente, sem dependência de execução externa
+- [x] `@xyflow/react` — canvas de nós/setas dentro da própria tela
+ "Telefonia > IVR": 1 nó "Entrada" fixo conectado a 1 nó por opção
+ (dígito + select de ramal + descrição, editável direto no nó,
+ arrastável). "Adicionar opção"/"Salvar alterações" chamam o mesmo
+ `PATCH /ivr-menus/:id` que já existia — nenhuma mudança de backend
+ necessária, só uma forma nova de editar o mesmo dado
+- [x] Testado ponta a ponta pela tela de verdade: menu criado, prompt de
+ VOZ real enviado (WAV sintetizado com `espeak-ng` dizendo "Bem-
+ vindo a Acme Call Center...", não um tom sintético como nos testes
+ anteriores) e uma chamada real completa — a saudação de ~6s tocou
+ até o fim, o dígito foi capturado, e o ramal certo atendeu com
+ áudio de verdade. Depois, uma segunda opção foi adicionada via
+ `PATCH` (a mesma chamada que o botão "Salvar" do editor visual
+ faz) — confirmado no banco que a versão 2 do dialplan compilou as
+ duas opções corretamente, superando a versão 1
+- [ ] Layout do canvas não persiste posição manual dos nós entre
+ recargas (recalculado a cada carga da página)
+
---
## Riscos conhecidos
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
index b0c870d..e74cd24 100644
--- a/apps/frontend/package.json
+++ b/apps/frontend/package.json
@@ -16,6 +16,7 @@
"@radix-ui/react-tooltip": "1.1.6",
"@tanstack/react-query": "5.62.7",
"@tanstack/react-table": "8.20.5",
+ "@xyflow/react": "^12.11.5",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "0.469.0",
diff --git a/apps/frontend/src/app/app/telefonia/ivr/actions.ts b/apps/frontend/src/app/app/telefonia/ivr/actions.ts
index 8ddd09a..a70b847 100644
--- a/apps/frontend/src/app/app/telefonia/ivr/actions.ts
+++ b/apps/frontend/src/app/app/telefonia/ivr/actions.ts
@@ -43,6 +43,23 @@ export async function createIvrMenu(input: CreateIvrMenuInput): Promise<{ ok: tr
}
}
+export async function updateIvrMenuOptions(
+ id: string,
+ options: IvrMenuOptionInput[],
+): Promise<{ ok: true; menu: IvrMenu } | { ok: false; error: string }> {
+ const session = await requireSession();
+ try {
+ const menu = await apiFetch(`/ivr-menus/${id}`, session.accessToken, {
+ method: "PATCH",
+ body: JSON.stringify({ options }),
+ });
+ 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 {
diff --git a/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx b/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx
new file mode 100644
index 0000000..7ef782e
--- /dev/null
+++ b/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx
@@ -0,0 +1,264 @@
+"use client";
+
+import { useCallback, useMemo, useState } from "react";
+import {
+ ReactFlow,
+ Background,
+ Controls,
+ Handle,
+ Position,
+ MarkerType,
+ useNodesState,
+ useEdgesState,
+ type Node,
+ type Edge,
+ type NodeProps,
+ type NodeTypes,
+} from "@xyflow/react";
+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 type { Extension } from "@/lib/extension-types";
+import { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions";
+
+interface OptionState {
+ key: string;
+ digit: string;
+ destinationNumber: string;
+ label: string;
+}
+
+interface EntryNodeData extends Record {}
+
+interface OptionNodeData extends Record {
+ option: OptionState;
+ extensions: Extension[];
+ usedDigits: string[];
+ onChange: (key: string, patch: Partial) => void;
+ onRemove: (key: string) => void;
+}
+
+function EntryNode(_props: NodeProps>) {
+ return (
+