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.
|
|
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
|
|
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
|
|
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
|
-
{
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
{
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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>
|