diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 45b6811..b0181ec 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,10 +1,17 @@ +import { Suspense } from "react"; import { AppHeader } from "@/components/app-header"; +import { AppSidebar } from "@/components/app-sidebar"; export default function AppLayout({ children }: LayoutProps<"/">) { return ( <> - -
{children}
+ }> + + +
+ +
{children}
+
); } diff --git a/src/components/app-header.tsx b/src/components/app-header.tsx index f80a0f8..4a2d32d 100644 --- a/src/components/app-header.tsx +++ b/src/components/app-header.tsx @@ -1,24 +1,21 @@ import { OrganizationSwitcher, UserButton } from "@clerk/nextjs"; import Link from "next/link"; import { Suspense } from "react"; +import { AppMobileNav } from "@/components/app-sidebar"; import { Logo } from "@/components/logo"; -import { NavLinks } from "@/components/nav-links"; import { ThemeToggle } from "@/components/theme-toggle"; +/** Top bar: hamburger and logo below `lg` (the menu lives in the sidebar from `lg`), workspace, theme and account always. */ export function AppHeader() { return (
- {/* On narrow screens the nav drops to its own row so nothing overlaps; from sm it sits between logo and controls. */} -
- +
+ }> + + + -
{/* Clerk's widgets read the pathname on the client: keep them behind Suspense (Next 16 cacheComponents). */} }> diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx new file mode 100644 index 0000000..3ff65af --- /dev/null +++ b/src/components/app-sidebar.tsx @@ -0,0 +1,27 @@ +import Link from "next/link"; +import { Logo } from "@/components/logo"; +import { MobileNav } from "@/components/mobile-nav"; +import { SidebarNav } from "@/components/sidebar-nav"; +import { isAdminSession } from "@/server/session-role"; + +/** Fixed left menu from `lg` up. Reads the session (role), so render it behind Suspense (Next 16 cacheComponents). */ +export async function AppSidebar() { + const isAdmin = await isAdminSession(); + return ( + + ); +} + +/** The hamburger for screens below `lg`; same role lookup, same Suspense requirement. */ +export async function AppMobileNav() { + return ; +} diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx new file mode 100644 index 0000000..90f9e82 --- /dev/null +++ b/src/components/mobile-nav.tsx @@ -0,0 +1,45 @@ +"use client"; + +import * as Dialog from "@radix-ui/react-dialog"; +import { Menu, X } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { Logo } from "@/components/logo"; +import { SidebarNav } from "@/components/sidebar-nav"; +import { Button } from "@/components/ui/button"; + +/** Hamburger button and slide-in drawer with the same menu as the sidebar, for screens below `lg`. */ +export function MobileNav({ isAdmin }: { isAdmin: boolean }) { + const [open, setOpen] = useState(false); + return ( + + + + + + + + Menu +
+ setOpen(false)}> + + + + + +
+
+ setOpen(false)} /> +
+
+
+
+ ); +} diff --git a/src/components/nav-links.tsx b/src/components/nav-links.tsx deleted file mode 100644 index e5aa694..0000000 --- a/src/components/nav-links.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { cn } from "@/lib/utils"; - -const LINKS = [ - { href: "/", label: "Registros" }, - { href: "/calendario", label: "Calendário" }, - { href: "/painel", label: "Painel" }, - { href: "/relatorios", label: "Relatórios" }, - { href: "/cadastros", label: "Cadastros" }, -] as const; - -const isActive = (pathname: string, href: string) => (href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`)); - -/** Main navigation with the current section marked (aria-current + highlight). */ -export function NavLinks() { - const pathname = usePathname(); - return ( -
    - {LINKS.map(({ href, label }) => { - const active = isActive(pathname, href); - return ( -
  • - - {label} - -
  • - ); - })} -
- ); -} diff --git a/src/components/sidebar-nav.tsx b/src/components/sidebar-nav.tsx new file mode 100644 index 0000000..ed3b5ac --- /dev/null +++ b/src/components/sidebar-nav.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { BarChart3, Building2, CalendarDays, Clock, FolderKanban, LayoutDashboard, Tag, Users, type LucideIcon } from "lucide-react"; +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; +import { isNavActive, navGroupsFor, type NavIcon } from "@/lib/nav"; +import { cn } from "@/lib/utils"; + +const ICONS: Record = { + registros: Clock, + calendario: CalendarDays, + painel: LayoutDashboard, + relatorios: BarChart3, + clientes: Building2, + projetos: FolderKanban, + tags: Tag, + membros: Users, +}; + +/** The main menu, shared by the fixed sidebar and the mobile drawer. Marks the current page with aria-current. */ +export function SidebarNav({ isAdmin, onNavigate }: { isAdmin: boolean; onNavigate?: () => void }) { + const pathname = usePathname(); + const aba = useSearchParams().get("aba"); + + return ( + + ); +} diff --git a/src/lib/nav.test.ts b/src/lib/nav.test.ts new file mode 100644 index 0000000..8e885e4 --- /dev/null +++ b/src/lib/nav.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { isNavActive, navGroupsFor, type NavItem } from "./nav"; + +const item = (id: NavItem["id"]) => navGroupsFor(true).flatMap((g) => g.items).find((i) => i.id === id)!; + +describe("navGroupsFor", () => { + const ids = (admin: boolean) => navGroupsFor(admin).flatMap((g) => g.items.map((i) => i.id)); + + it("hides Membros from members and keeps everything else", () => { + expect(ids(false)).not.toContain("membros"); + expect(ids(true)).toContain("membros"); + expect(ids(true).filter((id) => id !== "membros")).toEqual(ids(false)); + }); + + it("groups the menu as registros, analisar and gerenciar", () => { + expect(navGroupsFor(false).map((g) => g.label)).toEqual([null, "Analisar", "Gerenciar"]); + }); +}); + +describe("isNavActive", () => { + it("matches the home page only exactly", () => { + expect(isNavActive(item("registros"), "/", null)).toBe(true); + expect(isNavActive(item("registros"), "/painel", null)).toBe(false); + }); + + it("matches sections by prefix without confusing similar names", () => { + expect(isNavActive(item("relatorios"), "/relatorios", null)).toBe(true); + expect(isNavActive(item("relatorios"), "/relatorios/export", null)).toBe(true); + expect(isNavActive(item("painel"), "/relatorios", null)).toBe(false); + }); + + it("marks one cadastros tab at a time, defaulting to the first", () => { + expect(isNavActive(item("clientes"), "/cadastros", null)).toBe(true); + expect(isNavActive(item("projetos"), "/cadastros", null)).toBe(false); + expect(isNavActive(item("projetos"), "/cadastros", "projetos")).toBe(true); + expect(isNavActive(item("tags"), "/cadastros", "projetos")).toBe(false); + }); + + it("counts a project's own page as Projetos", () => { + expect(isNavActive(item("projetos"), "/cadastros/projetos/abc", null)).toBe(true); + expect(isNavActive(item("clientes"), "/cadastros/projetos/abc", null)).toBe(false); + }); + + it("never marks cadastros items on other pages", () => { + expect(isNavActive(item("clientes"), "/painel", null)).toBe(false); + }); +}); diff --git a/src/lib/nav.ts b/src/lib/nav.ts new file mode 100644 index 0000000..002e4eb --- /dev/null +++ b/src/lib/nav.ts @@ -0,0 +1,56 @@ +export type NavIcon = "registros" | "calendario" | "painel" | "relatorios" | "clientes" | "projetos" | "tags" | "membros"; + +export type NavItem = { + id: NavIcon; + label: string; + href: string; + /** For items that open a tab of /cadastros: the `aba` value that makes the item "current". */ + aba?: string; + adminOnly?: boolean; +}; +export type NavGroup = { label: string | null; items: NavItem[] }; + +const GROUPS: NavGroup[] = [ + { + label: null, + items: [ + { id: "registros", label: "Registros", href: "/" }, + { id: "calendario", label: "Calendário", href: "/calendario" }, + ], + }, + { + label: "Analisar", + items: [ + { id: "painel", label: "Painel", href: "/painel" }, + { id: "relatorios", label: "Relatórios", href: "/relatorios" }, + ], + }, + { + label: "Gerenciar", + items: [ + { id: "clientes", label: "Clientes", href: "/cadastros?aba=clientes", aba: "clientes" }, + { id: "projetos", label: "Projetos", href: "/cadastros?aba=projetos", aba: "projetos" }, + { id: "tags", label: "Tags", href: "/cadastros?aba=tags", aba: "tags" }, + { id: "membros", label: "Membros", href: "/cadastros?aba=membros", aba: "membros", adminOnly: true }, + ], + }, +]; + +/** The menu the caller may see: admin-only items are left out for members. */ +export function navGroupsFor(isAdmin: boolean): NavGroup[] { + return GROUPS.map((g) => ({ ...g, items: g.items.filter((i) => isAdmin || !i.adminOnly) })); +} + +const FIRST_CADASTROS_TAB = "clientes"; + +/** + * Whether `item` is the current page. Items of /cadastros match by tab (`aba`, defaulting to the first tab, like the page + * does); a project's own page (/cadastros/projetos/) counts as "Projetos". + */ +export function isNavActive(item: NavItem, pathname: string, aba: string | null): boolean { + if (item.aba) { + if (pathname === "/cadastros") return (aba ?? FIRST_CADASTROS_TAB) === item.aba; + return item.aba === "projetos" && pathname.startsWith("/cadastros/projetos/"); + } + return item.href === "/" ? pathname === "/" : pathname === item.href || pathname.startsWith(`${item.href}/`); +} diff --git a/src/server/session-role.ts b/src/server/session-role.ts new file mode 100644 index 0000000..8e421dc --- /dev/null +++ b/src/server/session-role.ts @@ -0,0 +1,8 @@ +import { auth } from "@clerk/nextjs/server"; +import { roleFromClerk } from "@/lib/roles"; + +/** Whether the signed-in user is an admin of the active organization. Cosmetic only (menu items): authorization stays in the tenant layer. */ +export async function isAdminSession(): Promise { + const { orgRole } = await auth(); + return roleFromClerk(orgRole) === "admin"; +}