From a779a7e51fb3d7c470388f1b57e86553e954efcb Mon Sep 17 00:00:00 2001 From: Matheus Date: Sun, 30 Aug 2026 20:34:46 -0300 Subject: [PATCH] =?UTF-8?q?feat(nav):=20arrastar-e-soltar=20pra=20reordena?= =?UTF-8?q?r=20o=20menu,=20persistindo=20entre=20sess=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pedido do usuário: "testa arrastando um sub menu pra ver se persiste" — o acordeão da fase anterior só tinha abrir/fechar, nada de arrastar. Perguntei o que ele queria dizer com "arrastar" antes de supor, e a resposta foi: quer a funcionalidade de verdade. @dnd-kit (core + sortable) — cada grupo do menu pode ser arrastado pra reordenar entre si; cada sub-item dentro de um grupo pode ser arrastado pra reordenar DENTRO do próprio grupo (nunca sai pra outro grupo arrastando, checado explicitamente no onDragEnd). Alça de arrastar dedicada (GripVertical) em vez do item inteiro, pra não conflitar com o clique de abrir/fechar acordeão ou navegar pelo link. Ordem salva em localStorage (nav-order.ts), nunca banco — preferência pessoal do navegador, mesma convenção já usada pelo ThemeToggle (claro/escuro/sistema). Uma chave só serve os dois menus (Platform e Tenant), sem colisão de rótulos entre eles. Achado real testando: aplicar a ordem salva direto no inicializador do useState diverge do HTML que o servidor mandou (SSR nunca tem acesso a localStorage) e disparava "Hydration failed" no React — inofensivo no resultado final, mas um erro real no console e uma renderização inteira desperdiçada. Corrigido aplicando a ordem salva só dentro de um useEffect (roda uma vez, só no client) — o mesmo padrão que o ThemeToggle já usava e eu não tinha replicado. Testado com Playwright de verdade em dois cenários: arrastar um grupo inteiro (ordem mudou e sobreviveu a um F5 completo) e arrastar um sub-item dentro de "Discador" (reordenou só ali dentro, confirmado que os outros grupos continuaram intactos, e também sobreviveu ao F5). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8 --- TODO.md | 38 ++ apps/frontend/package.json | 3 + .../src/components/shell/nav-list.tsx | 405 +++++++++++++----- .../src/components/shell/nav-order.ts | 71 +++ pnpm-lock.yaml | 56 +++ 5 files changed, 462 insertions(+), 111 deletions(-) create mode 100644 apps/frontend/src/components/shell/nav-order.ts diff --git a/TODO.md b/TODO.md index ff9a865..3e0c088 100644 --- a/TODO.md +++ b/TODO.md @@ -2299,6 +2299,44 @@ arcordeon no menu para que eu poder reocler cada sub menu") `aria-expanded="false"`, clicar abre (`true`), clicar de novo fecha (`false`) +## PHASE 64 — Arrastar-e-soltar pra reordenar o menu (pedido do usuário: +"testa arrastando um sub menu pra ver se persiste" — a resposta foi que +não existia arrastar nenhum ainda, e depois de perguntar o usuário quis +a funcionalidade de verdade) +- [x] `@dnd-kit/core` + `@dnd-kit/sortable` — cada grupo do menu (nível + raiz) pode ser arrastado pra reordenar entre si; cada sub-item + dentro de um grupo pode ser arrastado pra reordenar DENTRO do + próprio grupo (nunca sai pra outro grupo arrastando — checado + explicitamente no `onDragEnd` comparando o rótulo da seção nos + dois ids envolvidos). Alça de arrastar dedicada (ícone + `GripVertical`) em vez do item inteiro, pra não conflitar com o + clique de abrir/fechar acordeão ou navegar pelo link +- [x] Ordem salva em `localStorage` (`nav-order.ts`), nunca banco — + preferência pessoal do navegador, mesma convenção já usada pelo + `ThemeToggle` (claro/escuro/sistema). Uma chave só serve os dois + menus (Platform e Tenant): os rótulos de cada um nunca colidem, e + cada um vive na sua própria entrada dentro do objeto salvo. Item + novo que não estava salvo ainda (ex.: tela futura) entra no fim, + nunca some por causa de uma ordem antiga +- [x] **Achado real testando**: aplicar a ordem salva direto no + inicializador do `useState` (`useState(() => aplicarOrdem(items))`) + diverge do HTML que o servidor mandou (SSR nunca tem acesso a + `localStorage`) e disparava "Hydration failed" no React — + inofensivo no resultado final (React re-renderiza e corrige + sozinho), mas um erro de verdade no console e uma renderização + inteira desperdiçada. Corrigido aplicando a ordem salva só dentro + de um `useEffect` (roda uma vez, só no client, depois de montar) — + exatamente o mesmo padrão que o `ThemeToggle` já usava e eu não + tinha replicado +- [x] Testado com Playwright de verdade, dois cenários: (1) arrastar um + GRUPO do menu (ex.: "Dashboard" pra depois de "Discador") — a + ordem mudou na tela e sobreviveu a um F5 completo; (2) arrastar um + SUB-ITEM dentro de "Discador" (ex.: "Campanhas" pra depois de + "Leads") — reordenou só dentro do próprio grupo (confirmado que + "Call Center" e os outros grupos continuaram intactos) e também + sobreviveu ao F5. Confirmado ainda que corrigir a Hydration + eliminou o erro do console sem quebrar nenhum dos dois cenários + --- ## Riscos conhecidos diff --git a/apps/frontend/package.json b/apps/frontend/package.json index e74cd24..bf5566a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -9,6 +9,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-avatar": "1.1.2", "@radix-ui/react-dialog": "1.1.4", "@radix-ui/react-dropdown-menu": "2.1.4", diff --git a/apps/frontend/src/components/shell/nav-list.tsx b/apps/frontend/src/components/shell/nav-list.tsx index 1243870..63207bd 100644 --- a/apps/frontend/src/components/shell/nav-list.tsx +++ b/apps/frontend/src/components/shell/nav-list.tsx @@ -1,11 +1,41 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, GripVertical } from "lucide-react"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { cn } from "@/lib/utils"; import type { NavSection } from "./nav-types"; +import { applyStoredNavOrder, saveChildOrder, saveSectionOrder } from "./nav-order"; + +const SECTION_ID_PREFIX = "sec:"; +const LEAF_ID_PREFIX = "leaf:"; + +function DragHandle({ attributes, listeners }: { attributes: React.HTMLAttributes; listeners: Record | undefined }) { + return ( + + ); +} /** Lista de navegação compartilhada entre a sidebar fixa (desktop, secao * 166) e o drawer mobile (`MobileNavDrawer`) — a mesma implementação @@ -29,119 +59,272 @@ export function NavList({ items, collapsed = false, onNavigate }: { items: NavSe ), ); + // Ordem arrastável (pedido do usuário: "arrastar um sub menu pra + // reordenar") — preferência pessoal em localStorage (nav-order.ts), + // nunca banco. Começa com a ordem PADRÃO (igual ao que o servidor + // renderizou) e só troca pela ordem salva dentro de um `useEffect` + // (roda só depois de montar no client) — mesmo padrão já usado no + // `ThemeToggle`. Achado real testando: aplicar a ordem salva direto + // no inicializador do `useState` diverge do HTML vindo do servidor + // (que nunca tem acesso a localStorage) e dispara "Hydration failed" + // no React — inofensivo no resultado final, mas um erro real no + // console e uma re-renderização inteira desperdiçada. + const [orderedItems, setOrderedItems] = useState(items); + useEffect(() => { + setOrderedItems(applyStoredNavOrder(items)); + // eslint-disable-next-line react-hooks/exhaustive-deps -- só quando o CONJUNTO de rótulos muda (permissão mudou), não a cada render + }, [items.map((i) => i.label).join("|")]); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + function toggleSection(label: string) { setOpenSections((prev) => ({ ...prev, [label]: !prev[label] })); } + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeId = String(active.id); + const overId = String(over.id); + + if (activeId.startsWith(SECTION_ID_PREFIX) && overId.startsWith(SECTION_ID_PREFIX)) { + setOrderedItems((prev) => { + const oldIndex = prev.findIndex((s) => SECTION_ID_PREFIX + s.label === activeId); + const newIndex = prev.findIndex((s) => SECTION_ID_PREFIX + s.label === overId); + if (oldIndex === -1 || newIndex === -1) return prev; + const next = arrayMove(prev, oldIndex, newIndex); + saveSectionOrder(next); + return next; + }); + return; + } + + if (activeId.startsWith(LEAF_ID_PREFIX) && overId.startsWith(LEAF_ID_PREFIX)) { + const activeSectionLabel = activeId.split(":")[1]; + const overSectionLabel = overId.split(":")[1]; + // Nunca deixa um item sair da própria seção arrastando pra outra. + if (activeSectionLabel !== overSectionLabel) return; + + setOrderedItems((prev) => + prev.map((section) => { + if (section.label !== activeSectionLabel || !section.children) return section; + const oldIndex = section.children.findIndex((c) => LEAF_ID_PREFIX + section.label + ":" + c.label === activeId); + const newIndex = section.children.findIndex((c) => LEAF_ID_PREFIX + section.label + ":" + c.label === overId); + if (oldIndex === -1 || newIndex === -1) return section; + const nextChildren = arrayMove(section.children, oldIndex, newIndex); + saveChildOrder(section.label, nextChildren); + return { ...section, children: nextChildren }; + }), + ); + } + } + + // Modo colapsado (rail só com ícones) não tem espaço pra alça de + // arrastar nem sentido em reordenar sem rótulo visível — cai na lista + // simples, sem DnD. + if (collapsed) { + return ( +
    + {orderedItems.map((section) => ( + + ))} +
+ ); + } + return ( -
    - {items.map((section) => { - const Icon = section.icon; - const active = section.href && pathname === section.href; - - if (section.href) { - return ( -
  • - - - {!collapsed && {section.label}} - -
  • - ); - } - - // Item de página única ainda não construído (ex.: "Gravações", - // secao 169 — sem sub-itens na especificação) — mesmo tratamento - // "em breve" de um leaf, só que direto no nível de seção. - if (!section.children) { - return ( -
  • - - - {!collapsed && ( - <> - {section.label} - em breve - - )} - -
  • - ); - } - - const sectionHasActiveChild = section.children?.some((leaf) => leaf.href && pathname.startsWith(leaf.href)); - const isOpen = collapsed || (openSections[section.label] ?? false); - - return ( -
  • - - {!collapsed && isOpen && section.children && ( -
      - {section.children.map((leaf) => { - const leafActive = leaf.href && pathname.startsWith(leaf.href); - - if (leaf.href) { - return ( -
    • - - {leaf.label} - -
    • - ); - } - - return ( -
    • - - {leaf.label} - em breve - -
    • - ); - })} -
    - )} -
  • - ); - })} -
+ + SECTION_ID_PREFIX + s.label)} strategy={verticalListSortingStrategy}> +
    + {orderedItems.map((section) => ( + toggleSection(section.label)} + onNavigate={onNavigate} + /> + ))} +
+
+
+ ); +} + +function CollapsedRow({ section, pathname }: { section: NavSection; pathname: string }) { + const Icon = section.icon; + const active = section.href && pathname === section.href; + const sectionHasActiveChild = section.children?.some((leaf) => leaf.href && pathname.startsWith(leaf.href)); + + if (section.href) { + return ( +
  • + + + +
  • + ); + } + + return ( +
  • +
    + +
    +
  • + ); +} + +function SortableSectionRow({ + section, + pathname, + isOpen, + onToggle, + onNavigate, +}: { + section: NavSection; + pathname: string; + isOpen: boolean; + onToggle: () => void; + onNavigate?: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: SECTION_ID_PREFIX + section.label, + }); + const style = { transform: CSS.Transform.toString(transform), transition }; + const Icon = section.icon; + const active = section.href && pathname === section.href; + + if (section.href) { + return ( +
  • +
    + + + + {section.label} + +
    +
  • + ); + } + + if (!section.children) { + return ( +
  • +
    + + + + {section.label} + em breve + +
    +
  • + ); + } + + const sectionHasActiveChild = section.children.some((leaf) => leaf.href && pathname.startsWith(leaf.href)); + + return ( +
  • +
    + + +
    + {isOpen && ( + LEAF_ID_PREFIX + section.label + ":" + c.label)} strategy={verticalListSortingStrategy}> +
      + {section.children.map((leaf) => ( + + ))} +
    +
    + )} +
  • + ); +} + +function SortableLeafRow({ + sectionLabel, + leaf, + pathname, + onNavigate, +}: { + sectionLabel: string; + leaf: NonNullable[number]; + pathname: string; + onNavigate?: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: LEAF_ID_PREFIX + sectionLabel + ":" + leaf.label, + }); + const style = { transform: CSS.Transform.toString(transform), transition }; + const leafActive = leaf.href && pathname.startsWith(leaf.href); + + return ( +
  • + + {leaf.href ? ( + + {leaf.label} + + ) : ( + + {leaf.label} + em breve + + )} +
  • ); } diff --git a/apps/frontend/src/components/shell/nav-order.ts b/apps/frontend/src/components/shell/nav-order.ts new file mode 100644 index 0000000..0f81f7b --- /dev/null +++ b/apps/frontend/src/components/shell/nav-order.ts @@ -0,0 +1,71 @@ +import type { NavSection } from "./nav-types"; + +/** + * Ordem do menu arrastada pelo usuário (pedido: "arrastar um sub menu + * pra reordenar") — preferência PESSOAL, não dado do tenant, então + * localStorage (mesmo padrão já usado pelo `ThemeToggle`), nunca banco. + * Uma única chave serve Platform e Tenant: os rótulos de seção de cada + * menu nunca colidem entre si, então cada um vive na sua própria entrada + * dentro do mesmo objeto salvo. + */ +const STORAGE_KEY = "b2bcall-nav-order"; + +interface StoredOrder { + sections?: string[]; + children?: Record; +} + +function readStorage(): StoredOrder { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as StoredOrder) : {}; + } catch { + return {}; + } +} + +function writeStorage(next: StoredOrder): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // localStorage indisponível (aba anônima com bloqueio, etc.) — a + // ordem só não persiste entre sessões, sem quebrar a navegação. + } +} + +function reorderByLabels(items: T[], order: string[] | undefined): T[] { + if (!order || order.length === 0) return items; + const remaining = new Map(items.map((item) => [item.label, item])); + const ordered: T[] = []; + for (const label of order) { + const item = remaining.get(label); + if (item) { + ordered.push(item); + remaining.delete(label); + } + } + // Itens que não estavam salvos ainda (ex.: uma tela nova) entram no + // fim, na ordem original — nunca somem por causa de uma ordem antiga. + for (const item of items) { + if (remaining.has(item.label)) ordered.push(item); + } + return ordered; +} + +export function applyStoredNavOrder(items: NavSection[]): NavSection[] { + const stored = readStorage(); + const ordered = reorderByLabels(items, stored.sections); + return ordered.map((section) => + section.children ? { ...section, children: reorderByLabels(section.children, stored.children?.[section.label]) } : section, + ); +} + +export function saveSectionOrder(sections: NavSection[]): void { + const stored = readStorage(); + writeStorage({ ...stored, sections: sections.map((s) => s.label) }); +} + +export function saveChildOrder(sectionLabel: string, children: NonNullable): void { + const stored = readStorage(); + writeStorage({ ...stored, children: { ...stored.children, [sectionLabel]: children.map((c) => c.label) } }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad5467a..dbebbb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,15 @@ importers: apps/frontend: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.1.0) '@radix-ui/react-avatar': specifier: 1.1.2 version: 1.1.2(@types/react-dom@19.1.0(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -519,6 +528,28 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@electric-sql/pglite-socket@0.1.3': resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} hasBin: true @@ -3280,6 +3311,31 @@ snapshots: '@borewit/text-codec@0.2.2': {} + '@dnd-kit/accessibility@3.1.1(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@dnd-kit/utilities': 3.2.2(react@19.1.0) + react: 19.1.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.1.0)': + dependencies: + react: 19.1.0 + tslib: 2.8.1 + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3