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:
@@ -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 };
|
||||
Reference in New Issue
Block a user