rl-core-front 0.15.1 → 0.15.2

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.1",
3
+ "version": "0.15.2",
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",
@@ -854,6 +854,22 @@ export interface paths {
854
854
  patch?: never;
855
855
  trace?: never;
856
856
  };
857
+ "/exports/{jobId}/status": {
858
+ parameters: {
859
+ query?: never;
860
+ header?: never;
861
+ path?: never;
862
+ cookie?: never;
863
+ };
864
+ get: operations["ExportController_status"];
865
+ put?: never;
866
+ post?: never;
867
+ delete?: never;
868
+ options?: never;
869
+ head?: never;
870
+ patch?: never;
871
+ trace?: never;
872
+ };
857
873
  "/exports/{jobId}": {
858
874
  parameters: {
859
875
  query?: never;
@@ -1499,6 +1515,17 @@ export interface components {
1499
1515
  /** @example funkos */
1500
1516
  name: string;
1501
1517
  };
1518
+ ExportStatusResponse: {
1519
+ /** @enum {string} */
1520
+ state: "waiting" | "active" | "completed" | "failed" | "delayed" | "unknown";
1521
+ /** @example 12000 */
1522
+ processed: number;
1523
+ /** @example 25000 */
1524
+ totalRows: number | null;
1525
+ /** @example funkos_2026-09-05.xlsx */
1526
+ fileName: string | null;
1527
+ errorMessage: string | null;
1528
+ };
1502
1529
  StreamableFile: Record<string, never>;
1503
1530
  JobProgressResponse: {
1504
1531
  /** @example 120 */
@@ -3202,6 +3229,27 @@ export interface operations {
3202
3229
  };
3203
3230
  };
3204
3231
  };
3232
+ ExportController_status: {
3233
+ parameters: {
3234
+ query?: never;
3235
+ header?: never;
3236
+ path: {
3237
+ jobId: string;
3238
+ };
3239
+ cookie?: never;
3240
+ };
3241
+ requestBody?: never;
3242
+ responses: {
3243
+ 200: {
3244
+ headers: {
3245
+ [name: string]: unknown;
3246
+ };
3247
+ content: {
3248
+ "application/json": components["schemas"]["ExportStatusResponse"];
3249
+ };
3250
+ };
3251
+ };
3252
+ };
3205
3253
  ExportController_download: {
3206
3254
  parameters: {
3207
3255
  query?: never;
@@ -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, useState } from "react";
33
+ import { useMemo, useRef, useState } from "react";
34
34
 
35
35
  import { countConditions } from "#core/_utils/advanced-filter";
36
36
  import { activeFilterCount } from "#core/_utils/filter";
@@ -146,8 +146,12 @@ function columnLabel<T extends RowData>(
146
146
  id: string,
147
147
  ): string {
148
148
  const meta = col.meta as DataTableColumnMeta | undefined;
149
- if (meta?.label) {return meta.label;}
150
- if (typeof col.header === "string") {return col.header;}
149
+ if (meta?.label) {
150
+ return meta.label;
151
+ }
152
+ if (typeof col.header === "string") {
153
+ return col.header;
154
+ }
151
155
  return id;
152
156
  }
153
157
 
@@ -188,6 +192,16 @@ export function DataTable<T extends RowData>({
188
192
  /** A linha na mão e a linha embaixo do dedo, enquanto o arrasto dura. */
189
193
  const [draggedRow, setDraggedRow] = useState<string | null>(null);
190
194
  const [targetRow, setTargetRow] = useState<string | null>(null);
195
+ /*
196
+ Quanto a linha já subiu ou desceu, em pixels.
197
+
198
+ É o que faz a linha viajar junto com o dedo, como o card do quadro viaja
199
+ com o cursor. Sem isso o único sinal de que há um arrasto acontecendo é o
200
+ destaque na linha de destino, e quem arrasta fica sem saber se pegou a
201
+ linha certa — ou se pegou alguma.
202
+ */
203
+ const [dragOffset, setDragOffset] = useState(0);
204
+ const dragStartY = useRef(0);
191
205
 
192
206
  const { hiddenColumns, saveHiddenColumns } = useColumnVisibility(storageKey);
193
207
  const columnVisibility = useMemo<ColumnVisibilityState>(() => {
@@ -268,6 +282,8 @@ export function DataTable<T extends RowData>({
268
282
  // A captura mantém os eventos na alça mesmo com o dedo já longe dela;
269
283
  // sem ela, o arrasto morre no primeiro pixel fora do botão.
270
284
  event.currentTarget.setPointerCapture(event.pointerId);
285
+ dragStartY.current = event.clientY;
286
+ setDragOffset(0);
271
287
  setDraggedRow(rowId);
272
288
  setTargetRow(rowId);
273
289
  };
@@ -284,6 +300,8 @@ export function DataTable<T extends RowData>({
284
300
  return;
285
301
  }
286
302
 
303
+ setDragOffset(event.clientY - dragStartY.current);
304
+
287
305
  const under = document
288
306
  .elementFromPoint(event.clientX, event.clientY)
289
307
  ?.closest("tr[data-row-id]");
@@ -294,6 +312,19 @@ export function DataTable<T extends RowData>({
294
312
  }
295
313
  };
296
314
 
315
+ /**
316
+ * Onde a linha saiu e onde ela está agora, na página desenhada.
317
+ *
318
+ * Os dois só para saber de que lado a guia de destino aparece — descendo,
319
+ * embaixo do alvo; subindo, em cima dele.
320
+ */
321
+ const draggedIndex = table
322
+ .getRowModel()
323
+ .rows.findIndex((row) => row.id === draggedRow);
324
+ const targetIndex = table
325
+ .getRowModel()
326
+ .rows.findIndex((row) => row.id === targetRow);
327
+
297
328
  /** Soltou: avisa quem chamou e devolve a tabela ao estado parado. */
298
329
  const endRowDrag = (): void => {
299
330
  if (draggedRow && targetRow && draggedRow !== targetRow) {
@@ -302,6 +333,7 @@ export function DataTable<T extends RowData>({
302
333
 
303
334
  setDraggedRow(null);
304
335
  setTargetRow(null);
336
+ setDragOffset(0);
305
337
  };
306
338
 
307
339
  const filtersEnabled = Boolean(filters);
@@ -435,63 +467,80 @@ export function DataTable<T extends RowData>({
435
467
  </TableRow>
436
468
  )}
437
469
  {!loading &&
438
- table.getRowModel().rows.map((row) => (
439
- <TableRow
440
- key={row.id}
441
- data-row-id={row.id}
442
- className={cn(
443
- // A linha na mão fica esmaecida no lugar de origem e a de
444
- // destino se acende: são as duas pontas do gesto, e sem a
445
- // segunda não dá para saber onde a linha vai cair.
446
- draggedRow === row.id && "opacity-40",
447
- draggedRow && targetRow === row.id && draggedRow !== row.id
448
- ? "bg-accent"
449
- : undefined,
450
- )}
451
- >
452
- {reorderable && (
453
- <TableCell className="w-px pr-0">
454
- <button
455
- type="button"
456
- aria-label={t("table.reorder")}
457
- title={
458
- canReorder
459
- ? t("table.reorder")
460
- : t("table.reorderSorted")
461
- }
462
- disabled={!canReorder}
463
- // `touch-none` é o que impede a página de rolar embaixo
464
- // do dedo enquanto a linha viaja.
465
- className={cn(
466
- "flex touch-none items-center text-muted-foreground",
467
- canReorder
468
- ? "cursor-grab active:cursor-grabbing hover:text-foreground"
469
- : "cursor-not-allowed opacity-30",
470
- )}
471
- onPointerDown={
472
- canReorder ? startRowDrag(row.id) : undefined
473
- }
474
- onPointerMove={canReorder ? moveRowDrag : undefined}
475
- onPointerUp={canReorder ? endRowDrag : undefined}
476
- onPointerCancel={canReorder ? endRowDrag : undefined}
470
+ table.getRowModel().rows.map((row) => {
471
+ const dragging = draggedRow === row.id;
472
+ // A guia aparece do lado para onde a linha está indo: descendo,
473
+ // embaixo do alvo; subindo, em cima dele. É ela que responde
474
+ // "onde isto vai cair" antes de soltar.
475
+ const guide =
476
+ draggedRow !== null && targetRow === row.id && !dragging
477
+ ? targetIndex > draggedIndex
478
+ ? "border-b-2 border-b-primary"
479
+ : "border-t-2 border-t-primary"
480
+ : undefined;
481
+
482
+ return (
483
+ <TableRow
484
+ key={row.id}
485
+ data-row-id={row.id}
486
+ className={cn(
487
+ // A linha viaja com o dedo, destacada do resto da tabela —
488
+ // é o que diz que o arrasto pegou, e qual linha pegou.
489
+ dragging &&
490
+ "relative z-20 bg-card shadow-lg ring-1 ring-primary/50 [&>td]:opacity-90",
491
+ draggedRow !== null && "select-none",
492
+ guide,
493
+ )}
494
+ style={
495
+ dragging
496
+ ? { transform: `translateY(${dragOffset}px)` }
497
+ : undefined
498
+ }
499
+ >
500
+ {reorderable && (
501
+ <TableCell className="w-px pr-0">
502
+ <button
503
+ type="button"
504
+ aria-label={t("table.reorder")}
505
+ title={
506
+ canReorder
507
+ ? t("table.reorder")
508
+ : t("table.reorderSorted")
509
+ }
510
+ disabled={!canReorder}
511
+ // `touch-none` é o que impede a página de rolar embaixo
512
+ // do dedo enquanto a linha viaja.
513
+ className={cn(
514
+ "flex touch-none items-center text-muted-foreground",
515
+ canReorder
516
+ ? "cursor-grab active:cursor-grabbing hover:text-foreground"
517
+ : "cursor-not-allowed opacity-30",
518
+ )}
519
+ onPointerDown={
520
+ canReorder ? startRowDrag(row.id) : undefined
521
+ }
522
+ onPointerMove={canReorder ? moveRowDrag : undefined}
523
+ onPointerUp={canReorder ? endRowDrag : undefined}
524
+ onPointerCancel={canReorder ? endRowDrag : undefined}
525
+ >
526
+ <GripVertical className="h-4 w-4" />
527
+ </button>
528
+ </TableCell>
529
+ )}
530
+ {row.getVisibleCells().map((cell) => (
531
+ <TableCell
532
+ key={cell.id}
533
+ className={shrinkClass(cell.column.columnDef)}
477
534
  >
478
- <GripVertical className="h-4 w-4" />
479
- </button>
480
- </TableCell>
481
- )}
482
- {row.getVisibleCells().map((cell) => (
483
- <TableCell
484
- key={cell.id}
485
- className={shrinkClass(cell.column.columnDef)}
486
- >
487
- {flexRender(
488
- cell.column.columnDef.cell,
489
- cell.getContext(),
490
- )}
491
- </TableCell>
492
- ))}
493
- </TableRow>
494
- ))}
535
+ {flexRender(
536
+ cell.column.columnDef.cell,
537
+ cell.getContext(),
538
+ )}
539
+ </TableCell>
540
+ ))}
541
+ </TableRow>
542
+ );
543
+ })}
495
544
  </TableBody>
496
545
  </Table>
497
546
 
@@ -0,0 +1,71 @@
1
+ "use client";
2
+
3
+ import { Download, Loader2 } from "lucide-react";
4
+ import * as React from "react";
5
+
6
+ import { Button, type ButtonProps } from "#core/components/ui/button";
7
+ import { useI18n, useToast } from "#core/contexts";
8
+ import { type ExportQuery, useExport } from "#core/hooks/use-export";
9
+
10
+ export interface ExportButtonProps extends Omit<ButtonProps, "children"> {
11
+ /** Nome do relatório — o `:handler` da rota, declarado pelo `ExportHandler`. */
12
+ handler: string;
13
+ /** O recorte da tela. O relatório sai com o mesmo filtro que a listagem mostra. */
14
+ query?: ExportQuery;
15
+ /** Texto do botão. Ausente, "Exportar". */
16
+ label?: string;
17
+ }
18
+
19
+ /**
20
+ * Baixa a listagem em planilha.
21
+ *
22
+ * O relatório é gerado fora da requisição, então o botão não trava a tela: ele
23
+ * vira contador de linhas enquanto o arquivo é escrito e baixa sozinho no fim.
24
+ * Fica desabilitado durante a geração porque um segundo pedido só faria a mesma
25
+ * planilha duas vezes. A falha vira toast aqui dentro — a tela que usa o botão
26
+ * não escreve tratamento de erro nenhum.
27
+ */
28
+ const ExportButton = React.forwardRef<HTMLButtonElement, ExportButtonProps>(
29
+ ({ handler, query, label, variant = "outline", ...props }, ref) => {
30
+ const { t } = useI18n();
31
+ const { notify } = useToast();
32
+ const { request, generating, processed } = useExport();
33
+
34
+ // O aviso é do botão, não da tela: quem usa o componente não deveria
35
+ // precisar escrever tratamento de erro para ganhar o comportamento.
36
+ const run = async (): Promise<void> => {
37
+ try {
38
+ await request(handler, query);
39
+ } catch (error) {
40
+ notify((error as Error).message || t("export.failed"), "error");
41
+ }
42
+ };
43
+
44
+ return (
45
+ <Button
46
+ ref={ref}
47
+ variant={variant}
48
+ disabled={generating}
49
+ onClick={() => void run()}
50
+ {...props}
51
+ >
52
+ {generating ? (
53
+ <>
54
+ <Loader2 className="h-4 w-4 animate-spin" />
55
+ {processed > 0
56
+ ? t("export.progress", { rows: processed.toLocaleString() })
57
+ : t("export.starting")}
58
+ </>
59
+ ) : (
60
+ <>
61
+ <Download className="h-4 w-4" />
62
+ {label ?? t("export.label")}
63
+ </>
64
+ )}
65
+ </Button>
66
+ );
67
+ },
68
+ );
69
+ ExportButton.displayName = "ExportButton";
70
+
71
+ export { ExportButton };
@@ -14,6 +14,7 @@ export * from "./date-picker";
14
14
  export * from "./date-range-picker";
15
15
  export * from "./dialog";
16
16
  export * from "./dropdown-menu";
17
+ export * from "./export-button";
17
18
  export * from "./field";
18
19
  export * from "./filter-field";
19
20
  export * from "./filter-group";
@@ -0,0 +1,139 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ import { api } from "#core/_services/api/axios.factory";
6
+
7
+ /** De quanto em quanto tempo a tela pergunta como está o relatório. */
8
+ const POLL_INTERVAL_MS = 1500;
9
+
10
+ /** O recorte que vai junto — o mesmo da listagem. */
11
+ export interface ExportQuery {
12
+ filter?: string;
13
+ search?: string;
14
+ sortBy?: string;
15
+ sortDir?: "ASC" | "DESC";
16
+ }
17
+
18
+ interface ExportStatus {
19
+ state: "waiting" | "active" | "completed" | "failed" | "delayed" | "unknown";
20
+ processed: number;
21
+ totalRows: number | null;
22
+ fileName: string | null;
23
+ errorMessage: string | null;
24
+ }
25
+
26
+ export interface UseExportResult {
27
+ /** Pede o relatório. Devolve quando ele terminou — ou lança, se falhou. */
28
+ request: (handler: string, query?: ExportQuery) => Promise<void>;
29
+ /** Há um relatório sendo gerado agora. */
30
+ generating: boolean;
31
+ /** Linhas já escritas. Sem total: em stream ele só é conhecido no fim. */
32
+ processed: number;
33
+ }
34
+
35
+ /**
36
+ * Pede um relatório e o entrega quando fica pronto.
37
+ *
38
+ * Por consulta, e não por socket: o único evento que interessa aqui é "acabou",
39
+ * e perguntar de segundo em segundo custa menos que manter um caminho de tempo
40
+ * real que precisa de rede de segurança de qualquer forma — aba reaberta,
41
+ * conexão caída. A contagem de linhas vem de carona na mesma resposta.
42
+ *
43
+ * ```tsx
44
+ * const { request, generating } = useExport();
45
+ * <Button disabled={generating} onClick={() => request("funkos", { filter })}>
46
+ * ```
47
+ */
48
+ export function useExport(): UseExportResult {
49
+ const [generating, setGenerating] = useState(false);
50
+ const [processed, setProcessed] = useState(0);
51
+
52
+ // Evita continuar perguntando por um relatório de uma tela que já saiu.
53
+ const activeRef = useRef(true);
54
+
55
+ useEffect(() => {
56
+ activeRef.current = true;
57
+ return () => {
58
+ activeRef.current = false;
59
+ };
60
+ }, []);
61
+
62
+ const request = useCallback(
63
+ async (handler: string, query: ExportQuery = {}): Promise<void> => {
64
+ setGenerating(true);
65
+ setProcessed(0);
66
+
67
+ try {
68
+ const { data } = await api.post<{ jobId: string }>(
69
+ `/exports/${handler}`,
70
+ query,
71
+ );
72
+
73
+ const status = await waitUntilDone(data.jobId, activeRef, setProcessed);
74
+
75
+ if (!status) {
76
+ return;
77
+ }
78
+ if (status.state === "failed") {
79
+ throw new Error(status.errorMessage ?? "A geração do relatório falhou");
80
+ }
81
+
82
+ await download(data.jobId, status.fileName);
83
+ } finally {
84
+ setGenerating(false);
85
+ }
86
+ },
87
+ [],
88
+ );
89
+
90
+ return { request, generating, processed };
91
+ }
92
+
93
+ const waitUntilDone = async (
94
+ jobId: string,
95
+ active: { current: boolean },
96
+ onProgress: (processed: number) => void,
97
+ ): Promise<ExportStatus | null> => {
98
+ for (;;) {
99
+ if (!active.current) {
100
+ return null;
101
+ }
102
+
103
+ const { data } = await api.get<ExportStatus>(`/exports/${jobId}/status`);
104
+ onProgress(data.processed);
105
+
106
+ if (data.state === "completed" || data.state === "failed") {
107
+ return data;
108
+ }
109
+
110
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
111
+ }
112
+ };
113
+
114
+ /**
115
+ * Baixa o arquivo.
116
+ *
117
+ * Por `blob`, e não por um `<a href>` apontando para a rota: ela é autenticada,
118
+ * e link puro não manda cookie de API em outra origem — viria 401. O
119
+ * `revokeObjectURL` devolve a memória do blob, que senão fica presa até a aba
120
+ * fechar.
121
+ */
122
+ const download = async (
123
+ jobId: string,
124
+ fileName: string | null,
125
+ ): Promise<void> => {
126
+ const { data } = await api.get<Blob>(`/exports/${jobId}`, {
127
+ responseType: "blob",
128
+ });
129
+
130
+ const url = URL.createObjectURL(data);
131
+ const link = document.createElement("a");
132
+
133
+ link.href = url;
134
+ link.download = fileName ?? "relatorio.xlsx";
135
+ document.body.appendChild(link);
136
+ link.click();
137
+ link.remove();
138
+ URL.revokeObjectURL(url);
139
+ };
@@ -147,6 +147,12 @@ export const en: Messages = {
147
147
  footer:
148
148
  "Two keys open the create form because keyboards differ: the backslash moves around depending on the layout, while the number is always in the same place.",
149
149
  },
150
+ export: {
151
+ label: "Export",
152
+ starting: "Generating...",
153
+ progress: "{rows} rows",
154
+ failed: "Could not generate the report",
155
+ },
150
156
  table: {
151
157
  columns: "Columns",
152
158
  columnsButton: "Columns",
@@ -149,6 +149,12 @@ export const pt = {
149
149
  footer:
150
150
  "Duas teclas para abrir o cadastro porque o teclado não é um só: a barra invertida muda de lugar conforme o layout, e o número está sempre na mesma posição. Já o atalho de ampliar leva Alt porque precisa valer dentro dos campos — e uma tecla que escreve não pode servir de comando ali.",
151
151
  },
152
+ export: {
153
+ label: "Exportar",
154
+ starting: "Gerando...",
155
+ progress: "{rows} linhas",
156
+ failed: "Não foi possível gerar o relatório",
157
+ },
152
158
  table: {
153
159
  columns: "Colunas",
154
160
  columnsButton: "Colunas",
package/src/index.ts CHANGED
@@ -72,6 +72,7 @@ export {
72
72
  // Hooks de listagem, requisição e filtro
73
73
  // ---------------------------------------------------------------------------
74
74
  export type { CreateButtonProps } from "#core/components/ui/create-button";
75
+ export type { ExportButtonProps } from "#core/components/ui/export-button";
75
76
  export type { UseColumnVisibilityResult } from "#core/hooks/use-column-visibility";
76
77
  export { useColumnVisibility } from "#core/hooks/use-column-visibility";
77
78
  export type {
@@ -80,6 +81,8 @@ export type {
80
81
  UseConfirmResult,
81
82
  } from "#core/hooks/use-confirm";
82
83
  export { useConfirm } from "#core/hooks/use-confirm";
84
+ export type { ExportQuery, UseExportResult } from "#core/hooks/use-export";
85
+ export { useExport } from "#core/hooks/use-export";
83
86
  export type { UseFilterSchemaResult } from "#core/hooks/use-filter-schema";
84
87
  export { useFilterSchema } from "#core/hooks/use-filter-schema";
85
88
  export type { FilterMode, FiltersState } from "#core/hooks/use-filters";