feat(nav): arrastar-e-soltar pra reordenar o menu, persistindo entre sessões
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
38
TODO.md
38
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
|
`aria-expanded="false"`, clicar abre (`true`), clicar de novo fecha
|
||||||
(`false`)
|
(`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
|
## Riscos conhecidos
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-avatar": "1.1.2",
|
||||||
"@radix-ui/react-dialog": "1.1.4",
|
"@radix-ui/react-dialog": "1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
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 { cn } from "@/lib/utils";
|
||||||
import type { NavSection } from "./nav-types";
|
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<HTMLButtonElement>; listeners: Record<string, unknown> | undefined }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="shrink-0 cursor-grab touch-none text-muted-foreground/40 hover:text-muted-foreground active:cursor-grabbing"
|
||||||
|
aria-label="Arrastar para reordenar"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Lista de navegação compartilhada entre a sidebar fixa (desktop, secao
|
/** Lista de navegação compartilhada entre a sidebar fixa (desktop, secao
|
||||||
* 166) e o drawer mobile (`MobileNavDrawer`) — a mesma implementação
|
* 166) e o drawer mobile (`MobileNavDrawer`) — a mesma implementação
|
||||||
@@ -29,22 +59,112 @@ 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<NavSection[]>(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) {
|
function toggleSection(label: string) {
|
||||||
setOpenSections((prev) => ({ ...prev, [label]: !prev[label] }));
|
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 (
|
return (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{items.map((section) => {
|
{orderedItems.map((section) => (
|
||||||
|
<CollapsedRow key={section.label} section={section} pathname={pathname} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={orderedItems.map((s) => SECTION_ID_PREFIX + s.label)} strategy={verticalListSortingStrategy}>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{orderedItems.map((section) => (
|
||||||
|
<SortableSectionRow
|
||||||
|
key={section.label}
|
||||||
|
section={section}
|
||||||
|
pathname={pathname}
|
||||||
|
isOpen={openSections[section.label] ?? false}
|
||||||
|
onToggle={() => toggleSection(section.label)}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedRow({ section, pathname }: { section: NavSection; pathname: string }) {
|
||||||
const Icon = section.icon;
|
const Icon = section.icon;
|
||||||
const active = section.href && pathname === section.href;
|
const active = section.href && pathname === section.href;
|
||||||
|
const sectionHasActiveChild = section.children?.some((leaf) => leaf.href && pathname.startsWith(leaf.href));
|
||||||
|
|
||||||
if (section.href) {
|
if (section.href) {
|
||||||
return (
|
return (
|
||||||
<li key={section.label}>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href={section.href}
|
href={section.href}
|
||||||
onClick={onNavigate}
|
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||||
@@ -52,96 +172,159 @@ export function NavList({ items, collapsed = false, onNavigate }: { items: NavSe
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
{!collapsed && <span>{section.label}</span>}
|
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<li key={section.label} className="pt-2">
|
<li className="pt-2">
|
||||||
<span
|
<div
|
||||||
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground/50"
|
|
||||||
title="Ainda não construído nesta passada"
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
|
||||||
{!collapsed && (
|
|
||||||
<>
|
|
||||||
<span className="flex-1">{section.label}</span>
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide">em breve</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sectionHasActiveChild = section.children?.some((leaf) => leaf.href && pathname.startsWith(leaf.href));
|
|
||||||
const isOpen = collapsed || (openSections[section.label] ?? false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={section.label} className="pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleSection(section.label)}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-3 rounded-md px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors hover:bg-muted",
|
"flex items-center gap-3 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide",
|
||||||
|
sectionHasActiveChild ? "text-foreground" : "text-muted-foreground/70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li ref={setNodeRef} style={style} className={cn(isDragging && "opacity-50")}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<DragHandle attributes={attributes} listeners={listeners} />
|
||||||
|
<Link
|
||||||
|
href={section.href}
|
||||||
|
onClick={onNavigate}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center gap-3 rounded-md px-2 py-2 text-sm font-medium transition-colors",
|
||||||
|
active ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
<span>{section.label}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!section.children) {
|
||||||
|
return (
|
||||||
|
<li ref={setNodeRef} style={style} className={cn("pt-2", isDragging && "opacity-50")}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<DragHandle attributes={attributes} listeners={listeners} />
|
||||||
|
<span
|
||||||
|
className="flex flex-1 cursor-not-allowed items-center gap-3 rounded-md px-2 py-2 text-sm font-medium text-muted-foreground/50"
|
||||||
|
title="Ainda não construído nesta passada"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
<span className="flex-1">{section.label}</span>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide">em breve</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionHasActiveChild = section.children.some((leaf) => leaf.href && pathname.startsWith(leaf.href));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li ref={setNodeRef} style={style} className={cn("pt-2", isDragging && "opacity-50")}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<DragHandle attributes={attributes} listeners={listeners} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center gap-3 rounded-md px-2 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors hover:bg-muted",
|
||||||
sectionHasActiveChild ? "text-foreground" : "text-muted-foreground/70",
|
sectionHasActiveChild ? "text-foreground" : "text-muted-foreground/70",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
<Icon className="h-4 w-4 shrink-0" aria-hidden />
|
||||||
{!collapsed && (
|
|
||||||
<>
|
|
||||||
<span className="flex-1 text-left">{section.label}</span>
|
<span className="flex-1 text-left">{section.label}</span>
|
||||||
<ChevronDown className={cn("h-3.5 w-3.5 shrink-0 transition-transform", isOpen ? "rotate-180" : "")} aria-hidden />
|
<ChevronDown className={cn("h-3.5 w-3.5 shrink-0 transition-transform", isOpen ? "rotate-180" : "")} aria-hidden />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
{!collapsed && isOpen && section.children && (
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<SortableContext items={section.children.map((c) => LEAF_ID_PREFIX + section.label + ":" + c.label)} strategy={verticalListSortingStrategy}>
|
||||||
<ul className="ml-[26px] space-y-0.5 border-l border-border pl-3">
|
<ul className="ml-[26px] space-y-0.5 border-l border-border pl-3">
|
||||||
{section.children.map((leaf) => {
|
{section.children.map((leaf) => (
|
||||||
|
<SortableLeafRow key={leaf.label} sectionLabel={section.label} leaf={leaf} pathname={pathname} onNavigate={onNavigate} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</SortableContext>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableLeafRow({
|
||||||
|
sectionLabel,
|
||||||
|
leaf,
|
||||||
|
pathname,
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
sectionLabel: string;
|
||||||
|
leaf: NonNullable<NavSection["children"]>[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);
|
const leafActive = leaf.href && pathname.startsWith(leaf.href);
|
||||||
|
|
||||||
if (leaf.href) {
|
|
||||||
return (
|
return (
|
||||||
<li key={leaf.label}>
|
<li ref={setNodeRef} style={style} className={cn("flex items-center gap-1", isDragging && "opacity-50")}>
|
||||||
|
<DragHandle attributes={attributes} listeners={listeners} />
|
||||||
|
{leaf.href ? (
|
||||||
<Link
|
<Link
|
||||||
href={leaf.href}
|
href={leaf.href}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
aria-current={leafActive ? "page" : undefined}
|
aria-current={leafActive ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
|
"flex flex-1 items-center justify-between rounded-md px-2 py-1.5 text-sm font-medium transition-colors",
|
||||||
leafActive ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted",
|
leafActive ? "bg-primary/10 text-primary" : "text-foreground hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{leaf.label}
|
{leaf.label}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
) : (
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li key={leaf.label}>
|
|
||||||
<span
|
<span
|
||||||
className="flex cursor-not-allowed items-center justify-between rounded-md px-2 py-1.5 text-sm text-muted-foreground/50"
|
className="flex flex-1 cursor-not-allowed items-center justify-between rounded-md px-2 py-1.5 text-sm text-muted-foreground/50"
|
||||||
title="Ainda não construído nesta passada"
|
title="Ainda não construído nesta passada"
|
||||||
>
|
>
|
||||||
{leaf.label}
|
{leaf.label}
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide">em breve</span>
|
<span className="text-[10px] font-medium uppercase tracking-wide">em breve</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
71
apps/frontend/src/components/shell/nav-order.ts
Normal file
71
apps/frontend/src/components/shell/nav-order.ts
Normal file
@@ -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<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T extends { label: string }>(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<NavSection["children"]>): void {
|
||||||
|
const stored = readStorage();
|
||||||
|
writeStorage({ ...stored, children: { ...stored.children, [sectionLabel]: children.map((c) => c.label) } });
|
||||||
|
}
|
||||||
56
pnpm-lock.yaml
generated
56
pnpm-lock.yaml
generated
@@ -195,6 +195,15 @@ importers:
|
|||||||
|
|
||||||
apps/frontend:
|
apps/frontend:
|
||||||
dependencies:
|
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':
|
'@radix-ui/react-avatar':
|
||||||
specifier: 1.1.2
|
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)
|
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':
|
'@borewit/text-codec@0.2.2':
|
||||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
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':
|
'@electric-sql/pglite-socket@0.1.3':
|
||||||
resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==}
|
resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3280,6 +3311,31 @@ snapshots:
|
|||||||
|
|
||||||
'@borewit/text-codec@0.2.2': {}
|
'@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)':
|
'@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@electric-sql/pglite': 0.4.3
|
'@electric-sql/pglite': 0.4.3
|
||||||
|
|||||||
Reference in New Issue
Block a user