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.
Files changed (112) hide show
  1. package/README.md +129 -0
  2. package/package.json +113 -0
  3. package/src/_services/api/axios.factory.ts +97 -0
  4. package/src/_services/api/list-query.ts +26 -0
  5. package/src/_services/api/schema.d.ts +2527 -0
  6. package/src/_services/auth/index.ts +127 -0
  7. package/src/_utils/advanced-filter.ts +386 -0
  8. package/src/_utils/calendar.ts +103 -0
  9. package/src/_utils/crop-image.ts +65 -0
  10. package/src/_utils/filter.ts +313 -0
  11. package/src/_utils/format.ts +35 -0
  12. package/src/_utils/initials.ts +24 -0
  13. package/src/_utils/password.ts +7 -0
  14. package/src/components/app-shell.tsx +314 -0
  15. package/src/components/breadcrumbs.tsx +55 -0
  16. package/src/components/index.ts +7 -0
  17. package/src/components/language-selector.tsx +51 -0
  18. package/src/components/password-field.tsx +40 -0
  19. package/src/components/theme-toggle.tsx +27 -0
  20. package/src/components/ui/alert.tsx +27 -0
  21. package/src/components/ui/avatar.tsx +50 -0
  22. package/src/components/ui/badge.tsx +36 -0
  23. package/src/components/ui/button.tsx +62 -0
  24. package/src/components/ui/card.tsx +86 -0
  25. package/src/components/ui/checkbox.tsx +30 -0
  26. package/src/components/ui/column-visibility-modal.tsx +110 -0
  27. package/src/components/ui/confirm-dialog.tsx +69 -0
  28. package/src/components/ui/data-table.tsx +401 -0
  29. package/src/components/ui/date-range-picker.tsx +250 -0
  30. package/src/components/ui/dialog.tsx +138 -0
  31. package/src/components/ui/dropdown-menu.tsx +108 -0
  32. package/src/components/ui/field.tsx +20 -0
  33. package/src/components/ui/filter-chips.tsx +88 -0
  34. package/src/components/ui/filter-field.tsx +137 -0
  35. package/src/components/ui/filter-group.tsx +155 -0
  36. package/src/components/ui/filter-rule.tsx +221 -0
  37. package/src/components/ui/filter-sheet.tsx +201 -0
  38. package/src/components/ui/index.ts +30 -0
  39. package/src/components/ui/input.tsx +25 -0
  40. package/src/components/ui/label.tsx +23 -0
  41. package/src/components/ui/row-actions.tsx +32 -0
  42. package/src/components/ui/select.tsx +97 -0
  43. package/src/components/ui/separator.tsx +31 -0
  44. package/src/components/ui/sheet.tsx +119 -0
  45. package/src/components/ui/sonner.tsx +28 -0
  46. package/src/components/ui/spinner.tsx +12 -0
  47. package/src/components/ui/switch.tsx +29 -0
  48. package/src/components/ui/table.tsx +84 -0
  49. package/src/components/ui/tabs.tsx +52 -0
  50. package/src/components/user-avatar.tsx +54 -0
  51. package/src/contexts/auth-context.tsx +90 -0
  52. package/src/contexts/color-mode-context.tsx +105 -0
  53. package/src/contexts/i18n-context.tsx +76 -0
  54. package/src/contexts/index.ts +6 -0
  55. package/src/contexts/socket-context.tsx +153 -0
  56. package/src/contexts/toast-context.tsx +40 -0
  57. package/src/features/audit/audit-screen.tsx +299 -0
  58. package/src/features/audit/hooks/use-audit-trail.ts +68 -0
  59. package/src/features/audit/services/audit.service.ts +24 -0
  60. package/src/features/dashboard/dashboard-screen.tsx +73 -0
  61. package/src/features/login/components/backup-codes-dialog.tsx +66 -0
  62. package/src/features/login/components/index.ts +5 -0
  63. package/src/features/login/components/login-form.tsx +62 -0
  64. package/src/features/login/components/two-factor-setup.tsx +71 -0
  65. package/src/features/login/components/two-factor-verify.tsx +56 -0
  66. package/src/features/login/hooks/use-login-flow.ts +119 -0
  67. package/src/features/login/login-screen.tsx +83 -0
  68. package/src/features/login/validation/schemas.ts +30 -0
  69. package/src/features/logs/hooks/use-error-logs.ts +65 -0
  70. package/src/features/logs/hooks/use-request-logs.ts +65 -0
  71. package/src/features/logs/logs-screen.tsx +31 -0
  72. package/src/features/logs/panels/error-logs-panel.tsx +183 -0
  73. package/src/features/logs/panels/index.ts +3 -0
  74. package/src/features/logs/panels/request-logs-panel.tsx +124 -0
  75. package/src/features/logs/services/logs.service.ts +68 -0
  76. package/src/features/notifications/components/notification-item.tsx +67 -0
  77. package/src/features/notifications/hooks/use-notifications.ts +78 -0
  78. package/src/features/notifications/notifications-center.tsx +119 -0
  79. package/src/features/notifications/services/notifications.service.ts +38 -0
  80. package/src/features/profile/components/avatar-crop-dialog.tsx +133 -0
  81. package/src/features/profile/components/index.ts +2 -0
  82. package/src/features/profile/force-password-change.tsx +114 -0
  83. package/src/features/profile/profile-screen.tsx +385 -0
  84. package/src/features/rbac/panels/actions-panel.tsx +20 -0
  85. package/src/features/rbac/panels/catalog-panel.tsx +243 -0
  86. package/src/features/rbac/panels/groups-panel.tsx +269 -0
  87. package/src/features/rbac/panels/index.ts +8 -0
  88. package/src/features/rbac/panels/permissions-panel.tsx +204 -0
  89. package/src/features/rbac/panels/resources-panel.tsx +20 -0
  90. package/src/features/rbac/panels/roles-panel.tsx +224 -0
  91. package/src/features/rbac/panels/scopes-panel.tsx +20 -0
  92. package/src/features/rbac/rbac-screen.tsx +80 -0
  93. package/src/features/rbac/services/rbac.service.ts +110 -0
  94. package/src/features/recovery/forgot-password-screen.tsx +69 -0
  95. package/src/features/recovery/reset-password-screen.tsx +163 -0
  96. package/src/features/users/components/user-form-dialog.tsx +187 -0
  97. package/src/features/users/hooks/use-users.ts +181 -0
  98. package/src/features/users/services/users.service.ts +83 -0
  99. package/src/features/users/users-screen.tsx +331 -0
  100. package/src/hooks/use-column-visibility.ts +54 -0
  101. package/src/hooks/use-confirm.tsx +70 -0
  102. package/src/hooks/use-filter-schema.ts +85 -0
  103. package/src/hooks/use-filters.ts +148 -0
  104. package/src/hooks/use-list-query.ts +69 -0
  105. package/src/hooks/use-request.ts +114 -0
  106. package/src/i18n/messages/en.ts +296 -0
  107. package/src/i18n/messages/pt.ts +305 -0
  108. package/src/index.ts +60 -0
  109. package/src/lib/utils.ts +6 -0
  110. package/src/middleware.ts +42 -0
  111. package/src/styles/core.css +142 -0
  112. 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
+ }