Files
SeConectai_Vendas/frontend/components/ui/data-table.tsx
T
tiagoandClaude Sonnet 5 323912748f 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>
2026-08-16 09:41:13 -03:00

307 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };