rl-core-front 0.3.0 → 0.5.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 +1 -1
- package/src/_services/api/app-error-code.enum.ts +23 -0
- package/src/_services/api/schema.d.ts +86 -132
- package/src/_services/auth/index.ts +2 -0
- package/src/_utils/filter.ts +15 -34
- package/src/_utils/password.ts +40 -0
- package/src/_utils/permission.ts +14 -0
- package/src/_utils/storage.ts +64 -0
- package/src/_utils/user.ts +19 -0
- package/src/components/app-shell.tsx +85 -11
- package/src/components/brand-header.tsx +23 -8
- package/src/components/brand-panel.tsx +41 -0
- package/src/components/index.ts +1 -0
- package/src/components/password-field.tsx +49 -22
- package/src/components/ui/alert.tsx +34 -5
- package/src/components/ui/badge.tsx +2 -2
- package/src/components/ui/button.tsx +5 -0
- package/src/components/ui/confirm-dialog.tsx +3 -1
- package/src/components/ui/data-table.tsx +13 -4
- package/src/components/ui/dialog.tsx +120 -21
- package/src/components/ui/filter-sheet.tsx +47 -25
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/password-requirements.tsx +105 -0
- package/src/components/ui/segmented-control.tsx +75 -0
- package/src/components/ui/simple-select.tsx +74 -0
- package/src/components/ui/switch.tsx +3 -1
- package/src/components/ui/tabs.tsx +4 -8
- package/src/components/ui/track.styles.ts +31 -0
- package/src/contexts/brand-context.tsx +15 -0
- package/src/contexts/color-mode-context.tsx +4 -2
- package/src/contexts/i18n-context.tsx +72 -6
- package/src/contexts/socket-context.tsx +27 -1
- package/src/features/audit/audit-screen.tsx +13 -140
- package/src/features/audit/components/audit-diff.tsx +156 -0
- package/src/features/audit/components/index.ts +3 -0
- package/src/features/audit/components/record-audit-button.tsx +176 -0
- package/src/features/audit/enums/audit-data-action.enum.ts +20 -0
- package/src/features/audit/hooks/use-audit-trail.ts +9 -2
- package/src/features/audit/services/audit.service.ts +43 -1
- package/src/features/login/components/backup-codes-dialog.tsx +4 -4
- package/src/features/login/components/login-form.tsx +18 -3
- package/src/features/login/components/two-factor-setup.tsx +32 -2
- package/src/features/login/hooks/use-login-flow.ts +60 -7
- package/src/features/login/login-screen.tsx +99 -40
- package/src/features/logs/enums/error-type.enum.ts +21 -0
- package/src/features/logs/enums/request-outcome.enum.ts +14 -0
- package/src/features/logs/hooks/use-request-logs.ts +26 -3
- package/src/features/logs/logs-screen.tsx +2 -14
- package/src/features/logs/panels/index.ts +0 -1
- package/src/features/logs/panels/request-logs-panel.tsx +177 -25
- package/src/features/logs/services/logs.service.ts +13 -51
- package/src/features/notifications/components/notification-item.tsx +10 -8
- package/src/features/notifications/enums/notification-type.enum.ts +8 -0
- package/src/features/profile/force-password-change.tsx +18 -3
- package/src/features/profile/profile-screen.tsx +10 -2
- package/src/features/rbac/panels/groups-panel.tsx +78 -30
- package/src/features/rbac/panels/roles-panel.tsx +2 -2
- package/src/features/rbac/services/rbac.service.ts +23 -27
- package/src/features/recovery/reset-password-screen.tsx +16 -2
- package/src/features/users/components/user-form-dialog.tsx +65 -9
- package/src/features/users/hooks/use-users.ts +24 -3
- package/src/features/users/services/users.service.ts +16 -0
- package/src/features/users/users-screen.tsx +17 -12
- package/src/hooks/use-column-visibility.ts +13 -12
- package/src/hooks/use-countdown.ts +70 -0
- package/src/hooks/use-filters.ts +25 -2
- package/src/hooks/use-list-query.ts +14 -1
- package/src/hooks/use-request.ts +21 -2
- package/src/i18n/messages/en.ts +37 -6
- package/src/i18n/messages/pt.ts +41 -6
- package/src/index.ts +15 -1
- package/src/styles/core.css +99 -2
- package/src/styles/fonts/poppins-latin-400.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-500.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-600.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-700.woff2 +0 -0
- package/tailwind-preset.ts +17 -0
- package/src/components/ui/filter-chips.tsx +0 -88
- package/src/features/logs/hooks/use-error-logs.ts +0 -65
- package/src/features/logs/panels/error-logs-panel.tsx +0 -183
|
@@ -17,6 +17,9 @@ import { usePathname, useRouter } from "next/navigation";
|
|
|
17
17
|
import type { JSX } from "react";
|
|
18
18
|
import { useEffect, useMemo, useState } from "react";
|
|
19
19
|
|
|
20
|
+
import { anyOrTeam } from "#core/_utils/permission";
|
|
21
|
+
import { safeStorage } from "#core/_utils/storage";
|
|
22
|
+
import { fullName } from "#core/_utils/user";
|
|
20
23
|
import { Breadcrumbs } from "#core/components/breadcrumbs";
|
|
21
24
|
import { LanguageSelector } from "#core/components/language-selector";
|
|
22
25
|
import { ThemeToggle } from "#core/components/theme-toggle";
|
|
@@ -43,7 +46,37 @@ interface NavItem {
|
|
|
43
46
|
key: string;
|
|
44
47
|
href: string;
|
|
45
48
|
icon: React.ComponentType<{ className?: string }>;
|
|
46
|
-
|
|
49
|
+
/** Uma permissão, ou várias das quais basta ter uma. */
|
|
50
|
+
perm: string | string[] | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Item de menu declarado pelo projeto.
|
|
55
|
+
*
|
|
56
|
+
* O core não conhece as telas de quem o instala (§14), então elas entram por
|
|
57
|
+
* aqui em vez de o projeto reescrever o shell.
|
|
58
|
+
*
|
|
59
|
+
* ```tsx
|
|
60
|
+
* <AppShell navItems={[{ label: "nav.tasks", href: "/tarefas", icon: ListTodo }]}>
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export interface AppNavItem {
|
|
64
|
+
/**
|
|
65
|
+
* Chave de i18n (`nav.tasks`) ou o texto pronto (`"Tarefas"`).
|
|
66
|
+
*
|
|
67
|
+
* Chave sem tradução cadastrada volta como veio, então as duas formas
|
|
68
|
+
* funcionam — o projeto só precisa do dicionário quando for bilíngue.
|
|
69
|
+
*/
|
|
70
|
+
label: string;
|
|
71
|
+
href: string;
|
|
72
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
73
|
+
/**
|
|
74
|
+
* Permissão exigida para o item aparecer. Ausente, aparece para todos.
|
|
75
|
+
*
|
|
76
|
+
* Uma lista significa "qualquer uma destas" — é o que permite a mesma tela
|
|
77
|
+
* atender dois escopos, como `users:read:any` e `users:read:team`.
|
|
78
|
+
*/
|
|
79
|
+
permission?: string | string[] | null;
|
|
47
80
|
}
|
|
48
81
|
|
|
49
82
|
const DASHBOARD: NavItem = {
|
|
@@ -59,7 +92,14 @@ const PROFILE: NavItem = {
|
|
|
59
92
|
perm: null,
|
|
60
93
|
};
|
|
61
94
|
const ADMIN_CHILDREN: NavItem[] = [
|
|
62
|
-
{
|
|
95
|
+
{
|
|
96
|
+
key: "nav.users",
|
|
97
|
+
href: "/usuarios",
|
|
98
|
+
icon: Users,
|
|
99
|
+
// Gerente entra pela mesma porta: a lista que ele recebe já vem podada à
|
|
100
|
+
// equipe dele.
|
|
101
|
+
perm: anyOrTeam("users:read"),
|
|
102
|
+
},
|
|
63
103
|
{ key: "nav.rbac", href: "/rbac", icon: ShieldCheck, perm: "roles:read:any" },
|
|
64
104
|
{ key: "nav.logs", href: "/logs", icon: ScrollText, perm: "audit:read:any" },
|
|
65
105
|
// Restrito a administradores — só quem tem audit:read-trail:any (só Super Admin) vê este item.
|
|
@@ -71,7 +111,13 @@ const ADMIN_CHILDREN: NavItem[] = [
|
|
|
71
111
|
},
|
|
72
112
|
];
|
|
73
113
|
|
|
74
|
-
export
|
|
114
|
+
export interface AppShellProps {
|
|
115
|
+
children: React.ReactNode;
|
|
116
|
+
/** Telas do projeto no menu, entre o dashboard e o grupo de administração. */
|
|
117
|
+
navItems?: AppNavItem[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
|
|
75
121
|
const { user, loading, logout, hasPermission } = useAuth();
|
|
76
122
|
const { t } = useI18n();
|
|
77
123
|
const brand = useBrand();
|
|
@@ -79,16 +125,40 @@ export function AppShell({ children }: { children: React.ReactNode }): JSX.Eleme
|
|
|
79
125
|
const pathname = usePathname();
|
|
80
126
|
|
|
81
127
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
82
|
-
const [collapsed, setCollapsed] = useState<boolean>(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
128
|
+
const [collapsed, setCollapsed] = useState<boolean>(
|
|
129
|
+
() => safeStorage.get(COLLAPSE_KEY) === "1",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const allows = (perm: string | string[] | null): boolean => {
|
|
133
|
+
if (!perm) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
return Array.isArray(perm)
|
|
137
|
+
? perm.some((code) => hasPermission(code))
|
|
138
|
+
: hasPermission(perm);
|
|
139
|
+
};
|
|
86
140
|
|
|
87
141
|
const adminChildren = useMemo(
|
|
88
|
-
() => ADMIN_CHILDREN.filter((c) =>
|
|
142
|
+
() => ADMIN_CHILDREN.filter((c) => allows(c.perm)),
|
|
89
143
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
144
|
[user],
|
|
91
145
|
);
|
|
146
|
+
|
|
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[]>(
|
|
150
|
+
() =>
|
|
151
|
+
(navItems ?? [])
|
|
152
|
+
.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
|
+
})),
|
|
159
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
160
|
+
[user, navItems],
|
|
161
|
+
);
|
|
92
162
|
const adminActive = adminChildren.some((c) => pathname === c.href);
|
|
93
163
|
const [adminOpen, setAdminOpen] = useState(adminActive);
|
|
94
164
|
|
|
@@ -115,7 +185,7 @@ export function AppShell({ children }: { children: React.ReactNode }): JSX.Eleme
|
|
|
115
185
|
const toggleCollapsed = (): void =>
|
|
116
186
|
setCollapsed((v) => {
|
|
117
187
|
const next = !v;
|
|
118
|
-
|
|
188
|
+
safeStorage.set(COLLAPSE_KEY, next ? "1" : "0");
|
|
119
189
|
return next;
|
|
120
190
|
});
|
|
121
191
|
|
|
@@ -158,6 +228,10 @@ export function AppShell({ children }: { children: React.ReactNode }): JSX.Eleme
|
|
|
158
228
|
<div className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden p-3">
|
|
159
229
|
<NavLink item={DASHBOARD} mini={mini} />
|
|
160
230
|
|
|
231
|
+
{projectItems.map((item) => (
|
|
232
|
+
<NavLink key={item.href} item={item} mini={mini} />
|
|
233
|
+
))}
|
|
234
|
+
|
|
161
235
|
{adminChildren.length > 0 &&
|
|
162
236
|
(mini ? (
|
|
163
237
|
<>
|
|
@@ -246,7 +320,7 @@ export function AppShell({ children }: { children: React.ReactNode }): JSX.Eleme
|
|
|
246
320
|
/>
|
|
247
321
|
<div className="hidden text-left sm:block">
|
|
248
322
|
<p className="text-sm font-semibold leading-tight">
|
|
249
|
-
{user
|
|
323
|
+
{fullName(user)}
|
|
250
324
|
</p>
|
|
251
325
|
<p className="text-xs text-muted-foreground">
|
|
252
326
|
{user?.roles?.[0] ?? ""}
|
|
@@ -256,7 +330,7 @@ export function AppShell({ children }: { children: React.ReactNode }): JSX.Eleme
|
|
|
256
330
|
</button>
|
|
257
331
|
</DropdownMenuTrigger>
|
|
258
332
|
<DropdownMenuContent align="end" className="w-48">
|
|
259
|
-
<DropdownMenuLabel>{user
|
|
333
|
+
<DropdownMenuLabel>{fullName(user)}</DropdownMenuLabel>
|
|
260
334
|
<DropdownMenuSeparator />
|
|
261
335
|
<DropdownMenuItem onClick={() => router.push("/perfil")}>
|
|
262
336
|
<User className="h-4 w-4" />
|
|
@@ -7,6 +7,12 @@ import { useBrand } from "#core/contexts";
|
|
|
7
7
|
export interface BrandHeaderProps {
|
|
8
8
|
/** Título do passo ou da tela, exibido sob a marca. */
|
|
9
9
|
title: string;
|
|
10
|
+
/**
|
|
11
|
+
* Mostra logo e nome acima do título. Desligue quando a marca já estiver na
|
|
12
|
+
* tela por outro caminho — é o caso do `BrandPanel` ao lado do formulário,
|
|
13
|
+
* onde repeti-la no cartão seria dizer a mesma coisa duas vezes.
|
|
14
|
+
*/
|
|
15
|
+
showBrand?: boolean;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
/**
|
|
@@ -17,19 +23,28 @@ export interface BrandHeaderProps {
|
|
|
17
23
|
* ela está. O conteúdo vem do `BrandProvider` do projeto; sem ele, do padrão do
|
|
18
24
|
* core.
|
|
19
25
|
*/
|
|
20
|
-
export function BrandHeader({
|
|
26
|
+
export function BrandHeader({
|
|
27
|
+
title,
|
|
28
|
+
showBrand = true,
|
|
29
|
+
}: BrandHeaderProps): JSX.Element {
|
|
21
30
|
const brand = useBrand();
|
|
22
31
|
|
|
23
32
|
return (
|
|
24
33
|
<div className="mb-6 flex flex-col items-center gap-1">
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
{showBrand && (
|
|
35
|
+
<>
|
|
36
|
+
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
|
37
|
+
{brand.logo}
|
|
38
|
+
</div>
|
|
39
|
+
<span className="text-lg font-extrabold">{brand.name}</span>
|
|
40
|
+
{brand.tagline && (
|
|
41
|
+
<p className="text-sm text-muted-foreground">{brand.tagline}</p>
|
|
42
|
+
)}
|
|
43
|
+
</>
|
|
31
44
|
)}
|
|
32
|
-
<h1 className="mt-2 text-xl font-semibold"
|
|
45
|
+
<h1 className={showBrand ? "mt-2 text-xl font-semibold" : "text-xl font-semibold"}>
|
|
46
|
+
{title}
|
|
47
|
+
</h1>
|
|
33
48
|
</div>
|
|
34
49
|
);
|
|
35
50
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { useBrand } from "#core/contexts";
|
|
6
|
+
import { cn } from "#core/lib/utils";
|
|
7
|
+
|
|
8
|
+
export interface BrandPanelProps {
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A marca ocupando metade da tela de login, ao lado do formulário.
|
|
14
|
+
*
|
|
15
|
+
* É o irmão grande do `BrandHeader`: mesma fonte (o `BrandProvider` do
|
|
16
|
+
* projeto), espaço diferente. Serve a quem tem logo grande — no cabeçalho do
|
|
17
|
+
* cartão ela sairia espremida.
|
|
18
|
+
*
|
|
19
|
+
* O core não dimensiona a logo: ela é um `ReactNode` do projeto, que sabe se é
|
|
20
|
+
* um SVG, um `next/image` ou um ícone. Aqui ela só ganha o espaço.
|
|
21
|
+
*/
|
|
22
|
+
export function BrandPanel({ className }: BrandPanelProps): JSX.Element {
|
|
23
|
+
const brand = useBrand();
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className={cn(
|
|
28
|
+
"flex flex-1 flex-col items-center justify-center gap-4 bg-muted/30 p-8 text-center",
|
|
29
|
+
className,
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
{brand.logo}
|
|
33
|
+
<span className="text-3xl font-extrabold">{brand.name}</span>
|
|
34
|
+
{brand.tagline && (
|
|
35
|
+
<p className="max-w-sm text-sm text-muted-foreground">
|
|
36
|
+
{brand.tagline}
|
|
37
|
+
</p>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Barrel da pasta — importe daqui de fora; dentro da pasta use o caminho direto.
|
|
2
2
|
export * from "./app-shell";
|
|
3
3
|
export * from "./brand-header";
|
|
4
|
+
export * from "./brand-panel";
|
|
4
5
|
export * from "./breadcrumbs";
|
|
5
6
|
export * from "./language-selector";
|
|
6
7
|
export * from "./password-field";
|
|
@@ -3,37 +3,64 @@
|
|
|
3
3
|
import { Eye, EyeOff } from "lucide-react";
|
|
4
4
|
import { forwardRef, InputHTMLAttributes, useState } from "react";
|
|
5
5
|
|
|
6
|
-
import { Input } from "#core/components/ui";
|
|
6
|
+
import { Input, PasswordRequirements } from "#core/components/ui";
|
|
7
7
|
import { cn } from "#core/lib/utils";
|
|
8
8
|
|
|
9
9
|
interface PasswordFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
10
10
|
error?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Valor atual do campo. Presente, o campo mostra as exigências de senha
|
|
13
|
+
* logo abaixo — **só enquanto está em foco**.
|
|
14
|
+
*
|
|
15
|
+
* A lista some ao sair do campo de propósito: são cinco linhas, e em tela
|
|
16
|
+
* com dois campos de senha elas dobrariam de tamanho e empurrariam o botão
|
|
17
|
+
* para fora da vista. Enquanto se digita é quando a lista serve; depois,
|
|
18
|
+
* quem avisa que algo está errado é a mensagem de validação.
|
|
19
|
+
*
|
|
20
|
+
* A visibilidade é `focus-within` no CSS, e não estado: o olho de
|
|
21
|
+
* mostrar/ocultar vive dentro do campo, e clicar nele tiraria o foco do
|
|
22
|
+
* input — com estado, a lista piscaria a cada clique.
|
|
23
|
+
*/
|
|
24
|
+
rulesFor?: string;
|
|
11
25
|
}
|
|
12
26
|
|
|
13
27
|
export const PasswordField = forwardRef<HTMLInputElement, PasswordFieldProps>(
|
|
14
|
-
function PasswordField({ className, error, ...props }, ref) {
|
|
28
|
+
function PasswordField({ className, error, rulesFor, ...props }, ref) {
|
|
15
29
|
const [show, setShow] = useState(false);
|
|
16
30
|
return (
|
|
17
|
-
<div className="
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
<div className="group">
|
|
32
|
+
<div className="relative">
|
|
33
|
+
<Input
|
|
34
|
+
ref={ref}
|
|
35
|
+
type={show ? "text" : "password"}
|
|
36
|
+
className={cn(
|
|
37
|
+
"pr-10",
|
|
38
|
+
error && "border-destructive focus-visible:ring-destructive",
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
tabIndex={-1}
|
|
46
|
+
onClick={() => setShow((s) => !s)}
|
|
47
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground transition-colors hover:text-foreground"
|
|
48
|
+
aria-label={show ? "ocultar senha" : "mostrar senha"}
|
|
49
|
+
>
|
|
50
|
+
{show ? (
|
|
51
|
+
<EyeOff className="h-4 w-4" />
|
|
52
|
+
) : (
|
|
53
|
+
<Eye className="h-4 w-4" />
|
|
54
|
+
)}
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{rulesFor !== undefined && (
|
|
59
|
+
<PasswordRequirements
|
|
60
|
+
value={rulesFor}
|
|
61
|
+
className="mt-2 hidden group-focus-within:flex"
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
37
64
|
</div>
|
|
38
65
|
);
|
|
39
66
|
},
|
|
@@ -7,8 +7,8 @@ const alertVariants = cva("rounded-md border px-3 py-2 text-sm", {
|
|
|
7
7
|
variants: {
|
|
8
8
|
variant: {
|
|
9
9
|
info: "border-primary/30 bg-primary/10 text-primary",
|
|
10
|
-
success: "border-success/30 bg-success/10 text-success",
|
|
11
|
-
warning: "border-warning/30 bg-warning/10 text-warning",
|
|
10
|
+
success: "border-success/30 bg-success/10 text-success-strong",
|
|
11
|
+
warning: "border-warning/30 bg-warning/10 text-warning-strong",
|
|
12
12
|
error: "border-destructive/30 bg-destructive/10 text-destructive",
|
|
13
13
|
},
|
|
14
14
|
},
|
|
@@ -18,10 +18,39 @@ const alertVariants = cva("rounded-md border px-3 py-2 text-sm", {
|
|
|
18
18
|
export interface AlertProps
|
|
19
19
|
extends
|
|
20
20
|
React.HTMLAttributes<HTMLDivElement>,
|
|
21
|
-
VariantProps<typeof alertVariants> {
|
|
21
|
+
VariantProps<typeof alertVariants> {
|
|
22
|
+
/**
|
|
23
|
+
* Ícone à esquerda, alinhado à primeira linha do texto.
|
|
24
|
+
*
|
|
25
|
+
* Existe porque telas passaram a remontar o aviso à mão só para ter um ícone
|
|
26
|
+
* — e a cópia não acompanhou a correção de contraste das cores. Sem ícone, o
|
|
27
|
+
* componente renderiza exatamente como antes.
|
|
28
|
+
*/
|
|
29
|
+
icon?: React.ReactNode;
|
|
30
|
+
}
|
|
22
31
|
|
|
23
|
-
export function Alert({
|
|
32
|
+
export function Alert({
|
|
33
|
+
variant,
|
|
34
|
+
className,
|
|
35
|
+
icon,
|
|
36
|
+
children,
|
|
37
|
+
...props
|
|
38
|
+
}: AlertProps): React.JSX.Element {
|
|
24
39
|
return (
|
|
25
|
-
<div
|
|
40
|
+
<div
|
|
41
|
+
className={cn(
|
|
42
|
+
alertVariants({ variant }),
|
|
43
|
+
icon && "flex items-start gap-2",
|
|
44
|
+
className,
|
|
45
|
+
)}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
{icon && (
|
|
49
|
+
<span className="mt-0.5 shrink-0 [&>svg]:h-4 [&>svg]:w-4" aria-hidden>
|
|
50
|
+
{icon}
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
{icon ? <span>{children}</span> : children}
|
|
54
|
+
</div>
|
|
26
55
|
);
|
|
27
56
|
}
|
|
@@ -11,8 +11,8 @@ const badgeVariants = cva(
|
|
|
11
11
|
default: "border-transparent bg-primary/10 text-primary",
|
|
12
12
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
|
13
13
|
destructive: "border-transparent bg-destructive/10 text-destructive",
|
|
14
|
-
success: "border-transparent bg-success/15 text-success",
|
|
15
|
-
warning: "border-transparent bg-warning/15 text-warning",
|
|
14
|
+
success: "border-transparent bg-success/15 text-success-strong",
|
|
15
|
+
warning: "border-transparent bg-warning/15 text-warning-strong",
|
|
16
16
|
outline: "text-foreground",
|
|
17
17
|
},
|
|
18
18
|
},
|
|
@@ -52,6 +52,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
52
52
|
<Comp
|
|
53
53
|
className={cn(buttonVariants({ variant, size, className }))}
|
|
54
54
|
ref={ref}
|
|
55
|
+
// `<button>` sem `type` nasce `submit`: dentro de um form, um botão que
|
|
56
|
+
// só abre um modal acabava salvando o registro junto. Quem submete
|
|
57
|
+
// declara `type="submit"` — o spread abaixo deixa isso sobrescrever.
|
|
58
|
+
// Com `asChild` o filho pode não ser um `<button>`, então não força.
|
|
59
|
+
{...(asChild ? {} : { type: "button" as const })}
|
|
55
60
|
{...props}
|
|
56
61
|
/>
|
|
57
62
|
);
|
|
@@ -43,7 +43,9 @@ export function ConfirmDialog({
|
|
|
43
43
|
}: ConfirmDialogProps): React.JSX.Element {
|
|
44
44
|
return (
|
|
45
45
|
<Dialog open={open} onOpenChange={(o) => !o && onCancel()}>
|
|
46
|
-
|
|
46
|
+
{/* Sem ampliar: são duas linhas e dois botões, e o tamanho pequeno é
|
|
47
|
+
parte do recado — esticado só ganharia vazio. */}
|
|
48
|
+
<DialogContent className="max-w-sm" expandable={false}>
|
|
47
49
|
<DialogHeader>
|
|
48
50
|
<DialogTitle>{title}</DialogTitle>
|
|
49
51
|
{description && <DialogDescription>{description}</DialogDescription>}
|
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
ColumnVisibilityModal,
|
|
40
40
|
ColumnVisibilityOption,
|
|
41
41
|
} from "#core/components/ui/column-visibility-modal";
|
|
42
|
-
import { FilterChips } from "#core/components/ui/filter-chips";
|
|
43
42
|
import { FilterSheet } from "#core/components/ui/filter-sheet";
|
|
44
43
|
import { Spinner } from "#core/components/ui/spinner";
|
|
45
44
|
import {
|
|
@@ -94,6 +93,16 @@ interface DataTableProps<T extends RowData> {
|
|
|
94
93
|
* não aparece — listagem sem catálogo continua funcionando igual.
|
|
95
94
|
*/
|
|
96
95
|
filters?: FiltersState;
|
|
96
|
+
/**
|
|
97
|
+
* Campos que a `toolbar` já controla: somem do painel de filtros e não são
|
|
98
|
+
* contados no botão "Filtros", para a mesma escolha não existir em dois
|
|
99
|
+
* lugares.
|
|
100
|
+
*
|
|
101
|
+
* Some da interface, não do catálogo — é o catálogo que serializa o filtro,
|
|
102
|
+
* então o atalho da barra continua funcionando normalmente. O painel também
|
|
103
|
+
* não os desmarca ao aplicar nem ao limpar: não são dele.
|
|
104
|
+
*/
|
|
105
|
+
hiddenFilterFields?: string[];
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
/** Extras que uma coluna pode declarar em `meta`. */
|
|
@@ -144,6 +153,7 @@ export function DataTable<T extends RowData>({
|
|
|
144
153
|
onSortingChange,
|
|
145
154
|
enableSorting = true,
|
|
146
155
|
filters,
|
|
156
|
+
hiddenFilterFields,
|
|
147
157
|
}: DataTableProps<T>): JSX.Element {
|
|
148
158
|
const { t } = useI18n();
|
|
149
159
|
const [colsOpen, setColsOpen] = useState(false);
|
|
@@ -220,7 +230,7 @@ export function DataTable<T extends RowData>({
|
|
|
220
230
|
? filters.tree && filters.filter
|
|
221
231
|
? countConditions(filters.tree)
|
|
222
232
|
: 0
|
|
223
|
-
: activeFilterCount(filters.values);
|
|
233
|
+
: activeFilterCount(filters.values, hiddenFilterFields);
|
|
224
234
|
|
|
225
235
|
return (
|
|
226
236
|
<div>
|
|
@@ -257,8 +267,6 @@ export function DataTable<T extends RowData>({
|
|
|
257
267
|
</div>
|
|
258
268
|
</div>
|
|
259
269
|
|
|
260
|
-
{filters && <FilterChips filters={filters} />}
|
|
261
|
-
|
|
262
270
|
<Card>
|
|
263
271
|
<Table>
|
|
264
272
|
<TableHeader>
|
|
@@ -394,6 +402,7 @@ export function DataTable<T extends RowData>({
|
|
|
394
402
|
open={filtersOpen}
|
|
395
403
|
onOpenChange={setFiltersOpen}
|
|
396
404
|
filters={filters}
|
|
405
|
+
hiddenFields={hiddenFilterFields}
|
|
397
406
|
/>
|
|
398
407
|
)}
|
|
399
408
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
4
|
-
import { X } from "lucide-react";
|
|
4
|
+
import { Maximize2, Minimize2, X } from "lucide-react";
|
|
5
5
|
import * as React from "react";
|
|
6
6
|
|
|
7
7
|
import { cn } from "#core/lib/utils";
|
|
@@ -26,28 +26,127 @@ const DialogOverlay = React.forwardRef<
|
|
|
26
26
|
));
|
|
27
27
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Escala de largura do Tailwind, em ordem. Ampliar anda dois degraus: `md` vira
|
|
31
|
+
* `xl`, `2xl` vira `4xl`. O modal cresce mantendo a proporção que já tinha, em
|
|
32
|
+
* vez de saltar para a tela inteira — um formulário de duas colunas esticado a
|
|
33
|
+
* 100% fica pior, não melhor. Um degrau só passava despercebido.
|
|
34
|
+
*/
|
|
35
|
+
const WIDTH_SCALE: readonly string[] = [
|
|
36
|
+
"max-w-3xs",
|
|
37
|
+
"max-w-2xs",
|
|
38
|
+
"max-w-xs",
|
|
39
|
+
"max-w-sm",
|
|
40
|
+
"max-w-md",
|
|
41
|
+
"max-w-lg",
|
|
42
|
+
"max-w-xl",
|
|
43
|
+
"max-w-2xl",
|
|
44
|
+
"max-w-3xl",
|
|
45
|
+
"max-w-4xl",
|
|
46
|
+
"max-w-5xl",
|
|
47
|
+
"max-w-6xl",
|
|
48
|
+
"max-w-7xl",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Largura de quem não declara nenhuma. */
|
|
52
|
+
const DEFAULT_WIDTH = "max-w-lg";
|
|
53
|
+
|
|
54
|
+
/** Quantos degraus o botão sobe de uma vez. */
|
|
55
|
+
const EXPAND_STEPS = 2;
|
|
56
|
+
|
|
57
|
+
/** Só para quem já está no topo da escala: daí não há degrau, resta a tela. */
|
|
58
|
+
const FULL_WIDTH = "max-w-none";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A largura do modal ampliado.
|
|
62
|
+
*
|
|
63
|
+
* Lê a **última** `max-w-*` do `className` porque é ela que o `tailwind-merge`
|
|
64
|
+
* deixa valer. Largura fora da escala (`max-w-[42rem]`) não tem degrau a subir:
|
|
65
|
+
* amplia para a tela toda.
|
|
66
|
+
*/
|
|
67
|
+
const expandedWidth = (className?: string): string => {
|
|
68
|
+
const declared = (className ?? "")
|
|
69
|
+
.split(/\s+/)
|
|
70
|
+
.filter((item) => item.startsWith("max-w-"));
|
|
71
|
+
const current = declared[declared.length - 1] ?? DEFAULT_WIDTH;
|
|
72
|
+
const index = WIDTH_SCALE.indexOf(current);
|
|
73
|
+
const last = WIDTH_SCALE.length - 1;
|
|
74
|
+
|
|
75
|
+
// Fora da escala não há degrau a subir, e chutar um da escala encolheria um
|
|
76
|
+
// `max-w-[90rem]`. Amplia para a tela, que é o que o botão promete.
|
|
77
|
+
if (index === -1 || index === last) {
|
|
78
|
+
return FULL_WIDTH;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Perto do topo, dois degraus passariam do fim: para no último em vez de
|
|
82
|
+
// cair em tela cheia, que é justamente o salto que a escala evita.
|
|
83
|
+
return WIDTH_SCALE[Math.min(index + EXPAND_STEPS, last)];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export interface DialogContentProps
|
|
87
|
+
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
|
88
|
+
/**
|
|
89
|
+
* Botão de ampliar ao lado do fechar.
|
|
90
|
+
*
|
|
91
|
+
* Ligado por padrão: ampliar um degrau não atrapalha modal nenhum. Desligue
|
|
92
|
+
* com `expandable={false}` onde o tamanho é parte do desenho — confirmação
|
|
93
|
+
* curta, por exemplo, que esticada só fica com mais vazio.
|
|
94
|
+
*/
|
|
95
|
+
expandable?: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
29
98
|
const DialogContent = React.forwardRef<
|
|
30
99
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
31
|
-
|
|
32
|
-
>(({ className, children, ...props }, ref) =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
)
|
|
100
|
+
DialogContentProps
|
|
101
|
+
>(({ className, children, expandable = true, ...props }, ref) => {
|
|
102
|
+
// Reinicia a cada montagem: o Radix desmonta o conteúdo ao fechar, então
|
|
103
|
+
// reabrir traz o modal no tamanho normal — modal pequeno que volta ampliado
|
|
104
|
+
// surpreende quem o abriu.
|
|
105
|
+
const [expanded, setExpanded] = React.useState(false);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<DialogPortal>
|
|
109
|
+
<DialogOverlay />
|
|
110
|
+
<DialogPrimitive.Content
|
|
111
|
+
ref={ref}
|
|
112
|
+
className={cn(
|
|
113
|
+
"fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 overflow-y-auto",
|
|
114
|
+
"w-[calc(100%-2rem)] max-h-[calc(100%-2rem)] rounded-xl",
|
|
115
|
+
DEFAULT_WIDTH,
|
|
116
|
+
className,
|
|
117
|
+
// Depois do `className`: é o que faz a largura ampliada vencer a que
|
|
118
|
+
// o chamador declarou, e só enquanto estiver ampliado.
|
|
119
|
+
expanded && expandedWidth(className),
|
|
120
|
+
)}
|
|
121
|
+
{...props}
|
|
122
|
+
>
|
|
123
|
+
{children}
|
|
124
|
+
<div className="absolute right-4 top-4 flex items-center gap-2">
|
|
125
|
+
{expandable && (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={() => setExpanded((value) => !value)}
|
|
129
|
+
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
130
|
+
>
|
|
131
|
+
{expanded ? (
|
|
132
|
+
<Minimize2 className="h-4 w-4" />
|
|
133
|
+
) : (
|
|
134
|
+
<Maximize2 className="h-4 w-4" />
|
|
135
|
+
)}
|
|
136
|
+
<span className="sr-only">
|
|
137
|
+
{expanded ? "Restaurar" : "Ampliar"}
|
|
138
|
+
</span>
|
|
139
|
+
</button>
|
|
140
|
+
)}
|
|
141
|
+
<DialogPrimitive.Close className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
|
|
142
|
+
<X className="h-4 w-4" />
|
|
143
|
+
<span className="sr-only">Fechar</span>
|
|
144
|
+
</DialogPrimitive.Close>
|
|
145
|
+
</div>
|
|
146
|
+
</DialogPrimitive.Content>
|
|
147
|
+
</DialogPortal>
|
|
148
|
+
);
|
|
149
|
+
});
|
|
51
150
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
52
151
|
|
|
53
152
|
/**
|