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 +1 -1
- package/src/_services/api/schema.d.ts +48 -0
- package/src/components/ui/data-table.tsx +108 -59
- package/src/components/ui/export-button.tsx +71 -0
- package/src/components/ui/index.ts +1 -0
- package/src/hooks/use-export.ts +139 -0
- package/src/i18n/messages/en.ts +6 -0
- package/src/i18n/messages/pt.ts +6 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -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) {
|
|
150
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
+
};
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -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",
|
package/src/i18n/messages/pt.ts
CHANGED
|
@@ -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";
|