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>
307 lines
9.3 KiB
TypeScript
307 lines
9.3 KiB
TypeScript
'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 };
|