From 5bbe9e6800fc91aa4e1e9a96c7c42a111ba558ed Mon Sep 17 00:00:00 2001 From: Matheus Date: Sun, 30 Aug 2026 18:33:19 -0300 Subject: [PATCH] =?UTF-8?q?feat(ivr):=20persiste=20posi=C3=A7=C3=A3o=20dos?= =?UTF-8?q?=20n=C3=B3s=20do=20editor=20visual=20entre=20recargas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8 --- TODO.md | 18 +++++- apps/api/src/ivr/dto/create-ivr-menu.dto.ts | 20 ++++++- apps/api/src/ivr/ivr-menus.controller.ts | 11 +++- .../src/app/app/telefonia/ivr/actions.ts | 8 ++- .../app/app/telefonia/ivr/ivr-flow-editor.tsx | 55 +++++++++++++++---- apps/frontend/src/lib/callcenter-types.ts | 4 ++ docs/INBOUND_ROUTES.md | 17 +++++- .../migration.sql | 6 ++ packages/database/prisma/schema.prisma | 11 ++++ 9 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 packages/database/prisma/migrations/20260830220000_ivr_node_positions/migration.sql diff --git a/TODO.md b/TODO.md index da0ca9d..5a9103d 100644 --- a/TODO.md +++ b/TODO.md @@ -2200,8 +2200,22 @@ para fazer o fluxo de maneira visual usando nodeRED") `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) +- [x] **Layout do canvas agora persiste posição dos nós entre recargas** + (pedido do usuário: "faz a posição dos nós persistir entre + recargas") — `IvrMenu.entryPositionX/Y` + `IvrMenuOption.positionX/Y` + (nullable, puramente de apresentação, nunca entram no dialplan + compilado). Salvos no banco — nunca `localStorage`, mesma + convenção do resto do app (estado compartilhado entre quem edita + o tenant, não por navegador) — a cada "Salvar alterações", lendo + a posição de verdade do estado de nós do `@xyflow/react` (reflete + arrastos da sessão), não do estado de conteúdo das opções. Sem + posição salva ainda (menu novo, opção recém-adicionada), cai num + layout automático em coluna +- [x] 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 (SSR, cookie de sessão real) + já embute essas coordenadas nos props iniciais do componente — + não só a API, o carregamento real da tela também foi verificado --- diff --git a/apps/api/src/ivr/dto/create-ivr-menu.dto.ts b/apps/api/src/ivr/dto/create-ivr-menu.dto.ts index 2e271e1..abcf794 100644 --- a/apps/api/src/ivr/dto/create-ivr-menu.dto.ts +++ b/apps/api/src/ivr/dto/create-ivr-menu.dto.ts @@ -1,5 +1,5 @@ import { Type } from "class-transformer"; -import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator"; +import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator"; import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony"; import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator"; @@ -20,6 +20,16 @@ export class IvrMenuOptionDto { @IsString() @MaxLength(80) label?: string; + + // Posição do nó no editor visual (PHASE 61) — puramente de + // apresentação, nunca afeta o dialplan compilado. + @IsOptional() + @IsNumber() + positionX?: number; + + @IsOptional() + @IsNumber() + positionY?: number; } export class CreateIvrMenuDto { @@ -66,4 +76,12 @@ export class UpdateIvrMenuDto { @ValidateNested({ each: true }) @Type(() => IvrMenuOptionDto) options?: IvrMenuOptionDto[]; + + @IsOptional() + @IsNumber() + entryPositionX?: number; + + @IsOptional() + @IsNumber() + entryPositionY?: number; } diff --git a/apps/api/src/ivr/ivr-menus.controller.ts b/apps/api/src/ivr/ivr-menus.controller.ts index d77a6e1..80253e5 100644 --- a/apps/api/src/ivr/ivr-menus.controller.ts +++ b/apps/api/src/ivr/ivr-menus.controller.ts @@ -147,6 +147,8 @@ export class IvrMenusController { destinationNumber: opt.destinationNumber, destinationContext: opt.destinationContext ?? "default", label: opt.label, + positionX: opt.positionX, + positionY: opt.positionY, })), }); return created; @@ -220,7 +222,12 @@ export class IvrMenusController { const menu = await withTenantContext(prisma, tenantId, async (tx) => { const updated = await tx.ivrMenu.update({ where: { id }, - data: { ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.greeting !== undefined ? { greeting: dto.greeting } : {}) }, + data: { + ...(dto.name !== undefined ? { name: dto.name } : {}), + ...(dto.greeting !== undefined ? { greeting: dto.greeting } : {}), + ...(dto.entryPositionX !== undefined ? { entryPositionX: dto.entryPositionX } : {}), + ...(dto.entryPositionY !== undefined ? { entryPositionY: dto.entryPositionY } : {}), + }, }); if (dto.options) { await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } }); @@ -232,6 +239,8 @@ export class IvrMenusController { destinationNumber: opt.destinationNumber, destinationContext: opt.destinationContext ?? "default", label: opt.label, + positionX: opt.positionX, + positionY: opt.positionY, })), }); } diff --git a/apps/frontend/src/app/app/telefonia/ivr/actions.ts b/apps/frontend/src/app/app/telefonia/ivr/actions.ts index a70b847..2f00c66 100644 --- a/apps/frontend/src/app/app/telefonia/ivr/actions.ts +++ b/apps/frontend/src/app/app/telefonia/ivr/actions.ts @@ -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(`/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 }; 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 index 7ef782e..a12b1bc 100644 --- a/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx +++ b/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx @@ -28,6 +28,8 @@ interface OptionState { digit: string; destinationNumber: string; label: string; + positionX: number | null; + positionY: number | null; } interface EntryNodeData extends Record {} @@ -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 = { 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[] = 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( - () => 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(initialOptions); const [error, setError] = useState(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); diff --git a/apps/frontend/src/lib/callcenter-types.ts b/apps/frontend/src/lib/callcenter-types.ts index 66fd4c0..af92203 100644 --- a/apps/frontend/src/lib/callcenter-types.ts +++ b/apps/frontend/src/lib/callcenter-types.ts @@ -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[]; } diff --git a/docs/INBOUND_ROUTES.md b/docs/INBOUND_ROUTES.md index c132e9c..990cf84 100644 --- a/docs/INBOUND_ROUTES.md +++ b/docs/INBOUND_ROUTES.md @@ -201,12 +201,23 @@ uma segunda opção adicionada via `PATCH` (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. +### Posição dos nós persiste entre recargas (PHASE 61) + +`IvrMenu.entryPositionX/Y` + `IvrMenuOption.positionX/Y` (nullable, +puramente de apresentação — nunca entram no dialplan compilado). 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) a +cada "Salvar alterações" — o editor 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. Sem posição salva (menu novo, opção +recém-adicionada), cai num layout automático em coluna. Testado ponta a +ponta: `PATCH` com coordenadas específicas, `GET` de volta confirma os +mesmos valores, e a página carregada de novo (SSR) já embute essas +coordenadas nos props iniciais do componente. + ## O que falta - Sem TTS (texto→voz) — só upload de arquivo WAV já gravado. -- Editor visual não persiste posição manual dos nós entre recargas - (layout recalculado a cada carga da página — arrastar só ajuda - durante a mesma sessão de edição). - Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem destino "fila" — só ramal, dentro do contexto `default`. - Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID → diff --git a/packages/database/prisma/migrations/20260830220000_ivr_node_positions/migration.sql b/packages/database/prisma/migrations/20260830220000_ivr_node_positions/migration.sql new file mode 100644 index 0000000..4be7c17 --- /dev/null +++ b/packages/database/prisma/migrations/20260830220000_ivr_node_positions/migration.sql @@ -0,0 +1,6 @@ +-- PHASE 61: persistir posição dos nós do editor visual do IVR +ALTER TABLE "ivr_menu_options" ADD COLUMN "position_x" DOUBLE PRECISION, +ADD COLUMN "position_y" DOUBLE PRECISION; + +ALTER TABLE "ivr_menus" ADD COLUMN "entry_position_x" DOUBLE PRECISION, +ADD COLUMN "entry_position_y" DOUBLE PRECISION; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 853cf32..a936c38 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -506,6 +506,12 @@ model IvrMenu { enabled Boolean @default(true) + // Posição do nó "Entrada" no editor visual (PHASE 61, secao "Editor + // visual" em docs/INBOUND_ROUTES.md) — null até o primeiro "Salvar"; + // puramente de apresentação, nunca afeta o dialplan compilado. + entryPositionX Float? @map("entry_position_x") + entryPositionY Float? @map("entry_position_y") + createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") @@ -530,6 +536,11 @@ model IvrMenuOption { label String? + // Posição do nó desta opção no editor visual (PHASE 61) — mesma + // ressalva do IvrMenu.entryPosition* acima: só apresentação. + positionX Float? @map("position_x") + positionY Float? @map("position_y") + createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id])