feat(ivr): editor visual do menu (canvas de nós, sem Node-RED)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<IvrMenu>(`/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 {
|
||||
|
||||
264
apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx
Normal file
264
apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx
Normal file
@@ -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<string, unknown> {}
|
||||
|
||||
interface OptionNodeData extends Record<string, unknown> {
|
||||
option: OptionState;
|
||||
extensions: Extension[];
|
||||
usedDigits: string[];
|
||||
onChange: (key: string, patch: Partial<OptionState>) => void;
|
||||
onRemove: (key: string) => void;
|
||||
}
|
||||
|
||||
function EntryNode(_props: NodeProps<Node<EntryNodeData, "entry">>) {
|
||||
return (
|
||||
<div className="min-w-[170px] rounded-lg border-2 border-accent bg-surface px-4 py-3 shadow-panel">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<PhoneIncoming className="h-4 w-4 text-accent" aria-hidden />
|
||||
Entrada do menu
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Atende, toca o prompt e aguarda 1 dígito</p>
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
|
||||
const { option, extensions, 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} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Select
|
||||
className="nodrag w-20"
|
||||
value={option.digit}
|
||||
onChange={(e) => onChange(option.key, { digit: e.target.value })}
|
||||
>
|
||||
{ALLOWED_IVR_DIGITS.map((d) => (
|
||||
<option key={d} value={d} disabled={d !== option.digit && usedDigits.includes(d)}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="nodrag h-7 w-7"
|
||||
onClick={() => onRemove(option.key)}
|
||||
aria-label={`Remover opção ${option.digit}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
className="nodrag"
|
||||
value={option.destinationNumber}
|
||||
onChange={(e) => onChange(option.key, { destinationNumber: e.target.value })}
|
||||
>
|
||||
<option value="">Selecione um ramal…</option>
|
||||
{extensions.map((ext) => (
|
||||
<option key={ext.id} value={ext.number}>
|
||||
{ext.number} — {ext.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
className="nodrag"
|
||||
value={option.label}
|
||||
onChange={(e) => onChange(option.key, { label: e.target.value })}
|
||||
placeholder="Descrição (opcional)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NODE_TYPES: NodeTypes = { entry: EntryNode, option: OptionNode };
|
||||
|
||||
const ENTRY_NODE_ID = "entry";
|
||||
const COLUMN_WIDTH = 260;
|
||||
|
||||
function layoutNodes(options: OptionState[], extensions: Extension[], onChange: OptionNodeData["onChange"], onRemove: OptionNodeData["onRemove"]) {
|
||||
const usedDigits = options.map((o) => o.digit);
|
||||
const entry: Node<EntryNodeData, "entry"> = {
|
||||
id: ENTRY_NODE_ID,
|
||||
type: "entry",
|
||||
position: { x: (Math.max(options.length, 1) * COLUMN_WIDTH) / 2 - 85, y: 0 },
|
||||
data: {},
|
||||
draggable: true,
|
||||
};
|
||||
const optionNodes: Node<OptionNodeData, "option">[] = options.map((option, i) => ({
|
||||
id: option.key,
|
||||
type: "option",
|
||||
position: { x: i * COLUMN_WIDTH, y: 160 },
|
||||
data: { option, extensions, usedDigits, onChange, onRemove },
|
||||
draggable: true,
|
||||
}));
|
||||
const edges: Edge[] = options.map((option) => ({
|
||||
id: `${ENTRY_NODE_ID}-${option.key}`,
|
||||
source: ENTRY_NODE_ID,
|
||||
target: option.key,
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
}));
|
||||
return { nodes: [entry, ...optionNodes], edges };
|
||||
}
|
||||
|
||||
let keyCounter = 0;
|
||||
function nextKey(): string {
|
||||
keyCounter += 1;
|
||||
return `opt-${Date.now()}-${keyCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor visual do menu de IVR (PHASE 60, pedido do usuário: "ajusta o
|
||||
* IVR para fazer o fluxo de maneira visual") — canvas de nós/arestas em
|
||||
* cima do MESMO modelo que já existia (entrada + opções por dígito),
|
||||
* sem nenhuma dependência de execução externa. Cada nó de opção edita
|
||||
* 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[] }) {
|
||||
const initialOptions = useMemo<OptionState[]>(
|
||||
() => menu.options.map((o) => ({ key: o.id, digit: o.digit, destinationNumber: o.destinationNumber, label: o.label ?? "" })),
|
||||
[menu.options],
|
||||
);
|
||||
const [options, setOptions] = useState<OptionState[]>(initialOptions);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const onChange = useCallback((key: string, patch: Partial<OptionState>) => {
|
||||
setSaved(false);
|
||||
setOptions((prev) => prev.map((o) => (o.key === key ? { ...o, ...patch } : o)));
|
||||
}, []);
|
||||
|
||||
const onRemove = useCallback((key: string) => {
|
||||
setSaved(false);
|
||||
setOptions((prev) => prev.filter((o) => o.key !== key));
|
||||
}, []);
|
||||
|
||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||
() => layoutNodes(options, extensions, onChange, onRemove),
|
||||
[options, extensions, onChange, onRemove],
|
||||
);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
|
||||
// useNodesState/useEdgesState só inicializam uma vez — recalcula o
|
||||
// grafo sempre que a LISTA de opções muda (add/remove/digit editado
|
||||
// afeta layout e handles), preservando posição arrastada manualmente
|
||||
// quando só o conteúdo de um nó já existente muda.
|
||||
useMemo(() => {
|
||||
setNodes((prevNodes) => {
|
||||
const positions = new Map(prevNodes.map((n) => [n.id, n.position]));
|
||||
return initialNodes.map((n) => ({ ...n, position: positions.get(n.id) ?? n.position }));
|
||||
});
|
||||
setEdges(initialEdges);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- só quando initialNodes/Edges mudam de identidade (opções mudaram)
|
||||
}, [initialNodes, initialEdges]);
|
||||
|
||||
function addOption() {
|
||||
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: "" }]);
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
setError(null);
|
||||
if (options.length === 0) {
|
||||
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.");
|
||||
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 payload: IvrMenuOptionInput[] = options.map((o) => ({
|
||||
digit: o.digit,
|
||||
destinationNumber: o.destinationNumber.trim(),
|
||||
label: o.label.trim() || undefined,
|
||||
}));
|
||||
|
||||
setPending(true);
|
||||
updateIvrMenuOptions(menu.id, payload).then((result) => {
|
||||
setPending(false);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setSaved(true);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<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 className="flex items-center gap-2">
|
||||
{saved && <span className="text-xs text-accent">Salvo</span>}
|
||||
<Button type="button" size="sm" onClick={onSave} disabled={pending}>
|
||||
<Save className="h-3.5 w-3.5" aria-hidden /> {pending ? "Salvando…" : "Salvar alterações"}
|
||||
</Button>
|
||||
</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 style={{ height: 320 }} className="overflow-hidden rounded-lg border border-border bg-muted/20">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={NODE_TYPES}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,11 @@ 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 { EmptyState } 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, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions";
|
||||
import { IvrFlowEditor } from "./ivr-flow-editor";
|
||||
|
||||
const CUSTOM_PROMPT_PREFIX = "/ivr-prompts/";
|
||||
|
||||
@@ -69,24 +70,7 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
|
||||
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
|
||||
</p>
|
||||
<PromptControl menu={menu} />
|
||||
<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>
|
||||
<IvrFlowEditor menu={menu} extensions={extensions} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user