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:
2026-08-16 09:41:13 -03:00
co-authored by Claude Sonnet 5
parent f9738e54da
commit 323912748f
179 changed files with 29312 additions and 22 deletions
+115
View File
@@ -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>
</>
);
}
+46
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+66
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}