Files
SeConectai_Vendas/frontend/components/layout/app-shell.tsx
T
tiagoandClaude Sonnet 5 323912748f Frontend completo (Etapas 11-18) e extensões de backend (Etapas 13 e 18)
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>
2026-08-16 09:41:13 -03:00

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>
);
}