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,127 @@
1
+ import { api } from "#core/_services/api/axios.factory";
2
+ import type { components } from "#core/_services/api/schema";
3
+
4
+ export type LoginStatus = "SETUP_REQUIRED" | "TOTP_REQUIRED";
5
+
6
+ export interface LoginResponse {
7
+ status: LoginStatus;
8
+ pendingToken: string;
9
+ }
10
+
11
+ export interface TotpSetup {
12
+ secret: string;
13
+ otpauthUrl: string;
14
+ qrDataUrl: string;
15
+ }
16
+
17
+
18
+
19
+ export interface VerifyResponse {
20
+ user: Profile;
21
+ backupCodes?: string[];
22
+ }
23
+
24
+ /** Espelha PasswordTokenStatus do backend. */
25
+ export type PasswordTokenStatus = "valid" | "invalid" | "expired" | "used";
26
+ export type PasswordTokenPurpose = "password_reset" | "first_access";
27
+
28
+ export interface PasswordTokenCheck {
29
+ valid: boolean;
30
+ status: PasswordTokenStatus;
31
+ purpose?: PasswordTokenPurpose;
32
+ }
33
+
34
+ /**
35
+ * Forma única do usuário autenticado, **derivada do contrato do backend**
36
+ * (`openapi.json` → `schema.d.ts`). Campo que mudar lá quebra o build aqui,
37
+ * em vez de quebrar a tela do usuário.
38
+ */
39
+ export type Profile = components["schemas"]["SessionUserResponse"];
40
+
41
+
42
+ export const authService = {
43
+ login(email: string, password: string) {
44
+ return api
45
+ .post<LoginResponse>("/auth/login", { email, password })
46
+ .then((r) => r.data);
47
+ },
48
+
49
+ setupTotp(pendingToken: string) {
50
+ return api
51
+ .post<TotpSetup>("/auth/2fa/setup", { pendingToken })
52
+ .then((r) => r.data);
53
+ },
54
+
55
+ requestEmailCode(pendingToken: string) {
56
+ return api
57
+ .post<{ sent: boolean }>("/auth/2fa/email", { pendingToken })
58
+ .then((r) => r.data);
59
+ },
60
+
61
+ verify(
62
+ pendingToken: string,
63
+ code: string,
64
+ method?: "totp" | "email" | "backup",
65
+ ) {
66
+ return api
67
+ .post<VerifyResponse>("/auth/2fa/verify", { pendingToken, code, method })
68
+ .then((r) => r.data);
69
+ },
70
+
71
+ profile() {
72
+ return api.get<Profile>("/auth/profile").then((r) => r.data);
73
+ },
74
+
75
+ logout() {
76
+ return api.post("/auth/logout").then((r) => r.data);
77
+ },
78
+
79
+ forgotPassword(email: string) {
80
+ return api.post("/auth/forgot-password", { email }).then((r) => r.data);
81
+ },
82
+
83
+ /** Verifica o link de senha antes de exibir o formulário (não consome o token). */
84
+ inspectPasswordToken(token: string): Promise<PasswordTokenCheck> {
85
+ return api
86
+ .get<PasswordTokenCheck>(
87
+ `/auth/password-token/${encodeURIComponent(token)}`,
88
+ )
89
+ .then((r) => r.data);
90
+ },
91
+
92
+ resetPassword(token: string, newPassword: string) {
93
+ return api
94
+ .post("/auth/reset-password", { token, newPassword })
95
+ .then((r) => r.data);
96
+ },
97
+
98
+ changePassword(currentPassword: string, newPassword: string) {
99
+ return api
100
+ .patch("/users/me/password", { currentPassword, newPassword })
101
+ .then((r) => r.data);
102
+ },
103
+
104
+ updateProfile(data: { name?: string; lastName?: string; phone?: string }) {
105
+ return api.patch("/users/me", data).then((r) => r.data);
106
+ },
107
+
108
+ regenerateBackup(code: string) {
109
+ return api
110
+ .post<{ backupCodes: string[] }>("/auth/2fa/backup/regenerate", { code })
111
+ .then((r) => r.data);
112
+ },
113
+
114
+ uploadAvatar(file: File) {
115
+ const form = new FormData();
116
+ form.append("file", file);
117
+ return api
118
+ .post("/users/me/avatar", form, {
119
+ headers: { "Content-Type": "multipart/form-data" },
120
+ })
121
+ .then((r) => r.data);
122
+ },
123
+
124
+ deleteAvatar() {
125
+ return api.delete("/users/me/avatar").then((r) => r.data);
126
+ },
127
+ };
@@ -0,0 +1,386 @@
1
+ import { fromIsoDay, toIsoDay } from "#core/_utils/calendar";
2
+ import type { FilterField, FilterOperator } from "#core/_utils/filter";
3
+
4
+ /**
5
+ * Modo avançado do filtro: uma árvore de regras e grupos, com `E`/`OU` por
6
+ * grupo. É o que a DSL do backend já aceitava desde o começo — aqui só ganha
7
+ * interface.
8
+ */
9
+ export type Combinator = "$AND" | "$OR";
10
+
11
+ export interface AdvancedRule {
12
+ id: string;
13
+ kind: "rule";
14
+ /** Nome público do campo, como vem do catálogo. */
15
+ field: string;
16
+ operator: FilterOperator;
17
+ /** Valor único, ou a primeira ponta de um intervalo. */
18
+ value: string;
19
+ /** Segunda ponta (`$BETWEEN`/`$NOTBETWEEN`). */
20
+ value2?: string;
21
+ /** Seleção múltipla (`$IN`/`$NOTIN`). */
22
+ values?: string[];
23
+ }
24
+
25
+ export interface AdvancedGroup {
26
+ id: string;
27
+ kind: "group";
28
+ combinator: Combinator;
29
+ children: AdvancedNode[];
30
+ }
31
+
32
+ export type AdvancedNode = AdvancedRule | AdvancedGroup;
33
+
34
+ /**
35
+ * Tetos espelhados de `backend/src/core/query/filter.constants.ts`.
36
+ *
37
+ * Ficam repetidos de propósito: o backend precisa deles para se defender de
38
+ * qualquer cliente, e a tela precisa deles para avisar **antes** do 400. Se um
39
+ * mudar lá, muda aqui.
40
+ */
41
+ export const MAX_DEPTH = 3;
42
+ export const MAX_CONDITIONS = 50;
43
+ export const MAX_IN_ITEMS = 100;
44
+
45
+ /** Operadores que não têm campo de valor. */
46
+ export const NULLABILITY_OPERATORS: FilterOperator[] = ["$IS", "$NOT"];
47
+ /** Operadores com duas pontas. */
48
+ export const RANGE_OPERATORS: FilterOperator[] = ["$BETWEEN", "$NOTBETWEEN"];
49
+ /** Operadores de lista. */
50
+ export const LIST_OPERATORS: FilterOperator[] = ["$IN", "$NOTIN"];
51
+
52
+ const uid = (): string =>
53
+ `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
54
+
55
+ export const isGroup = (node: AdvancedNode): node is AdvancedGroup =>
56
+ node.kind === "group";
57
+
58
+ export const newRule = (field: FilterField): AdvancedRule => ({
59
+ id: uid(),
60
+ kind: "rule",
61
+ field: field.field,
62
+ operator: field.operators[0],
63
+ value: "",
64
+ });
65
+
66
+ export const newGroup = (combinator: Combinator = "$AND"): AdvancedGroup => ({
67
+ id: uid(),
68
+ kind: "group",
69
+ combinator,
70
+ children: [],
71
+ });
72
+
73
+ /** Árvore inicial: um grupo `E` com uma regra no primeiro campo do catálogo. */
74
+ export const initialTree = (schema: FilterField[]): AdvancedGroup => {
75
+ const root = newGroup("$AND");
76
+ if (schema.length > 0) {
77
+ root.children.push(newRule(schema[0]));
78
+ }
79
+ return root;
80
+ };
81
+
82
+ export const countConditions = (node: AdvancedNode): number =>
83
+ isGroup(node)
84
+ ? node.children.reduce((total, child) => total + countConditions(child), 0)
85
+ : 1;
86
+
87
+ /** Profundidade em níveis de grupo — o grupo raiz conta como 1. */
88
+ export const depthOf = (node: AdvancedNode): number =>
89
+ isGroup(node)
90
+ ? 1 + Math.max(0, ...node.children.map((child) => depthOf(child)))
91
+ : 0;
92
+
93
+ /** Substitui um nó na árvore pelo resultado de `update` (ou o remove com `null`). */
94
+ export const replaceNode = (
95
+ node: AdvancedNode,
96
+ id: string,
97
+ update: (found: AdvancedNode) => AdvancedNode | null,
98
+ ): AdvancedNode | null => {
99
+ if (node.id === id) {
100
+ return update(node);
101
+ }
102
+ if (!isGroup(node)) {
103
+ return node;
104
+ }
105
+ const children = node.children
106
+ .map((child) => replaceNode(child, id, update))
107
+ .filter((child): child is AdvancedNode => child !== null);
108
+ return { ...node, children };
109
+ };
110
+
111
+ const splitList = (raw: string): string[] =>
112
+ raw
113
+ .split(",")
114
+ .map((item) => item.trim())
115
+ .filter((item) => item !== "");
116
+
117
+ const isDateField = (field: FilterField): boolean =>
118
+ field.type === "DATE" || field.type === "DATETIME";
119
+
120
+ /**
121
+ * Dia local → ISO com o fuso de quem filtrou, como no modo simples: dia sem
122
+ * hora seria lido como UTC e o período começaria atrasado.
123
+ */
124
+ const dayBound = (value: string, bound: "start" | "end"): string => {
125
+ const day = fromIsoDay(value);
126
+ if (!day) {
127
+ return value;
128
+ }
129
+ const date = new Date(day);
130
+ if (bound === "start") {
131
+ date.setHours(0, 0, 0, 0);
132
+ } else {
133
+ date.setHours(23, 59, 59, 999);
134
+ }
135
+ return date.toISOString();
136
+ };
137
+
138
+ const coerce = (field: FilterField, raw: string): unknown => {
139
+ if (field.type === "NUMBER") {
140
+ const parsed = Number(raw);
141
+ return Number.isFinite(parsed) ? parsed : raw;
142
+ }
143
+ if (field.type === "BOOLEAN") {
144
+ return raw === "true";
145
+ }
146
+ return raw;
147
+ };
148
+
149
+ /** Uma regra → o par `{ campo: { operador: valor } }` da DSL. */
150
+ const ruleToCondition = (
151
+ rule: AdvancedRule,
152
+ schema: FilterField[],
153
+ ): Record<string, unknown> | null => {
154
+ const field = schema.find((item) => item.field === rule.field);
155
+ if (!field) {
156
+ return null;
157
+ }
158
+
159
+ if (NULLABILITY_OPERATORS.includes(rule.operator)) {
160
+ // "" no select vale null; o resto é booleano.
161
+ const operand = rule.value === "" ? null : rule.value === "true";
162
+ return { [rule.field]: { [rule.operator]: operand } };
163
+ }
164
+
165
+ if (RANGE_OPERATORS.includes(rule.operator)) {
166
+ if (!rule.value || !rule.value2) {
167
+ return null;
168
+ }
169
+ const pair = isDateField(field)
170
+ ? [dayBound(rule.value, "start"), dayBound(rule.value2, "end")]
171
+ : [coerce(field, rule.value), coerce(field, rule.value2)];
172
+ return { [rule.field]: { [rule.operator]: pair } };
173
+ }
174
+
175
+ if (LIST_OPERATORS.includes(rule.operator)) {
176
+ const items = rule.values?.length ? rule.values : splitList(rule.value);
177
+ if (items.length === 0) {
178
+ return null;
179
+ }
180
+ return {
181
+ [rule.field]: {
182
+ [rule.operator]: items.map((item) => coerce(field, item)),
183
+ },
184
+ };
185
+ }
186
+
187
+ if (rule.value === "") {
188
+ return null;
189
+ }
190
+ const single = isDateField(field)
191
+ ? dayBound(rule.value, rule.operator === "$LTE" ? "end" : "start")
192
+ : coerce(field, rule.value);
193
+ return { [rule.field]: { [rule.operator]: single } };
194
+ };
195
+
196
+ const nodeToJson = (
197
+ node: AdvancedNode,
198
+ schema: FilterField[],
199
+ ): Record<string, unknown> | null => {
200
+ if (!isGroup(node)) {
201
+ return ruleToCondition(node, schema);
202
+ }
203
+ const parts = node.children
204
+ .map((child) => nodeToJson(child, schema))
205
+ .filter((part): part is Record<string, unknown> => part !== null);
206
+ if (parts.length === 0) {
207
+ return null;
208
+ }
209
+ return { [node.combinator]: parts };
210
+ };
211
+
212
+ /**
213
+ * Árvore → JSON da query.
214
+ *
215
+ * O topo sai sempre com o combinador explícito (`{"$AND":[…]}`), e é isso que
216
+ * faz a tela voltar no modo avançado depois de um F5.
217
+ */
218
+ export const buildAdvancedFilter = (
219
+ root: AdvancedGroup,
220
+ schema: FilterField[],
221
+ ): string | undefined => {
222
+ const json = nodeToJson(root, schema);
223
+ return json ? JSON.stringify(json) : undefined;
224
+ };
225
+
226
+ export interface AdvancedValidation {
227
+ valid: boolean;
228
+ /** Chave de i18n do motivo, quando inválido. */
229
+ reason?: "tooDeep" | "tooManyConditions" | "tooManyItems";
230
+ }
231
+
232
+ /** Confere os tetos do backend antes de mandar, para o erro não virar 400. */
233
+ export const validateTree = (root: AdvancedGroup): AdvancedValidation => {
234
+ if (depthOf(root) > MAX_DEPTH) {
235
+ return { valid: false, reason: "tooDeep" };
236
+ }
237
+ if (countConditions(root) > MAX_CONDITIONS) {
238
+ return { valid: false, reason: "tooManyConditions" };
239
+ }
240
+ const overflowing = (node: AdvancedNode): boolean =>
241
+ isGroup(node)
242
+ ? node.children.some(overflowing)
243
+ : (node.values?.length ?? splitList(node.value).length) > MAX_IN_ITEMS &&
244
+ LIST_OPERATORS.includes(node.operator);
245
+ return overflowing(root)
246
+ ? { valid: false, reason: "tooManyItems" }
247
+ : { valid: true };
248
+ };
249
+
250
+ /** O JSON veio de um filtro avançado? É o que decide o modo ao hidratar a URL. */
251
+ export const isAdvancedFilter = (raw: string | null | undefined): boolean => {
252
+ if (!raw) {
253
+ return false;
254
+ }
255
+ try {
256
+ const parsed: unknown = JSON.parse(raw);
257
+ return (
258
+ typeof parsed === "object" &&
259
+ parsed !== null &&
260
+ ("$AND" in parsed || "$OR" in parsed)
261
+ );
262
+ } catch {
263
+ return false;
264
+ }
265
+ };
266
+
267
+ const OPERATOR_SET = new Set<string>([
268
+ "$EQ",
269
+ "$NE",
270
+ "$IS",
271
+ "$NOT",
272
+ "$GT",
273
+ "$GTE",
274
+ "$LT",
275
+ "$LTE",
276
+ "$BETWEEN",
277
+ "$NOTBETWEEN",
278
+ "$IN",
279
+ "$NOTIN",
280
+ "$LIKE",
281
+ "$NOTLIKE",
282
+ "$STARTSWITH",
283
+ "$ENDSWITH",
284
+ "$SUBSTRING",
285
+ ]);
286
+
287
+ const asText = (field: FilterField, value: unknown): string => {
288
+ if (typeof value === "string" && isDateField(field)) {
289
+ const parsed = new Date(value);
290
+ return Number.isNaN(parsed.getTime()) ? value : toIsoDay(parsed);
291
+ }
292
+ return typeof value === "boolean" || typeof value === "number"
293
+ ? String(value)
294
+ : ((value as string | null) ?? "");
295
+ };
296
+
297
+ const jsonToNode = (
298
+ json: Record<string, unknown>,
299
+ schema: FilterField[],
300
+ ): AdvancedNode | null => {
301
+ const entries = Object.entries(json);
302
+ if (entries.length === 0) {
303
+ return null;
304
+ }
305
+ const [key, value] = entries[0];
306
+
307
+ if (key === "$AND" || key === "$OR") {
308
+ if (!Array.isArray(value)) {
309
+ return null;
310
+ }
311
+ const children = value
312
+ .map((child) =>
313
+ typeof child === "object" && child !== null
314
+ ? jsonToNode(child as Record<string, unknown>, schema)
315
+ : null,
316
+ )
317
+ .filter((child): child is AdvancedNode => child !== null);
318
+ return { id: uid(), kind: "group", combinator: key, children };
319
+ }
320
+
321
+ const field = schema.find((item) => item.field === key);
322
+ if (!field || typeof value !== "object" || value === null) {
323
+ return null;
324
+ }
325
+ const [operator, operand] = Object.entries(
326
+ value as Record<string, unknown>,
327
+ )[0] ?? [null, null];
328
+ if (!operator || !OPERATOR_SET.has(operator)) {
329
+ return null;
330
+ }
331
+
332
+ const rule: AdvancedRule = {
333
+ id: uid(),
334
+ kind: "rule",
335
+ field: key,
336
+ operator: operator as FilterOperator,
337
+ value: "",
338
+ };
339
+
340
+ if (Array.isArray(operand)) {
341
+ if (RANGE_OPERATORS.includes(rule.operator)) {
342
+ rule.value = asText(field, operand[0]);
343
+ rule.value2 = asText(field, operand[1]);
344
+ } else {
345
+ rule.values = operand.map((item) => asText(field, item));
346
+ rule.value = rule.values.join(", ");
347
+ }
348
+ return rule;
349
+ }
350
+
351
+ rule.value = operand === null ? "" : asText(field, operand);
352
+ return rule;
353
+ };
354
+
355
+ /**
356
+ * JSON → árvore, para reabrir a tela no mesmo filtro.
357
+ *
358
+ * Devolve `null` para o que não souber ler: a URL é editável à mão e um filtro
359
+ * estranho não pode impedir a tela de abrir.
360
+ */
361
+ export const parseAdvancedFilter = (
362
+ raw: string | null | undefined,
363
+ schema: FilterField[],
364
+ ): AdvancedGroup | null => {
365
+ if (!raw) {
366
+ return null;
367
+ }
368
+ let parsed: unknown;
369
+ try {
370
+ parsed = JSON.parse(raw);
371
+ } catch {
372
+ return null;
373
+ }
374
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
375
+ return null;
376
+ }
377
+
378
+ const node = jsonToNode(parsed as Record<string, unknown>, schema);
379
+ if (!node) {
380
+ return null;
381
+ }
382
+ // A raiz é sempre um grupo: uma regra solta ganha um `E` em volta.
383
+ return isGroup(node)
384
+ ? node
385
+ : { id: uid(), kind: "group", combinator: "$AND", children: [node] };
386
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Aritmética de calendário para o seletor de período.
3
+ *
4
+ * Sem biblioteca de data: o que o componente precisa é grade do mês, soma de
5
+ * dias/meses e comparação por dia — não compensa uma dependência nova para isso.
6
+ * Toda `Date` criada aqui nasce ao **meio-dia**: em dia de mudança de horário de
7
+ * verão, meia-noite pode não existir e o dia "pula".
8
+ */
9
+ const NOON = 12;
10
+
11
+ /** `Date` local no dia informado, ao meio-dia. */
12
+ export const dayOf = (year: number, month: number, day: number): Date =>
13
+ new Date(year, month, day, NOON, 0, 0, 0);
14
+
15
+ /** `Date` → `"yyyy-MM-dd"` pelo calendário local (nada de `toISOString`, que é UTC). */
16
+ export const toIsoDay = (date: Date): string => {
17
+ const month = String(date.getMonth() + 1).padStart(2, "0");
18
+ const day = String(date.getDate()).padStart(2, "0");
19
+ return `${date.getFullYear()}-${month}-${day}`;
20
+ };
21
+
22
+ /** `"yyyy-MM-dd"` (ou ISO completo) → `Date` local ao meio-dia. `null` se não der. */
23
+ export const fromIsoDay = (value: string | undefined): Date | null => {
24
+ if (!value) {
25
+ return null;
26
+ }
27
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
28
+ if (!match) {
29
+ return null;
30
+ }
31
+ const date = dayOf(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
32
+ return Number.isNaN(date.getTime()) ? null : date;
33
+ };
34
+
35
+ export const addDays = (date: Date, amount: number): Date =>
36
+ dayOf(date.getFullYear(), date.getMonth(), date.getDate() + amount);
37
+
38
+ export const addMonths = (date: Date, amount: number): Date => {
39
+ const target = dayOf(date.getFullYear(), date.getMonth() + amount, 1);
40
+ // 31/03 - 1 mês não pode virar 03/03: prende no último dia do mês destino.
41
+ const lastDay = dayOf(target.getFullYear(), target.getMonth() + 1, 0).getDate();
42
+ return dayOf(
43
+ target.getFullYear(),
44
+ target.getMonth(),
45
+ Math.min(date.getDate(), lastDay),
46
+ );
47
+ };
48
+
49
+ export const isSameDay = (a: Date, b: Date): boolean =>
50
+ a.getFullYear() === b.getFullYear() &&
51
+ a.getMonth() === b.getMonth() &&
52
+ a.getDate() === b.getDate();
53
+
54
+ export const isSameMonth = (a: Date, b: Date): boolean =>
55
+ a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
56
+
57
+ /** Dia dentro do intervalo fechado — a ordem das pontas não importa. */
58
+ export const isWithin = (day: Date, from: Date, to: Date): boolean => {
59
+ const [start, end] = from <= to ? [from, to] : [to, from];
60
+ return day >= start && day <= end;
61
+ };
62
+
63
+ export const today = (): Date => {
64
+ const now = new Date();
65
+ return dayOf(now.getFullYear(), now.getMonth(), now.getDate());
66
+ };
67
+
68
+ /**
69
+ * Grade do mês: 6 semanas × 7 dias, começando na segunda-feira.
70
+ *
71
+ * Seis semanas fixas de propósito — com 5 ou 6 conforme o mês, o painel muda de
72
+ * altura ao navegar e o calendário "pula" na tela.
73
+ */
74
+ export const monthGrid = (month: Date): Date[] => {
75
+ const first = dayOf(month.getFullYear(), month.getMonth(), 1);
76
+ // getDay(): 0 = domingo. Queremos segunda como primeira coluna.
77
+ const offset = (first.getDay() + 6) % 7;
78
+ const start = addDays(first, -offset);
79
+ return Array.from({ length: 42 }, (_, index) => addDays(start, index));
80
+ };
81
+
82
+ /** "agosto 2026" — o mês vem do locale escolhido pelo usuário, não do navegador. */
83
+ export const monthLabel = (month: Date, locale: string): string =>
84
+ new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(
85
+ month,
86
+ );
87
+
88
+ /** Iniciais dos dias da semana, de segunda a domingo, no locale. */
89
+ export const weekdayLabels = (locale: string): string[] => {
90
+ const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
91
+ // 2026-06-01 é uma segunda-feira — âncora só para varrer a semana.
92
+ return Array.from({ length: 7 }, (_, index) =>
93
+ formatter.format(dayOf(2026, 5, 1 + index)).replace(".", ""),
94
+ );
95
+ };
96
+
97
+ /** "01/08/2026" no locale, a partir de `"yyyy-MM-dd"`. */
98
+ export const formatDay = (value: string, locale: string): string => {
99
+ const date = fromIsoDay(value);
100
+ return date
101
+ ? new Intl.DateTimeFormat(locale, { dateStyle: "short" }).format(date)
102
+ : value;
103
+ };
@@ -0,0 +1,65 @@
1
+ /** Área recortada em pixels da imagem original, como o cropper devolve. */
2
+ export interface CropAreaPixels {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ }
8
+
9
+ /** Lado do avatar gerado. O backend também redimensiona; aqui já sai pequeno. */
10
+ const OUTPUT_SIZE = 512;
11
+ const OUTPUT_TYPE = "image/webp";
12
+ const OUTPUT_QUALITY = 0.85;
13
+
14
+ function loadImage(src: string): Promise<HTMLImageElement> {
15
+ return new Promise((resolve, reject) => {
16
+ const image = new Image();
17
+ image.onload = () => resolve(image);
18
+ image.onerror = () => reject(new Error("Não foi possível ler a imagem"));
19
+ image.src = src;
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Recorta a área escolhida e devolve o arquivo já pronto para upload.
25
+ *
26
+ * O recorte acontece no cliente para o backend receber a imagem final: menos
27
+ * bytes na rede e nada de guardar a original só para cortar depois.
28
+ */
29
+ export async function cropImageToFile(
30
+ src: string,
31
+ area: CropAreaPixels,
32
+ filename = "avatar.webp",
33
+ ): Promise<File> {
34
+ const image = await loadImage(src);
35
+
36
+ const canvas = document.createElement("canvas");
37
+ canvas.width = OUTPUT_SIZE;
38
+ canvas.height = OUTPUT_SIZE;
39
+
40
+ const ctx = canvas.getContext("2d");
41
+ if (!ctx) {
42
+ throw new Error("Canvas indisponível neste navegador");
43
+ }
44
+
45
+ ctx.drawImage(
46
+ image,
47
+ area.x,
48
+ area.y,
49
+ area.width,
50
+ area.height,
51
+ 0,
52
+ 0,
53
+ OUTPUT_SIZE,
54
+ OUTPUT_SIZE,
55
+ );
56
+
57
+ const blob = await new Promise<Blob | null>((resolve) =>
58
+ canvas.toBlob(resolve, OUTPUT_TYPE, OUTPUT_QUALITY),
59
+ );
60
+ if (!blob) {
61
+ throw new Error("Não foi possível gerar a imagem recortada");
62
+ }
63
+
64
+ return new File([blob], filename, { type: OUTPUT_TYPE });
65
+ }