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