rl-core-front 0.13.0 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rl-core-front",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Telas e componentes Next.js do core: login com 2FA, usu\u00e1rios, RBAC, auditoria, logs e listagens com filtro din\u00e2mico",
5
5
  "author": "Rodrigo Liberti",
6
6
  "license": "MIT",
@@ -0,0 +1,65 @@
1
+ /** Um degrau da migalha. Sem `href` quando não há para onde ir. */
2
+ export interface BreadcrumbStep {
3
+ label: string;
4
+ href?: string;
5
+ }
6
+
7
+ /** O que a migalha precisa saber de um item de menu. */
8
+ export interface BreadcrumbNavItem {
9
+ label: string;
10
+ href?: string;
11
+ children?: BreadcrumbNavItem[];
12
+ }
13
+
14
+ /**
15
+ * A trilha do caminho atual, lida do **menu** e não da URL.
16
+ *
17
+ * A URL é onde a tela mora; o menu é onde a pessoa acha que está. Os dois
18
+ * combinam na maioria das telas e divergem justamente nas reorganizadas: uma
19
+ * tela em `Cadastros › Funkos` pode morar em `/funkos/catalogos`, e a migalha
20
+ * por URL mostrava "funkos › catalogos" — dois nomes que não estão em lugar
21
+ * nenhum do menu.
22
+ *
23
+ * Rota fora do menu (link direto, detalhe de um registro) cai nos segmentos da
24
+ * URL: melhor um nome cru do que nenhum rastro.
25
+ *
26
+ * Grupo que só abre e fecha entra sem `href` — vira texto, e não link para uma
27
+ * rota que não existe.
28
+ */
29
+ export const breadcrumbTrail = (
30
+ pathname: string,
31
+ navItems: readonly BreadcrumbNavItem[] | undefined,
32
+ segmentLabels: Record<string, string> = {},
33
+ ): BreadcrumbStep[] => {
34
+ const fromMenu = findInMenu(pathname, navItems ?? []);
35
+ if (fromMenu) {
36
+ return fromMenu;
37
+ }
38
+
39
+ const segments = pathname.split("/").filter(Boolean);
40
+ return segments.map((segment, index) => ({
41
+ label: segmentLabels[segment] ?? capitalize(segment),
42
+ href: "/" + segments.slice(0, index + 1).join("/"),
43
+ }));
44
+ };
45
+
46
+ /** Busca em profundidade: o primeiro item com o `href` pedido ganha. */
47
+ const findInMenu = (
48
+ pathname: string,
49
+ items: readonly BreadcrumbNavItem[],
50
+ ): BreadcrumbStep[] | null => {
51
+ for (const item of items) {
52
+ if (item.href === pathname) {
53
+ return [{ label: item.label, href: item.href }];
54
+ }
55
+
56
+ const below = findInMenu(pathname, item.children ?? []);
57
+ if (below) {
58
+ return [{ label: item.label, href: item.href }, ...below];
59
+ }
60
+ }
61
+ return null;
62
+ };
63
+
64
+ const capitalize = (text: string): string =>
65
+ text.charAt(0).toUpperCase() + text.slice(1);
@@ -557,7 +557,7 @@ export function AppShell({
557
557
  )}
558
558
  >
559
559
  <div className="p-4 md:p-8">
560
- {!routeBlocked && <Breadcrumbs />}
560
+ {!routeBlocked && <Breadcrumbs navItems={navItems} />}
561
561
  {routeBlocked ? (forbidden ?? <ForbiddenScreen />) : children}
562
562
  </div>
563
563
  </main>
@@ -4,8 +4,18 @@ import { ChevronRight, Home } from "lucide-react";
4
4
  import { usePathname, useRouter } from "next/navigation";
5
5
  import type { JSX } from "react";
6
6
 
7
+ import {
8
+ type BreadcrumbNavItem,
9
+ breadcrumbTrail,
10
+ } from "#core/_utils/breadcrumb-trail";
7
11
  import { useI18n } from "#core/contexts";
8
12
 
13
+ /**
14
+ * Rótulo das telas do próprio core — o projeto não precisa cadastrá-las.
15
+ *
16
+ * Vale só para o caminho de exceção: tela que está no menu tem o rótulo do
17
+ * menu, que é o que a pessoa acabou de clicar.
18
+ */
9
19
  const SEGMENT_LABELS: Record<string, string> = {
10
20
  dashboard: "nav.dashboard",
11
21
  usuarios: "nav.users",
@@ -14,17 +24,17 @@ const SEGMENT_LABELS: Record<string, string> = {
14
24
  perfil: "nav.profile",
15
25
  };
16
26
 
17
- export function Breadcrumbs(): JSX.Element {
27
+ export interface BreadcrumbsProps {
28
+ /** O mesmo menu do `AppShell`: é dele que sai a trilha. */
29
+ navItems?: BreadcrumbNavItem[];
30
+ }
31
+
32
+ export function Breadcrumbs({ navItems }: BreadcrumbsProps): JSX.Element {
18
33
  const pathname = usePathname();
19
34
  const router = useRouter();
20
35
  const { t } = useI18n();
21
36
 
22
- const segments = pathname.split("/").filter(Boolean);
23
- const crumbs = segments.map((seg, i) => ({
24
- label: SEGMENT_LABELS[seg] ? t(SEGMENT_LABELS[seg]) : seg,
25
- href: "/" + segments.slice(0, i + 1).join("/"),
26
- isLast: i === segments.length - 1,
27
- }));
37
+ const trail = breadcrumbTrail(pathname, navItems, SEGMENT_LABELS);
28
38
 
29
39
  return (
30
40
  <nav className="mb-4 flex items-center gap-1.5 text-sm text-muted-foreground">
@@ -35,21 +45,32 @@ export function Breadcrumbs(): JSX.Element {
35
45
  <Home className="h-4 w-4" />
36
46
  {t("nav.home")}
37
47
  </button>
38
- {crumbs.map((c) => (
39
- <span key={c.href} className="flex items-center gap-1.5">
40
- <ChevronRight className="h-4 w-4 text-muted-foreground/50" />
41
- {c.isLast ? (
42
- <span className="font-semibold text-foreground">{c.label}</span>
43
- ) : (
44
- <button
45
- onClick={() => router.push(c.href)}
46
- className="transition-colors hover:text-foreground"
47
- >
48
- {c.label}
49
- </button>
50
- )}
51
- </span>
52
- ))}
48
+ {trail.map((step, index) => {
49
+ const isLast = index === trail.length - 1;
50
+ return (
51
+ <span key={`${step.href ?? step.label}`} className="flex items-center gap-1.5">
52
+ <ChevronRight className="h-4 w-4 text-muted-foreground/50" />
53
+ {isLast || !step.href ? (
54
+ // Sem `href` é grupo do menu: abre e fecha, não navega. Virar
55
+ // botão aqui prometia uma tela que não existe.
56
+ <span
57
+ className={
58
+ isLast ? "font-semibold text-foreground" : undefined
59
+ }
60
+ >
61
+ {t(step.label)}
62
+ </span>
63
+ ) : (
64
+ <button
65
+ onClick={() => router.push(step.href as string)}
66
+ className="transition-colors hover:text-foreground"
67
+ >
68
+ {t(step.label)}
69
+ </button>
70
+ )}
71
+ </span>
72
+ );
73
+ })}
53
74
  </nav>
54
75
  );
55
76
  }
@@ -4,6 +4,7 @@ import type { JSX } from "react";
4
4
 
5
5
  import type { FilterDraftValue, FilterField } from "#core/_utils/filter";
6
6
  import { Checkbox } from "#core/components/ui/checkbox";
7
+ import { Combobox } from "#core/components/ui/combobox";
7
8
  import { DateRangePicker } from "#core/components/ui/date-range-picker";
8
9
  import { Input } from "#core/components/ui/input";
9
10
  import { Label } from "#core/components/ui/label";
@@ -99,6 +100,42 @@ export function FilterFieldControl({
99
100
  });
100
101
  };
101
102
 
103
+ /*
104
+ * `SELECT` é lista de registro, e lista de registro não tem tamanho: as
105
+ * linhas de uma coleção são trinta e as coleções, cem. Em caixas de marcar,
106
+ * dois campos desses tomavam o painel inteiro e o resto do filtro ficava
107
+ * abaixo da dobra. O `Combobox` ocupa uma linha, traz busca e mostra o que
108
+ * foi escolhido em etiquetas.
109
+ *
110
+ * `ENUM` fica como está: é conjunto fechado e curto — status, tipo de
111
+ * aquisição —, e ali ver as opções todas de uma vez vale mais do que
112
+ * economizar duas linhas.
113
+ */
114
+ if (field.type === "SELECT") {
115
+ return (
116
+ <div className="space-y-2">
117
+ <span className="text-sm font-medium">{label}</span>
118
+ {field.options?.length ? (
119
+ <Combobox
120
+ multiple
121
+ clearable
122
+ options={field.options.map((option) => ({
123
+ id: option.value,
124
+ name: option.label,
125
+ }))}
126
+ value={selected}
127
+ onChange={(values) => onChange({ values })}
128
+ placeholder={t("filters.all")}
129
+ />
130
+ ) : (
131
+ <p className="text-sm text-muted-foreground">
132
+ {t("filters.noOptions")}
133
+ </p>
134
+ )}
135
+ </div>
136
+ );
137
+ }
138
+
102
139
  return (
103
140
  <fieldset className="space-y-2">
104
141
  <legend className="text-sm font-medium">{label}</legend>