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.
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/_services/api/schema.d.ts +381 -2
- package/src/components/app-shell.tsx +229 -51
- package/src/components/ui/data-table.tsx +18 -1
- package/src/components/ui/dialog.tsx +157 -62
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/job-progress.tsx +100 -0
- package/src/components/ui/sheet.tsx +1 -1
- package/src/features/errors/error-state.tsx +50 -0
- package/src/features/errors/forbidden-screen.tsx +37 -0
- package/src/features/errors/not-found-screen.tsx +35 -0
- package/src/features/queues/hooks/use-queue-jobs.ts +103 -0
- package/src/features/queues/queues-screen.tsx +264 -0
- package/src/features/queues/services/queues.service.ts +57 -0
- package/src/features/rbac/components/permission-matrix.tsx +210 -0
- package/src/features/rbac/panels/definitions-panel.tsx +75 -0
- package/src/features/rbac/panels/index.ts +1 -0
- package/src/features/rbac/panels/roles-panel.tsx +113 -57
- package/src/features/rbac/rbac-screen.tsx +35 -37
- package/src/hooks/use-job-progress.ts +192 -0
- package/src/i18n/messages/en.ts +52 -0
- package/src/i18n/messages/pt.ts +53 -0
- package/src/index.ts +24 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
{
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
className,
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
}
|