feat(ivr): persiste posição dos nós do editor visual entre recargas

Pedido do usuário: "faz a posição dos nós persistir entre recargas" —
o editor visual da PHASE 60 recalculava o layout do zero a cada carga
da página, perdendo qualquer arrasto manual assim que a tela recarregava.

`IvrMenu.entryPositionX/Y` + `IvrMenuOption.positionX/Y` (nullable,
puramente de apresentação — nunca entram no dialplan compilado, só no
layout do canvas). Salvos no banco, nunca localStorage: mesma convenção
do resto do app, estado compartilhado entre quem quer que edite o
tenant, não por navegador/dispositivo. "Salvar alterações" agora lê a
posição de verdade do estado de nós do @xyflow/react (reflete arrastos
feitos na sessão), não do estado de conteúdo das opções — os dois
tinham ficado dessincronizados desde a PHASE 60. Sem posição salva ainda
(menu novo, opção recém-adicionada), continua caindo num layout
automático em coluna.

Testado ponta a ponta: PATCH com coordenadas específicas (incluindo o
nó "Entrada"), GET de volta confirma os mesmos valores, e a página
carregada de novo com uma sessão real (cookie de login, não só a API
crua) já embute essas coordenadas nos props iniciais do componente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 18:33:19 -03:00
parent 0148b926a0
commit 5bbe9e6800
9 changed files with 130 additions and 20 deletions

View File

@@ -23,6 +23,8 @@ export interface IvrMenuOptionInput {
digit: string;
destinationNumber: string;
label?: string;
positionX?: number;
positionY?: number;
}
export interface CreateIvrMenuInput {
@@ -46,12 +48,16 @@ export async function createIvrMenu(input: CreateIvrMenuInput): Promise<{ ok: tr
export async function updateIvrMenuOptions(
id: string,
options: IvrMenuOptionInput[],
entryPosition?: { x: number; y: number },
): 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 }),
body: JSON.stringify({
options,
...(entryPosition ? { entryPositionX: entryPosition.x, entryPositionY: entryPosition.y } : {}),
}),
});
revalidatePath("/app/telefonia/ivr");
return { ok: true, menu };

View File

@@ -28,6 +28,8 @@ interface OptionState {
digit: string;
destinationNumber: string;
label: string;
positionX: number | null;
positionY: number | null;
}
interface EntryNodeData extends Record<string, unknown> {}
@@ -108,19 +110,25 @@ 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"]) {
function layoutNodes(
options: OptionState[],
entryPosition: { x: number; y: number } | null,
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 },
position: entryPosition ?? { 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 },
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 },
draggable: true,
}));
@@ -149,9 +157,21 @@ function nextKey(): string {
*/
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.map((o) => ({
key: o.id,
digit: o.digit,
destinationNumber: o.destinationNumber,
label: o.label ?? "",
positionX: o.positionX,
positionY: o.positionY,
})),
[menu.options],
);
const initialEntryPosition = useMemo(
() => (menu.entryPositionX != null && menu.entryPositionY != null ? { x: menu.entryPositionX, y: menu.entryPositionY } : null),
[menu.entryPositionX, menu.entryPositionY],
);
const [options, setOptions] = useState<OptionState[]>(initialOptions);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
@@ -168,7 +188,8 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
}, []);
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => layoutNodes(options, extensions, onChange, onRemove),
() => layoutNodes(options, initialEntryPosition, extensions, 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],
);
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
@@ -191,7 +212,7 @@ 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: "" }]);
setOptions((prev) => [...prev, { key: nextKey(), digit: nextDigit, destinationNumber: "", label: "", positionX: null, positionY: null }]);
}
function onSave() {
@@ -210,14 +231,24 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
return;
}
const payload: IvrMenuOptionInput[] = options.map((o) => ({
digit: o.digit,
destinationNumber: o.destinationNumber.trim(),
label: o.label.trim() || undefined,
}));
// Lê a posição de verdade de `nodes` (reflete arrastos feitos nesta
// sessão), não de `options` — a única fonte de posição atualizada
// 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);
return {
digit: o.digit,
destinationNumber: o.destinationNumber.trim(),
label: o.label.trim() || undefined,
positionX: node?.position.x,
positionY: node?.position.y,
};
});
const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID);
const entryPosition = entryNode ? { x: entryNode.position.x, y: entryNode.position.y } : undefined;
setPending(true);
updateIvrMenuOptions(menu.id, payload).then((result) => {
updateIvrMenuOptions(menu.id, payload, entryPosition).then((result) => {
setPending(false);
if (!result.ok) {
setError(result.error);

View File

@@ -69,6 +69,8 @@ export interface IvrMenuOption {
destinationNumber: string;
destinationContext: string;
label: string | null;
positionX: number | null;
positionY: number | null;
}
export interface IvrMenu {
@@ -78,6 +80,8 @@ export interface IvrMenu {
greeting: string | null;
enabled: boolean;
createdAt: string;
entryPositionX: number | null;
entryPositionY: number | null;
options: IvrMenuOption[];
}