rl-core-front 0.6.0 → 0.8.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.
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as DialogPrimitive from "@radix-ui/react-dialog";
4
- import { Maximize2, Minimize2, X } from "lucide-react";
4
+ import { Expand, Shrink, X } from "lucide-react";
5
5
  import * as React from "react";
6
6
 
7
7
  import { cn } from "#core/lib/utils";
@@ -83,6 +83,61 @@ const expandedWidth = (className?: string): string => {
83
83
  return WIDTH_SCALE[Math.min(index + EXPAND_STEPS, last)];
84
84
  };
85
85
 
86
+ /**
87
+ * Cabeçalho e rodapé vêm antes de `DialogContent`/`DialogForm` porque os dois
88
+ * procuram por eles entre os próprios filhos. A busca é pelo `displayName`, e
89
+ * não pela referência do componente: o pacote é publicado em código-fonte e
90
+ * resolvido por subpath (`#core/*`), então o consumidor pode acabar com outra
91
+ * instância do módulo — aí `child.type === DialogHeader` é falso mesmo sendo o
92
+ * mesmo componente, e o cabeçalho cai silenciosamente no corpo, desalinhado.
93
+ */
94
+ const DialogHeader = ({
95
+ className,
96
+ ...props
97
+ }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
98
+ <div
99
+ className={cn(
100
+ "flex min-w-0 flex-col space-y-1.5 text-left",
101
+ className,
102
+ )}
103
+ {...props}
104
+ />
105
+ );
106
+ DialogHeader.displayName = "DialogHeader";
107
+
108
+ const DialogFooter = ({
109
+ className,
110
+ ...props
111
+ }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
112
+ <div
113
+ className={cn(
114
+ // O `-mb-2` é correção óptica, não capricho: o espaçamento em volta das
115
+ // duas linhas já é simétrico (16px de cada lado), e as bordas do modal
116
+ // também (24px). O rodapé parece mais alto porque um botão tem 40px e o
117
+ // título tem 18 — e o botão ainda carrega ar interno próprio, que o
118
+ // título não tem. Comer 8px da borda de baixo aproxima os dois blocos
119
+ // sem encostar o botão na linha, que é o que aconteceria mexendo no
120
+ // `pt-4`. Fica no rodapé, e não no `p-6` do conteúdo, para modal sem
121
+ // rodapé seguir com a borda inteira.
122
+ "-mb-2 flex shrink-0 flex-col-reverse gap-2 border-t border-border pt-4 sm:flex-row sm:justify-end",
123
+ className,
124
+ )}
125
+ {...props}
126
+ />
127
+ );
128
+ DialogFooter.displayName = "DialogFooter";
129
+
130
+ /** Acha um filho pelo `displayName` — ver a nota acima. */
131
+ const indexOfChild = (
132
+ items: React.ReactNode[],
133
+ displayName: string,
134
+ ): number =>
135
+ items.findIndex(
136
+ (child) =>
137
+ React.isValidElement(child) &&
138
+ (child.type as { displayName?: string })?.displayName === displayName,
139
+ );
140
+
86
141
  export interface DialogContentProps
87
142
  extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
88
143
  /**
@@ -98,19 +153,30 @@ export interface DialogContentProps
98
153
  const DialogContent = React.forwardRef<
99
154
  React.ElementRef<typeof DialogPrimitive.Content>,
100
155
  DialogContentProps
101
- >(({ className, children, expandable = true, ...props }, ref) => {
156
+ >(({ className, children, expandable = true, onOpenAutoFocus, ...props }, ref) => {
102
157
  // Reinicia a cada montagem: o Radix desmonta o conteúdo ao fechar, então
103
158
  // reabrir traz o modal no tamanho normal — modal pequeno que volta ampliado
104
159
  // surpreende quem o abriu.
105
160
  const [expanded, setExpanded] = React.useState(false);
106
161
 
162
+ // O cabeçalho sai da lista para dividir a primeira linha com os botões; o
163
+ // resto segue na ordem em que o chamador escreveu.
164
+ const items = React.Children.toArray(children);
165
+ const headerAt = indexOfChild(items, "DialogHeader");
166
+ const header = headerAt >= 0 ? items[headerAt] : null;
167
+ const body = headerAt >= 0 ? items.filter((_, i) => i !== headerAt) : items;
168
+
107
169
  return (
108
170
  <DialogPortal>
109
171
  <DialogOverlay />
110
172
  <DialogPrimitive.Content
111
173
  ref={ref}
112
174
  className={cn(
113
- "fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 overflow-y-auto",
175
+ // `flex flex-col` + `overflow-hidden`: o scroll é do corpo, não do
176
+ // modal inteiro. Com `overflow-y-auto` aqui, título e botões rolavam
177
+ // junto e sumiam — quem descia até o fim de uma lista longa perdia o
178
+ // "Salvar" de vista.
179
+ "fixed left-[50%] top-[50%] z-50 flex flex-col translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 overflow-hidden",
114
180
  "w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] rounded-xl",
115
181
  DEFAULT_WIDTH,
116
182
  className,
@@ -118,31 +184,56 @@ const DialogContent = React.forwardRef<
118
184
  // o chamador declarou, e só enquanto estiver ampliado.
119
185
  expanded && expandedWidth(className),
120
186
  )}
187
+ onOpenAutoFocus={(event) => {
188
+ onOpenAutoFocus?.(event);
189
+ if (event.defaultPrevented) {
190
+ return;
191
+ }
192
+ // O Radix foca o primeiro elemento focável, e desde que os botões de
193
+ // ampliar/fechar entraram no cabeçalho esse primeiro passou a ser o
194
+ // de ampliar — abrir o modal e teclar Enter maximizava a janela em
195
+ // vez de enviar o formulário. Quando há campo, o foco é dele.
196
+ const conteudo = event.target as HTMLElement | null;
197
+ const primeiroCampo = conteudo?.querySelector<HTMLElement>(
198
+ "form input:not([type='hidden']), form textarea, form select",
199
+ );
200
+ if (primeiroCampo) {
201
+ event.preventDefault();
202
+ primeiroCampo.focus();
203
+ }
204
+ }}
121
205
  {...props}
122
206
  >
123
- {children}
124
- <div className="absolute right-4 top-4 flex items-center gap-2">
125
- {expandable && (
126
- <button
127
- type="button"
128
- onClick={() => setExpanded((value) => !value)}
129
- className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring"
130
- >
131
- {expanded ? (
132
- <Minimize2 className="h-4 w-4" />
133
- ) : (
134
- <Maximize2 className="h-4 w-4" />
135
- )}
136
- <span className="sr-only">
137
- {expanded ? "Restaurar" : "Ampliar"}
138
- </span>
139
- </button>
140
- )}
141
- <DialogPrimitive.Close className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
142
- <X className="h-4 w-4" />
143
- <span className="sr-only">Fechar</span>
144
- </DialogPrimitive.Close>
207
+ {/* Título e botões na mesma linha de um flex, em vez de os botões
208
+ flutuarem `absolute`: posicionados por coordenada eles nunca
209
+ batiam com a altura do título — mudava o tamanho da fonte e o
210
+ alinhamento saía. Aqui `items-center` resolve sozinho. */}
211
+ <div className="flex shrink-0 items-center justify-between gap-4 border-b border-border pb-4">
212
+ <div className="min-w-0 flex-1">{header}</div>
213
+ <div className="flex items-center gap-2">
214
+ {expandable && (
215
+ <button
216
+ type="button"
217
+ onClick={() => setExpanded((value) => !value)}
218
+ className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
219
+ >
220
+ {expanded ? (
221
+ <Shrink className="h-4 w-4" />
222
+ ) : (
223
+ <Expand className="h-4 w-4" />
224
+ )}
225
+ <span className="sr-only">
226
+ {expanded ? "Restaurar" : "Ampliar"}
227
+ </span>
228
+ </button>
229
+ )}
230
+ <DialogPrimitive.Close className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
231
+ <X className="h-4 w-4" />
232
+ <span className="sr-only">Fechar</span>
233
+ </DialogPrimitive.Close>
234
+ </div>
145
235
  </div>
236
+ {body}
146
237
  </DialogPrimitive.Content>
147
238
  </DialogPortal>
148
239
  );
@@ -152,48 +243,52 @@ DialogContent.displayName = DialogPrimitive.Content.displayName;
152
243
  /**
153
244
  * Corpo do modal como formulário — é o que faz o `Enter` submeter.
154
245
  * O `preventDefault` fica aqui porque, sem ele, o Enter recarrega a página.
155
- * O `DialogFooter` com o botão `type="submit"` precisa ficar dentro deste form.
246
+ *
247
+ * O `DialogFooter` é separado dos demais filhos e fica **fora da área
248
+ * rolável**, ainda dentro do `<form>`. As duas metades importam: fora do
249
+ * scroll porque `sticky` deixa o conteúdo correr por baixo dos botões — com
250
+ * fundo próprio ele só disfarça, e qualquer filho com fundo ou `z-index`
251
+ * atravessa; dentro do `<form>` porque é lá que mora o `type="submit"`, e
252
+ * tirá-lo dali quebraria o Enter, que é a razão de este componente existir.
253
+ *
254
+ * A separação é feita aqui, e não pedida ao chamador, para os oito modais que
255
+ * já existem continuarem escritos como estão.
156
256
  */
157
257
  const DialogForm = React.forwardRef<
158
258
  HTMLFormElement,
159
259
  React.ComponentPropsWithoutRef<"form">
160
- >(({ className, onSubmit, ...props }, ref) => (
161
- <form
162
- ref={ref}
163
- onSubmit={(e) => {
164
- e.preventDefault();
165
- onSubmit?.(e);
166
- }}
167
- className={cn("flex flex-col gap-4", className)}
168
- {...props}
169
- />
170
- ));
171
- DialogForm.displayName = "DialogForm";
172
-
173
- const DialogHeader = ({
174
- className,
175
- ...props
176
- }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
177
- <div
178
- className={cn("flex flex-col space-y-1.5 text-left", className)}
179
- {...props}
180
- />
181
- );
182
- DialogHeader.displayName = "DialogHeader";
260
+ >(({ className, onSubmit, children, ...props }, ref) => {
261
+ const items = React.Children.toArray(children);
262
+ const footerAt = indexOfChild(items, "DialogFooter");
263
+ const hasFooter = footerAt >= 0;
183
264
 
184
- const DialogFooter = ({
185
- className,
186
- ...props
187
- }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
188
- <div
189
- className={cn(
190
- "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
191
- className,
192
- )}
193
- {...props}
194
- />
195
- );
196
- DialogFooter.displayName = "DialogFooter";
265
+ return (
266
+ <form
267
+ ref={ref}
268
+ onSubmit={(e) => {
269
+ e.preventDefault();
270
+ onSubmit?.(e);
271
+ }}
272
+ className={cn("flex min-h-0 flex-1 flex-col gap-4", className)}
273
+ {...props}
274
+ >
275
+ {/* `min-h-0` é o que permite encolher dentro do flex e ativar o scroll:
276
+ sem ele o corpo assume a altura do conteúdo e estoura o modal. */}
277
+ {/* `p-1` reserva a folga que o anel de foco ocupa: o `ring` é
278
+ desenhado fora da borda do input (`ring-2` + `ring-offset-1` = 3px),
279
+ e sem essa margem o container de scroll corta o anel.
280
+ Nos dois eixos: na horizontal cortava o lado dos campos, e na
281
+ vertical cortava a borda de baixo do último campo — que é o que
282
+ aparece em todo modal com um campo colado no rodapé.
283
+ `-m-1` devolve o recuo para o conteúdo seguir alinhado. */}
284
+ <div className="-m-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-1 [scrollbar-width:thin]">
285
+ {hasFooter ? items.slice(0, footerAt) : items}
286
+ </div>
287
+ {hasFooter && items.slice(footerAt)}
288
+ </form>
289
+ );
290
+ });
291
+ DialogForm.displayName = "DialogForm";
197
292
 
198
293
  const DialogTitle = React.forwardRef<
199
294
  React.ElementRef<typeof DialogPrimitive.Title>,
@@ -17,6 +17,7 @@ export * from "./filter-group";
17
17
  export * from "./filter-rule";
18
18
  export * from "./filter-sheet";
19
19
  export * from "./input";
20
+ export * from "./job-progress";
20
21
  export * from "./label";
21
22
  export * from "./password-requirements";
22
23
  export * from "./row-actions";
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ import { AlertTriangle, CheckCircle2 } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import { Alert } from "#core/components/ui/alert";
7
+ import { Spinner } from "#core/components/ui/spinner";
8
+ import { useI18n } from "#core/contexts";
9
+ import type { JobState } from "#core/hooks/use-job-progress";
10
+ import { cn } from "#core/lib/utils";
11
+
12
+ export interface JobProgressProps {
13
+ state: JobState;
14
+ /** Inteiro de 0 a 100. */
15
+ percent: number;
16
+ processed: number;
17
+ total: number;
18
+ /** O que o processador está fazendo agora, quando ele diz. */
19
+ message?: string | null;
20
+ error?: { code: string | null; message: string } | null;
21
+ /** Substitui o texto de conclusão — a feature costuma ter algo melhor a dizer. */
22
+ successMessage?: string;
23
+ className?: string;
24
+ }
25
+
26
+ /**
27
+ * Barra de um job em segundo plano.
28
+ *
29
+ * Existe no core para que cada feature com importação, exportação ou relatório
30
+ * não redesenhe o mesmo par barra + contagem. Não conhece job nenhum: recebe o
31
+ * que o `useJobProgress` devolve.
32
+ */
33
+ export function JobProgress({
34
+ state,
35
+ percent,
36
+ processed,
37
+ total,
38
+ message,
39
+ error,
40
+ successMessage,
41
+ className,
42
+ }: JobProgressProps): JSX.Element | null {
43
+ const { t } = useI18n();
44
+
45
+ if (state === "idle") {
46
+ return null;
47
+ }
48
+
49
+ if (state === "failed") {
50
+ return (
51
+ <Alert
52
+ variant="error"
53
+ icon={<AlertTriangle />}
54
+ className={className}
55
+ >
56
+ {error?.message ?? t("job.failed")}
57
+ </Alert>
58
+ );
59
+ }
60
+
61
+ if (state === "completed") {
62
+ return (
63
+ <Alert
64
+ variant="success"
65
+ icon={<CheckCircle2 />}
66
+ className={className}
67
+ >
68
+ {successMessage ?? t("job.completed")}
69
+ </Alert>
70
+ );
71
+ }
72
+
73
+ return (
74
+ <div className={cn("space-y-2", className)}>
75
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
76
+ <Spinner className="h-4 w-4" />
77
+ <span className="flex-1 truncate">{message ?? t("job.running")}</span>
78
+ {/* Contagem ao lado do percentual: em importação, "120 de 175" diz
79
+ mais sobre o que falta do que "69%". */}
80
+ {total > 0 && (
81
+ <span className="tabular-nums">
82
+ {processed} / {total}
83
+ </span>
84
+ )}
85
+ </div>
86
+ <div
87
+ className="h-2 w-full overflow-hidden rounded-full bg-muted"
88
+ role="progressbar"
89
+ aria-valuenow={percent}
90
+ aria-valuemin={0}
91
+ aria-valuemax={100}
92
+ >
93
+ <div
94
+ className="h-full rounded-full bg-primary transition-[width] duration-300"
95
+ style={{ width: `${percent}%` }}
96
+ />
97
+ </div>
98
+ </div>
99
+ );
100
+ }
@@ -45,7 +45,7 @@ const SheetContent = React.forwardRef<
45
45
  {...props}
46
46
  >
47
47
  {children}
48
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
48
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
49
49
  <X className="h-4 w-4" />
50
50
  <span className="sr-only">Fechar</span>
51
51
  </DialogPrimitive.Close>
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import type { LucideIcon } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import { Button } from "#core/components/ui";
7
+
8
+ export interface ErrorStateProps {
9
+ icon: LucideIcon;
10
+ code: string;
11
+ title: string;
12
+ description: string;
13
+ /** Ação principal. Sem ela a tela é só o aviso — usado onde não há para onde voltar. */
14
+ action?: { label: string; onClick: () => void };
15
+ }
16
+
17
+ /**
18
+ * O corpo comum das telas de erro (403, 404).
19
+ *
20
+ * Existe para as duas não divergirem: são a mesma peça com ícone e texto
21
+ * diferentes, e escritas separadas elas viram dois desenhos parecidos-mas-não-
22
+ * iguais na primeira vez que alguém ajusta só uma.
23
+ *
24
+ * Não depende do `AppShell`: a 404 do Next é capturada na raiz do app, fora da
25
+ * área autenticada, e uma tela de erro que só funciona logado não serve para o
26
+ * caso mais comum — link velho aberto por quem nem sessão tem.
27
+ */
28
+ export function ErrorState({
29
+ icon: Icon,
30
+ code,
31
+ title,
32
+ description,
33
+ action,
34
+ }: ErrorStateProps): JSX.Element {
35
+ return (
36
+ <div className="flex min-h-[60vh] flex-col items-center justify-center px-6 text-center">
37
+ <div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
38
+ <Icon className="h-8 w-8 text-muted-foreground" />
39
+ </div>
40
+
41
+ <p className="mb-2 font-mono text-sm text-muted-foreground">{code}</p>
42
+ <h1 className="mb-3 text-2xl font-bold">{title}</h1>
43
+ <p className="mb-8 max-w-md text-sm text-muted-foreground">
44
+ {description}
45
+ </p>
46
+
47
+ {action && <Button onClick={action.onClick}>{action.label}</Button>}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { ShieldOff } from "lucide-react";
4
+ import { useRouter } from "next/navigation";
5
+ import type { JSX } from "react";
6
+
7
+ import { useI18n } from "#core/contexts";
8
+ import { ErrorState } from "#core/features/errors/error-state";
9
+
10
+ /**
11
+ * 403 — a pessoa está autenticada, mas a tela não é dela.
12
+ *
13
+ * Diz que **falta permissão**, e não que a página não existe: quem chegou aqui
14
+ * costuma ter recebido o link de alguém, e "peça acesso a um administrador" é a
15
+ * única instrução acionável. Fingir 404 esconderia o motivo e transformaria um
16
+ * pedido de acesso de um minuto em suporte.
17
+ *
18
+ * O `AppShell` a mostra sozinho quando a rota exige permissão que falta; o
19
+ * projeto troca por outra passando `forbidden` ao shell.
20
+ */
21
+ export function ForbiddenScreen(): JSX.Element {
22
+ const { t } = useI18n();
23
+ const router = useRouter();
24
+
25
+ return (
26
+ <ErrorState
27
+ icon={ShieldOff}
28
+ code="403"
29
+ title={t("errors.forbiddenTitle")}
30
+ description={t("errors.forbiddenDescription")}
31
+ action={{
32
+ label: t("errors.backToDashboard"),
33
+ onClick: () => router.push("/dashboard"),
34
+ }}
35
+ />
36
+ );
37
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { FileQuestion } from "lucide-react";
4
+ import { useRouter } from "next/navigation";
5
+ import type { JSX } from "react";
6
+
7
+ import { useI18n } from "#core/contexts";
8
+ import { ErrorState } from "#core/features/errors/error-state";
9
+
10
+ /**
11
+ * 404 no tema do sistema.
12
+ *
13
+ * O projeto adota criando `src/app/not-found.tsx` com uma linha
14
+ * (`export { NotFoundScreen as default } from "rl-core-front"`) — ou escreve a
15
+ * própria no lugar, já que `not-found.tsx` é rota especial do Next e pertence
16
+ * ao projeto. Sem isso vale a página padrão do Next: fundo branco, texto em
17
+ * inglês, ignorando tema e marca.
18
+ */
19
+ export function NotFoundScreen(): JSX.Element {
20
+ const { t } = useI18n();
21
+ const router = useRouter();
22
+
23
+ return (
24
+ <ErrorState
25
+ icon={FileQuestion}
26
+ code="404"
27
+ title={t("errors.notFoundTitle")}
28
+ description={t("errors.notFoundDescription")}
29
+ action={{
30
+ label: t("errors.backToDashboard"),
31
+ onClick: () => router.push("/dashboard"),
32
+ }}
33
+ />
34
+ );
35
+ }
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ import {
6
+ QueueCounts,
7
+ QueueJob,
8
+ QueueJobState,
9
+ queuesService,
10
+ } from "#core/features/queues/services/queues.service";
11
+ import { useRequest } from "#core/hooks/use-request";
12
+
13
+ const EMPTY_COUNTS: QueueCounts = {
14
+ waiting: 0,
15
+ active: 0,
16
+ completed: 0,
17
+ failed: 0,
18
+ delayed: 0,
19
+ };
20
+
21
+ export interface UseQueueJobsResult {
22
+ rows: QueueJob[];
23
+ counts: QueueCounts;
24
+ total: number;
25
+ page: number;
26
+ pageSize: number;
27
+ state: QueueJobState;
28
+ loading: boolean;
29
+ setPage: (page: number) => void;
30
+ setPageSize: (size: number) => void;
31
+ setState: (state: QueueJobState) => void;
32
+ refresh: () => Promise<void>;
33
+ retry: (id: string) => Promise<void>;
34
+ remove: (id: string) => Promise<void>;
35
+ }
36
+
37
+ /**
38
+ * A fila como a tela de administração a vê.
39
+ *
40
+ * Abre em `failed` de propósito: quem entra aqui está atrás do que deu errado.
41
+ * O que está andando bem não precisa de tela.
42
+ */
43
+ export function useQueueJobs(): UseQueueJobsResult {
44
+ const { run, loading } = useRequest();
45
+
46
+ const [rows, setRows] = useState<QueueJob[]>([]);
47
+ const [counts, setCounts] = useState<QueueCounts>(EMPTY_COUNTS);
48
+ const [total, setTotal] = useState(0);
49
+ const [page, setPage] = useState(0);
50
+ const [pageSize, setPageSize] = useState(20);
51
+ const [state, setStateValue] = useState<QueueJobState>(QueueJobState.FAILED);
52
+
53
+ const fetch = useCallback(async (): Promise<void> => {
54
+ const [list, summary] = await Promise.all([
55
+ // O `page` da tabela é base zero; o da API, base um.
56
+ run(() => queuesService.list({ state, page: page + 1, limit: pageSize })),
57
+ run(() => queuesService.counts()),
58
+ ]);
59
+ if (list) {
60
+ setRows(list.items);
61
+ setTotal(list.meta.total);
62
+ }
63
+ if (summary) {
64
+ setCounts(summary);
65
+ }
66
+ }, [run, state, page, pageSize]);
67
+
68
+ useEffect(() => {
69
+ void fetch();
70
+ }, [fetch]);
71
+
72
+ const setState = (next: QueueJobState): void => {
73
+ setStateValue(next);
74
+ // Página 3 de "falhos" não existe em "concluídos": trocar de estado sem
75
+ // voltar ao começo abriria uma página vazia.
76
+ setPage(0);
77
+ };
78
+
79
+ /**
80
+ * Depois de reprocessar ou remover, relê a lista **e** os contadores: o job
81
+ * mudou de estado, e a tela ficaria mostrando o que já não está ali.
82
+ */
83
+ const act = async (action: Promise<void>): Promise<void> => {
84
+ await action;
85
+ await fetch();
86
+ };
87
+
88
+ return {
89
+ rows,
90
+ counts,
91
+ total,
92
+ page,
93
+ pageSize,
94
+ state,
95
+ loading,
96
+ setPage,
97
+ setPageSize,
98
+ setState,
99
+ refresh: fetch,
100
+ retry: (id) => act(queuesService.retry(id)),
101
+ remove: (id) => act(queuesService.remove(id)),
102
+ };
103
+ }