rl-core-front 0.16.8 → 0.17.0

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.16.8",
3
+ "version": "0.17.0",
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",
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Check, ChevronDown, X } from "lucide-react";
3
+ import { Check, ChevronDown, Plus, X } from "lucide-react";
4
4
  import type { JSX } from "react";
5
5
  import { useEffect, useId, useMemo, useRef, useState } from "react";
6
6
 
@@ -39,8 +39,26 @@ export interface ComboboxProps {
39
39
  searchPlaceholder?: string;
40
40
  emptyMessage?: string;
41
41
  disabled?: boolean;
42
+ /**
43
+ * Deixa criar o que foi digitado quando nenhuma opção tem exatamente esse
44
+ * nome. A linha "Criar" aparece no fim da lista e o Enter a escolhe quando
45
+ * ela é a apontada. Quem recebe o texto é o `onCreate` — o componente não
46
+ * sabe se isso vira POST ou só um valor no formulário.
47
+ */
48
+ creatable?: boolean;
49
+ /** Chamado com o texto digitado, já sem espaços nas pontas. */
50
+ onCreate?: (text: string) => void;
51
+ /** Texto da linha de criação. Ausente, `Criar "{text}"`. */
52
+ createLabel?: (text: string) => string;
42
53
  }
43
54
 
55
+ /** Id da linha de criação na lista apontável — nenhuma opção real o usa. */
56
+ const CREATE_ID = "\u0000create";
57
+
58
+ /** Id DOM do item, para o `aria-activedescendant`. O caractere nulo não vai para o HTML. */
59
+ const idDoItem = (lista: string, id: string): string =>
60
+ `${lista}-${id === CREATE_ID ? "create" : id}`;
61
+
44
62
  /**
45
63
  * Escolha com busca, de um valor ou de vários.
46
64
  *
@@ -52,8 +70,10 @@ export interface ComboboxProps {
52
70
  * marcar: a busca, a lista e o painel são os mesmos. Dois componentes seriam o
53
71
  * mesmo arquivo duas vezes.
54
72
  *
55
- * escolhe — não cria. Quem cadastra o item nem sempre é quem pode cadastrar
56
- * na origem, e um "criar" aqui daria um botão que falha na cara de quem clicou.
73
+ * Por padrão só escolhe — não cria. Quem cadastra o item nem sempre é quem
74
+ * pode cadastrar na origem, e um "criar" aqui daria um botão que falha na
75
+ * cara de quem clicou. Com `creatable`, a decisão é de quem usa: a lista
76
+ * ganha a linha "Criar" e o texto sai pelo `onCreate`.
57
77
  *
58
78
  * **O foco nunca sai do campo de busca.** Quem aponta o item é o `ativo`, e não
59
79
  * o foco do navegador: dentro do menu do Radix, foco em item entrega as teclas
@@ -70,6 +90,9 @@ export function Combobox({
70
90
  searchPlaceholder,
71
91
  emptyMessage,
72
92
  disabled,
93
+ creatable = false,
94
+ onCreate,
95
+ createLabel,
73
96
  }: ComboboxProps): JSX.Element {
74
97
  const { t } = useI18n();
75
98
  const [open, setOpen] = useState(false);
@@ -98,7 +121,7 @@ export function Combobox({
98
121
  );
99
122
 
100
123
  const termo = search.trim();
101
- const visiveis = useMemo(
124
+ const filtradas = useMemo(
102
125
  () =>
103
126
  options.filter((option) =>
104
127
  option.name.toLowerCase().includes(termo.toLowerCase()),
@@ -106,6 +129,21 @@ export function Combobox({
106
129
  [options, termo],
107
130
  );
108
131
 
132
+ /**
133
+ * A linha "Criar" só quando há texto e nenhuma opção tem exatamente esse
134
+ * nome — com "Kronos" na lista, digitar "kronos" deve escolher, não criar.
135
+ */
136
+ const podeCriar =
137
+ creatable &&
138
+ termo !== "" &&
139
+ !options.some((option) => option.name.toLowerCase() === termo.toLowerCase());
140
+
141
+ /** O que se aponta pelo teclado: as opções filtradas e, no fim, a de criar. */
142
+ const visiveis = useMemo(
143
+ () => (podeCriar ? [...filtradas, { id: CREATE_ID, name: termo }] : filtradas),
144
+ [filtradas, podeCriar, termo],
145
+ );
146
+
109
147
  /** A posição do apontado; sem ele — ou fora da busca — é a do primeiro. */
110
148
  const ativo = Math.max(
111
149
  visiveis.findIndex((option) => option.id === apontado),
@@ -113,6 +151,15 @@ export function Combobox({
113
151
  );
114
152
 
115
153
  const alternar = (id: string): void => {
154
+ if (id === CREATE_ID) {
155
+ onCreate?.(termo);
156
+ setSearch("");
157
+ if (!multiple) {
158
+ setOpen(false);
159
+ }
160
+ return;
161
+ }
162
+
116
163
  if (!multiple) {
117
164
  onChange([id]);
118
165
  setOpen(false);
@@ -296,7 +343,7 @@ export function Combobox({
296
343
  // Quem lê a tela precisa saber qual item as setas apontam: o foco
297
344
  // fica aqui, então é este atributo que conta a navegação.
298
345
  aria-activedescendant={
299
- visiveis[ativo] ? `${idLista}-${visiveis[ativo].id}` : undefined
346
+ visiveis[ativo] ? idDoItem(idLista, visiveis[ativo].id) : undefined
300
347
  }
301
348
  onChange={(event) => setSearch(event.target.value)}
302
349
  /*
@@ -348,10 +395,10 @@ export function Combobox({
348
395
  />
349
396
  </div>
350
397
 
351
- {visiveis.map((option, indice) => (
398
+ {filtradas.map((option, indice) => (
352
399
  <DropdownMenuItem
353
400
  key={option.id}
354
- id={`${idLista}-${option.id}`}
401
+ id={idDoItem(idLista, option.id)}
355
402
  ref={(node) => {
356
403
  itensRef.current[option.id] = node;
357
404
  }}
@@ -383,6 +430,32 @@ export function Combobox({
383
430
  </DropdownMenuItem>
384
431
  ))}
385
432
 
433
+ {podeCriar && (
434
+ <DropdownMenuItem
435
+ id={idDoItem(idLista, CREATE_ID)}
436
+ ref={(node) => {
437
+ itensRef.current[CREATE_ID] = node;
438
+ }}
439
+ className={cn(
440
+ "flex w-full items-center gap-2",
441
+ ativo === filtradas.length && "bg-accent text-accent-foreground",
442
+ )}
443
+ onPointerMove={(event) => {
444
+ event.preventDefault();
445
+ setApontado(CREATE_ID);
446
+ }}
447
+ onSelect={(event) => {
448
+ event.preventDefault();
449
+ alternar(CREATE_ID);
450
+ }}
451
+ >
452
+ <Plus className="h-4 w-4 shrink-0" />
453
+ <span className="truncate">
454
+ {createLabel ? createLabel(termo) : t("common.createNamed", { text: termo })}
455
+ </span>
456
+ </DropdownMenuItem>
457
+ )}
458
+
386
459
  {visiveis.length === 0 && (
387
460
  <p className="px-2 py-3 text-center text-sm text-muted-foreground">
388
461
  {emptyMessage ?? t("common.noResults")}
@@ -14,28 +14,30 @@ export interface ExportButtonProps extends Omit<ButtonProps, "children"> {
14
14
  query?: ExportQuery;
15
15
  /** Texto do botão. Ausente, "Exportar". */
16
16
  label?: string;
17
+ /** O que o painel de jobs mostra enquanto gera. Ausente, "Relatório". */
18
+ jobLabel?: string;
17
19
  }
18
20
 
19
21
  /**
20
22
  * Baixa a listagem em planilha.
21
23
  *
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.
24
+ * O relatório é gerado fora da requisição, então o botão não trava a tela: o
25
+ * andamento aparece no painel de jobs do canto, e o arquivo baixa sozinho no
26
+ * fim. Fica desabilitado durante a geração porque um segundo pedido só faria a
27
+ * mesma planilha duas vezes. A falha vira toast aqui dentro — a tela que usa o
28
+ * botão não escreve tratamento de erro nenhum.
27
29
  */
28
30
  const ExportButton = React.forwardRef<HTMLButtonElement, ExportButtonProps>(
29
- ({ handler, query, label, variant = "outline", ...props }, ref) => {
31
+ ({ handler, query, label, jobLabel, variant = "outline", ...props }, ref) => {
30
32
  const { t } = useI18n();
31
33
  const { notify } = useToast();
32
- const { request, generating, processed } = useExport();
34
+ const { request, generating } = useExport();
33
35
 
34
36
  // O aviso é do botão, não da tela: quem usa o componente não deveria
35
37
  // precisar escrever tratamento de erro para ganhar o comportamento.
36
38
  const run = async (): Promise<void> => {
37
39
  try {
38
- await request(handler, query);
40
+ await request(handler, query, { label: jobLabel });
39
41
  } catch (error) {
40
42
  notify((error as Error).message || t("export.failed"), "error");
41
43
  }
@@ -52,9 +54,7 @@ const ExportButton = React.forwardRef<HTMLButtonElement, ExportButtonProps>(
52
54
  {generating ? (
53
55
  <>
54
56
  <Loader2 className="h-4 w-4 animate-spin" />
55
- {processed > 0
56
- ? t("export.progress", { rows: processed.toLocaleString() })
57
- : t("export.starting")}
57
+ {t("export.starting")}
58
58
  </>
59
59
  ) : (
60
60
  <>
@@ -1,44 +1,54 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useCallback, useState } from "react";
4
4
 
5
5
  import { api } from "#core/_services/api/axios.factory";
6
+ import { useI18n } from "#core/contexts/i18n-context";
7
+ import { useJobs } from "#core/contexts/jobs-context";
6
8
 
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. */
9
+ /** O recorte que vai junto o mesmo da listagem, ou os parâmetros do documento. */
11
10
  export interface ExportQuery {
12
11
  filter?: string;
13
12
  search?: string;
14
13
  sortBy?: string;
15
14
  sortDir?: "ASC" | "DESC";
15
+ /**
16
+ * Parâmetros de um relatório em **documento** (mês, ids escolhidos…),
17
+ * validados pelo handler ainda na rota. Planilha ignora.
18
+ */
19
+ params?: Record<string, unknown>;
20
+ }
21
+
22
+ export interface ExportOptions {
23
+ /** O que o painel de jobs mostra enquanto gera. Ausente, "Relatório". */
24
+ label?: string;
16
25
  }
17
26
 
18
- interface ExportStatus {
19
- state: "waiting" | "active" | "completed" | "failed" | "delayed" | "unknown";
20
- processed: number;
27
+ /** O que o processador devolve — o nome do arquivo é o que o download usa. */
28
+ interface ExportJobSummary {
29
+ fileName: string;
21
30
  totalRows: number | null;
22
- fileName: string | null;
23
- errorMessage: string | null;
24
31
  }
25
32
 
26
33
  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. */
34
+ /** Pede o relatório. Devolve quando ele foi baixado — ou lança, se falhou. */
35
+ request: (
36
+ handler: string,
37
+ query?: ExportQuery,
38
+ options?: ExportOptions,
39
+ ) => Promise<void>;
40
+ /** Há um relatório pedido por este hook sendo gerado agora. */
30
41
  generating: boolean;
31
- /** Linhas já escritas. Sem total: em stream ele só é conhecido no fim. */
32
- processed: number;
33
42
  }
34
43
 
35
44
  /**
36
45
  * Pede um relatório e o entrega quando fica pronto.
37
46
  *
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.
47
+ * O acompanhamento é do `JobsProvider`: o pedido vira uma sessão no painel
48
+ * de jobs do canto o mesmo do upload de imagem que recebe progresso pelo
49
+ * socket e se reconcilia por HTTP quando a conexão cai. Sair da tela não
50
+ * interrompe o acompanhamento; o download automático, sim, porque a promessa
51
+ * dele morre com quem a esperava — o arquivo continua na pasta até a faxina.
42
52
  *
43
53
  * ```tsx
44
54
  * const { request, generating } = useExport();
@@ -46,23 +56,18 @@ export interface UseExportResult {
46
56
  * ```
47
57
  */
48
58
  export function useExport(): UseExportResult {
59
+ const { t } = useI18n();
60
+ const { start } = useJobs();
49
61
  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
 
62
63
  const request = useCallback(
63
- async (handler: string, query: ExportQuery = {}): Promise<void> => {
64
+ async (
65
+ handler: string,
66
+ query: ExportQuery = {},
67
+ options: ExportOptions = {},
68
+ ): Promise<void> => {
64
69
  setGenerating(true);
65
- setProcessed(0);
70
+ const job = start<ExportJobSummary>(options.label ?? t("export.job"));
66
71
 
67
72
  try {
68
73
  const { data } = await api.post<{ jobId: string }>(
@@ -70,46 +75,29 @@ export function useExport(): UseExportResult {
70
75
  query,
71
76
  );
72
77
 
73
- const status = await waitUntilDone(data.jobId, activeRef, setProcessed);
78
+ job.bind(data.jobId);
74
79
 
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
- }
80
+ const summary = await job.done;
81
81
 
82
- await download(data.jobId, status.fileName);
82
+ await download(data.jobId, summary.fileName);
83
+ } catch (error) {
84
+ // `fail` é idempotente para quem chama: se o socket já trouxe a
85
+ // falha, isto não duplica nada — mas se foi o HTTP que recusou, é o
86
+ // que tira a sessão do painel.
87
+ job.fail(messageOf(error, t("export.failed")));
88
+ throw error;
83
89
  } finally {
84
90
  setGenerating(false);
85
91
  }
86
92
  },
87
- [],
93
+ [start, t],
88
94
  );
89
95
 
90
- return { request, generating, processed };
96
+ return { request, generating };
91
97
  }
92
98
 
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
- };
99
+ const messageOf = (error: unknown, fallback: string): string =>
100
+ error instanceof Error && error.message ? error.message : fallback;
113
101
 
114
102
  /**
115
103
  * Baixa o arquivo.
@@ -119,10 +107,7 @@ const waitUntilDone = async (
119
107
  * `revokeObjectURL` devolve a memória do blob, que senão fica presa até a aba
120
108
  * fechar.
121
109
  */
122
- const download = async (
123
- jobId: string,
124
- fileName: string | null,
125
- ): Promise<void> => {
110
+ const download = async (jobId: string, fileName: string): Promise<void> => {
126
111
  const { data } = await api.get<Blob>(`/exports/${jobId}`, {
127
112
  responseType: "blob",
128
113
  });
@@ -131,7 +116,7 @@ const download = async (
131
116
  const link = document.createElement("a");
132
117
 
133
118
  link.href = url;
134
- link.download = fileName ?? "relatorio.xlsx";
119
+ link.download = fileName;
135
120
  document.body.appendChild(link);
136
121
  link.click();
137
122
  link.remove();
@@ -170,7 +170,7 @@ export const en: Messages = {
170
170
  export: {
171
171
  label: "Export",
172
172
  starting: "Generating...",
173
- progress: "{rows} rows",
173
+ job: "Report",
174
174
  failed: "Could not generate the report",
175
175
  },
176
176
  table: {
@@ -294,6 +294,7 @@ export const en: Messages = {
294
294
  deleteWarning:
295
295
  "This action cannot be undone. The record leaves the lists and stops appearing in the system.",
296
296
  create: "Create",
297
+ createNamed: 'Create "{text}"',
297
298
  confirm: "Confirm",
298
299
  send: "Send",
299
300
  yes: "Yes",
@@ -172,7 +172,7 @@ export const pt = {
172
172
  export: {
173
173
  label: "Exportar",
174
174
  starting: "Gerando...",
175
- progress: "{rows} linhas",
175
+ job: "Relatório",
176
176
  failed: "Não foi possível gerar o relatório",
177
177
  },
178
178
  table: {
@@ -297,6 +297,7 @@ export const pt = {
297
297
  deleteWarning:
298
298
  "Esta ação não pode ser desfeita. O registro sai das listas e deixa de aparecer no sistema.",
299
299
  create: "Criar",
300
+ createNamed: 'Criar "{text}"',
300
301
  confirm: "Confirmar",
301
302
  send: "Enviar",
302
303
  yes: "Sim",
package/src/index.ts CHANGED
@@ -83,7 +83,11 @@ export type {
83
83
  } from "#core/hooks/use-confirm";
84
84
  export { useConfirm } from "#core/hooks/use-confirm";
85
85
  export { useDocumentTitle } from "#core/hooks/use-document-title";
86
- export type { ExportQuery, UseExportResult } from "#core/hooks/use-export";
86
+ export type {
87
+ ExportOptions,
88
+ ExportQuery,
89
+ UseExportResult,
90
+ } from "#core/hooks/use-export";
87
91
  export { useExport } from "#core/hooks/use-export";
88
92
  export type { UseFilterSchemaResult } from "#core/hooks/use-filter-schema";
89
93
  export { useFilterSchema } from "#core/hooks/use-filter-schema";