Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<AppHeader />
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-8">{children}</main>
<Suspense fallback={<aside className="fixed inset-y-0 left-0 z-20 hidden w-60 border-r bg-background lg:block" aria-hidden />}>
<AppSidebar />
</Suspense>
<div className="flex flex-1 flex-col lg:pl-60">
<AppHeader />
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-8">{children}</main>
</div>
</>
);
}
17 changes: 7 additions & 10 deletions src/components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur">
{/* On narrow screens the nav drops to its own row so nothing overlaps; from sm it sits between logo and controls. */}
<div className="mx-auto flex min-h-14 w-full max-w-5xl flex-wrap items-center gap-x-4 gap-y-1 px-4 py-2">
<Link href="/" aria-label="Compasso — início" className="shrink-0">
<div className="flex min-h-14 w-full items-center gap-2 px-4 py-2">
<Suspense fallback={<div className="size-9 shrink-0 lg:hidden" aria-hidden />}>
<AppMobileNav />
</Suspense>
<Link href="/" aria-label="Compasso — início" className="shrink-0 lg:hidden">
<Logo />
</Link>
<nav aria-label="Principal" className="order-last w-full overflow-x-auto sm:order-none sm:ml-2 sm:w-auto sm:flex-1 sm:border-l sm:pl-4">
{/* usePathname reads the URL on the client: keep it behind Suspense (Next 16 cacheComponents). */}
<Suspense fallback={<div className="h-8 w-64 animate-pulse rounded-md bg-muted" aria-hidden />}>
<NavLinks />
</Suspense>
</nav>
<div className="ml-auto flex min-w-0 items-center gap-2">
{/* Clerk's widgets read the pathname on the client: keep them behind Suspense (Next 16 cacheComponents). */}
<Suspense fallback={<div className="h-9 w-40 animate-pulse rounded-md bg-muted" aria-hidden />}>
Expand Down
27 changes: 27 additions & 0 deletions src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside className="fixed inset-y-0 left-0 z-20 hidden w-60 flex-col border-r bg-background lg:flex">
<div className="flex h-14 shrink-0 items-center px-5">
<Link href="/" aria-label="Compasso — início">
<Logo />
</Link>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-4">
<SidebarNav isAdmin={isAdmin} />
</div>
</aside>
);
}

/** The hamburger for screens below `lg`; same role lookup, same Suspense requirement. */
export async function AppMobileNav() {
return <MobileNav isAdmin={await isAdminSession()} />;
}
45 changes: 45 additions & 0 deletions src/components/mobile-nav.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden" aria-label="Abrir menu">
<Menu aria-hidden />
</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/50 lg:hidden" />
<Dialog.Content
aria-describedby={undefined}
className="fixed inset-y-0 left-0 z-50 flex w-72 max-w-[85vw] flex-col gap-2 border-r bg-background p-3 shadow-lg outline-none lg:hidden"
>
<Dialog.Title className="sr-only">Menu</Dialog.Title>
<div className="flex h-10 items-center justify-between">
<Link href="/" aria-label="Compasso — início" onClick={() => setOpen(false)}>
<Logo />
</Link>
<Dialog.Close asChild>
<Button variant="ghost" size="icon" aria-label="Fechar menu">
<X aria-hidden />
</Button>
</Dialog.Close>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<SidebarNav isAdmin={isAdmin} onNavigate={() => setOpen(false)} />
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
41 changes: 0 additions & 41 deletions src/components/nav-links.tsx

This file was deleted.

56 changes: 56 additions & 0 deletions src/components/sidebar-nav.tsx
Original file line number Diff line number Diff line change
@@ -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<NavIcon, LucideIcon> = {
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 (
<nav aria-label="Principal" className="grid gap-1">
{navGroupsFor(isAdmin).map((group, index) => (
<div key={group.label ?? index} className="grid gap-0.5">
{group.label && <p className="px-3 pb-1 pt-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">{group.label}</p>}
<ul className="grid gap-0.5">
{group.items.map((item) => {
const active = isNavActive(item, pathname, aba);
const Icon = ICONS[item.id];
return (
<li key={item.id}>
<Link
href={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
className={cn(
"flex h-9 items-center gap-3 rounded-md px-3 text-sm font-medium transition-colors",
active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<Icon className="size-4 shrink-0" aria-hidden />
{item.label}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
);
}
47 changes: 47 additions & 0 deletions src/lib/nav.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
56 changes: 56 additions & 0 deletions src/lib/nav.ts
Original file line number Diff line number Diff line change
@@ -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/<id>) 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}/`);
}
8 changes: 8 additions & 0 deletions src/server/session-role.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const { orgRole } = await auth();
return roleFromClerk(orgRole) === "admin";
}
Loading