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
@@ -0,0 +1,86 @@
import * as React from 'react';
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface DataPaginationProps {
/** 1-indexado, compatível com `page`/`limit`/`meta.total` da API (Etapas 1–8 do backend). */
page: number;
limit: number;
total: number;
onPageChange: (page: number) => void;
className?: string;
}
function getPageNumbers(page: number, totalPages: number): (number | 'ellipsis')[] {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
const pages = new Set([1, totalPages, page, page - 1, page + 1]);
const sorted = [...pages].filter((p) => p >= 1 && p <= totalPages).sort((a, b) => a - b);
const result: (number | 'ellipsis')[] = [];
sorted.forEach((p, i) => {
if (i > 0 && p - sorted[i - 1] > 1) result.push('ellipsis');
result.push(p);
});
return result;
}
/** Controles de página compatíveis com os parâmetros de paginação do backend (Etapa 12). */
function DataPagination({ page, limit, total, onPageChange, className }: DataPaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / limit));
const firstItem = total === 0 ? 0 : (page - 1) * limit + 1;
const lastItem = Math.min(page * limit, total);
return (
<nav
aria-label="Paginação"
className={cn('flex flex-col items-center justify-between gap-3 sm:flex-row', className)}
>
<p className="text-sm text-muted-foreground">
{total === 0 ? 'Nenhum resultado' : `Mostrando ${firstItem}–${lastItem} de ${total}`}
</p>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
aria-label="Página anterior"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeftIcon />
</Button>
{getPageNumbers(page, totalPages).map((p, index) =>
p === 'ellipsis' ? (
<span key={`ellipsis-${index}`} className="flex size-11 items-center justify-center">
<MoreHorizontalIcon className="size-4 text-muted-foreground" aria-hidden />
</span>
) : (
<Button
key={p}
variant={p === page ? 'outline' : 'ghost'}
size="icon"
aria-label={`Página ${p}`}
aria-current={p === page ? 'page' : undefined}
onClick={() => onPageChange(p)}
>
{p}
</Button>
),
)}
<Button
variant="ghost"
size="icon"
aria-label="Próxima página"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
<ChevronRightIcon />
</Button>
</div>
</nav>
);
}
export { DataPagination };