rl-core-front 0.7.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.
@@ -5,6 +5,7 @@ import {
5
5
  ChevronRight,
6
6
  History,
7
7
  LayoutDashboard,
8
+ ListChecks,
8
9
  LogOut,
9
10
  Menu,
10
11
  ScrollText,
@@ -51,6 +52,23 @@ interface NavItem {
51
52
  perm: string | string[] | null;
52
53
  }
53
54
 
55
+ /**
56
+ * Item de menu que abre e fecha em vez de navegar.
57
+ *
58
+ * O "Administração" era isto escrito à mão dentro do componente; agora é só o
59
+ * primeiro cliente do grupo genérico, e o projeto declara os dele por
60
+ * `AppNavItem.children`.
61
+ */
62
+ interface NavGroup {
63
+ key: string;
64
+ icon: React.ComponentType<{ className?: string }>;
65
+ children: NavItem[];
66
+ }
67
+
68
+ type NavEntry = NavItem | NavGroup;
69
+
70
+ const isGroup = (entry: NavEntry): entry is NavGroup => "children" in entry;
71
+
54
72
  /**
55
73
  * Item de menu declarado pelo projeto.
56
74
  *
@@ -69,7 +87,14 @@ export interface AppNavItem {
69
87
  * funcionam — o projeto só precisa do dicionário quando for bilíngue.
70
88
  */
71
89
  label: string;
72
- href: string;
90
+ /**
91
+ * Rota da tela. Opcional **só** em item com `children`: o pai de um grupo
92
+ * abre e fecha, não navega.
93
+ *
94
+ * Pai que navega e expande fica ambíguo no celular, onde o toque faz as duas
95
+ * coisas — por isso o grupo não herda rota de filho nenhum.
96
+ */
97
+ href?: string;
73
98
  icon: React.ComponentType<{ className?: string }>;
74
99
  /**
75
100
  * Permissão exigida para o item aparecer. Ausente, aparece para todos.
@@ -78,8 +103,34 @@ export interface AppNavItem {
78
103
  * atender dois escopos, como `users:read:any` e `users:read:team`.
79
104
  */
80
105
  permission?: string | string[] | null;
106
+ /**
107
+ * Telas filhas, recolhidas sob o pai — o mesmo comportamento que o grupo de
108
+ * administração já tinha.
109
+ *
110
+ * ```tsx
111
+ * { label: "nav.funkos", icon: Package, children: [
112
+ * { label: "nav.funkosCollection", href: "/funkos", icon: Boxes },
113
+ * { label: "nav.funkosCatalogs", href: "/funkos/catalogos", icon: Tags },
114
+ * ] }
115
+ * ```
116
+ *
117
+ * A permissão continua valendo por item: cada filho é filtrado pelo que
118
+ * declarou, e pai que ficou sem nenhum filho visível não é desenhado — grupo
119
+ * vazio é uma seta que não abre nada.
120
+ *
121
+ * Um nível só. Filho de filho não é menu lateral, é sitemap.
122
+ */
123
+ children?: AppNavItem[];
81
124
  }
82
125
 
126
+ /** Item declarado pelo projeto no formato interno do menu. */
127
+ const toNavItem = (item: AppNavItem & { href: string }): NavItem => ({
128
+ key: item.label,
129
+ href: item.href,
130
+ icon: item.icon,
131
+ perm: item.permission ?? null,
132
+ });
133
+
83
134
  const DASHBOARD: NavItem = {
84
135
  key: "nav.dashboard",
85
136
  href: "/dashboard",
@@ -103,6 +154,14 @@ const ADMIN_CHILDREN: NavItem[] = [
103
154
  },
104
155
  { key: "nav.rbac", href: "/rbac", icon: ShieldCheck, perm: "roles:read:any" },
105
156
  { key: "nav.logs", href: "/logs", icon: ScrollText, perm: "audit:read:any" },
157
+ // Só aparece para quem opera a fila — em ambiente sem Redis ninguém tem a
158
+ // permissão, e o item some sozinho.
159
+ {
160
+ key: "nav.queues",
161
+ href: "/filas",
162
+ icon: ListChecks,
163
+ perm: "queues:read:any",
164
+ },
106
165
  // Restrito a administradores — só quem tem audit:read-trail:any (só Super Admin) vê este item.
107
166
  {
108
167
  key: "nav.audit",
@@ -157,18 +216,39 @@ export function AppShell({
157
216
  [user],
158
217
  );
159
218
 
160
- // Os itens do projeto passam pelo mesmo filtro de permissão dos do core: um
161
- // menu que mostra o que o usuário não pode abrir só entrega 403.
162
- const projectItems = useMemo<NavItem[]>(
219
+ const adminGroup: NavGroup = {
220
+ key: "nav.admin",
221
+ icon: Settings,
222
+ children: adminChildren,
223
+ };
224
+
225
+ /**
226
+ * Os itens do projeto passam pelo mesmo filtro de permissão dos do core: um
227
+ * menu que mostra o que o usuário não pode abrir só entrega 403.
228
+ *
229
+ * O filtro é por item, inclusive dentro do grupo: um pai cujo último filho
230
+ * visível sumiu não é desenhado, e um pai sem `href` também não vira link
231
+ * solto — grupo vazio é uma seta que não abre nada.
232
+ */
233
+ const projectEntries = useMemo<NavEntry[]>(
163
234
  () =>
164
235
  (navItems ?? [])
165
236
  .filter((item) => allows(item.permission ?? null))
166
- .map((item) => ({
167
- key: item.label,
168
- href: item.href,
169
- icon: item.icon,
170
- perm: item.permission ?? null,
171
- })),
237
+ .map((item): NavEntry | null => {
238
+ if (item.children?.length) {
239
+ const children = item.children
240
+ .filter((child) => allows(child.permission ?? null))
241
+ .filter((child): child is AppNavItem & { href: string } =>
242
+ Boolean(child.href),
243
+ )
244
+ .map(toNavItem);
245
+ return children.length > 0
246
+ ? { key: item.label, icon: item.icon, children }
247
+ : null;
248
+ }
249
+ return item.href ? toNavItem({ ...item, href: item.href }) : null;
250
+ })
251
+ .filter((entry): entry is NavEntry => entry !== null),
172
252
  // eslint-disable-next-line react-hooks/exhaustive-deps
173
253
  [user, navItems],
174
254
  );
@@ -190,20 +270,39 @@ export function AppShell({
190
270
  DASHBOARD,
191
271
  PROFILE,
192
272
  ...ADMIN_CHILDREN,
193
- ...(navItems ?? []).map((item) => ({
194
- key: item.label,
195
- href: item.href,
196
- icon: item.icon,
197
- perm: item.permission ?? null,
198
- })),
273
+ // Achata pai e filhos: a rota de um filho precisa ser barrada como
274
+ // qualquer outra, e o pai de grupo não tem rota para checar.
275
+ ...(navItems ?? [])
276
+ .flatMap((item) => [item, ...(item.children ?? [])])
277
+ .filter((item): item is AppNavItem & { href: string } =>
278
+ Boolean(item.href),
279
+ )
280
+ .map(toNavItem),
199
281
  ];
200
282
  const current = known.find((item) => item.href === pathname);
201
283
  return current ? !allows(current.perm) : false;
202
284
  // eslint-disable-next-line react-hooks/exhaustive-deps
203
285
  }, [user, navItems, pathname]);
204
286
 
205
- const adminActive = adminChildren.some((c) => pathname === c.href);
206
- const [adminOpen, setAdminOpen] = useState(adminActive);
287
+ /**
288
+ * Aberto/fechado por grupo, e não um booleano só como era com o
289
+ * "Administração" sozinho.
290
+ *
291
+ * Sem entrada no mapa, o grupo nasce aberto quando a rota atual é de um
292
+ * filho — o que o `adminActive` já fazia. A entrada aparece no primeiro
293
+ * clique: a partir daí manda a escolha do usuário, não a rota.
294
+ */
295
+ const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
296
+
297
+ const isGroupOpen = (group: NavGroup): boolean =>
298
+ openGroups[group.key] ??
299
+ group.children.some((child) => pathname === child.href);
300
+
301
+ const toggleGroup = (group: NavGroup): void =>
302
+ setOpenGroups((current) => ({
303
+ ...current,
304
+ [group.key]: !isGroupOpen(group),
305
+ }));
207
306
 
208
307
  useEffect(() => {
209
308
  if (!loading && !user) {router.replace("/login");}
@@ -241,17 +340,29 @@ export function AppShell({
241
340
  item,
242
341
  mini,
243
342
  nested,
343
+ parentKey,
244
344
  }: {
245
345
  item: NavItem;
246
346
  mini: boolean;
247
347
  nested?: boolean;
348
+ /**
349
+ * Grupo a que o item pertence, só para o tooltip do modo recolhido.
350
+ *
351
+ * Ali os filhos aparecem soltos, sem o pai: "Catálogos" sozinho, ao lado de
352
+ * "Tarefas", não diria de quê. O caminho no tooltip resolve isso sem
353
+ * obrigar o rótulo a repetir o nome da feature no menu aberto.
354
+ */
355
+ parentKey?: string;
248
356
  }): JSX.Element => {
249
357
  const active = pathname === item.href;
250
358
  const Icon = item.icon;
359
+ const tooltip = parentKey
360
+ ? `${t(parentKey)} › ${t(item.key)}`
361
+ : t(item.key);
251
362
  return (
252
363
  <button
253
364
  onClick={() => go(item.href)}
254
- title={mini ? t(item.key) : undefined}
365
+ title={mini ? tooltip : undefined}
255
366
  className={cn(
256
367
  "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
257
368
  mini && "justify-center px-2",
@@ -267,46 +378,70 @@ export function AppShell({
267
378
  );
268
379
  };
269
380
 
381
+ /**
382
+ * Um grupo do menu — o pai que abre e fecha, e os filhos indentados.
383
+ *
384
+ * Com a barra recolhida não há onde desenhar seta nem rótulo, então os
385
+ * filhos aparecem soltos, separados do resto; é o que o "Administração" já
386
+ * fazia, e agora vale para qualquer grupo.
387
+ */
388
+ const NavGroupBlock = ({
389
+ group,
390
+ mini,
391
+ }: {
392
+ group: NavGroup;
393
+ mini: boolean;
394
+ }): JSX.Element => {
395
+ const Icon = group.icon;
396
+ const open = isGroupOpen(group);
397
+ if (mini) {
398
+ return (
399
+ <>
400
+ <Separator className="my-2" />
401
+ {group.children.map((child) => (
402
+ <NavLink key={child.href} item={child} mini parentKey={group.key} />
403
+ ))}
404
+ </>
405
+ );
406
+ }
407
+ return (
408
+ <div className="mt-2">
409
+ <button
410
+ onClick={() => toggleGroup(group)}
411
+ className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
412
+ >
413
+ <Icon className="h-5 w-5 shrink-0" />
414
+ <span className="flex-1 text-left">{t(group.key)}</span>
415
+ <ChevronRight
416
+ className={cn("h-4 w-4 transition-transform", open && "rotate-90")}
417
+ />
418
+ </button>
419
+ {open && (
420
+ <div className="mt-1 flex flex-col gap-1">
421
+ {group.children.map((child) => (
422
+ <NavLink key={child.href} item={child} mini={false} nested />
423
+ ))}
424
+ </div>
425
+ )}
426
+ </div>
427
+ );
428
+ };
429
+
270
430
  const NavContent = ({ mini }: { mini: boolean }): JSX.Element => (
271
431
  <div className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden p-3">
272
432
  <NavLink item={DASHBOARD} mini={mini} />
273
433
 
274
- {projectItems.map((item) => (
275
- <NavLink key={item.href} item={item} mini={mini} />
276
- ))}
277
-
278
- {adminChildren.length > 0 &&
279
- (mini ? (
280
- <>
281
- <Separator className="my-2" />
282
- {adminChildren.map((c) => (
283
- <NavLink key={c.href} item={c} mini />
284
- ))}
285
- </>
434
+ {projectEntries.map((entry) =>
435
+ isGroup(entry) ? (
436
+ <NavGroupBlock key={entry.key} group={entry} mini={mini} />
286
437
  ) : (
287
- <div className="mt-2">
288
- <button
289
- onClick={() => setAdminOpen((v) => !v)}
290
- className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
291
- >
292
- <Settings className="h-5 w-5 shrink-0" />
293
- <span className="flex-1 text-left">{t("nav.admin")}</span>
294
- <ChevronRight
295
- className={cn(
296
- "h-4 w-4 transition-transform",
297
- adminOpen && "rotate-90",
298
- )}
299
- />
300
- </button>
301
- {adminOpen && (
302
- <div className="mt-1 flex flex-col gap-1">
303
- {adminChildren.map((c) => (
304
- <NavLink key={c.href} item={c} mini={false} nested />
305
- ))}
306
- </div>
307
- )}
308
- </div>
309
- ))}
438
+ <NavLink key={entry.href} item={entry} mini={mini} />
439
+ ),
440
+ )}
441
+
442
+ {adminChildren.length > 0 && (
443
+ <NavGroupBlock group={adminGroup} mini={mini} />
444
+ )}
310
445
 
311
446
  <div className="mt-1">
312
447
  <NavLink item={PROFILE} mini={mini} />
@@ -111,7 +111,15 @@ const DialogFooter = ({
111
111
  }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element => (
112
112
  <div
113
113
  className={cn(
114
- "flex shrink-0 flex-col-reverse gap-2 border-t border-border pt-4 sm:flex-row sm:justify-end",
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",
115
123
  className,
116
124
  )}
117
125
  {...props}
@@ -266,11 +274,14 @@ const DialogForm = React.forwardRef<
266
274
  >
267
275
  {/* `min-h-0` é o que permite encolher dentro do flex e ativar o scroll:
268
276
  sem ele o corpo assume a altura do conteúdo e estoura o modal. */}
269
- {/* `px-1` reserva a folga que o anel de foco ocupa: o `ring` é
270
- desenhado fora da borda do input, e sem essa margem o container
271
- de scroll corta o anel do primeiro e do último campo.
272
- `-mx-1` devolve o recuo para o conteúdo seguir alinhado. */}
273
- <div className="-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1 [scrollbar-width:thin]">
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]">
274
285
  {hasFooter ? items.slice(0, footerAt) : items}
275
286
  </div>
276
287
  {hasFooter && items.slice(footerAt)}
@@ -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
+ }
@@ -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
+ }