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,15 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { yupResolver } from "@hookform/resolvers/yup";
|
|
3
4
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
|
4
5
|
import type { JSX } from "react";
|
|
5
|
-
import { useEffect, useState } from "react";
|
|
6
|
+
import { useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { useForm } from "react-hook-form";
|
|
8
|
+
import * as yup from "yup";
|
|
6
9
|
|
|
7
10
|
import {
|
|
8
11
|
Badge,
|
|
9
12
|
Button,
|
|
10
13
|
Card,
|
|
11
14
|
CardContent,
|
|
12
|
-
Checkbox,
|
|
13
15
|
Dialog,
|
|
14
16
|
DialogContent,
|
|
15
17
|
DialogFooter,
|
|
@@ -21,6 +23,7 @@ import {
|
|
|
21
23
|
Label,
|
|
22
24
|
} from "#core/components/ui";
|
|
23
25
|
import { useAuth, useI18n } from "#core/contexts";
|
|
26
|
+
import { PermissionMatrix } from "#core/features/rbac/components/permission-matrix";
|
|
24
27
|
import {
|
|
25
28
|
Permission,
|
|
26
29
|
rbacService,
|
|
@@ -29,6 +32,12 @@ import {
|
|
|
29
32
|
import { useConfirm } from "#core/hooks/use-confirm";
|
|
30
33
|
import { RequestOperation, useRequest } from "#core/hooks/use-request";
|
|
31
34
|
|
|
35
|
+
interface RoleFormValues {
|
|
36
|
+
name: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
permissionIds: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
export function RolesPanel(): JSX.Element {
|
|
33
42
|
const { run, loading } = useRequest();
|
|
34
43
|
const { t } = useI18n();
|
|
@@ -38,12 +47,29 @@ export function RolesPanel(): JSX.Element {
|
|
|
38
47
|
const [perms, setPerms] = useState<Permission[]>([]);
|
|
39
48
|
const [open, setOpen] = useState(false);
|
|
40
49
|
const [editing, setEditing] = useState<Role | null>(null);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
|
|
51
|
+
// Mesmo padrão das outras telas do core (react-hook-form + Yup): o erro sai
|
|
52
|
+
// sob o campo, e não como toast da API depois da viagem até o servidor.
|
|
53
|
+
const schema = yup.object({
|
|
54
|
+
name: yup.string().trim().required(t("validation.required")),
|
|
55
|
+
description: yup.string().optional(),
|
|
56
|
+
permissionIds: yup.array().of(yup.string().required()).required(),
|
|
45
57
|
});
|
|
46
58
|
|
|
59
|
+
const {
|
|
60
|
+
register,
|
|
61
|
+
handleSubmit,
|
|
62
|
+
reset,
|
|
63
|
+
setValue,
|
|
64
|
+
watch,
|
|
65
|
+
formState: { errors },
|
|
66
|
+
} = useForm<RoleFormValues>({
|
|
67
|
+
resolver: yupResolver(schema),
|
|
68
|
+
defaultValues: { name: "", description: "", permissionIds: [] },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const permissionIds = watch("permissionIds");
|
|
72
|
+
|
|
47
73
|
const load = async (): Promise<void> => {
|
|
48
74
|
const [r, p] = await Promise.all([
|
|
49
75
|
run(() => rbacService.listRoles()),
|
|
@@ -59,12 +85,12 @@ export function RolesPanel(): JSX.Element {
|
|
|
59
85
|
|
|
60
86
|
const openNew = (): void => {
|
|
61
87
|
setEditing(null);
|
|
62
|
-
|
|
88
|
+
reset({ name: "", description: "", permissionIds: [] });
|
|
63
89
|
setOpen(true);
|
|
64
90
|
};
|
|
65
91
|
const openEdit = (role: Role): void => {
|
|
66
92
|
setEditing(role);
|
|
67
|
-
|
|
93
|
+
reset({
|
|
68
94
|
name: role.name,
|
|
69
95
|
description: role.description ?? "",
|
|
70
96
|
permissionIds: (role.permissions ?? []).map((p) => p.id),
|
|
@@ -72,12 +98,12 @@ export function RolesPanel(): JSX.Element {
|
|
|
72
98
|
setOpen(true);
|
|
73
99
|
};
|
|
74
100
|
|
|
75
|
-
const save = async (): Promise<void> => {
|
|
101
|
+
const save = async (values: RoleFormValues): Promise<void> => {
|
|
76
102
|
const res = editing
|
|
77
|
-
? await run(() => rbacService.updateRole(editing.id,
|
|
103
|
+
? await run(() => rbacService.updateRole(editing.id, values), {
|
|
78
104
|
success: RequestOperation.Update,
|
|
79
105
|
})
|
|
80
|
-
: await run(() => rbacService.createRole(
|
|
106
|
+
: await run(() => rbacService.createRole(values), {
|
|
81
107
|
success: RequestOperation.Create,
|
|
82
108
|
});
|
|
83
109
|
if (res) {
|
|
@@ -106,13 +132,6 @@ export function RolesPanel(): JSX.Element {
|
|
|
106
132
|
|
|
107
133
|
const canManage =
|
|
108
134
|
hasPermission("roles:update:any") || hasPermission("roles:delete:any");
|
|
109
|
-
const togglePerm = (id: string): void =>
|
|
110
|
-
setForm((f) => ({
|
|
111
|
-
...f,
|
|
112
|
-
permissionIds: f.permissionIds.includes(id)
|
|
113
|
-
? f.permissionIds.filter((x) => x !== id)
|
|
114
|
-
: [...f.permissionIds, id],
|
|
115
|
-
}));
|
|
116
135
|
|
|
117
136
|
return (
|
|
118
137
|
<div>
|
|
@@ -122,6 +141,7 @@ export function RolesPanel(): JSX.Element {
|
|
|
122
141
|
{t("common.create")}
|
|
123
142
|
</Button>
|
|
124
143
|
)}
|
|
144
|
+
|
|
125
145
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
126
146
|
{roles.map((role) => (
|
|
127
147
|
<Card key={role.id}>
|
|
@@ -152,62 +172,52 @@ export function RolesPanel(): JSX.Element {
|
|
|
152
172
|
</div>
|
|
153
173
|
)}
|
|
154
174
|
</div>
|
|
155
|
-
<p className="mb-
|
|
175
|
+
<p className="mb-3 text-sm text-muted-foreground">
|
|
156
176
|
{role.description}
|
|
157
177
|
</p>
|
|
158
|
-
<
|
|
159
|
-
{(role.permissions ?? []).map((p) => (
|
|
160
|
-
<Badge key={p.id} variant="outline">
|
|
161
|
-
{p.code}
|
|
162
|
-
</Badge>
|
|
163
|
-
))}
|
|
164
|
-
</div>
|
|
178
|
+
<RoleSummary role={role} />
|
|
165
179
|
</CardContent>
|
|
166
180
|
</Card>
|
|
167
181
|
))}
|
|
168
182
|
</div>
|
|
169
183
|
|
|
170
184
|
<Dialog open={open} onOpenChange={(o) => !o && setOpen(false)}>
|
|
171
|
-
<DialogContent className="max-w-
|
|
185
|
+
<DialogContent className="max-w-3xl">
|
|
172
186
|
<DialogHeader>
|
|
173
187
|
<DialogTitle>
|
|
174
188
|
{editing ? t("common.edit") : t("common.create")}
|
|
175
189
|
</DialogTitle>
|
|
176
190
|
</DialogHeader>
|
|
177
|
-
<DialogForm onSubmit={() => void save()}>
|
|
178
|
-
<
|
|
179
|
-
<
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
191
|
+
<DialogForm onSubmit={handleSubmit((values) => void save(values))}>
|
|
192
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
193
|
+
<Field label={t("rbac.roleName")} error={errors.name?.message}>
|
|
194
|
+
<Input {...register("name")} />
|
|
195
|
+
</Field>
|
|
196
|
+
<Field
|
|
197
|
+
label={t("common.description")}
|
|
198
|
+
error={errors.description?.message}
|
|
199
|
+
>
|
|
200
|
+
<Input {...register("description")} />
|
|
201
|
+
</Field>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div className="space-y-2">
|
|
205
|
+
<Label>{t("rbac.screensAndPermissions")}</Label>
|
|
206
|
+
<PermissionMatrix
|
|
207
|
+
permissions={perms}
|
|
208
|
+
value={permissionIds}
|
|
209
|
+
onChange={(ids) =>
|
|
210
|
+
setValue("permissionIds", ids, { shouldDirty: true })
|
|
189
211
|
}
|
|
190
212
|
/>
|
|
191
|
-
</Field>
|
|
192
|
-
<div className="space-y-2">
|
|
193
|
-
<Label>{t("dashboard.permissions")}</Label>
|
|
194
|
-
<div className="flex max-h-60 flex-col gap-2 overflow-y-auto rounded-md border border-border p-3">
|
|
195
|
-
{perms.map((p) => (
|
|
196
|
-
<label
|
|
197
|
-
key={p.id}
|
|
198
|
-
className="flex cursor-pointer items-center gap-2 text-sm"
|
|
199
|
-
>
|
|
200
|
-
<Checkbox
|
|
201
|
-
checked={form.permissionIds.includes(p.id)}
|
|
202
|
-
onCheckedChange={() => togglePerm(p.id)}
|
|
203
|
-
/>
|
|
204
|
-
{p.code}
|
|
205
|
-
</label>
|
|
206
|
-
))}
|
|
207
|
-
</div>
|
|
208
213
|
</div>
|
|
214
|
+
|
|
209
215
|
<DialogFooter className="gap-2">
|
|
210
|
-
<Button
|
|
216
|
+
<Button
|
|
217
|
+
type="button"
|
|
218
|
+
variant="outline"
|
|
219
|
+
onClick={() => setOpen(false)}
|
|
220
|
+
>
|
|
211
221
|
{t("common.cancel")}
|
|
212
222
|
</Button>
|
|
213
223
|
<Button type="submit" disabled={loading}>
|
|
@@ -222,3 +232,49 @@ export function RolesPanel(): JSX.Element {
|
|
|
222
232
|
</div>
|
|
223
233
|
);
|
|
224
234
|
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Resumo do papel: as **áreas** que ele alcança, não os códigos.
|
|
238
|
+
*
|
|
239
|
+
* O cartão listava uma badge por permissão — no Super Admin, que tem todas,
|
|
240
|
+
* isso é uma parede de 48 códigos que não se lê e empurra os outros papéis
|
|
241
|
+
* para fora da tela.
|
|
242
|
+
*/
|
|
243
|
+
function RoleSummary({ role }: { role: Role }): JSX.Element {
|
|
244
|
+
const { t } = useI18n();
|
|
245
|
+
const permissions = useMemo(() => role.permissions ?? [], [role.permissions]);
|
|
246
|
+
|
|
247
|
+
const resources = useMemo(() => {
|
|
248
|
+
const names = new Set(
|
|
249
|
+
permissions.map((p) => p.resource?.description?.trim() || p.code.split(":")[0]),
|
|
250
|
+
);
|
|
251
|
+
return [...names].sort((a, b) => a.localeCompare(b));
|
|
252
|
+
}, [permissions]);
|
|
253
|
+
|
|
254
|
+
if (permissions.length === 0) {
|
|
255
|
+
return (
|
|
256
|
+
<p className="text-sm text-muted-foreground">{t("rbac.noPermissions")}</p>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const shown = resources.slice(0, 6);
|
|
261
|
+
const rest = resources.length - shown.length;
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div className="space-y-2">
|
|
265
|
+
<p className="text-xs text-muted-foreground">
|
|
266
|
+
{t("rbac.permissionCount")
|
|
267
|
+
.replace("{count}", String(permissions.length))
|
|
268
|
+
.replace("{screens}", String(resources.length))}
|
|
269
|
+
</p>
|
|
270
|
+
<div className="flex flex-wrap gap-1">
|
|
271
|
+
{shown.map((name) => (
|
|
272
|
+
<Badge key={name} variant="outline">
|
|
273
|
+
{name}
|
|
274
|
+
</Badge>
|
|
275
|
+
))}
|
|
276
|
+
{rest > 0 && <Badge variant="outline">+{rest}</Badge>}
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
@@ -5,14 +5,23 @@ import type { JSX } from "react";
|
|
|
5
5
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "#core/components/ui";
|
|
6
6
|
import { useAuth, useI18n } from "#core/contexts";
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
DefinitionsPanel,
|
|
9
9
|
GroupsPanel,
|
|
10
|
-
PermissionsPanel,
|
|
11
|
-
ResourcesPanel,
|
|
12
10
|
RolesPanel,
|
|
13
|
-
ScopesPanel,
|
|
14
11
|
} from "#core/features/rbac/panels";
|
|
15
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Controle de acesso, organizado pelo que se vem fazer aqui.
|
|
15
|
+
*
|
|
16
|
+
* **Papéis** é o trabalho de todo dia — é onde se decide que telas cada tipo de
|
|
17
|
+
* pessoa alcança. **Grupos** é o organograma, para quem usa escopo de equipe.
|
|
18
|
+
* **Definições** é a mecânica (recursos, ações, escopos e o catálogo de
|
|
19
|
+
* permissões), que o seed já preenche e quase nunca se toca.
|
|
20
|
+
*
|
|
21
|
+
* Eram seis abas no mesmo nível, três delas CRUD do modelo relacional com
|
|
22
|
+
* rótulo em inglês: a tarefa comum ficava do lado da manutenção rara, e a tela
|
|
23
|
+
* pedia que se soubesse o desenho do RBAC antes de conceder um acesso.
|
|
24
|
+
*/
|
|
16
25
|
export function RbacScreen(): JSX.Element {
|
|
17
26
|
const { t } = useI18n();
|
|
18
27
|
const { hasPermission } = useAuth();
|
|
@@ -20,41 +29,30 @@ export function RbacScreen(): JSX.Element {
|
|
|
20
29
|
const tabs = [
|
|
21
30
|
{
|
|
22
31
|
value: "roles",
|
|
23
|
-
label: "
|
|
24
|
-
perm: "roles:read:any",
|
|
32
|
+
label: t("rbac.tabs.roles"),
|
|
33
|
+
perm: ["roles:read:any"],
|
|
25
34
|
panel: <RolesPanel />,
|
|
26
35
|
},
|
|
27
|
-
{
|
|
28
|
-
value: "permissions",
|
|
29
|
-
label: t("dashboard.permissions"),
|
|
30
|
-
perm: "permissions:read:any",
|
|
31
|
-
panel: <PermissionsPanel />,
|
|
32
|
-
},
|
|
33
36
|
{
|
|
34
37
|
value: "groups",
|
|
35
|
-
label: "
|
|
36
|
-
perm: "groups:read:any",
|
|
38
|
+
label: t("rbac.tabs.groups"),
|
|
39
|
+
perm: ["groups:read:any"],
|
|
37
40
|
panel: <GroupsPanel />,
|
|
38
41
|
},
|
|
39
42
|
{
|
|
40
|
-
value: "
|
|
41
|
-
label: "
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{
|
|
52
|
-
value: "scopes",
|
|
53
|
-
label: "Scopes",
|
|
54
|
-
perm: "scopes:read:any",
|
|
55
|
-
panel: <ScopesPanel />,
|
|
43
|
+
value: "definitions",
|
|
44
|
+
label: t("rbac.tabs.definitions"),
|
|
45
|
+
// Aparece para quem alcança qualquer uma das quatro de dentro: exigir as
|
|
46
|
+
// quatro esconderia a aba de quem administra só o catálogo.
|
|
47
|
+
perm: [
|
|
48
|
+
"permissions:read:any",
|
|
49
|
+
"resources:read:any",
|
|
50
|
+
"actions:read:any",
|
|
51
|
+
"scopes:read:any",
|
|
52
|
+
],
|
|
53
|
+
panel: <DefinitionsPanel />,
|
|
56
54
|
},
|
|
57
|
-
].filter((
|
|
55
|
+
].filter((tab) => tab.perm.some((code) => hasPermission(code)));
|
|
58
56
|
|
|
59
57
|
return (
|
|
60
58
|
<div>
|
|
@@ -62,15 +60,15 @@ export function RbacScreen(): JSX.Element {
|
|
|
62
60
|
{tabs.length > 0 && (
|
|
63
61
|
<Tabs defaultValue={tabs[0].value}>
|
|
64
62
|
<TabsList>
|
|
65
|
-
{tabs.map((
|
|
66
|
-
<TabsTrigger key={
|
|
67
|
-
{
|
|
63
|
+
{tabs.map((tab) => (
|
|
64
|
+
<TabsTrigger key={tab.value} value={tab.value}>
|
|
65
|
+
{tab.label}
|
|
68
66
|
</TabsTrigger>
|
|
69
67
|
))}
|
|
70
68
|
</TabsList>
|
|
71
|
-
{tabs.map((
|
|
72
|
-
<TabsContent key={
|
|
73
|
-
{
|
|
69
|
+
{tabs.map((tab) => (
|
|
70
|
+
<TabsContent key={tab.value} value={tab.value}>
|
|
71
|
+
{tab.panel}
|
|
74
72
|
</TabsContent>
|
|
75
73
|
))}
|
|
76
74
|
</Tabs>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { api } from "#core/_services/api/axios.factory";
|
|
6
|
+
import { useSocket } from "#core/contexts/socket-context";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Eventos que a fila do backend empurra (ver `job.constants.ts` do
|
|
10
|
+
* `rl-core-api`). Saem pela mesma conexão das notificações — o hook não abre
|
|
11
|
+
* socket nenhum.
|
|
12
|
+
*/
|
|
13
|
+
export const JOB_PROGRESS_EVENT = "job:progress";
|
|
14
|
+
export const JOB_COMPLETED_EVENT = "job:completed";
|
|
15
|
+
export const JOB_FAILED_EVENT = "job:failed";
|
|
16
|
+
|
|
17
|
+
export type JobState = "idle" | "running" | "completed" | "failed";
|
|
18
|
+
|
|
19
|
+
interface JobProgressEvent {
|
|
20
|
+
jobId: string;
|
|
21
|
+
name: string;
|
|
22
|
+
processed: number;
|
|
23
|
+
total: number;
|
|
24
|
+
percent: number;
|
|
25
|
+
message?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface JobCompletedEvent<TSummary> {
|
|
29
|
+
jobId: string;
|
|
30
|
+
name: string;
|
|
31
|
+
summary: TSummary;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface JobFailedEvent {
|
|
35
|
+
jobId: string;
|
|
36
|
+
name: string;
|
|
37
|
+
errorCode: string | null;
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `GET /jobs/:id` — a mesma informação, para quem perdeu os eventos. */
|
|
42
|
+
interface JobStatusResponse<TSummary> {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
state: "waiting" | "active" | "completed" | "failed" | "delayed" | "unknown";
|
|
46
|
+
progress: {
|
|
47
|
+
processed: number;
|
|
48
|
+
total: number;
|
|
49
|
+
percent: number;
|
|
50
|
+
message?: string;
|
|
51
|
+
} | null;
|
|
52
|
+
summary: TSummary | null;
|
|
53
|
+
error: { code: string | null; message: string } | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UseJobProgressResult<TSummary> {
|
|
57
|
+
state: JobState;
|
|
58
|
+
percent: number;
|
|
59
|
+
processed: number;
|
|
60
|
+
total: number;
|
|
61
|
+
message: string | null;
|
|
62
|
+
/** Relatório que o processador devolveu — só depois de `completed`. */
|
|
63
|
+
summary: TSummary | null;
|
|
64
|
+
error: { code: string | null; message: string } | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const IDLE = {
|
|
68
|
+
state: "idle" as JobState,
|
|
69
|
+
percent: 0,
|
|
70
|
+
processed: 0,
|
|
71
|
+
total: 0,
|
|
72
|
+
message: null,
|
|
73
|
+
summary: null,
|
|
74
|
+
error: null,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Acompanha um job em segundo plano.
|
|
79
|
+
*
|
|
80
|
+
* ```tsx
|
|
81
|
+
* const [jobId, setJobId] = useState<string | null>(null);
|
|
82
|
+
* const job = useJobProgress<ImportSummary>(jobId);
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* Três coisas que o evento sozinho não resolve, e por isso o hook também
|
|
86
|
+
* consulta `GET /jobs/:id`:
|
|
87
|
+
*
|
|
88
|
+
* - o job pode ter terminado **antes** de a tela assinar (arquivo pequeno);
|
|
89
|
+
* - a aba pode ter sido recarregada no meio, com o `jobId` guardado;
|
|
90
|
+
* - o socket pode cair, e o que passou na janela offline não volta sozinho —
|
|
91
|
+
* por isso a releitura também acontece a cada reconexão.
|
|
92
|
+
*/
|
|
93
|
+
export function useJobProgress<TSummary = unknown>(
|
|
94
|
+
jobId: string | null,
|
|
95
|
+
): UseJobProgressResult<TSummary> {
|
|
96
|
+
const { on, reconnectCount } = useSocket();
|
|
97
|
+
const [result, setResult] = useState<UseJobProgressResult<TSummary>>(
|
|
98
|
+
IDLE as UseJobProgressResult<TSummary>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Job novo começa do zero: sem isto a barra do anterior ficaria em 100%.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
setResult(IDLE as UseJobProgressResult<TSummary>);
|
|
104
|
+
}, [jobId]);
|
|
105
|
+
|
|
106
|
+
const sync = useCallback(async (): Promise<void> => {
|
|
107
|
+
if (!jobId) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const { data } = await api.get<JobStatusResponse<TSummary>>(
|
|
112
|
+
`/jobs/${jobId}`,
|
|
113
|
+
);
|
|
114
|
+
setResult({
|
|
115
|
+
state:
|
|
116
|
+
data.state === "completed"
|
|
117
|
+
? "completed"
|
|
118
|
+
: data.state === "failed"
|
|
119
|
+
? "failed"
|
|
120
|
+
: "running",
|
|
121
|
+
percent: data.progress?.percent ?? 0,
|
|
122
|
+
processed: data.progress?.processed ?? 0,
|
|
123
|
+
total: data.progress?.total ?? 0,
|
|
124
|
+
message: data.progress?.message ?? null,
|
|
125
|
+
summary: data.summary,
|
|
126
|
+
error: data.error,
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
// Job expirado ou fora de alcance: o socket ainda pode trazer o fim, e
|
|
130
|
+
// derrubar a tela por causa da consulta de apoio seria pior.
|
|
131
|
+
}
|
|
132
|
+
}, [jobId]);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
void sync();
|
|
136
|
+
}, [sync, reconnectCount]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!jobId) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const offProgress = on<JobProgressEvent>(JOB_PROGRESS_EVENT, (data) => {
|
|
144
|
+
if (data.jobId !== jobId) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
setResult((current) => ({
|
|
148
|
+
...current,
|
|
149
|
+
state: "running",
|
|
150
|
+
percent: data.percent,
|
|
151
|
+
processed: data.processed,
|
|
152
|
+
total: data.total,
|
|
153
|
+
message: data.message ?? null,
|
|
154
|
+
}));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const offCompleted = on<JobCompletedEvent<TSummary>>(
|
|
158
|
+
JOB_COMPLETED_EVENT,
|
|
159
|
+
(data) => {
|
|
160
|
+
if (data.jobId !== jobId) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
setResult((current) => ({
|
|
164
|
+
...current,
|
|
165
|
+
state: "completed",
|
|
166
|
+
percent: 100,
|
|
167
|
+
summary: data.summary,
|
|
168
|
+
error: null,
|
|
169
|
+
}));
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const offFailed = on<JobFailedEvent>(JOB_FAILED_EVENT, (data) => {
|
|
174
|
+
if (data.jobId !== jobId) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
setResult((current) => ({
|
|
178
|
+
...current,
|
|
179
|
+
state: "failed",
|
|
180
|
+
error: { code: data.errorCode, message: data.message },
|
|
181
|
+
}));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return () => {
|
|
185
|
+
offProgress();
|
|
186
|
+
offCompleted();
|
|
187
|
+
offFailed();
|
|
188
|
+
};
|
|
189
|
+
}, [jobId, on]);
|
|
190
|
+
|
|
191
|
+
return result;
|
|
192
|
+
}
|
package/src/i18n/messages/en.ts
CHANGED
|
@@ -11,6 +11,7 @@ export const en: Messages = {
|
|
|
11
11
|
profile: "My profile",
|
|
12
12
|
logs: "Logs",
|
|
13
13
|
audit: "Audit",
|
|
14
|
+
queues: "Queues",
|
|
14
15
|
},
|
|
15
16
|
logs: {
|
|
16
17
|
outcomeSuccess: "Success",
|
|
@@ -84,6 +85,31 @@ export const en: Messages = {
|
|
|
84
85
|
permissions: "Permissions",
|
|
85
86
|
roles: "Roles",
|
|
86
87
|
},
|
|
88
|
+
job: {
|
|
89
|
+
running: "Processing…",
|
|
90
|
+
completed: "Processing finished",
|
|
91
|
+
failed: "The processing could not be completed",
|
|
92
|
+
},
|
|
93
|
+
queues: {
|
|
94
|
+
title: "Queues",
|
|
95
|
+
subtitle: "Background processing — what is running and what failed",
|
|
96
|
+
job: "Job",
|
|
97
|
+
state: "State",
|
|
98
|
+
progress: "Progress",
|
|
99
|
+
attempts: "Attempts",
|
|
100
|
+
createdAt: "Created at",
|
|
101
|
+
reason: "Failure reason",
|
|
102
|
+
retry: "Retry",
|
|
103
|
+
removeTitle: "Remove this job?",
|
|
104
|
+
removeDescription:
|
|
105
|
+
"It leaves the queue and can no longer be retried from here.",
|
|
106
|
+
empty: "No jobs in this state",
|
|
107
|
+
stateWaiting: "Waiting",
|
|
108
|
+
stateActive: "Active",
|
|
109
|
+
stateCompleted: "Completed",
|
|
110
|
+
stateFailed: "Failed",
|
|
111
|
+
stateDelayed: "Delayed",
|
|
112
|
+
},
|
|
87
113
|
table: {
|
|
88
114
|
columns: "Columns",
|
|
89
115
|
columnsButton: "Columns",
|
|
@@ -93,6 +119,7 @@ export const en: Messages = {
|
|
|
93
119
|
results: "results",
|
|
94
120
|
perPage: "per page",
|
|
95
121
|
empty: "No records",
|
|
122
|
+
emptyFiltered: "No records match this filter",
|
|
96
123
|
},
|
|
97
124
|
filters: {
|
|
98
125
|
button: "Filters",
|
|
@@ -170,6 +197,7 @@ export const en: Messages = {
|
|
|
170
197
|
no: "No",
|
|
171
198
|
actions: "Actions",
|
|
172
199
|
loading: "Loading...",
|
|
200
|
+
description: "Description",
|
|
173
201
|
language: "Language",
|
|
174
202
|
theme: "Theme",
|
|
175
203
|
light: "Light",
|
|
@@ -295,6 +323,23 @@ export const en: Messages = {
|
|
|
295
323
|
title: "Roles & Permissions",
|
|
296
324
|
manager: "Manager",
|
|
297
325
|
noManager: "No manager",
|
|
326
|
+
tabs: {
|
|
327
|
+
roles: "Roles",
|
|
328
|
+
groups: "Groups",
|
|
329
|
+
definitions: "Definitions",
|
|
330
|
+
permissions: "Permissions",
|
|
331
|
+
resources: "Areas",
|
|
332
|
+
actions: "Actions",
|
|
333
|
+
scopes: "Scopes",
|
|
334
|
+
},
|
|
335
|
+
roleName: "Role name",
|
|
336
|
+
screensAndPermissions: "Areas and permissions",
|
|
337
|
+
searchPermissions: "Search area or permission…",
|
|
338
|
+
noPermissionsFound: "No permissions found",
|
|
339
|
+
noPermissions: "No permissions granted",
|
|
340
|
+
permissionCount: "{count} permissions across {screens} areas",
|
|
341
|
+
definitionsHint:
|
|
342
|
+
"The building blocks of a permission code. The seed creates them — only touch this when adding a new resource.",
|
|
298
343
|
},
|
|
299
344
|
validation: {
|
|
300
345
|
required: "Required field",
|
|
@@ -312,6 +357,13 @@ export const en: Messages = {
|
|
|
312
357
|
},
|
|
313
358
|
},
|
|
314
359
|
errors: {
|
|
360
|
+
notFoundTitle: "Page not found",
|
|
361
|
+
notFoundDescription:
|
|
362
|
+
"The address you opened does not exist or has moved. Check the link and try again.",
|
|
363
|
+
forbiddenTitle: "You don't have access to this area",
|
|
364
|
+
forbiddenDescription:
|
|
365
|
+
"Your account lacks permission to open this screen. Ask a system administrator for access.",
|
|
366
|
+
backToDashboard: "Back to home",
|
|
315
367
|
unexpected: "Unexpected error",
|
|
316
368
|
codes: {
|
|
317
369
|
ACCOUNT_INACTIVE:
|