rl-core-front 0.15.1 → 0.15.4

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.4",
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,24 @@ 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);
205
+ /*
206
+ A altura da linha na mão, medida no momento em que ela é pega.
207
+
208
+ É o tanto que as linhas do caminho se afastam para abrir o buraco onde ela
209
+ vai cair. Medida e não fixa: a altura de uma linha depende do conteúdo, e um
210
+ número chutado abriria um vão maior ou menor que a linha que vem.
211
+ */
212
+ const [dragHeight, setDragHeight] = useState(0);
191
213
 
192
214
  const { hiddenColumns, saveHiddenColumns } = useColumnVisibility(storageKey);
193
215
  const columnVisibility = useMemo<ColumnVisibilityState>(() => {
@@ -268,30 +290,75 @@ export function DataTable<T extends RowData>({
268
290
  // A captura mantém os eventos na alça mesmo com o dedo já longe dela;
269
291
  // sem ela, o arrasto morre no primeiro pixel fora do botão.
270
292
  event.currentTarget.setPointerCapture(event.pointerId);
293
+ setDragHeight(
294
+ event.currentTarget.closest("tr")?.getBoundingClientRect().height ?? 0,
295
+ );
296
+ dragStartY.current = event.clientY;
297
+ setDragOffset(0);
271
298
  setDraggedRow(rowId);
272
299
  setTargetRow(rowId);
273
300
  };
274
301
 
275
302
  /**
276
- * Que linha está embaixo do ponteiro.
303
+ * Onde a linha está agora: quantas linhas ela já subiu ou desceu.
277
304
  *
278
- * Pelo ponto e não pelo alvo do evento: com a captura ativa, todo `move`
279
- * chega na alça de origem, e é o `elementFromPoint` que sabe onde o dedo
280
- * está de verdade.
305
+ * Conta pelo deslocamento, e não por quem está embaixo do ponteiro. Quem
306
+ * está embaixo do ponteiro é a **própria linha arrastada** ela viaja com o
307
+ * dedo —, e perguntar ao ponto devolvia origem e destino iguais: o arrasto
308
+ * terminava sem gravar nada. Além disso as linhas paradas se movem para
309
+ * abrir o vão, então o que o ponto responde muda durante o gesto e a
310
+ * escolha do destino ficaria oscilando entre duas linhas.
281
311
  */
282
312
  const moveRowDrag = (event: ReactPointerEvent<HTMLButtonElement>): void => {
283
- if (!draggedRow) {
313
+ if (!draggedRow || dragHeight === 0) {
284
314
  return;
285
315
  }
286
316
 
287
- const under = document
288
- .elementFromPoint(event.clientX, event.clientY)
289
- ?.closest("tr[data-row-id]");
290
- const id = under?.getAttribute("data-row-id");
317
+ const offset = event.clientY - dragStartY.current;
318
+ setDragOffset(offset);
319
+
320
+ const rows = table.getRowModel().rows;
321
+ const from = rows.findIndex((row) => row.id === draggedRow);
322
+ // Meia linha de deslocamento já troca de lugar: é o ponto em que a linha
323
+ // arrastada cobre mais a vizinha do que o próprio lugar de origem.
324
+ const destino = Math.min(
325
+ Math.max(from + Math.round(offset / dragHeight), 0),
326
+ rows.length - 1,
327
+ );
291
328
 
292
- if (id) {
293
- setTargetRow(id);
329
+ setTargetRow(rows[destino]?.id ?? draggedRow);
330
+ };
331
+
332
+ /** Onde a linha saiu e onde ela está agora, na página desenhada. */
333
+ const draggedIndex = table
334
+ .getRowModel()
335
+ .rows.findIndex((row) => row.id === draggedRow);
336
+ const targetIndex = table
337
+ .getRowModel()
338
+ .rows.findIndex((row) => row.id === targetRow);
339
+
340
+ /**
341
+ * Quanto uma linha parada se desloca para abrir o buraco.
342
+ *
343
+ * As linhas entre a origem e o destino sobem (ou descem) a altura de uma
344
+ * linha, e o vão aparece exatamente onde a linha vai cair. É isso que
345
+ * responde "para onde isto está indo" durante o gesto — um traço de destino
346
+ * diz o mesmo em teoria, mas exige ler a tabela para achá-lo.
347
+ */
348
+ const rowShift = (index: number): number => {
349
+ if (draggedRow === null || draggedIndex < 0 || targetIndex < 0) {
350
+ return 0;
294
351
  }
352
+
353
+ if (index > draggedIndex && index <= targetIndex) {
354
+ return -dragHeight;
355
+ }
356
+
357
+ if (index >= targetIndex && index < draggedIndex) {
358
+ return dragHeight;
359
+ }
360
+
361
+ return 0;
295
362
  };
296
363
 
297
364
  /** Soltou: avisa quem chamou e devolve a tabela ao estado parado. */
@@ -302,6 +369,7 @@ export function DataTable<T extends RowData>({
302
369
 
303
370
  setDraggedRow(null);
304
371
  setTargetRow(null);
372
+ setDragOffset(0);
305
373
  };
306
374
 
307
375
  const filtersEnabled = Boolean(filters);
@@ -435,63 +503,88 @@ export function DataTable<T extends RowData>({
435
503
  </TableRow>
436
504
  )}
437
505
  {!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}
506
+ table.getRowModel().rows.map((row, index) => {
507
+ const dragging = draggedRow === row.id;
508
+ const shift = dragging ? 0 : rowShift(index);
509
+
510
+ return (
511
+ <TableRow
512
+ key={row.id}
513
+ data-row-id={row.id}
514
+ className={cn(
515
+ // A linha viaja com o dedo, destacada do resto da tabela —
516
+ // é o que diz que o arrasto pegou, e qual linha pegou.
517
+ dragging &&
518
+ "relative z-20 bg-card shadow-xl ring-1 ring-primary/50",
519
+ draggedRow !== null && "select-none",
520
+ /*
521
+ A transição existe só enquanto o arrasto dura, e só nas
522
+ linhas paradas: na que segue o dedo ela viraria atraso,
523
+ e depois de soltar ela animaria a volta ao zero por
524
+ cima da lista já reordenada — dois movimentos ao mesmo
525
+ tempo, que é o tremor que se vê ao largar a linha.
526
+ */
527
+ draggedRow !== null &&
528
+ !dragging &&
529
+ "transition-transform duration-200",
530
+ )}
531
+ style={
532
+ dragging
533
+ ? {
534
+ // O `scale` é o "levantou da mesa": a linha na mão
535
+ // fica maior que as paradas, como o card que sai
536
+ // da coluna no quadro.
537
+ transform: `translateY(${dragOffset}px) scale(1.01)`,
538
+ }
539
+ : shift !== 0
540
+ ? { transform: `translateY(${shift}px)` }
541
+ : undefined
542
+ }
543
+ >
544
+ {reorderable && (
545
+ <TableCell className="w-px pr-0">
546
+ <button
547
+ type="button"
548
+ aria-label={t("table.reorder")}
549
+ title={
550
+ canReorder
551
+ ? t("table.reorder")
552
+ : t("table.reorderSorted")
553
+ }
554
+ disabled={!canReorder}
555
+ // `touch-none` é o que impede a página de rolar embaixo
556
+ // do dedo enquanto a linha viaja.
557
+ className={cn(
558
+ "flex touch-none items-center text-muted-foreground",
559
+ canReorder
560
+ ? "cursor-grab active:cursor-grabbing hover:text-foreground"
561
+ : "cursor-not-allowed opacity-30",
562
+ )}
563
+ onPointerDown={
564
+ canReorder ? startRowDrag(row.id) : undefined
565
+ }
566
+ onPointerMove={canReorder ? moveRowDrag : undefined}
567
+ onPointerUp={canReorder ? endRowDrag : undefined}
568
+ onPointerCancel={canReorder ? endRowDrag : undefined}
569
+ >
570
+ <GripVertical className="h-4 w-4" />
571
+ </button>
572
+ </TableCell>
573
+ )}
574
+ {row.getVisibleCells().map((cell) => (
575
+ <TableCell
576
+ key={cell.id}
577
+ className={shrinkClass(cell.column.columnDef)}
477
578
  >
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
- ))}
579
+ {flexRender(
580
+ cell.column.columnDef.cell,
581
+ cell.getContext(),
582
+ )}
583
+ </TableCell>
584
+ ))}
585
+ </TableRow>
586
+ );
587
+ })}
495
588
  </TableBody>
496
589
  </Table>
497
590
 
@@ -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";