rl-core-front 0.1.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/README.md +129 -0
- package/package.json +113 -0
- package/src/_services/api/axios.factory.ts +97 -0
- package/src/_services/api/list-query.ts +26 -0
- package/src/_services/api/schema.d.ts +2527 -0
- package/src/_services/auth/index.ts +127 -0
- package/src/_utils/advanced-filter.ts +386 -0
- package/src/_utils/calendar.ts +103 -0
- package/src/_utils/crop-image.ts +65 -0
- package/src/_utils/filter.ts +313 -0
- package/src/_utils/format.ts +35 -0
- package/src/_utils/initials.ts +24 -0
- package/src/_utils/password.ts +7 -0
- package/src/components/app-shell.tsx +314 -0
- package/src/components/breadcrumbs.tsx +55 -0
- package/src/components/index.ts +7 -0
- package/src/components/language-selector.tsx +51 -0
- package/src/components/password-field.tsx +40 -0
- package/src/components/theme-toggle.tsx +27 -0
- package/src/components/ui/alert.tsx +27 -0
- package/src/components/ui/avatar.tsx +50 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +86 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/column-visibility-modal.tsx +110 -0
- package/src/components/ui/confirm-dialog.tsx +69 -0
- package/src/components/ui/data-table.tsx +401 -0
- package/src/components/ui/date-range-picker.tsx +250 -0
- package/src/components/ui/dialog.tsx +138 -0
- package/src/components/ui/dropdown-menu.tsx +108 -0
- package/src/components/ui/field.tsx +20 -0
- package/src/components/ui/filter-chips.tsx +88 -0
- package/src/components/ui/filter-field.tsx +137 -0
- package/src/components/ui/filter-group.tsx +155 -0
- package/src/components/ui/filter-rule.tsx +221 -0
- package/src/components/ui/filter-sheet.tsx +201 -0
- package/src/components/ui/index.ts +30 -0
- package/src/components/ui/input.tsx +25 -0
- package/src/components/ui/label.tsx +23 -0
- package/src/components/ui/row-actions.tsx +32 -0
- package/src/components/ui/select.tsx +97 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/sheet.tsx +119 -0
- package/src/components/ui/sonner.tsx +28 -0
- package/src/components/ui/spinner.tsx +12 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/table.tsx +84 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/components/user-avatar.tsx +54 -0
- package/src/contexts/auth-context.tsx +90 -0
- package/src/contexts/color-mode-context.tsx +105 -0
- package/src/contexts/i18n-context.tsx +76 -0
- package/src/contexts/index.ts +6 -0
- package/src/contexts/socket-context.tsx +153 -0
- package/src/contexts/toast-context.tsx +40 -0
- package/src/features/audit/audit-screen.tsx +299 -0
- package/src/features/audit/hooks/use-audit-trail.ts +68 -0
- package/src/features/audit/services/audit.service.ts +24 -0
- package/src/features/dashboard/dashboard-screen.tsx +73 -0
- package/src/features/login/components/backup-codes-dialog.tsx +66 -0
- package/src/features/login/components/index.ts +5 -0
- package/src/features/login/components/login-form.tsx +62 -0
- package/src/features/login/components/two-factor-setup.tsx +71 -0
- package/src/features/login/components/two-factor-verify.tsx +56 -0
- package/src/features/login/hooks/use-login-flow.ts +119 -0
- package/src/features/login/login-screen.tsx +83 -0
- package/src/features/login/validation/schemas.ts +30 -0
- package/src/features/logs/hooks/use-error-logs.ts +65 -0
- package/src/features/logs/hooks/use-request-logs.ts +65 -0
- package/src/features/logs/logs-screen.tsx +31 -0
- package/src/features/logs/panels/error-logs-panel.tsx +183 -0
- package/src/features/logs/panels/index.ts +3 -0
- package/src/features/logs/panels/request-logs-panel.tsx +124 -0
- package/src/features/logs/services/logs.service.ts +68 -0
- package/src/features/notifications/components/notification-item.tsx +67 -0
- package/src/features/notifications/hooks/use-notifications.ts +78 -0
- package/src/features/notifications/notifications-center.tsx +119 -0
- package/src/features/notifications/services/notifications.service.ts +38 -0
- package/src/features/profile/components/avatar-crop-dialog.tsx +133 -0
- package/src/features/profile/components/index.ts +2 -0
- package/src/features/profile/force-password-change.tsx +114 -0
- package/src/features/profile/profile-screen.tsx +385 -0
- package/src/features/rbac/panels/actions-panel.tsx +20 -0
- package/src/features/rbac/panels/catalog-panel.tsx +243 -0
- package/src/features/rbac/panels/groups-panel.tsx +269 -0
- package/src/features/rbac/panels/index.ts +8 -0
- package/src/features/rbac/panels/permissions-panel.tsx +204 -0
- package/src/features/rbac/panels/resources-panel.tsx +20 -0
- package/src/features/rbac/panels/roles-panel.tsx +224 -0
- package/src/features/rbac/panels/scopes-panel.tsx +20 -0
- package/src/features/rbac/rbac-screen.tsx +80 -0
- package/src/features/rbac/services/rbac.service.ts +110 -0
- package/src/features/recovery/forgot-password-screen.tsx +69 -0
- package/src/features/recovery/reset-password-screen.tsx +163 -0
- package/src/features/users/components/user-form-dialog.tsx +187 -0
- package/src/features/users/hooks/use-users.ts +181 -0
- package/src/features/users/services/users.service.ts +83 -0
- package/src/features/users/users-screen.tsx +331 -0
- package/src/hooks/use-column-visibility.ts +54 -0
- package/src/hooks/use-confirm.tsx +70 -0
- package/src/hooks/use-filter-schema.ts +85 -0
- package/src/hooks/use-filters.ts +148 -0
- package/src/hooks/use-list-query.ts +69 -0
- package/src/hooks/use-request.ts +114 -0
- package/src/i18n/messages/en.ts +296 -0
- package/src/i18n/messages/pt.ts +305 -0
- package/src/index.ts +60 -0
- package/src/lib/utils.ts +6 -0
- package/src/middleware.ts +42 -0
- package/src/styles/core.css +142 -0
- package/tailwind-preset.ts +103 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ColumnDef,
|
|
5
|
+
OnChangeFn,
|
|
6
|
+
PaginationState,
|
|
7
|
+
SortingState,
|
|
8
|
+
} from "@tanstack/react-table";
|
|
9
|
+
import {
|
|
10
|
+
Ban,
|
|
11
|
+
CheckCircle2,
|
|
12
|
+
KeyRound,
|
|
13
|
+
MailPlus,
|
|
14
|
+
Pencil,
|
|
15
|
+
Plus,
|
|
16
|
+
ShieldOff,
|
|
17
|
+
Trash2,
|
|
18
|
+
} from "lucide-react";
|
|
19
|
+
import type { JSX } from "react";
|
|
20
|
+
import { useMemo, useState } from "react";
|
|
21
|
+
|
|
22
|
+
import { Badge, Button, DataTable, DataTableFeatures, RowActions } from "#core/components/ui";
|
|
23
|
+
import { useAuth, useI18n } from "#core/contexts";
|
|
24
|
+
import { UserFormDialog } from "#core/features/users/components/user-form-dialog";
|
|
25
|
+
import { useUsers } from "#core/features/users/hooks/use-users";
|
|
26
|
+
import {
|
|
27
|
+
CreateUserPayload,
|
|
28
|
+
User,
|
|
29
|
+
} from "#core/features/users/services/users.service";
|
|
30
|
+
import { useConfirm } from "#core/hooks/use-confirm";
|
|
31
|
+
|
|
32
|
+
export function UsersScreen(): JSX.Element {
|
|
33
|
+
const u = useUsers();
|
|
34
|
+
const { hasPermission } = useAuth();
|
|
35
|
+
const { t } = useI18n();
|
|
36
|
+
const { confirm, confirmDialog } = useConfirm();
|
|
37
|
+
const [formOpen, setFormOpen] = useState(false);
|
|
38
|
+
const [editing, setEditing] = useState<User | null>(null);
|
|
39
|
+
|
|
40
|
+
const canUpdate = hasPermission("users:update:any");
|
|
41
|
+
const canReset2fa = hasPermission("users:update:any");
|
|
42
|
+
const canReset = hasPermission("users:reset-password:any");
|
|
43
|
+
const canDeactivate = hasPermission("users:deactivate:any");
|
|
44
|
+
const canDelete = hasPermission("users:delete:any");
|
|
45
|
+
const canInvite = hasPermission("users:invite:any");
|
|
46
|
+
const showActions =
|
|
47
|
+
canUpdate ||
|
|
48
|
+
canInvite ||
|
|
49
|
+
canReset ||
|
|
50
|
+
canReset2fa ||
|
|
51
|
+
canDeactivate ||
|
|
52
|
+
canDelete;
|
|
53
|
+
|
|
54
|
+
const openCreate = (): void => {
|
|
55
|
+
setEditing(null);
|
|
56
|
+
setFormOpen(true);
|
|
57
|
+
};
|
|
58
|
+
const openEdit = (row: User): void => {
|
|
59
|
+
setEditing(row);
|
|
60
|
+
setFormOpen(true);
|
|
61
|
+
};
|
|
62
|
+
const submitForm = async (
|
|
63
|
+
data: CreateUserPayload,
|
|
64
|
+
id?: string,
|
|
65
|
+
): Promise<boolean> => (id ? u.update(id, data) : u.create(data));
|
|
66
|
+
|
|
67
|
+
const columns = useMemo<ColumnDef<DataTableFeatures, User, unknown>[]>(() => {
|
|
68
|
+
const cols: ColumnDef<DataTableFeatures, User, unknown>[] = [
|
|
69
|
+
{
|
|
70
|
+
// `accessorKey` continua `name`: é por ele que a ordenação vai ao
|
|
71
|
+
// backend, que não sabe ordenar por um campo composto.
|
|
72
|
+
accessorKey: "name",
|
|
73
|
+
header: t("users.name"),
|
|
74
|
+
meta: { label: t("users.name") },
|
|
75
|
+
cell: ({ row }) =>
|
|
76
|
+
[row.original.name, row.original.lastName]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join(" "),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
accessorKey: "email",
|
|
82
|
+
header: t("users.email"),
|
|
83
|
+
meta: { label: t("users.email") },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "roles",
|
|
87
|
+
header: t("users.roles"),
|
|
88
|
+
meta: { label: t("users.roles") },
|
|
89
|
+
enableSorting: false,
|
|
90
|
+
cell: ({ row }) => (
|
|
91
|
+
<div className="flex flex-wrap gap-1">
|
|
92
|
+
{row.original.roles?.map((r) => (
|
|
93
|
+
<Badge key={r.name} variant="secondary">
|
|
94
|
+
{r.name}
|
|
95
|
+
</Badge>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
accessorKey: "isActive",
|
|
102
|
+
header: t("users.status"),
|
|
103
|
+
meta: { label: t("users.status") },
|
|
104
|
+
/**
|
|
105
|
+
* Três estados, não dois. Bloqueado por tentativas é diferente de
|
|
106
|
+
* inativo: a conta segue ativa, mas não entra até um admin reenviar o
|
|
107
|
+
* link de senha. Mostrar só "Ativo" fazia o aviso de conta bloqueada
|
|
108
|
+
* parecer errado para quem abria esta tela.
|
|
109
|
+
*/
|
|
110
|
+
cell: ({ row }) => {
|
|
111
|
+
if (!row.original.isActive) {
|
|
112
|
+
return <Badge variant="destructive">{t("users.inactive")}</Badge>;
|
|
113
|
+
}
|
|
114
|
+
if (row.original.locked) {
|
|
115
|
+
return <Badge variant="warning">{t("users.locked")}</Badge>;
|
|
116
|
+
}
|
|
117
|
+
return <Badge variant="success">{t("users.active")}</Badge>;
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
accessorKey: "twoFactorEnabled",
|
|
122
|
+
header: "2FA",
|
|
123
|
+
meta: { label: "2FA" },
|
|
124
|
+
enableSorting: false,
|
|
125
|
+
cell: ({ row }) => (
|
|
126
|
+
<Badge
|
|
127
|
+
variant={row.original.twoFactorEnabled ? "success" : "warning"}
|
|
128
|
+
>
|
|
129
|
+
{row.original.twoFactorEnabled ? "✓" : "—"}
|
|
130
|
+
</Badge>
|
|
131
|
+
),
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
if (showActions) {
|
|
136
|
+
cols.push({
|
|
137
|
+
id: "actions",
|
|
138
|
+
header: () => <div className="text-center">{t("common.actions")}</div>,
|
|
139
|
+
meta: { shrink: true },
|
|
140
|
+
enableHiding: false,
|
|
141
|
+
enableSorting: false,
|
|
142
|
+
cell: ({ row }) => {
|
|
143
|
+
const item = row.original;
|
|
144
|
+
return (
|
|
145
|
+
<RowActions>
|
|
146
|
+
{canUpdate && (
|
|
147
|
+
<Button
|
|
148
|
+
variant="ghost"
|
|
149
|
+
size="iconSm"
|
|
150
|
+
title={t("common.edit")}
|
|
151
|
+
onClick={() => openEdit(item)}
|
|
152
|
+
>
|
|
153
|
+
<Pencil className="h-4 w-4" />
|
|
154
|
+
</Button>
|
|
155
|
+
)}
|
|
156
|
+
{canInvite && (
|
|
157
|
+
<Button
|
|
158
|
+
variant="ghost"
|
|
159
|
+
size="iconSm"
|
|
160
|
+
title={t("users.sendFirstAccessLink")}
|
|
161
|
+
onClick={() => {
|
|
162
|
+
void confirm({
|
|
163
|
+
title: t("users.sendFirstAccessLink"),
|
|
164
|
+
description: item.email,
|
|
165
|
+
confirmLabel: t("common.send"),
|
|
166
|
+
}).then((ok) => {
|
|
167
|
+
if (ok) {
|
|
168
|
+
void u.sendFirstAccessLink(item.id);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
<MailPlus className="h-4 w-4" />
|
|
174
|
+
</Button>
|
|
175
|
+
)}
|
|
176
|
+
{canReset && (
|
|
177
|
+
<Button
|
|
178
|
+
variant="ghost"
|
|
179
|
+
size="iconSm"
|
|
180
|
+
title={t("users.sendPasswordResetLink")}
|
|
181
|
+
onClick={() => {
|
|
182
|
+
void confirm({
|
|
183
|
+
title: t("users.sendPasswordResetLink"),
|
|
184
|
+
description: item.email,
|
|
185
|
+
confirmLabel: t("common.send"),
|
|
186
|
+
}).then((ok) => {
|
|
187
|
+
if (ok) {
|
|
188
|
+
void u.sendPasswordResetLink(item.id);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
<KeyRound className="h-4 w-4" />
|
|
194
|
+
</Button>
|
|
195
|
+
)}
|
|
196
|
+
{canReset2fa && item.twoFactorEnabled && (
|
|
197
|
+
<Button
|
|
198
|
+
variant="ghost"
|
|
199
|
+
size="iconSm"
|
|
200
|
+
title={t("users.resetTwoFactor")}
|
|
201
|
+
onClick={() => {
|
|
202
|
+
void confirm({
|
|
203
|
+
title: t("users.resetTwoFactor"),
|
|
204
|
+
description: item.name,
|
|
205
|
+
destructive: true,
|
|
206
|
+
}).then((ok) => {
|
|
207
|
+
if (ok) {
|
|
208
|
+
void u.resetTwoFactor(item.id);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
<ShieldOff className="h-4 w-4" />
|
|
214
|
+
</Button>
|
|
215
|
+
)}
|
|
216
|
+
{canDeactivate && (
|
|
217
|
+
<Button
|
|
218
|
+
variant="ghost"
|
|
219
|
+
size="iconSm"
|
|
220
|
+
title={
|
|
221
|
+
item.isActive ? t("users.inactive") : t("users.active")
|
|
222
|
+
}
|
|
223
|
+
onClick={() => u.setActive(item.id, !item.isActive)}
|
|
224
|
+
>
|
|
225
|
+
{item.isActive ? (
|
|
226
|
+
<Ban className="h-4 w-4" />
|
|
227
|
+
) : (
|
|
228
|
+
<CheckCircle2 className="h-4 w-4 text-success" />
|
|
229
|
+
)}
|
|
230
|
+
</Button>
|
|
231
|
+
)}
|
|
232
|
+
{canDelete && (
|
|
233
|
+
<Button
|
|
234
|
+
variant="ghost"
|
|
235
|
+
size="iconSm"
|
|
236
|
+
className="text-destructive"
|
|
237
|
+
title={t("common.delete")}
|
|
238
|
+
onClick={() => {
|
|
239
|
+
void confirm({
|
|
240
|
+
title: t("common.delete"),
|
|
241
|
+
description: item.name,
|
|
242
|
+
confirmLabel: t("common.delete"),
|
|
243
|
+
destructive: true,
|
|
244
|
+
}).then((ok) => {
|
|
245
|
+
if (ok) {
|
|
246
|
+
void u.remove(item.id);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
<Trash2 className="h-4 w-4" />
|
|
252
|
+
</Button>
|
|
253
|
+
)}
|
|
254
|
+
</RowActions>
|
|
255
|
+
);
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return cols;
|
|
260
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
261
|
+
}, [
|
|
262
|
+
t,
|
|
263
|
+
showActions,
|
|
264
|
+
canUpdate,
|
|
265
|
+
canInvite,
|
|
266
|
+
canReset,
|
|
267
|
+
canReset2fa,
|
|
268
|
+
canDeactivate,
|
|
269
|
+
canDelete,
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
const pagination: PaginationState = {
|
|
273
|
+
pageIndex: u.page,
|
|
274
|
+
pageSize: u.pageSize,
|
|
275
|
+
};
|
|
276
|
+
const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
|
|
277
|
+
const next = typeof updater === "function" ? updater(pagination) : updater;
|
|
278
|
+
if (next.pageSize !== u.pageSize) {
|
|
279
|
+
u.setPageSize(next.pageSize);
|
|
280
|
+
u.setPage(0);
|
|
281
|
+
} else if (next.pageIndex !== u.page) {
|
|
282
|
+
u.setPage(next.pageIndex);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const onSortingChange: OnChangeFn<SortingState> = (updater) => {
|
|
286
|
+
const next = typeof updater === "function" ? updater(u.sorting) : updater;
|
|
287
|
+
u.setSorting(next);
|
|
288
|
+
u.setPage(0);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<div>
|
|
293
|
+
<h1 className="mb-4 text-3xl font-bold">{t("users.title")}</h1>
|
|
294
|
+
|
|
295
|
+
<DataTable
|
|
296
|
+
columns={columns}
|
|
297
|
+
data={u.rows}
|
|
298
|
+
getRowId={(r) => r.id}
|
|
299
|
+
storageKey="users"
|
|
300
|
+
loading={u.loading}
|
|
301
|
+
manualPagination
|
|
302
|
+
rowCount={u.total}
|
|
303
|
+
pagination={pagination}
|
|
304
|
+
onPaginationChange={onPaginationChange}
|
|
305
|
+
manualSorting
|
|
306
|
+
sorting={u.sorting}
|
|
307
|
+
onSortingChange={onSortingChange}
|
|
308
|
+
filters={u.filters}
|
|
309
|
+
toolbar={
|
|
310
|
+
hasPermission("users:create:any") && (
|
|
311
|
+
<Button onClick={openCreate}>
|
|
312
|
+
<Plus className="h-4 w-4" />
|
|
313
|
+
{t("users.newUser")}
|
|
314
|
+
</Button>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
/>
|
|
318
|
+
|
|
319
|
+
<UserFormDialog
|
|
320
|
+
open={formOpen}
|
|
321
|
+
loading={u.acting}
|
|
322
|
+
roles={u.roles}
|
|
323
|
+
editing={editing}
|
|
324
|
+
onClose={() => setFormOpen(false)}
|
|
325
|
+
onSubmit={submitForm}
|
|
326
|
+
/>
|
|
327
|
+
|
|
328
|
+
{confirmDialog}
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
const STORAGE_PREFIX = "cols:";
|
|
6
|
+
|
|
7
|
+
export interface UseColumnVisibilityResult {
|
|
8
|
+
hiddenColumns: string[];
|
|
9
|
+
isColumnVisible: (columnId: string) => boolean;
|
|
10
|
+
saveHiddenColumns: (nextHidden: string[]) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Persiste as colunas ocultas de uma tabela em localStorage (por storageKey).
|
|
15
|
+
* Retorna a lista de ocultas + helpers para ler/salvar.
|
|
16
|
+
*/
|
|
17
|
+
export function useColumnVisibility(
|
|
18
|
+
storageKey?: string,
|
|
19
|
+
): UseColumnVisibilityResult {
|
|
20
|
+
const [hiddenColumns, setHiddenColumns] = useState<string[]>([]);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!storageKey || typeof window === "undefined") {return;}
|
|
24
|
+
try {
|
|
25
|
+
const raw = window.localStorage.getItem(`${STORAGE_PREFIX}${storageKey}`);
|
|
26
|
+
if (raw) {setHiddenColumns(JSON.parse(raw));}
|
|
27
|
+
} catch {
|
|
28
|
+
setHiddenColumns([]);
|
|
29
|
+
}
|
|
30
|
+
}, [storageKey]);
|
|
31
|
+
|
|
32
|
+
const isColumnVisible = useCallback(
|
|
33
|
+
(columnId: string) => !hiddenColumns.includes(columnId),
|
|
34
|
+
[hiddenColumns],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const saveHiddenColumns = useCallback(
|
|
38
|
+
(nextHidden: string[]) => {
|
|
39
|
+
setHiddenColumns(nextHidden);
|
|
40
|
+
if (!storageKey || typeof window === "undefined") {return;}
|
|
41
|
+
try {
|
|
42
|
+
window.localStorage.setItem(
|
|
43
|
+
`${STORAGE_PREFIX}${storageKey}`,
|
|
44
|
+
JSON.stringify(nextHidden),
|
|
45
|
+
);
|
|
46
|
+
} catch {
|
|
47
|
+
/* ignore */
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
[storageKey],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return { hiddenColumns, isColumnVisible, saveHiddenColumns };
|
|
54
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
import { useCallback, useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { ConfirmDialog } from "#core/components/ui";
|
|
7
|
+
import { useI18n } from "#core/contexts";
|
|
8
|
+
|
|
9
|
+
export interface ConfirmOptions {
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
confirmLabel?: string;
|
|
13
|
+
cancelLabel?: string;
|
|
14
|
+
/** Ação sem volta (excluir, resetar 2FA) — pinta o botão de vermelho. */
|
|
15
|
+
destructive?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Pending {
|
|
19
|
+
options: ConfirmOptions;
|
|
20
|
+
resolve: (confirmed: boolean) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UseConfirmResult {
|
|
24
|
+
/** Aguarda a decisão do usuário: `true` confirmou, `false` cancelou. */
|
|
25
|
+
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
|
26
|
+
/** Renderize no final da tela — é o modal em si. */
|
|
27
|
+
confirmDialog: JSX.Element;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Substitui o `confirm()` do browser mantendo o mesmo jeito de chamar:
|
|
32
|
+
*
|
|
33
|
+
* if (!(await confirm({ title: "Excluir?" }))) { return; }
|
|
34
|
+
*
|
|
35
|
+
* A promise resolve quando o usuário decide — cancelar (ou fechar no `Esc`)
|
|
36
|
+
* resolve `false`, então o fluxo de quem chamou não muda.
|
|
37
|
+
*/
|
|
38
|
+
export function useConfirm(): UseConfirmResult {
|
|
39
|
+
const { t } = useI18n();
|
|
40
|
+
const [pending, setPending] = useState<Pending | null>(null);
|
|
41
|
+
|
|
42
|
+
const confirm = useCallback(
|
|
43
|
+
(options: ConfirmOptions) =>
|
|
44
|
+
new Promise<boolean>((resolve) => setPending({ options, resolve })),
|
|
45
|
+
[],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const settle = useCallback(
|
|
49
|
+
(confirmed: boolean) => {
|
|
50
|
+
pending?.resolve(confirmed);
|
|
51
|
+
setPending(null);
|
|
52
|
+
},
|
|
53
|
+
[pending],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const confirmDialog = (
|
|
57
|
+
<ConfirmDialog
|
|
58
|
+
open={!!pending}
|
|
59
|
+
title={pending?.options.title ?? ""}
|
|
60
|
+
description={pending?.options.description}
|
|
61
|
+
confirmLabel={pending?.options.confirmLabel ?? t("common.confirm")}
|
|
62
|
+
cancelLabel={pending?.options.cancelLabel ?? t("common.cancel")}
|
|
63
|
+
destructive={pending?.options.destructive}
|
|
64
|
+
onConfirm={() => settle(true)}
|
|
65
|
+
onCancel={() => settle(false)}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return { confirm, confirmDialog };
|
|
70
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { api } from "#core/_services/api/axios.factory";
|
|
6
|
+
import type { FilterField, FilterOption } from "#core/_utils/filter";
|
|
7
|
+
|
|
8
|
+
export interface UseFilterSchemaResult {
|
|
9
|
+
schema: FilterField[];
|
|
10
|
+
loading: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Catálogo por URL. Ele muda com deploy, não durante a sessão — recarregar a
|
|
15
|
+
* cada abertura do painel só gastaria requisição.
|
|
16
|
+
*/
|
|
17
|
+
const cache = new Map<string, FilterField[]>();
|
|
18
|
+
|
|
19
|
+
/** Item de um endpoint de opções: ou já vem `{value,label}`, ou tem `name`. */
|
|
20
|
+
interface RemoteOption {
|
|
21
|
+
value?: string;
|
|
22
|
+
label?: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const toOption = (item: RemoteOption): FilterOption | null => {
|
|
28
|
+
const value = item.value ?? item.name ?? item.id;
|
|
29
|
+
if (!value) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return { value, label: item.label ?? item.name ?? value };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Busca o catálogo de campos filtráveis e resolve as opções dos campos
|
|
37
|
+
* `SELECT` — assim o painel só precisa olhar para `field.options`.
|
|
38
|
+
*/
|
|
39
|
+
export function useFilterSchema(url: string): UseFilterSchemaResult {
|
|
40
|
+
const [schema, setSchema] = useState<FilterField[]>(cache.get(url) ?? []);
|
|
41
|
+
const [loading, setLoading] = useState(!cache.has(url));
|
|
42
|
+
|
|
43
|
+
const load = useCallback(async (): Promise<void> => {
|
|
44
|
+
const cached = cache.get(url);
|
|
45
|
+
if (cached) {
|
|
46
|
+
setSchema(cached);
|
|
47
|
+
setLoading(false);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const { data } = await api.get<FilterField[]>(url);
|
|
53
|
+
const resolved = await Promise.all(data.map(withRemoteOptions));
|
|
54
|
+
cache.set(url, resolved);
|
|
55
|
+
setSchema(resolved);
|
|
56
|
+
} catch {
|
|
57
|
+
// Sem catálogo o botão de filtros some, e a listagem continua de pé —
|
|
58
|
+
// filtro é acessório, não pode derrubar a tela.
|
|
59
|
+
setSchema([]);
|
|
60
|
+
} finally {
|
|
61
|
+
setLoading(false);
|
|
62
|
+
}
|
|
63
|
+
}, [url]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
void load();
|
|
67
|
+
}, [load]);
|
|
68
|
+
|
|
69
|
+
return { schema, loading };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const withRemoteOptions = async (field: FilterField): Promise<FilterField> => {
|
|
73
|
+
if (!field.optionsUrl) {
|
|
74
|
+
return field;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const { data } = await api.get<RemoteOption[]>(field.optionsUrl);
|
|
78
|
+
const options = data
|
|
79
|
+
.map(toOption)
|
|
80
|
+
.filter((option): option is FilterOption => option !== null);
|
|
81
|
+
return { ...field, options };
|
|
82
|
+
} catch {
|
|
83
|
+
return { ...field, options: [] };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import type { AdvancedGroup, AdvancedValidation } from "#core/_utils/advanced-filter";
|
|
6
|
+
import {
|
|
7
|
+
buildAdvancedFilter,
|
|
8
|
+
initialTree,
|
|
9
|
+
isAdvancedFilter,
|
|
10
|
+
parseAdvancedFilter,
|
|
11
|
+
validateTree,
|
|
12
|
+
} from "#core/_utils/advanced-filter";
|
|
13
|
+
import type { FilterField, FilterValues } from "#core/_utils/filter";
|
|
14
|
+
import { buildFilter, parseFilterValues } from "#core/_utils/filter";
|
|
15
|
+
import { useFilterSchema } from "#core/hooks/use-filter-schema";
|
|
16
|
+
|
|
17
|
+
export type FilterMode = "simple" | "advanced";
|
|
18
|
+
|
|
19
|
+
/** Estado completo dos filtros de uma listagem — repassado inteiro ao `DataTable`. */
|
|
20
|
+
export interface FiltersState {
|
|
21
|
+
schema: FilterField[];
|
|
22
|
+
loading: boolean;
|
|
23
|
+
mode: FilterMode;
|
|
24
|
+
setMode: (mode: FilterMode) => void;
|
|
25
|
+
/** Modo simples: um campo, uma condição, tudo unido por `E`. */
|
|
26
|
+
values: FilterValues;
|
|
27
|
+
setValues: (values: FilterValues) => void;
|
|
28
|
+
/** Modo avançado: árvore de regras e grupos. */
|
|
29
|
+
tree: AdvancedGroup | null;
|
|
30
|
+
setTree: (tree: AdvancedGroup) => void;
|
|
31
|
+
/** Tetos do backend conferidos antes de enviar. */
|
|
32
|
+
validation: AdvancedValidation;
|
|
33
|
+
/** JSON pronto para a query — `undefined` quando não há filtro. */
|
|
34
|
+
filter: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const QUERY_PARAM = "filter";
|
|
38
|
+
|
|
39
|
+
const writeToUrl = (encoded: string | undefined): void => {
|
|
40
|
+
const params = new URLSearchParams(window.location.search);
|
|
41
|
+
if (encoded) {
|
|
42
|
+
params.set(QUERY_PARAM, encoded);
|
|
43
|
+
} else {
|
|
44
|
+
params.delete(QUERY_PARAM);
|
|
45
|
+
}
|
|
46
|
+
const query = params.toString();
|
|
47
|
+
window.history.replaceState(
|
|
48
|
+
null,
|
|
49
|
+
"",
|
|
50
|
+
query ? `${window.location.pathname}?${query}` : window.location.pathname,
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Estado dos filtros de uma listagem, espelhado na querystring.
|
|
56
|
+
*
|
|
57
|
+
* Espelhar na URL faz o filtro sobreviver ao F5 e virar link compartilhável.
|
|
58
|
+
* O espelho é `replaceState` e não `router.replace`: o filtro não é navegação,
|
|
59
|
+
* e trocá-lo não deve empilhar histórico nem refazer o render da rota.
|
|
60
|
+
*/
|
|
61
|
+
export function useFilters(schemaUrl: string): FiltersState {
|
|
62
|
+
const { schema, loading } = useFilterSchema(schemaUrl);
|
|
63
|
+
|
|
64
|
+
const [mode, setModeState] = useState<FilterMode>("simple");
|
|
65
|
+
const [values, setValuesState] = useState<FilterValues>({});
|
|
66
|
+
const [tree, setTreeState] = useState<AdvancedGroup | null>(null);
|
|
67
|
+
const [hydrated, setHydrated] = useState(false);
|
|
68
|
+
|
|
69
|
+
// Hidrata da URL uma vez, quando o catálogo chega: sem ele não dá para saber
|
|
70
|
+
// o tipo de cada campo, e portanto nem como ler o JSON. O combinador no topo
|
|
71
|
+
// do JSON é o que revela que o filtro veio do modo avançado.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (hydrated || loading) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const raw = new URLSearchParams(window.location.search).get(QUERY_PARAM);
|
|
77
|
+
if (raw && isAdvancedFilter(raw)) {
|
|
78
|
+
const parsed = parseAdvancedFilter(raw, schema);
|
|
79
|
+
if (parsed) {
|
|
80
|
+
setTreeState(parsed);
|
|
81
|
+
setModeState("advanced");
|
|
82
|
+
}
|
|
83
|
+
} else if (raw) {
|
|
84
|
+
setValuesState(parseFilterValues(raw, schema));
|
|
85
|
+
}
|
|
86
|
+
setHydrated(true);
|
|
87
|
+
}, [hydrated, loading, schema]);
|
|
88
|
+
|
|
89
|
+
const setValues = useCallback(
|
|
90
|
+
(next: FilterValues): void => {
|
|
91
|
+
setValuesState(next);
|
|
92
|
+
writeToUrl(buildFilter(next, schema));
|
|
93
|
+
},
|
|
94
|
+
[schema],
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const setTree = useCallback(
|
|
98
|
+
(next: AdvancedGroup): void => {
|
|
99
|
+
setTreeState(next);
|
|
100
|
+
// Árvore acima dos tetos não vai para a URL nem para a API: o painel
|
|
101
|
+
// mostra o aviso e o filtro anterior continua valendo.
|
|
102
|
+
writeToUrl(
|
|
103
|
+
validateTree(next).valid ? buildAdvancedFilter(next, schema) : undefined,
|
|
104
|
+
);
|
|
105
|
+
},
|
|
106
|
+
[schema],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const setMode = useCallback(
|
|
110
|
+
(next: FilterMode): void => {
|
|
111
|
+
setModeState(next);
|
|
112
|
+
if (next === "advanced") {
|
|
113
|
+
// Entrar no avançado sem árvore começa com uma regra em branco.
|
|
114
|
+
setTreeState((current) => current ?? initialTree(schema));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
writeToUrl(buildFilter(values, schema));
|
|
118
|
+
},
|
|
119
|
+
[schema, values],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const validation = useMemo<AdvancedValidation>(
|
|
123
|
+
() => (tree ? validateTree(tree) : { valid: true }),
|
|
124
|
+
[tree],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const filter = useMemo(() => {
|
|
128
|
+
if (mode === "advanced") {
|
|
129
|
+
return tree && validation.valid
|
|
130
|
+
? buildAdvancedFilter(tree, schema)
|
|
131
|
+
: undefined;
|
|
132
|
+
}
|
|
133
|
+
return buildFilter(values, schema);
|
|
134
|
+
}, [mode, tree, validation.valid, values, schema]);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
schema,
|
|
138
|
+
loading,
|
|
139
|
+
mode,
|
|
140
|
+
setMode,
|
|
141
|
+
values,
|
|
142
|
+
setValues,
|
|
143
|
+
tree,
|
|
144
|
+
setTree,
|
|
145
|
+
validation,
|
|
146
|
+
filter,
|
|
147
|
+
};
|
|
148
|
+
}
|