Adiciona o frontend Next.js em frontend/ (setup, design system, autenticação/tema, layout/navegação, área master, área tenant completa: clientes, produtos, vendas, recebimentos, whatsapp, e-mail, alertas, equipe e perfil). Estende o backend de forma aditiva e autorizada: cookie httpOnly para refresh token (Etapa 13) e CRUD de equipe + edição de perfil do tenant (Etapa 18, módulo src/modules/equipe/ novo e PUT /tenant/perfil). Detalhes completos em Etapas/V2/*. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
116 lines
3.6 KiB
TypeScript
116 lines
3.6 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { usePathname } from 'next/navigation';
|
|
|
|
import { cn } from '@/lib/utils';
|
|
import { Sidebar } from '@/components/layout/sidebar';
|
|
import { Topbar } from '@/components/layout/topbar';
|
|
import { Drawer } from '@/components/layout/drawer';
|
|
import { BottomTabBar } from '@/components/layout/bottom-tab-bar';
|
|
import { Breadcrumb, type BreadcrumbEntry } from '@/components/layout/breadcrumb';
|
|
import { persistSidebarCollapsed } from '@/lib/layout/sidebar-cookie';
|
|
import type { NavItem } from '@/lib/layout/nav-items';
|
|
|
|
interface AppShellProps {
|
|
/** Logo/marca completa (sidebar desktop expandida e header do drawer). */
|
|
brand: React.ReactNode;
|
|
/** Só o símbolo — sidebar desktop recolhida e topbar mobile. */
|
|
brandCollapsed: React.ReactNode;
|
|
items: NavItem[];
|
|
bottomHrefs: string[];
|
|
footer: React.ReactNode;
|
|
/** Rodapé enxuto (só avatar + logout) para a sidebar recolhida. */
|
|
footerCollapsed: React.ReactNode;
|
|
initialCollapsed: boolean;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
function findBreadcrumb(items: NavItem[], pathname: string): BreadcrumbEntry[] {
|
|
for (const item of items) {
|
|
if (item.children) {
|
|
const child = item.children.find(
|
|
(c) => pathname === c.href || pathname.startsWith(`${c.href}/`),
|
|
);
|
|
if (child) return [{ label: item.label }, { label: child.label }];
|
|
}
|
|
if (pathname === item.href || pathname.startsWith(`${item.href}/`)) {
|
|
return [{ label: item.label }];
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Casco de navegação das áreas autenticadas (Etapa 14): compõe
|
|
* Sidebar/Topbar/Drawer/BottomTabBar num único lugar para não duplicar essa
|
|
* orquestração entre o shell do master e o do tenant — cada um só passa
|
|
* marca, itens de menu e rodapé de usuário diferentes.
|
|
*/
|
|
export function AppShell({
|
|
brand,
|
|
brandCollapsed,
|
|
items,
|
|
bottomHrefs,
|
|
footer,
|
|
footerCollapsed,
|
|
initialCollapsed,
|
|
children,
|
|
}: AppShellProps) {
|
|
const pathname = usePathname();
|
|
const [collapsed, setCollapsed] = React.useState(initialCollapsed);
|
|
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
|
|
|
const toggleCollapsed = React.useCallback(() => {
|
|
setCollapsed((prev) => {
|
|
const next = !prev;
|
|
persistSidebarCollapsed(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const bottomItems = React.useMemo(
|
|
() =>
|
|
bottomHrefs
|
|
.map((href) => items.find((item) => item.href === href))
|
|
.filter((item): item is NavItem => Boolean(item)),
|
|
[items, bottomHrefs],
|
|
);
|
|
const moreItems = React.useMemo(
|
|
() => items.filter((item) => !bottomHrefs.includes(item.href)),
|
|
[items, bottomHrefs],
|
|
);
|
|
const breadcrumbItems = React.useMemo(() => findBreadcrumb(items, pathname), [items, pathname]);
|
|
|
|
return (
|
|
<div className="min-h-dvh bg-neutral-50">
|
|
<Sidebar
|
|
brand={brand}
|
|
brandCollapsed={brandCollapsed}
|
|
items={items}
|
|
footer={footer}
|
|
footerCollapsed={footerCollapsed}
|
|
collapsed={collapsed}
|
|
onToggleCollapsed={toggleCollapsed}
|
|
/>
|
|
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} brand={brand} items={items} footer={footer} />
|
|
|
|
<div
|
|
className={cn(
|
|
'flex min-h-dvh flex-col transition-[padding] duration-150',
|
|
collapsed ? 'md:pl-16' : 'md:pl-60',
|
|
)}
|
|
>
|
|
<Topbar
|
|
breadcrumb={<Breadcrumb items={breadcrumbItems} />}
|
|
mobileBrand={brandCollapsed}
|
|
onMenuClick={() => setDrawerOpen(true)}
|
|
/>
|
|
<main className="flex-1 p-4 pb-20 md:p-6 md:pb-6">{children}</main>
|
|
</div>
|
|
|
|
<BottomTabBar items={bottomItems} moreItems={moreItems} />
|
|
</div>
|
|
);
|
|
}
|