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
+306
View File
@@ -0,0 +1,306 @@
'use client';
import * as React from 'react';
import { ArrowDownIcon, ArrowUpIcon, ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useIsDesktop } from '@/hooks/use-media-query';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card } from '@/components/ui/card';
import { SkeletonTableRow, SkeletonCard } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import { DataPagination } from '@/components/ui/data-pagination';
export interface DataTableSort {
columnId: string;
direction: 'asc' | 'desc';
}
export interface DataTableColumn<T> {
id: string;
header: string;
cell: (row: T) => React.ReactNode;
sortable?: boolean;
/** Campo em destaque no card mobile (limite recomendado: 1–2 por tabela). */
primary?: boolean;
/** Não mostrado no card mobile (ex: coluna técnica só relevante em tela larga). */
hideOnMobileCard?: boolean;
className?: string;
}
interface DataTableProps<T> {
columns: DataTableColumn<T>[];
data: T[];
keyField: (row: T) => string;
loading?: boolean;
sort?: DataTableSort;
onSortChange?: (sort: DataTableSort) => void;
emptyTitle?: string;
emptyDescription?: string;
emptyAction?: React.ReactNode;
rowActions?: (row: T) => React.ReactNode;
onRowClick?: (row: T) => void;
pagination?: {
page: number;
limit: number;
total: number;
onPageChange: (page: number) => void;
};
className?: string;
}
function SortButton<T>({
column,
sort,
onSortChange,
}: {
column: DataTableColumn<T>;
sort?: DataTableSort;
onSortChange: (sort: DataTableSort) => void;
}) {
const active = sort?.columnId === column.id;
const nextDirection = active && sort?.direction === 'asc' ? 'desc' : 'asc';
return (
<button
type="button"
className="-my-2 -ml-1 flex min-h-11 items-center gap-1 rounded-sm py-2 pl-1 font-medium hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
onClick={() => onSortChange({ columnId: column.id, direction: nextDirection })}
>
{column.header}
{active ? (
sort.direction === 'asc' ? (
<ArrowUpIcon className="size-3.5" aria-hidden />
) : (
<ArrowDownIcon className="size-3.5" aria-hidden />
)
) : (
<ArrowUpIcon className="size-3.5 opacity-0 group-hover:opacity-30" aria-hidden />
)}
<span className="sr-only">
{active ? `ordenado ${sort.direction === 'asc' ? 'crescente' : 'decrescente'}` : 'ordenar'}
</span>
</button>
);
}
function MobileRowCard<T>({
row,
columns,
rowActions,
onRowClick,
}: {
row: T;
columns: DataTableColumn<T>[];
rowActions?: (row: T) => React.ReactNode;
onRowClick?: (row: T) => void;
}) {
const [expanded, setExpanded] = React.useState(false);
const primaryColumns = columns.filter((c) => c.primary);
const secondaryColumns = columns.filter((c) => !c.primary && !c.hideOnMobileCard);
return (
<Card
className={cn('flex flex-col gap-3 p-4', onRowClick && 'cursor-pointer')}
onClick={onRowClick ? () => onRowClick(row) : undefined}
role={onRowClick ? 'button' : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={
onRowClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onRowClick(row);
}
}
: undefined
}
>
{primaryColumns.length > 0 && (
<div className="flex flex-col gap-0.5">
{primaryColumns.map((column) => (
<div key={column.id} className="text-base font-medium text-foreground">
{column.cell(row)}
</div>
))}
</div>
)}
{secondaryColumns.length > 0 && (
<>
<button
type="button"
className="-mx-2 flex min-h-11 items-center gap-1 self-start px-2 text-xs font-medium text-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
onClick={(e) => {
e.stopPropagation();
setExpanded((v) => !v);
}}
aria-expanded={expanded}
>
{expanded ? 'Ver menos' : 'Ver mais'}
<ChevronDownIcon
className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
aria-hidden
/>
</button>
{expanded && (
<dl className="flex flex-col gap-2 border-t border-border pt-3">
{secondaryColumns.map((column) => (
<div key={column.id} className="flex items-center justify-between gap-4 text-sm">
<dt className="text-muted-foreground">{column.header}</dt>
<dd className="tabular-nums text-foreground">{column.cell(row)}</dd>
</div>
))}
</dl>
)}
</>
)}
{rowActions && (
<div
className="flex items-center gap-2 border-t border-border pt-3"
onClick={(e) => e.stopPropagation()}
>
{rowActions(row)}
</div>
)}
</Card>
);
}
/**
* Componente central do design system (Etapa 12). Abaixo de `md` renderiza
* como lista de Cards empilhados em vez de fazer scroll horizontal na
* tabela — cada `column.primary` vira um campo em destaque no card, o
* resto fica atrás de "Ver mais".
*/
function DataTable<T>({
columns,
data,
keyField,
loading,
sort,
onSortChange,
emptyTitle = 'Nenhum resultado encontrado',
emptyDescription,
emptyAction,
rowActions,
onRowClick,
pagination,
className,
}: DataTableProps<T>) {
const isDesktop = useIsDesktop();
if (loading) {
return (
<div className={className}>
{isDesktop ? (
<div className="rounded-lg border border-border">
{Array.from({ length: 5 }).map((_, i) => (
<SkeletonTableRow key={i} columns={columns.length + (rowActions ? 1 : 0)} />
))}
</div>
) : (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
)}
</div>
);
}
if (data.length === 0) {
return (
<div className={className}>
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</div>
);
}
return (
<div className={cn('flex flex-col gap-4', className)}>
{isDesktop ? (
// `overflow-x-auto` (não `overflow-hidden`): com muitas colunas perto
// do breakpoint `md`, a tabela pode ficar mais larga que o
// container — sem scroll horizontal, a(s) última(s) coluna(s)
// simplesmente sumiam cortadas em vez de ficarem acessíveis
// (achado da Etapa 15, testando `/master/tenants` em 768px).
<div className="overflow-x-auto overflow-y-hidden rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={column.id} className={cn('group', column.className)}>
{column.sortable && onSortChange ? (
<SortButton column={column} sort={sort} onSortChange={onSortChange} />
) : (
column.header
)}
</TableHead>
))}
{rowActions && (
<TableHead className="w-px">
<span className="sr-only">Ações</span>
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row) => (
<TableRow
key={keyField(row)}
className={onRowClick ? 'cursor-pointer' : undefined}
onClick={onRowClick ? () => onRowClick(row) : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={
onRowClick
? (e) => {
if (e.key === 'Enter') onRowClick(row);
}
: undefined
}
>
{columns.map((column) => (
<TableCell key={column.id} className={cn('tabular-nums', column.className)}>
{column.cell(row)}
</TableCell>
))}
{rowActions && (
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">{rowActions(row)}</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col gap-3">
{data.map((row) => (
<MobileRowCard
key={keyField(row)}
row={row}
columns={columns}
rowActions={rowActions}
onRowClick={onRowClick}
/>
))}
</div>
)}
{pagination && <DataPagination {...pagination} />}
</div>
);
}
export { DataTable };