rl-core-front 0.15.5 → 0.15.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.15.5",
3
+ "version": "0.15.6",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usuários, RBAC, auditoria, logs e listagens com filtro dinâmico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@ import {
30
30
  SlidersHorizontal,
31
31
  } from "lucide-react";
32
32
  import type { JSX, PointerEvent as ReactPointerEvent } from "react";
33
- import { useMemo, useRef, useState } from "react";
33
+ import { Fragment, useMemo, useRef, useState } from "react";
34
34
 
35
35
  import { countConditions } from "#core/_utils/advanced-filter";
36
36
  import { activeFilterCount } from "#core/_utils/filter";
@@ -42,11 +42,13 @@ import {
42
42
  ColumnVisibilityOption,
43
43
  } from "#core/components/ui/column-visibility-modal";
44
44
  import { FilterSheet } from "#core/components/ui/filter-sheet";
45
+ import { SegmentedControl } from "#core/components/ui/segmented-control";
45
46
  import { Spinner } from "#core/components/ui/spinner";
46
47
  import {
47
48
  Table,
48
49
  TableBody,
49
50
  TableCell,
51
+ TableFooter,
50
52
  TableHead,
51
53
  TableHeader,
52
54
  TableRow,
@@ -54,6 +56,8 @@ import {
54
56
  import { useI18n } from "#core/contexts";
55
57
  import { useColumnVisibility } from "#core/hooks/use-column-visibility";
56
58
  import type { FiltersState } from "#core/hooks/use-filters";
59
+ import type { TableView } from "#core/hooks/use-table-view";
60
+ import { useTableView } from "#core/hooks/use-table-view";
57
61
  import { cn } from "#core/lib/utils";
58
62
 
59
63
  export const dataTableFeatures = tableFeatures({
@@ -148,6 +152,66 @@ interface DataTableProps<T extends RowData> {
148
152
  * gravada, e arrastar escreveria uma sequência que ninguém viu.
149
153
  */
150
154
  onRowReorder?: (activeId: string, overId: string) => void;
155
+ /**
156
+ * Desenha a listagem em cartões, e não em linhas.
157
+ *
158
+ * Recebe as linhas da página atual, já ordenadas e filtradas, e devolve o
159
+ * que aparece no lugar da tabela. Com esta prop a barra ganha o alternador
160
+ * lista/cartões, e a escolha fica guardada por `storageKey` — dado que se vê
161
+ * (uma coleção, um catálogo de fotos) se compara olhando, e dado que se
162
+ * confere se compara lendo; a mesma listagem serve às duas coisas.
163
+ *
164
+ * Em cartões não há cabeçalho, logo não há por onde ordenar: a ordem
165
+ * escolhida na lista continua valendo, e o botão de colunas some — cartão
166
+ * não tem coluna para esconder.
167
+ */
168
+ renderGrid?: (rows: T[]) => React.ReactNode;
169
+ /** Como a listagem abre na primeira visita, antes de alguém escolher. */
170
+ defaultView?: TableView;
171
+ /**
172
+ * A que seção cada linha pertence — sem isto, não há agrupamento.
173
+ *
174
+ * O grupo é sempre a chave primária da ordem: ordenar por uma coluna ordena
175
+ * **dentro** da seção. Do contrário, um clique no cabeçalho intercalaria as
176
+ * seções e o cabeçalho de grupo reapareceria a cada troca de valor — dez
177
+ * faixas onde existem duas.
178
+ */
179
+ groupBy?: (row: T) => string;
180
+ /**
181
+ * O cabeçalho de cada seção: uma linha que atravessa a tabela inteira.
182
+ *
183
+ * Recebe a chave e as linhas da seção — é de onde saem contagem e total sem
184
+ * o consumidor precisar refazer a divisão que a tabela acabou de fazer. Sem
185
+ * esta prop o agrupamento só ordena, e nada é desenhado.
186
+ */
187
+ renderGroupHeader?: (group: DataTableGroup<T>) => React.ReactNode;
188
+ /**
189
+ * A faixa de cada seção fecha e abre no clique.
190
+ *
191
+ * Fechada, a seção fica só na faixa — e é por isso que a faixa recebe
192
+ * `collapsed`: quem a desenha decide o que dizer quando as linhas não estão
193
+ * à vista (a contagem, em geral, que aberta é redundante). A escolha vale
194
+ * enquanto a listagem estiver na tela; não é gravada.
195
+ */
196
+ collapsibleGroups?: boolean;
197
+ /**
198
+ * A ordem das seções.
199
+ *
200
+ * Chave que não aparecer aqui vai para o fim, na ordem em que apareceu nos
201
+ * dados. Sem a prop, a ordem é a de chegada — e "a de chegada" muda quando
202
+ * alguém ordena por uma coluna, por isso toda listagem com seções fixas
203
+ * (a receber / recebidas) declara esta lista.
204
+ */
205
+ groupOrder?: string[];
206
+ }
207
+
208
+ /** Uma seção da listagem, como o cabeçalho de grupo a recebe. */
209
+ export interface DataTableGroup<T> {
210
+ key: string;
211
+ /** As linhas da seção — todas, mesmo com ela fechada. */
212
+ rows: T[];
213
+ /** Sempre `false` sem `collapsibleGroups`. */
214
+ collapsed: boolean;
151
215
  }
152
216
 
153
217
  /** Extras que uma coluna pode declarar em `meta`. */
@@ -159,6 +223,33 @@ export interface DataTableColumnMeta {
159
223
  * É o caso da coluna de ações: sem isso o `<table>` a esticava.
160
224
  */
161
225
  shrink?: boolean;
226
+ /**
227
+ * A coluna existe, mas não aparece até alguém ligá-la em "Colunas".
228
+ *
229
+ * Para o dado que a listagem tem e quase ninguém procura — a loja, o dia da
230
+ * compra — em tabela que já mostra o essencial. Vale só na primeira visita:
231
+ * a partir do momento em que alguém escolhe as colunas, a escolha guardada é
232
+ * que manda, e uma coluna nova nascendo escondida não mexe no que a pessoa
233
+ * já arrumou.
234
+ */
235
+ defaultHidden?: boolean;
236
+ }
237
+
238
+ /**
239
+ * O id que a coluna terá na tabela: o declarado, ou a chave do acessor.
240
+ *
241
+ * É o mesmo par que o TanStack usa para nomear a coluna, e precisa ser lido
242
+ * antes dela existir — o padrão de visibilidade entra na montagem da tabela.
243
+ */
244
+ function columnId<T extends RowData>(
245
+ col: ColumnDef<DataTableFeatures, T, unknown>,
246
+ ): string | undefined {
247
+ if (col.id) {
248
+ return col.id;
249
+ }
250
+ const acessor = (col as { accessorKey?: unknown }).accessorKey;
251
+
252
+ return typeof acessor === "string" ? acessor : undefined;
162
253
  }
163
254
 
164
255
  function columnLabel<T extends RowData>(
@@ -175,6 +266,85 @@ function columnLabel<T extends RowData>(
175
266
  return id;
176
267
  }
177
268
 
269
+ /**
270
+ * As linhas repartidas em seções, na ordem em que as seções saem.
271
+ *
272
+ * Estável de propósito: dentro da seção, as linhas mantêm a ordem que a tabela
273
+ * já lhes deu — a ordenação por coluna continua valendo, só que dentro de cada
274
+ * faixa. Seção declarada em `order` que não tiver nenhuma linha não vira faixa
275
+ * vazia: some, como o bloco vazio de uma listagem some.
276
+ */
277
+ function groupRows<T, R extends { original: T }>(
278
+ rows: R[],
279
+ groupBy: (row: T) => string,
280
+ order?: string[],
281
+ ): { key: string; rows: R[] }[] {
282
+ const buckets = new Map<string, R[]>();
283
+
284
+ rows.forEach((row) => {
285
+ const key = groupBy(row.original);
286
+ const bucket = buckets.get(key);
287
+ if (bucket) {
288
+ bucket.push(row);
289
+ } else {
290
+ buckets.set(key, [row]);
291
+ }
292
+ });
293
+
294
+ const declared = (order ?? []).filter((key) => buckets.has(key));
295
+ const rest = [...buckets.keys()].filter((key) => !declared.includes(key));
296
+
297
+ return [...declared, ...rest].map((key) => ({
298
+ key,
299
+ rows: buckets.get(key) ?? [],
300
+ }));
301
+ }
302
+
303
+ /**
304
+ * O corpo da tabela em seções, com o índice de onde cada uma começa.
305
+ *
306
+ * Uma seção só, sem faixa, quando não há agrupamento — assim o corpo se
307
+ * desenha do mesmo jeito nos dois casos, em vez de dois caminhos que precisam
308
+ * ser mantidos iguais. O `offset` é a posição da primeira linha da seção na
309
+ * lista visível: é dele que sai o índice que o arrasto usa para medir o
310
+ * deslocamento.
311
+ */
312
+ function bodySections<T, R extends { original: T }>(
313
+ rows: R[],
314
+ groupBy: ((row: T) => string) | undefined,
315
+ order: string[] | undefined,
316
+ collapsedKeys: string[],
317
+ ): {
318
+ key: string;
319
+ offset: number;
320
+ rows: R[];
321
+ header: DataTableGroup<T> | null;
322
+ }[] {
323
+ if (!groupBy) {
324
+ return [{ key: "", offset: 0, rows, header: null }];
325
+ }
326
+
327
+ let offset = 0;
328
+
329
+ return groupRows(rows, groupBy, order).map((group) => {
330
+ const collapsed = collapsedKeys.includes(group.key);
331
+ const section = {
332
+ key: group.key,
333
+ offset,
334
+ rows: collapsed ? [] : group.rows,
335
+ header: {
336
+ key: group.key,
337
+ rows: group.rows.map((row) => row.original),
338
+ collapsed,
339
+ },
340
+ };
341
+
342
+ offset += section.rows.length;
343
+
344
+ return section;
345
+ });
346
+ }
347
+
178
348
  /**
179
349
  * `w-px` + `whitespace-nowrap` é o jeito de fazer uma coluna de tabela encolher
180
350
  * até o conteúdo: o navegador trata a largura mínima como o conteúdo e o espaço
@@ -207,9 +377,17 @@ export function DataTable<T extends RowData>({
207
377
  emptyMessage,
208
378
  hideFooter = false,
209
379
  onRowReorder,
380
+ renderGrid,
381
+ defaultView = "list",
382
+ groupBy,
383
+ renderGroupHeader,
384
+ groupOrder,
385
+ collapsibleGroups = false,
210
386
  }: DataTableProps<T>): JSX.Element {
211
387
  const { t } = useI18n();
212
388
  const [colsOpen, setColsOpen] = useState(false);
389
+ /** As seções fechadas, por chave. */
390
+ const [collapsedGroups, setCollapsedGroups] = useState<string[]>([]);
213
391
  const [filtersOpen, setFiltersOpen] = useState(false);
214
392
  /** A linha na mão e a linha embaixo do dedo, enquanto o arrasto dura. */
215
393
  const [draggedRow, setDraggedRow] = useState<string | null>(null);
@@ -233,7 +411,22 @@ export function DataTable<T extends RowData>({
233
411
  */
234
412
  const [dragHeight, setDragHeight] = useState(0);
235
413
 
236
- const { hiddenColumns, saveHiddenColumns } = useColumnVisibility(storageKey);
414
+ const { view, setView } = useTableView(storageKey, defaultView);
415
+
416
+ const ocultasPorPadrao = useMemo(
417
+ () =>
418
+ columns
419
+ .filter((column) => (column.meta as DataTableColumnMeta)?.defaultHidden)
420
+ .map((column) => columnId(column))
421
+ .filter((id): id is string => Boolean(id)),
422
+ [columns],
423
+ );
424
+ const emCartoes = Boolean(renderGrid) && view === "grid";
425
+
426
+ const { hiddenColumns, saveHiddenColumns } = useColumnVisibility(
427
+ storageKey,
428
+ ocultasPorPadrao,
429
+ );
237
430
  const columnVisibility = useMemo<ColumnVisibilityState>(() => {
238
431
  const v: ColumnVisibilityState = {};
239
432
  hiddenColumns.forEach((id) => {
@@ -289,6 +482,17 @@ export function DataTable<T extends RowData>({
289
482
  ...(manualPagination ? { rowCount: rowCount ?? 0 } : {}),
290
483
  });
291
484
 
485
+ /*
486
+ Linha de fechamento: existe só se alguma coluna visível declarar `footer`.
487
+
488
+ Vem do `columnDef` e não de uma última linha no `data` porque totalizador
489
+ não é registro — no corpo, ele ordenaria junto com os outros e apareceria no
490
+ meio da tabela à primeira troca de coluna.
491
+ */
492
+ const temFechamento = table
493
+ .getAllColumns()
494
+ .some((c) => c.getIsVisible() && Boolean(c.columnDef.footer));
495
+
292
496
  const hideable: ColumnVisibilityOption[] = table
293
497
  .getAllColumns()
294
498
  .filter((c) => c.getCanHide())
@@ -312,7 +516,30 @@ export function DataTable<T extends RowData>({
312
516
  A coluna da alça continua no lugar para a tabela não mudar de largura — só
313
517
  o botão fica inerte, e o `title` diz o motivo.
314
518
  */
315
- const canReorder = reorderable && (table.state.sorting ?? []).length === 0;
519
+ const canReorder =
520
+ reorderable && (table.state.sorting ?? []).length === 0 && !groupBy;
521
+
522
+ /*
523
+ A lista já repartida em seções, achatada de volta numa sequência.
524
+
525
+ Achatada, e não desenhada como uma tabela por seção: seção que vira tabela
526
+ própria mede as próprias linhas, e a mesma coluna acaba com uma largura em
527
+ cada faixa. Uma tabela só é o que garante que a coluna vale o mesmo de cima
528
+ a baixo — sem largura fixa em lugar nenhum.
529
+ */
530
+ const sections = bodySections(
531
+ table.getRowModel().rows,
532
+ groupBy,
533
+ groupOrder,
534
+ collapsedGroups,
535
+ );
536
+
537
+ const toggleGroup = (key: string): void =>
538
+ setCollapsedGroups((atual) =>
539
+ atual.includes(key)
540
+ ? atual.filter((fechada) => fechada !== key)
541
+ : [...atual, key],
542
+ );
316
543
  const colSpan =
317
544
  (table.getVisibleLeafColumns().length || 1) + (reorderable ? 1 : 0);
318
545
 
@@ -405,6 +632,18 @@ export function DataTable<T extends RowData>({
405
632
  };
406
633
 
407
634
  const filtersEnabled = Boolean(filters);
635
+ const columnsButton =
636
+ Boolean(storageKey) && hideable.length > 0 && !emCartoes;
637
+ /*
638
+ A barra só existe se tiver o que mostrar.
639
+
640
+ Vazia, ela continuava ocupando os 12px do `mb-3` e empurrava a tabela para
641
+ baixo — a listagem sem toolbar, sem filtro e sem escolha de colunas ficava
642
+ com um vão maior que o das outras, sem nada que o explicasse.
643
+ */
644
+ const temBarra = Boolean(
645
+ toolbar || toolbarEnd || renderGrid || filtersEnabled || columnsButton,
646
+ );
408
647
  const activeFilters = !filters
409
648
  ? 0
410
649
  : filters.mode === "advanced"
@@ -420,6 +659,82 @@ export function DataTable<T extends RowData>({
420
659
  emptyMessage ??
421
660
  (activeFilters > 0 ? t("table.emptyFiltered") : t("table.empty"));
422
661
 
662
+ /*
663
+ Três blocos, e a ordem tem motivo: quantos são à esquerda, para onde ir no
664
+ meio, e o tamanho da página à direita — o controle que se mexe uma vez fica
665
+ na ponta, e a navegação, que se usa o tempo todo, no centro. Em tela estreita
666
+ eles empilham em vez de espremer.
667
+
668
+ Fora do JSX da tabela porque a mesma navegação serve à lista e aos cartões:
669
+ o formato muda o que está acima dele, não em que página se está.
670
+ */
671
+ const rodape = (
672
+ <div className="flex flex-col items-center gap-3 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:flex-row sm:justify-between">
673
+ <span className="sm:flex-1">
674
+ {total} {t("table.results")}
675
+ </span>
676
+
677
+ <div className="flex items-center gap-1">
678
+ <Button
679
+ variant="ghost"
680
+ size="icon"
681
+ aria-label={t("table.previousPage")}
682
+ disabled={!table.getCanPreviousPage()}
683
+ onClick={() => table.previousPage()}
684
+ >
685
+ <ChevronLeft className="h-4 w-4" />
686
+ </Button>
687
+
688
+ {pageRange(pageIndex, pageCount).map((item, posicao) =>
689
+ item === "gap" ? (
690
+ // O buraco não é botão: `key` pela posição porque duas elipses
691
+ // são indistinguíveis entre si.
692
+ <span key={`gap-${posicao}`} className="px-1 text-muted-foreground">
693
+
694
+ </span>
695
+ ) : (
696
+ <Button
697
+ key={item}
698
+ variant={item === pageIndex + 1 ? "default" : "ghost"}
699
+ size="icon"
700
+ // Quadrado de 40px como base, mas cresce com o número: a
701
+ // página 128 não pode vazar do botão.
702
+ className="w-auto min-w-10 px-2 tabular-nums"
703
+ aria-current={item === pageIndex + 1 ? "page" : undefined}
704
+ onClick={() => table.setPageIndex(item - 1)}
705
+ >
706
+ {item}
707
+ </Button>
708
+ ),
709
+ )}
710
+
711
+ <Button
712
+ variant="ghost"
713
+ size="icon"
714
+ aria-label={t("table.nextPage")}
715
+ disabled={!table.getCanNextPage()}
716
+ onClick={() => table.nextPage()}
717
+ >
718
+ <ChevronRight className="h-4 w-4" />
719
+ </Button>
720
+ </div>
721
+
722
+ <div className="sm:flex-1 sm:text-right">
723
+ <select
724
+ value={pageSize}
725
+ onChange={(e) => table.setPageSize(Number(e.target.value))}
726
+ className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
727
+ >
728
+ {pageSizeOptions.map((n) => (
729
+ <option key={n} value={n}>
730
+ {n} {t("table.perPage")}
731
+ </option>
732
+ ))}
733
+ </select>
734
+ </div>
735
+ </div>
736
+ );
737
+
423
738
  return (
424
739
  <div>
425
740
  {/*
@@ -431,272 +746,307 @@ export function DataTable<T extends RowData>({
431
746
  `min-w-0` é o par obrigatório disso — filho de flex não encolhe abaixo
432
747
  do próprio conteúdo sem ele, e o `flex-wrap` sozinho não bastaria.
433
748
  */}
434
- <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
435
- <div className="flex min-w-0 flex-wrap items-center gap-2">
436
- {toolbar}
437
- </div>
438
- <div className="flex flex-wrap items-center gap-2">
439
- {toolbarEnd}
440
- {/* Filtro antes de colunas: filtrar é a ação do dia a dia, escolher
749
+ {temBarra && (
750
+ <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
751
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
752
+ {toolbar}
753
+ </div>
754
+ <div className="flex flex-wrap items-center gap-2">
755
+ {renderGrid && (
756
+ <SegmentedControl
757
+ label={t("table.view")}
758
+ value={view}
759
+ onValueChange={(next) => setView(next as TableView)}
760
+ options={[
761
+ { value: "list", label: t("table.viewList") },
762
+ { value: "grid", label: t("table.viewGrid") },
763
+ ]}
764
+ />
765
+ )}
766
+ {toolbarEnd}
767
+ {/* Filtro antes de colunas: filtrar é a ação do dia a dia, escolher
441
768
  coluna se faz uma vez e a tela guarda. */}
442
- {filtersEnabled && (
443
- <Button
444
- variant="outline"
445
- size="sm"
446
- onClick={() => setFiltersOpen(true)}
447
- >
448
- <Filter className="h-4 w-4" />
449
- {t("filters.button")}
450
- {activeFilters > 0 && (
451
- <span className="ml-1 rounded-full bg-primary px-1.5 text-xs text-primary-foreground">
452
- {activeFilters}
453
- </span>
454
- )}
455
- </Button>
769
+ {filtersEnabled && (
770
+ <Button
771
+ variant="outline"
772
+ size="sm"
773
+ onClick={() => setFiltersOpen(true)}
774
+ >
775
+ <Filter className="h-4 w-4" />
776
+ {t("filters.button")}
777
+ {activeFilters > 0 && (
778
+ <span className="ml-1 rounded-full bg-primary px-1.5 text-xs text-primary-foreground">
779
+ {activeFilters}
780
+ </span>
781
+ )}
782
+ </Button>
783
+ )}
784
+ {columnsButton && (
785
+ <Button
786
+ variant="outline"
787
+ size="sm"
788
+ onClick={() => setColsOpen(true)}
789
+ >
790
+ <SlidersHorizontal className="h-4 w-4" />
791
+ {t("table.columnsButton")}
792
+ </Button>
793
+ )}
794
+ </div>
795
+ </div>
796
+ )}
797
+
798
+ {emCartoes && renderGrid ? (
799
+ <div className="flex flex-col gap-3">
800
+ {loading && (
801
+ <Card className="p-10">
802
+ <Spinner className="mx-auto" />
803
+ </Card>
456
804
  )}
457
- {storageKey && hideable.length > 0 && (
458
- <Button
459
- variant="outline"
460
- size="sm"
461
- onClick={() => setColsOpen(true)}
462
- >
463
- <SlidersHorizontal className="h-4 w-4" />
464
- {t("table.columnsButton")}
465
- </Button>
805
+ {!loading && table.getRowModel().rows.length === 0 && (
806
+ <Card className="p-10 text-center text-muted-foreground">
807
+ {emptyText}
808
+ </Card>
466
809
  )}
467
- </div>
468
- </div>
810
+ {!loading &&
811
+ table.getRowModel().rows.length > 0 &&
812
+ renderGrid(table.getRowModel().rows.map((row) => row.original))}
469
813
 
470
- <Card>
471
- <Table>
472
- <TableHeader>
473
- {table.getHeaderGroups().map((hg) => (
474
- <TableRow key={hg.id}>
475
- {reorderable && (
476
- <TableHead className="w-px">
477
- <span className="sr-only">{t("table.reorder")}</span>
478
- </TableHead>
479
- )}
480
- {hg.headers.map((header) => {
481
- const canSort = header.column.getCanSort();
482
- const sorted = header.column.getIsSorted();
483
- return (
484
- <TableHead
485
- key={header.id}
486
- className={shrinkClass(header.column.columnDef)}
487
- >
488
- {header.isPlaceholder ? null : canSort ? (
489
- <button
490
- className="flex items-center gap-1 hover:text-foreground"
491
- onClick={header.column.getToggleSortingHandler()}
492
- >
493
- {flexRender(
814
+ {/* O rodapé ganha moldura própria: solto sobre o fundo, a navegação
815
+ ficaria pendurada abaixo dos cartões, sem nada que a segure. */}
816
+ {!semRodape && <Card>{rodape}</Card>}
817
+ </div>
818
+ ) : (
819
+ <Card>
820
+ <Table>
821
+ <TableHeader>
822
+ {table.getHeaderGroups().map((hg) => (
823
+ <TableRow key={hg.id}>
824
+ {reorderable && (
825
+ <TableHead className="w-px">
826
+ <span className="sr-only">{t("table.reorder")}</span>
827
+ </TableHead>
828
+ )}
829
+ {hg.headers.map((header) => {
830
+ const canSort = header.column.getCanSort();
831
+ const sorted = header.column.getIsSorted();
832
+ return (
833
+ <TableHead
834
+ key={header.id}
835
+ className={shrinkClass(header.column.columnDef)}
836
+ >
837
+ {header.isPlaceholder ? null : canSort ? (
838
+ <button
839
+ className="flex items-center gap-1 hover:text-foreground"
840
+ onClick={header.column.getToggleSortingHandler()}
841
+ >
842
+ {flexRender(
843
+ header.column.columnDef.header,
844
+ header.getContext(),
845
+ )}
846
+ {sorted === "asc" ? (
847
+ <ArrowUp className="h-3.5 w-3.5 text-primary" />
848
+ ) : sorted === "desc" ? (
849
+ <ArrowDown className="h-3.5 w-3.5 text-primary" />
850
+ ) : (
851
+ // Seta dupla apagada na coluna livre e seta única
852
+ // em destaque na que ordena: sem a cor, achar a
853
+ // coluna ativa exige comparar o desenho de todas.
854
+ <ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
855
+ )}
856
+ </button>
857
+ ) : (
858
+ flexRender(
494
859
  header.column.columnDef.header,
495
860
  header.getContext(),
496
- )}
497
- {sorted === "asc" ? (
498
- <ArrowUp className="h-3.5 w-3.5 text-primary" />
499
- ) : sorted === "desc" ? (
500
- <ArrowDown className="h-3.5 w-3.5 text-primary" />
861
+ )
862
+ )}
863
+ </TableHead>
864
+ );
865
+ })}
866
+ </TableRow>
867
+ ))}
868
+ </TableHeader>
869
+ <TableBody>
870
+ {loading && (
871
+ <TableRow>
872
+ <TableCell colSpan={colSpan} className="h-24 text-center">
873
+ <Spinner className="mx-auto" />
874
+ </TableCell>
875
+ </TableRow>
876
+ )}
877
+ {!loading && table.getRowModel().rows.length === 0 && (
878
+ <TableRow>
879
+ <TableCell
880
+ colSpan={colSpan}
881
+ className="h-24 text-center text-muted-foreground"
882
+ >
883
+ {emptyText}
884
+ </TableCell>
885
+ </TableRow>
886
+ )}
887
+ {!loading &&
888
+ sections.map((section) => (
889
+ <Fragment key={section.key}>
890
+ {section.header && renderGroupHeader && (
891
+ <TableRow className="hover:bg-transparent">
892
+ {/* Uma célula atravessando a tabela: é o que faz a
893
+ faixa parecer cabeçalho, e não registro sem
894
+ dados. */}
895
+ <TableCell
896
+ colSpan={colSpan}
897
+ className="bg-muted/40 py-2"
898
+ >
899
+ {collapsibleGroups ? (
900
+ <button
901
+ type="button"
902
+ className="flex w-full items-center gap-3 text-left"
903
+ aria-expanded={!section.header.collapsed}
904
+ onClick={() => toggleGroup(section.key)}
905
+ >
906
+ <ChevronRight
907
+ className={cn(
908
+ "h-3.5 w-3.5 flex-none text-muted-foreground transition-transform",
909
+ !section.header.collapsed && "rotate-90",
910
+ )}
911
+ />
912
+ {/* O conteúdo ocupa o resto: é o que deixa o
913
+ total encostar na borda direita, como
914
+ encostaria fora do botão. */}
915
+ <span className="min-w-0 flex-1">
916
+ {renderGroupHeader(section.header)}
917
+ </span>
918
+ </button>
501
919
  ) : (
502
- // Seta dupla apagada na coluna livre e seta única
503
- // em destaque na que ordena: sem a cor, achar a
504
- // coluna ativa exige comparar o desenho de todas.
505
- <ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
920
+ renderGroupHeader(section.header)
506
921
  )}
507
- </button>
508
- ) : (
509
- flexRender(
510
- header.column.columnDef.header,
511
- header.getContext(),
512
- )
513
- )}
514
- </TableHead>
515
- );
516
- })}
517
- </TableRow>
518
- ))}
519
- </TableHeader>
520
- <TableBody>
521
- {loading && (
522
- <TableRow>
523
- <TableCell colSpan={colSpan} className="h-24 text-center">
524
- <Spinner className="mx-auto" />
525
- </TableCell>
526
- </TableRow>
527
- )}
528
- {!loading && table.getRowModel().rows.length === 0 && (
529
- <TableRow>
530
- <TableCell
531
- colSpan={colSpan}
532
- className="h-24 text-center text-muted-foreground"
533
- >
534
- {emptyText}
535
- </TableCell>
536
- </TableRow>
537
- )}
538
- {!loading &&
539
- table.getRowModel().rows.map((row, index) => {
540
- const dragging = draggedRow === row.id;
541
- const shift = dragging ? 0 : rowShift(index);
542
-
543
- return (
544
- <TableRow
545
- key={row.id}
546
- data-row-id={row.id}
547
- className={cn(
548
- // A linha viaja com o dedo, destacada do resto da tabela —
549
- // é o que diz que o arrasto pegou, e qual linha pegou.
550
- dragging &&
551
- "relative z-20 bg-card shadow-xl ring-1 ring-primary/50",
552
- draggedRow !== null && "select-none",
553
- /*
554
- A transição existe só enquanto o arrasto dura, e só nas
555
- linhas paradas: na que segue o dedo ela viraria atraso,
556
- e depois de soltar ela animaria a volta ao zero por
557
- cima da lista já reordenada — dois movimentos ao mesmo
558
- tempo, que é o tremor que se vê ao largar a linha.
559
- */
560
- draggedRow !== null &&
561
- !dragging &&
562
- "transition-transform duration-200",
922
+ </TableCell>
923
+ </TableRow>
563
924
  )}
564
- style={
565
- dragging
566
- ? {
567
- // O `scale` é o "levantou da mesa": a linha na mão
568
- // fica maior que as paradas, como o card que sai
569
- // da coluna no quadro.
570
- transform: `translateY(${dragOffset}px) scale(1.01)`,
571
- }
572
- : shift !== 0
573
- ? { transform: `translateY(${shift}px)` }
574
- : undefined
575
- }
576
- >
577
- {reorderable && (
578
- <TableCell className="w-px pr-0">
579
- <button
580
- type="button"
581
- aria-label={t("table.reorder")}
582
- title={
583
- canReorder
584
- ? t("table.reorder")
585
- : t("table.reorderSorted")
586
- }
587
- disabled={!canReorder}
588
- // `touch-none` é o que impede a página de rolar embaixo
589
- // do dedo enquanto a linha viaja.
925
+
926
+ {section.rows.map((row, indexNaSecao) => {
927
+ const index = section.offset + indexNaSecao;
928
+ const dragging = draggedRow === row.id;
929
+ const shift = dragging ? 0 : rowShift(index);
930
+
931
+ return (
932
+ <TableRow
933
+ key={row.id}
934
+ data-row-id={row.id}
590
935
  className={cn(
591
- "flex touch-none items-center text-muted-foreground",
592
- canReorder
593
- ? "cursor-grab active:cursor-grabbing hover:text-foreground"
594
- : "cursor-not-allowed opacity-30",
936
+ // A linha viaja com o dedo, destacada do resto da tabela —
937
+ // é o que diz que o arrasto pegou, e qual linha pegou.
938
+ dragging &&
939
+ "relative z-20 bg-card shadow-xl ring-1 ring-primary/50",
940
+ draggedRow !== null && "select-none",
941
+ /*
942
+ A transição existe só enquanto o arrasto dura, e só nas
943
+ linhas paradas: na que segue o dedo ela viraria atraso,
944
+ e depois de soltar ela animaria a volta ao zero por
945
+ cima da lista já reordenada — dois movimentos ao mesmo
946
+ tempo, que é o tremor que se vê ao largar a linha.
947
+ */
948
+ draggedRow !== null &&
949
+ !dragging &&
950
+ "transition-transform duration-200",
595
951
  )}
596
- onPointerDown={
597
- canReorder ? startRowDrag(row.id) : undefined
952
+ style={
953
+ dragging
954
+ ? {
955
+ // O `scale` é o "levantou da mesa": a linha na mão
956
+ // fica maior que as paradas, como o card que sai
957
+ // da coluna no quadro.
958
+ transform: `translateY(${dragOffset}px) scale(1.01)`,
959
+ }
960
+ : shift !== 0
961
+ ? { transform: `translateY(${shift}px)` }
962
+ : undefined
598
963
  }
599
- onPointerMove={canReorder ? moveRowDrag : undefined}
600
- onPointerUp={canReorder ? endRowDrag : undefined}
601
- onPointerCancel={canReorder ? endRowDrag : undefined}
602
964
  >
603
- <GripVertical className="h-4 w-4" />
604
- </button>
605
- </TableCell>
606
- )}
607
- {row.getVisibleCells().map((cell) => (
965
+ {reorderable && (
966
+ <TableCell className="w-px pr-0">
967
+ <button
968
+ type="button"
969
+ aria-label={t("table.reorder")}
970
+ title={
971
+ canReorder
972
+ ? t("table.reorder")
973
+ : groupBy
974
+ ? t("table.reorderGrouped")
975
+ : t("table.reorderSorted")
976
+ }
977
+ disabled={!canReorder}
978
+ // `touch-none` é o que impede a página de rolar embaixo
979
+ // do dedo enquanto a linha viaja.
980
+ className={cn(
981
+ "flex touch-none items-center text-muted-foreground",
982
+ canReorder
983
+ ? "cursor-grab active:cursor-grabbing hover:text-foreground"
984
+ : "cursor-not-allowed opacity-30",
985
+ )}
986
+ onPointerDown={
987
+ canReorder ? startRowDrag(row.id) : undefined
988
+ }
989
+ onPointerMove={
990
+ canReorder ? moveRowDrag : undefined
991
+ }
992
+ onPointerUp={
993
+ canReorder ? endRowDrag : undefined
994
+ }
995
+ onPointerCancel={
996
+ canReorder ? endRowDrag : undefined
997
+ }
998
+ >
999
+ <GripVertical className="h-4 w-4" />
1000
+ </button>
1001
+ </TableCell>
1002
+ )}
1003
+ {row.getVisibleCells().map((cell) => (
1004
+ <TableCell
1005
+ key={cell.id}
1006
+ className={shrinkClass(cell.column.columnDef)}
1007
+ >
1008
+ {flexRender(
1009
+ cell.column.columnDef.cell,
1010
+ cell.getContext(),
1011
+ )}
1012
+ </TableCell>
1013
+ ))}
1014
+ </TableRow>
1015
+ );
1016
+ })}
1017
+ </Fragment>
1018
+ ))}
1019
+ </TableBody>
1020
+
1021
+ {temFechamento && !loading && (
1022
+ <TableFooter>
1023
+ {table.getFooterGroups().map((grupo) => (
1024
+ <TableRow key={grupo.id}>
1025
+ {/* A coluna da alça não tem fechamento, mas precisa da
1026
+ célula: sem ela a linha inteira sobe uma casa. */}
1027
+ {reorderable && <TableCell className="w-px" />}
1028
+ {grupo.headers.map((header) => (
608
1029
  <TableCell
609
- key={cell.id}
610
- className={shrinkClass(cell.column.columnDef)}
1030
+ key={header.id}
1031
+ className={shrinkClass(header.column.columnDef)}
611
1032
  >
612
- {flexRender(
613
- cell.column.columnDef.cell,
614
- cell.getContext(),
615
- )}
1033
+ {header.isPlaceholder
1034
+ ? null
1035
+ : flexRender(
1036
+ header.column.columnDef.footer,
1037
+ header.getContext(),
1038
+ )}
616
1039
  </TableCell>
617
1040
  ))}
618
1041
  </TableRow>
619
- );
620
- })}
621
- </TableBody>
622
- </Table>
623
-
624
- {/*
625
- Três blocos, e a ordem tem motivo: quantos são à esquerda, para onde
626
- ir no meio, e o tamanho da página à direita — o controle que se mexe
627
- uma vez fica na ponta, e a navegação, que se usa o tempo todo, no
628
- centro. Em tela estreita eles empilham em vez de espremer.
629
- */}
630
- {!semRodape && (
631
- <div className="flex flex-col items-center gap-3 border-t border-border px-4 py-3 text-sm text-muted-foreground sm:flex-row sm:justify-between">
632
- <span className="sm:flex-1">
633
- {total} {t("table.results")}
634
- </span>
635
-
636
- <div className="flex items-center gap-1">
637
- <Button
638
- variant="ghost"
639
- size="icon"
640
- aria-label={t("table.previousPage")}
641
- disabled={!table.getCanPreviousPage()}
642
- onClick={() => table.previousPage()}
643
- >
644
- <ChevronLeft className="h-4 w-4" />
645
- </Button>
646
-
647
- {pageRange(pageIndex, pageCount).map((item, posicao) =>
648
- item === "gap" ? (
649
- // O buraco não é botão: `key` pela posição porque duas elipses
650
- // são indistinguíveis entre si.
651
- <span
652
- key={`gap-${posicao}`}
653
- className="px-1 text-muted-foreground"
654
- >
655
-
656
- </span>
657
- ) : (
658
- <Button
659
- key={item}
660
- variant={item === pageIndex + 1 ? "default" : "ghost"}
661
- size="icon"
662
- // Quadrado de 40px como base, mas cresce com o número: a
663
- // página 128 não pode vazar do botão.
664
- className="w-auto min-w-10 px-2 tabular-nums"
665
- aria-current={item === pageIndex + 1 ? "page" : undefined}
666
- onClick={() => table.setPageIndex(item - 1)}
667
- >
668
- {item}
669
- </Button>
670
- ),
671
- )}
672
-
673
- <Button
674
- variant="ghost"
675
- size="icon"
676
- aria-label={t("table.nextPage")}
677
- disabled={!table.getCanNextPage()}
678
- onClick={() => table.nextPage()}
679
- >
680
- <ChevronRight className="h-4 w-4" />
681
- </Button>
682
- </div>
683
-
684
- <div className="sm:flex-1 sm:text-right">
685
- <select
686
- value={pageSize}
687
- onChange={(e) => table.setPageSize(Number(e.target.value))}
688
- className="h-9 rounded-md border border-input bg-transparent px-2 text-sm"
689
- >
690
- {pageSizeOptions.map((n) => (
691
- <option key={n} value={n}>
692
- {n} {t("table.perPage")}
693
- </option>
694
1042
  ))}
695
- </select>
696
- </div>
697
- </div>
698
- )}
699
- </Card>
1043
+ </TableFooter>
1044
+ )}
1045
+ </Table>
1046
+
1047
+ {!semRodape && rodape}
1048
+ </Card>
1049
+ )}
700
1050
 
701
1051
  {storageKey && (
702
1052
  <ColumnVisibilityModal
@@ -36,6 +36,27 @@ const TableBody = React.forwardRef<
36
36
  ));
37
37
  TableBody.displayName = "TableBody";
38
38
 
39
+ /**
40
+ * A linha de fechamento da tabela: total, saldo, contagem.
41
+ *
42
+ * `tfoot` e não uma última linha do corpo: ela não é registro — não ordena
43
+ * junto, não entra na contagem e continua embaixo quando a ordenação muda.
44
+ */
45
+ const TableFooter = React.forwardRef<
46
+ HTMLTableSectionElement,
47
+ React.HTMLAttributes<HTMLTableSectionElement>
48
+ >(({ className, ...props }, ref) => (
49
+ <tfoot
50
+ ref={ref}
51
+ className={cn(
52
+ "border-t border-border bg-muted/40 font-medium [&_tr]:border-0",
53
+ className,
54
+ )}
55
+ {...props}
56
+ />
57
+ ));
58
+ TableFooter.displayName = "TableFooter";
59
+
39
60
  const TableRow = React.forwardRef<
40
61
  HTMLTableRowElement,
41
62
  React.HTMLAttributes<HTMLTableRowElement>
@@ -90,4 +111,12 @@ const TableCell = React.forwardRef<
90
111
  ));
91
112
  TableCell.displayName = "TableCell";
92
113
 
93
- export { Table, TableBody, TableCell,TableHead, TableHeader, TableRow };
114
+ export {
115
+ Table,
116
+ TableBody,
117
+ TableCell,
118
+ TableFooter,
119
+ TableHead,
120
+ TableHeader,
121
+ TableRow,
122
+ };
@@ -15,11 +15,16 @@ export interface UseColumnVisibilityResult {
15
15
  /**
16
16
  * Persiste as colunas ocultas de uma tabela em localStorage (por storageKey).
17
17
  * Retorna a lista de ocultas + helpers para ler/salvar.
18
+ *
19
+ * `defaultHidden` é só o ponto de partida: vale enquanto ninguém tiver
20
+ * escolhido as colunas nesta tela. Depois da primeira escolha, a lista guardada
21
+ * é a verdade — inclusive quando ela mostra uma coluna que nasceu escondida.
18
22
  */
19
23
  export function useColumnVisibility(
20
24
  storageKey?: string,
25
+ defaultHidden: string[] = [],
21
26
  ): UseColumnVisibilityResult {
22
- const [hiddenColumns, setHiddenColumns] = useState<string[]>([]);
27
+ const [hiddenColumns, setHiddenColumns] = useState<string[]>(defaultHidden);
23
28
 
24
29
  useEffect(() => {
25
30
  if (!storageKey) {return;}
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ import { safeStorage } from "#core/_utils/storage";
6
+
7
+ const STORAGE_PREFIX = "view:";
8
+
9
+ /** Como a listagem se apresenta: em linhas ou em cartões. */
10
+ export type TableView = "list" | "grid";
11
+
12
+ export interface UseTableViewResult {
13
+ view: TableView;
14
+ setView: (next: TableView) => void;
15
+ }
16
+
17
+ /**
18
+ * Guarda em qual formato a pessoa deixou a listagem, por `storageKey`.
19
+ *
20
+ * Mesma ideia das colunas escondidas: é escolha de quem olha, não da tela, e
21
+ * refazê-la a cada visita transformaria o alternador num incômodo. Sem
22
+ * `storageKey` o formato dura só enquanto a tela estiver aberta — igual às
23
+ * colunas, que também só persistem quando a tabela tem chave.
24
+ */
25
+ export function useTableView(
26
+ storageKey?: string,
27
+ defaultView: TableView = "list",
28
+ ): UseTableViewResult {
29
+ const [view, setStoredView] = useState<TableView>(defaultView);
30
+
31
+ useEffect(() => {
32
+ if (!storageKey) {
33
+ return;
34
+ }
35
+ const raw = safeStorage.get(`${STORAGE_PREFIX}${storageKey}`);
36
+
37
+ if (raw === "list" || raw === "grid") {
38
+ setStoredView(raw);
39
+ }
40
+ }, [storageKey]);
41
+
42
+ const setView = useCallback(
43
+ (next: TableView) => {
44
+ setStoredView(next);
45
+
46
+ if (storageKey) {
47
+ safeStorage.set(`${STORAGE_PREFIX}${storageKey}`, next);
48
+ }
49
+ },
50
+ [storageKey],
51
+ );
52
+
53
+ return { view, setView };
54
+ }
@@ -165,8 +165,12 @@ export const en: Messages = {
165
165
  nextPage: "Next page",
166
166
  empty: "No records",
167
167
  emptyFiltered: "No records match this filter",
168
+ view: "List format",
169
+ viewList: "List",
170
+ viewGrid: "Cards",
168
171
  reorder: "Drag to reorder",
169
172
  reorderSorted: "Clear the column sorting to drag rows",
173
+ reorderGrouped: "Rows cannot be reordered while the list is grouped",
170
174
  },
171
175
  filters: {
172
176
  button: "Filters",
@@ -167,9 +167,13 @@ export const pt = {
167
167
  nextPage: "Próxima página",
168
168
  empty: "Nenhum registro",
169
169
  emptyFiltered: "Nenhum registro para este filtro",
170
+ view: "Formato da listagem",
171
+ viewList: "Lista",
172
+ viewGrid: "Cartões",
170
173
  reorder: "Arraste para reordenar",
171
174
  reorderSorted:
172
175
  "Limpe a ordenação por coluna para arrastar as linhas",
176
+ reorderGrouped: "Não dá para reordenar com a lista dividida em seções",
173
177
  },
174
178
  filters: {
175
179
  button: "Filtros",
package/src/index.ts CHANGED
@@ -110,6 +110,8 @@ export {
110
110
  EXPAND_SHORTCUT_LABEL,
111
111
  useShortcut,
112
112
  } from "#core/hooks/use-shortcut";
113
+ export type { TableView, UseTableViewResult } from "#core/hooks/use-table-view";
114
+ export { useTableView } from "#core/hooks/use-table-view";
113
115
  // RequestOperation é enum (valor, não só tipo): sem ele o projeto não consegue
114
116
  // pedir o toast padrão de criado/alterado/removido que o core já traduz.
115
117
  export { RequestOperation, useRequest } from "#core/hooks/use-request";