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 +1 -1
- package/src/_services/api/schema.d.ts +48 -0
- package/src/components/ui/data-table.tsx +163 -70
- 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,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
|
-
*
|
|
303
|
+
* Onde a linha está agora: quantas linhas ela já subiu ou desceu.
|
|
277
304
|
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
))}
|
|
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
|
+
};
|
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";
|