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.
@@ -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,
@@ -36,6 +37,7 @@ import {
36
37
  } from "#core/components/ui";
37
38
  import { UserAvatar } from "#core/components/user-avatar";
38
39
  import { useAuth, useBrand, useI18n } from "#core/contexts";
40
+ import { ForbiddenScreen } from "#core/features/errors/forbidden-screen";
39
41
  import { NotificationsCenter } from "#core/features/notifications/notifications-center";
40
42
  import { ForcePasswordChange } from "#core/features/profile/force-password-change";
41
43
  import { cn } from "#core/lib/utils";
@@ -50,6 +52,23 @@ interface NavItem {
50
52
  perm: string | string[] | null;
51
53
  }
52
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
+
53
72
  /**
54
73
  * Item de menu declarado pelo projeto.
55
74
  *
@@ -68,7 +87,14 @@ export interface AppNavItem {
68
87
  * funcionam — o projeto só precisa do dicionário quando for bilíngue.
69
88
  */
70
89
  label: string;
71
- 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;
72
98
  icon: React.ComponentType<{ className?: string }>;
73
99
  /**
74
100
  * Permissão exigida para o item aparecer. Ausente, aparece para todos.
@@ -77,8 +103,34 @@ export interface AppNavItem {
77
103
  * atender dois escopos, como `users:read:any` e `users:read:team`.
78
104
  */
79
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[];
80
124
  }
81
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
+
82
134
  const DASHBOARD: NavItem = {
83
135
  key: "nav.dashboard",
84
136
  href: "/dashboard",
@@ -102,6 +154,14 @@ const ADMIN_CHILDREN: NavItem[] = [
102
154
  },
103
155
  { key: "nav.rbac", href: "/rbac", icon: ShieldCheck, perm: "roles:read:any" },
104
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
+ },
105
165
  // Restrito a administradores — só quem tem audit:read-trail:any (só Super Admin) vê este item.
106
166
  {
107
167
  key: "nav.audit",
@@ -115,9 +175,21 @@ export interface AppShellProps {
115
175
  children: React.ReactNode;
116
176
  /** Telas do projeto no menu, entre o dashboard e o grupo de administração. */
117
177
  navItems?: AppNavItem[];
178
+ /**
179
+ * O que mostrar quando a rota atual exige permissão que o usuário não tem.
180
+ *
181
+ * Padrão: a `ForbiddenScreen` do core. Passe outra para trocar a tela sem
182
+ * abrir mão do bloqueio — que é o ponto: esconder o item do menu não impede
183
+ * ninguém de digitar a URL.
184
+ */
185
+ forbidden?: React.ReactNode;
118
186
  }
119
187
 
120
- export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
188
+ export function AppShell({
189
+ children,
190
+ navItems,
191
+ forbidden,
192
+ }: AppShellProps): JSX.Element {
121
193
  const { user, loading, logout, hasPermission } = useAuth();
122
194
  const { t } = useI18n();
123
195
  const brand = useBrand();
@@ -144,23 +216,93 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
144
216
  [user],
145
217
  );
146
218
 
147
- // Os itens do projeto passam pelo mesmo filtro de permissão dos do core: um
148
- // menu que mostra o que o usuário não pode abrir só entrega 403.
149
- 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[]>(
150
234
  () =>
151
235
  (navItems ?? [])
152
236
  .filter((item) => allows(item.permission ?? null))
153
- .map((item) => ({
154
- key: item.label,
155
- href: item.href,
156
- icon: item.icon,
157
- perm: item.permission ?? null,
158
- })),
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),
159
252
  // eslint-disable-next-line react-hooks/exhaustive-deps
160
253
  [user, navItems],
161
254
  );
162
- const adminActive = adminChildren.some((c) => pathname === c.href);
163
- const [adminOpen, setAdminOpen] = useState(adminActive);
255
+ /**
256
+ * A rota atual exige permissão que o usuário não tem?
257
+ *
258
+ * Percorre **todas** as rotas conhecidas, não só as que sobreviveram ao
259
+ * filtro do menu: é justamente a que foi filtrada que precisa ser barrada
260
+ * aqui. Esconder o item nunca impediu ninguém de digitar a URL — sem este
261
+ * bloqueio a tela montava, chamava a API, tomava 403 e virava uma página
262
+ * vazia com um toast, em vez de dizer que falta acesso.
263
+ *
264
+ * Rota que não está em lista nenhuma passa: o shell não sabe o que ela exige,
265
+ * e chutar bloqueio esconderia tela legítima do projeto que não declarou
266
+ * `navItems`.
267
+ */
268
+ const routeBlocked = useMemo(() => {
269
+ const known: NavItem[] = [
270
+ DASHBOARD,
271
+ PROFILE,
272
+ ...ADMIN_CHILDREN,
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),
281
+ ];
282
+ const current = known.find((item) => item.href === pathname);
283
+ return current ? !allows(current.perm) : false;
284
+ // eslint-disable-next-line react-hooks/exhaustive-deps
285
+ }, [user, navItems, pathname]);
286
+
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
+ }));
164
306
 
165
307
  useEffect(() => {
166
308
  if (!loading && !user) {router.replace("/login");}
@@ -198,17 +340,29 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
198
340
  item,
199
341
  mini,
200
342
  nested,
343
+ parentKey,
201
344
  }: {
202
345
  item: NavItem;
203
346
  mini: boolean;
204
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;
205
356
  }): JSX.Element => {
206
357
  const active = pathname === item.href;
207
358
  const Icon = item.icon;
359
+ const tooltip = parentKey
360
+ ? `${t(parentKey)} › ${t(item.key)}`
361
+ : t(item.key);
208
362
  return (
209
363
  <button
210
364
  onClick={() => go(item.href)}
211
- title={mini ? t(item.key) : undefined}
365
+ title={mini ? tooltip : undefined}
212
366
  className={cn(
213
367
  "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
214
368
  mini && "justify-center px-2",
@@ -224,46 +378,70 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
224
378
  );
225
379
  };
226
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
+
227
430
  const NavContent = ({ mini }: { mini: boolean }): JSX.Element => (
228
431
  <div className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden p-3">
229
432
  <NavLink item={DASHBOARD} mini={mini} />
230
433
 
231
- {projectItems.map((item) => (
232
- <NavLink key={item.href} item={item} mini={mini} />
233
- ))}
234
-
235
- {adminChildren.length > 0 &&
236
- (mini ? (
237
- <>
238
- <Separator className="my-2" />
239
- {adminChildren.map((c) => (
240
- <NavLink key={c.href} item={c} mini />
241
- ))}
242
- </>
434
+ {projectEntries.map((entry) =>
435
+ isGroup(entry) ? (
436
+ <NavGroupBlock key={entry.key} group={entry} mini={mini} />
243
437
  ) : (
244
- <div className="mt-2">
245
- <button
246
- onClick={() => setAdminOpen((v) => !v)}
247
- 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"
248
- >
249
- <Settings className="h-5 w-5 shrink-0" />
250
- <span className="flex-1 text-left">{t("nav.admin")}</span>
251
- <ChevronRight
252
- className={cn(
253
- "h-4 w-4 transition-transform",
254
- adminOpen && "rotate-90",
255
- )}
256
- />
257
- </button>
258
- {adminOpen && (
259
- <div className="mt-1 flex flex-col gap-1">
260
- {adminChildren.map((c) => (
261
- <NavLink key={c.href} item={c} mini={false} nested />
262
- ))}
263
- </div>
264
- )}
265
- </div>
266
- ))}
438
+ <NavLink key={entry.href} item={entry} mini={mini} />
439
+ ),
440
+ )}
441
+
442
+ {adminChildren.length > 0 && (
443
+ <NavGroupBlock group={adminGroup} mini={mini} />
444
+ )}
267
445
 
268
446
  <div className="mt-1">
269
447
  <NavLink item={PROFILE} mini={mini} />
@@ -379,8 +557,8 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
379
557
  )}
380
558
  >
381
559
  <div className="p-4 md:p-8">
382
- <Breadcrumbs />
383
- {children}
560
+ {!routeBlocked && <Breadcrumbs />}
561
+ {routeBlocked ? (forbidden ?? <ForbiddenScreen />) : children}
384
562
  </div>
385
563
  </main>
386
564
  </div>
@@ -103,6 +103,15 @@ interface DataTableProps<T extends RowData> {
103
103
  * não os desmarca ao aplicar nem ao limpar: não são dele.
104
104
  */
105
105
  hiddenFilterFields?: string[];
106
+ /**
107
+ * Texto da tabela vazia, quando a listagem sabe algo que a tabela não sabe.
108
+ *
109
+ * Sem isto, "Nenhum registro" é a resposta para tudo — inclusive para
110
+ * listagem podada por permissão, onde os registros existem e é o alcance de
111
+ * quem olha que não chega neles. Uma tela vazia que não diz o motivo é
112
+ * indistinguível de bug, e manda o usuário procurar defeito onde não há.
113
+ */
114
+ emptyMessage?: string;
106
115
  }
107
116
 
108
117
  /** Extras que uma coluna pode declarar em `meta`. */
@@ -154,6 +163,7 @@ export function DataTable<T extends RowData>({
154
163
  enableSorting = true,
155
164
  filters,
156
165
  hiddenFilterFields,
166
+ emptyMessage,
157
167
  }: DataTableProps<T>): JSX.Element {
158
168
  const { t } = useI18n();
159
169
  const [colsOpen, setColsOpen] = useState(false);
@@ -232,6 +242,13 @@ export function DataTable<T extends RowData>({
232
242
  : 0
233
243
  : activeFilterCount(filters.values, hiddenFilterFields);
234
244
 
245
+ // Vazio com filtro aplicado não é "não há registros", é "não há *estes*
246
+ // registros" — e a diferença decide se o usuário vai limpar o filtro ou
247
+ // procurar um bug.
248
+ const emptyText =
249
+ emptyMessage ??
250
+ (activeFilters > 0 ? t("table.emptyFiltered") : t("table.empty"));
251
+
235
252
  return (
236
253
  <div>
237
254
  <div className="mb-3 flex items-center justify-between gap-2">
@@ -323,7 +340,7 @@ export function DataTable<T extends RowData>({
323
340
  colSpan={colSpan}
324
341
  className="h-24 text-center text-muted-foreground"
325
342
  >
326
- {t("table.empty")}
343
+ {emptyText}
327
344
  </TableCell>
328
345
  </TableRow>
329
346
  )}