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>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Ellipsis } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { NavList } from '@/components/layout/nav-list';
|
||||
import type { NavItem } from '@/lib/layout/nav-items';
|
||||
|
||||
function isActive(pathname: string, href: string): boolean {
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
interface BottomTabBarProps {
|
||||
/** Os 4 itens principais, já resolvidos na ordem de exibição. */
|
||||
items: NavItem[];
|
||||
/** Restante do menu, exibido no sheet "Mais". */
|
||||
moreItems: NavItem[];
|
||||
}
|
||||
|
||||
/** Bottom tab bar mobile (`<md`) — 4 itens fixos + "Mais" com o restante do menu. */
|
||||
export function BottomTabBar({ items, moreItems }: BottomTabBarProps) {
|
||||
const pathname = usePathname();
|
||||
const [moreOpen, setMoreOpen] = React.useState(false);
|
||||
const moreActive = moreItems.some(
|
||||
(item) => isActive(pathname, item.href) || item.children?.some((child) => isActive(pathname, child.href)),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
className="fixed inset-x-0 bottom-0 z-20 flex h-16 items-stretch border-t border-border bg-surface md:hidden"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(pathname, item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex flex-1 flex-col items-center justify-center gap-0.5 text-[11px] font-medium text-neutral-500',
|
||||
active && 'text-primary-on-light',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" aria-hidden />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoreOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
className={cn(
|
||||
'flex flex-1 flex-col items-center justify-center gap-0.5 text-[11px] font-medium text-neutral-500',
|
||||
moreActive && 'text-primary-on-light',
|
||||
)}
|
||||
>
|
||||
<Ellipsis className="size-5" aria-hidden />
|
||||
Mais
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<SheetContent side="bottom" className="max-h-[70vh] gap-0 p-0">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Mais opções</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="overflow-y-auto px-3 pb-4">
|
||||
<NavList items={moreItems} onNavigate={() => setMoreOpen(false)} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface BreadcrumbEntry {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerado manualmente a partir do item de menu ativo em `AppShell` nesta
|
||||
* primeira versão (Etapa 14) — automatizar a partir da árvore de rotas do
|
||||
* Next é uma melhoria natural de uma etapa futura, quando houver páginas
|
||||
* com mais de 2 níveis (ex: detalhe dentro de uma lista).
|
||||
*/
|
||||
export function Breadcrumb({ items, className }: { items: BreadcrumbEntry[]; className?: string }) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={cn('min-w-0', className)}>
|
||||
<ol className="flex min-w-0 items-center gap-1.5 text-sm">
|
||||
{items.map((item, index) => {
|
||||
const last = index === items.length - 1;
|
||||
return (
|
||||
<li key={`${item.label}-${index}`} className="flex min-w-0 items-center gap-1.5">
|
||||
{index > 0 && <ChevronRight className="size-3.5 shrink-0 text-neutral-500" aria-hidden />}
|
||||
{item.href && !last ? (
|
||||
<Link href={item.href} className="truncate text-neutral-500 hover:text-foreground">
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className={cn('truncate', last ? 'font-medium text-foreground' : 'text-neutral-500')}
|
||||
aria-current={last ? 'page' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { NavList } from '@/components/layout/nav-list';
|
||||
import type { NavItem } from '@/lib/layout/nav-items';
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
brand: React.ReactNode;
|
||||
items: NavItem[];
|
||||
footer: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu deslizante mobile — réplica do menu completo da sidebar desktop.
|
||||
* Fecha ao navegar (`NavList.onNavigate`) ou ao tocar fora (comportamento
|
||||
* padrão do `Sheet`/Radix Dialog).
|
||||
*/
|
||||
export function Drawer({ open, onOpenChange, brand, items, footer }: DrawerProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="left" className="w-4/5 gap-0 p-0">
|
||||
<SheetHeader className="h-16 flex-row items-center gap-2 px-4 py-0">
|
||||
{brand}
|
||||
<SheetTitle className="sr-only">Menu de navegação</SheetTitle>
|
||||
</SheetHeader>
|
||||
<Separator />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-4">
|
||||
<NavList items={items} onNavigate={() => onOpenChange(false)} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2 p-3">{footer}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { AppShell } from '@/components/layout/app-shell';
|
||||
import { UserFooter } from '@/components/layout/user-footer';
|
||||
import { useAuth } from '@/lib/auth/auth-context';
|
||||
import { MASTER_NAV_ITEMS, MASTER_BOTTOM_HREFS } from '@/lib/layout/nav-items';
|
||||
|
||||
function MasterBrandSymbol() {
|
||||
return (
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand-master text-sm font-bold text-white">
|
||||
SV
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Identidade fixa do painel master — nunca whitelabel (ver `TenantShell`). */
|
||||
function MasterBrand() {
|
||||
return (
|
||||
<>
|
||||
<MasterBrandSymbol />
|
||||
<span className="truncate text-sm font-semibold text-neutral-950">SeConectai Vendas</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MasterShell({
|
||||
initialCollapsed,
|
||||
children,
|
||||
}: {
|
||||
initialCollapsed: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { logout } = useAuth();
|
||||
const handleLogout = React.useCallback(() => {
|
||||
void logout();
|
||||
}, [logout]);
|
||||
|
||||
return (
|
||||
// `Sidebar`/`BottomTabBar`/`NavList` destacam o item ativo com os tokens
|
||||
// `--color-primary(-on-light)` — dinâmicos por tenant (Etapa 13). O
|
||||
// master tem identidade fixa (seção 6 da arquitetura), então essa
|
||||
// sobrescrita local troca esses tokens por `--brand-master` só dentro
|
||||
// desta árvore, sem tocar no `:root` (evitaria vazar pro `/app` do
|
||||
// tenant na mesma aba).
|
||||
<div
|
||||
style={{ '--color-primary': 'var(--brand-master)', '--color-primary-on-light': 'var(--brand-master)' } as React.CSSProperties}
|
||||
>
|
||||
<AppShell
|
||||
brand={<MasterBrand />}
|
||||
brandCollapsed={<MasterBrandSymbol />}
|
||||
items={MASTER_NAV_ITEMS}
|
||||
bottomHrefs={MASTER_BOTTOM_HREFS}
|
||||
footer={<UserFooter label="Administrador master" onLogout={handleLogout} />}
|
||||
footerCollapsed={<UserFooter label="Administrador master" compact onLogout={handleLogout} />}
|
||||
initialCollapsed={initialCollapsed}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NavItem } from '@/lib/layout/nav-items';
|
||||
|
||||
function isActive(pathname: string, href: string): boolean {
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
function isGroupActive(pathname: string, item: NavItem): boolean {
|
||||
if (isActive(pathname, item.href)) return true;
|
||||
return item.children?.some((child) => isActive(pathname, child.href)) ?? false;
|
||||
}
|
||||
|
||||
interface NavListProps {
|
||||
items: NavItem[];
|
||||
/** Modo ícones-apenas (sidebar recolhida) — grupos viram link direto pro primeiro filho. */
|
||||
collapsed?: boolean;
|
||||
/** Disparado ao navegar (o Drawer usa isso para fechar). */
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de navegação com estado de item/grupo ativo — compartilhada entre
|
||||
* `Sidebar` (desktop) e `Drawer` (mobile) para não duplicar essa lógica em
|
||||
* dois lugares.
|
||||
*/
|
||||
export function NavList({ items, collapsed = false, onNavigate }: NavListProps) {
|
||||
const pathname = usePathname();
|
||||
const [openHref, setOpenHref] = React.useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col gap-1">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isGroupActive(pathname, item);
|
||||
|
||||
if (item.children && !collapsed) {
|
||||
const open = openHref === item.href || (openHref === null && active);
|
||||
return (
|
||||
<div key={item.href}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenHref(open ? '' : item.href)}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-neutral-500 transition-colors hover:bg-muted hover:text-foreground',
|
||||
active && 'bg-primary/10 text-primary-on-light',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4.5 shrink-0" aria-hidden />
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
<ChevronDown
|
||||
className={cn('size-4 shrink-0 transition-transform', open && 'rotate-180')}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="ml-4 flex flex-col gap-0.5 border-l border-border py-1 pl-4">
|
||||
{item.children.map((child) => (
|
||||
<Link
|
||||
key={child.href}
|
||||
href={child.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={isActive(pathname, child.href) ? 'page' : undefined}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2 text-sm text-neutral-500 transition-colors hover:bg-muted hover:text-foreground',
|
||||
isActive(pathname, child.href) && 'font-medium text-primary-on-light',
|
||||
)}
|
||||
>
|
||||
{child.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-neutral-500 transition-colors hover:bg-muted hover:text-foreground',
|
||||
active && 'bg-primary/10 text-primary-on-light',
|
||||
collapsed && 'mx-auto size-11 justify-center px-0 py-0',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4.5 shrink-0" aria-hidden />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { NavList } from '@/components/layout/nav-list';
|
||||
import type { NavItem } from '@/lib/layout/nav-items';
|
||||
|
||||
interface SidebarProps {
|
||||
brand: React.ReactNode;
|
||||
brandCollapsed: React.ReactNode;
|
||||
items: NavItem[];
|
||||
footer: React.ReactNode;
|
||||
footerCollapsed: React.ReactNode;
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar fixa (desktop, `md+`). Itens de menu e rodapé de usuário chegam
|
||||
* via props — sem nada hardcoded — para ser reaproveitada entre o shell do
|
||||
* master e o do tenant com conteúdo diferente (Etapa 14).
|
||||
*/
|
||||
export function Sidebar({
|
||||
brand,
|
||||
brandCollapsed,
|
||||
items,
|
||||
footer,
|
||||
footerCollapsed,
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-30 hidden flex-col border-r border-border bg-surface transition-[width] duration-150 md:flex',
|
||||
collapsed ? 'w-16' : 'w-60',
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex h-16 shrink-0 items-center gap-2 overflow-hidden px-4', collapsed && 'justify-center px-2')}>
|
||||
{collapsed ? brandCollapsed : brand}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-4">
|
||||
<NavList items={items} collapsed={collapsed} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className={cn('flex flex-col gap-2 p-3', collapsed && 'items-center')}>
|
||||
{collapsed ? footerCollapsed : footer}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleCollapsed}
|
||||
aria-label={collapsed ? 'Expandir menu' : 'Recolher menu'}
|
||||
className={collapsed ? 'self-center' : 'self-end'}
|
||||
>
|
||||
{collapsed ? <PanelLeftOpen className="size-4" /> : <PanelLeftClose className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { AppShell } from '@/components/layout/app-shell';
|
||||
import { UserFooter } from '@/components/layout/user-footer';
|
||||
import { useAuth } from '@/lib/auth/auth-context';
|
||||
import { TenantThemeProvider, useTenantTheme } from '@/lib/theme/theme-provider';
|
||||
import { TENANT_NAV_ITEMS, TENANT_BOTTOM_HREFS, filterByRole } from '@/lib/layout/nav-items';
|
||||
import type { UserRole } from '@/lib/auth/jwt';
|
||||
|
||||
const ROLE_LABELS: Record<UserRole, string> = {
|
||||
tenant_admin: 'Administrador',
|
||||
vendedor: 'Vendedor',
|
||||
financeiro: 'Financeiro',
|
||||
visualizador: 'Visualizador',
|
||||
};
|
||||
|
||||
/** Logo/nome resolvidos do tema do tenant (Etapa 13); cai para a marca padrão sem tema. */
|
||||
function TenantBrand({ collapsed = false }: { collapsed?: boolean }) {
|
||||
const { theme } = useTenantTheme();
|
||||
const name = theme?.nome ?? 'SeConectai Vendas';
|
||||
|
||||
return (
|
||||
<>
|
||||
{theme?.logoUrl ? (
|
||||
<Image
|
||||
src={theme.logoUrl}
|
||||
alt={name}
|
||||
width={36}
|
||||
height={36}
|
||||
className="size-9 shrink-0 rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{!collapsed && <span className="truncate text-sm font-semibold text-neutral-950">{name}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantShellInner({
|
||||
initialCollapsed,
|
||||
children,
|
||||
}: {
|
||||
initialCollapsed: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { user, logout } = useAuth();
|
||||
const handleLogout = React.useCallback(() => {
|
||||
void logout();
|
||||
}, [logout]);
|
||||
|
||||
const items = React.useMemo(() => filterByRole(TENANT_NAV_ITEMS, user?.role), [user?.role]);
|
||||
const roleLabel = user?.role ? ROLE_LABELS[user.role] : 'Usuário';
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
brand={<TenantBrand />}
|
||||
brandCollapsed={<TenantBrand collapsed />}
|
||||
items={items}
|
||||
bottomHrefs={TENANT_BOTTOM_HREFS}
|
||||
footer={<UserFooter label={roleLabel} onLogout={handleLogout} />}
|
||||
footerCollapsed={<UserFooter label={roleLabel} compact onLogout={handleLogout} />}
|
||||
initialCollapsed={initialCollapsed}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell da área `/app`. Resolve o tema do tenant a partir do `tenant_id` do
|
||||
* usuário autenticado (Etapa 13) e filtra os itens de menu por `role` — ex:
|
||||
* "Equipe" só aparece para `tenant_admin` (Etapa 14, critério de aceite).
|
||||
*/
|
||||
export function TenantShell({
|
||||
initialCollapsed,
|
||||
children,
|
||||
}: {
|
||||
initialCollapsed: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<TenantThemeProvider tenantId={user?.tenant_id ?? null}>
|
||||
<TenantShellInner initialCollapsed={initialCollapsed}>{children}</TenantShellInner>
|
||||
</TenantThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Bell, Menu, Search } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface TopbarProps {
|
||||
breadcrumb: React.ReactNode;
|
||||
/** Logo compacta (só símbolo) exibida no lugar do breadcrumb no mobile. */
|
||||
mobileBrand: React.ReactNode;
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topbar fixa no topo. Desktop: breadcrumb + busca rápida + notificações.
|
||||
* Mobile (`<md`): logo símbolo + hambúrguer (abre o `Drawer`) + notificações
|
||||
* — a busca funcional real fica para a Etapa 18/19, aqui só o componente.
|
||||
*/
|
||||
export function Topbar({ breadcrumb, mobileBrand, onMenuClick }: TopbarProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-3 border-b border-border bg-surface px-4 md:px-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="md:hidden"
|
||||
onClick={onMenuClick}
|
||||
aria-label="Abrir menu"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="md:hidden">{mobileBrand}</div>
|
||||
<div className="hidden min-w-0 md:block">{breadcrumb}</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden max-w-xs flex-1 items-center md:flex">
|
||||
<div className="relative w-full">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-neutral-500" />
|
||||
<Input placeholder="Buscar clientes, vendas..." className="pl-9" aria-label="Busca rápida" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="Notificações">
|
||||
<Bell className="size-5" />
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { LogOut } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface UserFooterProps {
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
onLogout: () => void;
|
||||
/** Sidebar recolhida/drawer fechado: só avatar + botão de sair, sem texto. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rodapé de identidade + logout da sidebar/drawer (Etapa 14). O access
|
||||
* token (`lib/auth/jwt.ts`) não carrega nome/e-mail do usuário — só
|
||||
* `sub`/`tipo`/`tenant_id`/`role` — então a identificação aqui é o papel do
|
||||
* usuário, não o nome; ver observação da Etapa 14 no controle de progresso.
|
||||
*/
|
||||
export function UserFooter({ label, sublabel, onLogout, compact = false }: UserFooterProps) {
|
||||
const initial = label.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full items-center gap-2', compact && 'w-auto flex-col')}>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-semibold text-foreground">
|
||||
{initial}
|
||||
</span>
|
||||
{!compact && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{label}</p>
|
||||
{sublabel && <p className="truncate text-xs text-neutral-500">{sublabel}</p>}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onLogout}
|
||||
aria-label="Sair"
|
||||
title="Sair"
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user