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,313 @@
|
|
|
1
|
+
import type { components } from "#core/_services/api/schema";
|
|
2
|
+
import { formatDay, fromIsoDay, toIsoDay } from "#core/_utils/calendar";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tipos derivados do `openapi.json` (§5): o catálogo é servido pelo backend,
|
|
6
|
+
* então campo que mudar lá quebra o build aqui, não a tela.
|
|
7
|
+
*/
|
|
8
|
+
export type FilterField = components["schemas"]["FilterFieldResponse"];
|
|
9
|
+
export type FilterFieldType = FilterField["type"];
|
|
10
|
+
export type FilterOperator = FilterField["operators"][number];
|
|
11
|
+
export type FilterOption = components["schemas"]["FilterOptionResponse"];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Valor cru de um campo no painel.
|
|
15
|
+
*
|
|
16
|
+
* Uma forma só para todos os tipos: o tipo do campo decide quais chaves usa, e
|
|
17
|
+
* a serialização vive num lugar só (`buildFilter`).
|
|
18
|
+
*/
|
|
19
|
+
export interface FilterDraftValue {
|
|
20
|
+
/** Texto, booleano ou valor único. */
|
|
21
|
+
value?: string;
|
|
22
|
+
/** Seleção múltipla (`ENUM`/`SELECT`). */
|
|
23
|
+
values?: string[];
|
|
24
|
+
/** Intervalo (número ou data). */
|
|
25
|
+
from?: string;
|
|
26
|
+
to?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type FilterValues = Record<string, FilterDraftValue>;
|
|
30
|
+
|
|
31
|
+
/** Operador preferido de cada tipo no modo simples. */
|
|
32
|
+
const PREFERRED_OPERATOR: Record<FilterFieldType, FilterOperator[]> = {
|
|
33
|
+
TEXT: ["$SUBSTRING", "$LIKE", "$EQ"],
|
|
34
|
+
NUMBER: ["$EQ"],
|
|
35
|
+
DATE: ["$BETWEEN"],
|
|
36
|
+
DATETIME: ["$BETWEEN"],
|
|
37
|
+
BOOLEAN: ["$EQ"],
|
|
38
|
+
ENUM: ["$IN", "$EQ"],
|
|
39
|
+
SELECT: ["$IN", "$EQ"],
|
|
40
|
+
UUID: ["$EQ"],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Primeiro operador preferido que o campo aceita. */
|
|
44
|
+
const operatorFor = (
|
|
45
|
+
field: FilterField,
|
|
46
|
+
candidates: FilterOperator[],
|
|
47
|
+
): FilterOperator | null =>
|
|
48
|
+
candidates.find((operator) => field.operators.includes(operator)) ?? null;
|
|
49
|
+
|
|
50
|
+
const isBlank = (value: string | undefined): boolean =>
|
|
51
|
+
value === undefined || value.trim() === "";
|
|
52
|
+
|
|
53
|
+
/** Campo tem algo preenchido? É o que conta o badge e o que vira condição. */
|
|
54
|
+
export const hasValue = (draft: FilterDraftValue | undefined): boolean => {
|
|
55
|
+
if (!draft) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
!isBlank(draft.value) ||
|
|
60
|
+
!isBlank(draft.from) ||
|
|
61
|
+
!isBlank(draft.to) ||
|
|
62
|
+
(draft.values?.length ?? 0) > 0
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const activeFilterCount = (values: FilterValues): number =>
|
|
67
|
+
Object.values(values).filter(hasValue).length;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Ponta de um intervalo de datas em ISO **com o fuso de quem filtrou**.
|
|
71
|
+
*
|
|
72
|
+
* O calendário devolve o dia (`"2026-06-30"`), e o dia sozinho é lido como UTC:
|
|
73
|
+
* quem está em Brasília perderia as três primeiras horas do período. Aqui o
|
|
74
|
+
* início vira 00:00 e o fim 23:59:59.999 no horário local, já convertidos.
|
|
75
|
+
*/
|
|
76
|
+
const toDayBound = (value: string, bound: "start" | "end"): string => {
|
|
77
|
+
const day = fromIsoDay(value);
|
|
78
|
+
if (!day) {
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
const date = new Date(day);
|
|
82
|
+
if (bound === "start") {
|
|
83
|
+
date.setHours(0, 0, 0, 0);
|
|
84
|
+
} else {
|
|
85
|
+
date.setHours(23, 59, 59, 999);
|
|
86
|
+
}
|
|
87
|
+
return date.toISOString();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Condição de um campo de intervalo: `$BETWEEN`, ou só um dos lados. */
|
|
91
|
+
const rangeCondition = (
|
|
92
|
+
field: FilterField,
|
|
93
|
+
draft: FilterDraftValue,
|
|
94
|
+
): Record<string, unknown> | null => {
|
|
95
|
+
const isDate = field.type === "DATE" || field.type === "DATETIME";
|
|
96
|
+
const bound = (value: string, side: "start" | "end"): string =>
|
|
97
|
+
isDate ? toDayBound(value, side) : value;
|
|
98
|
+
|
|
99
|
+
const from = isBlank(draft.from) ? null : bound(draft.from as string, "start");
|
|
100
|
+
const to = isBlank(draft.to) ? null : bound(draft.to as string, "end");
|
|
101
|
+
|
|
102
|
+
if (from && to && field.operators.includes("$BETWEEN")) {
|
|
103
|
+
return { $BETWEEN: [from, to] };
|
|
104
|
+
}
|
|
105
|
+
if (from && to) {
|
|
106
|
+
return { $GTE: from, $LTE: to };
|
|
107
|
+
}
|
|
108
|
+
if (from) {
|
|
109
|
+
return { $GTE: from };
|
|
110
|
+
}
|
|
111
|
+
if (to) {
|
|
112
|
+
return { $LTE: to };
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const conditionFor = (
|
|
118
|
+
field: FilterField,
|
|
119
|
+
draft: FilterDraftValue,
|
|
120
|
+
): Record<string, unknown> | null => {
|
|
121
|
+
switch (field.type) {
|
|
122
|
+
case "NUMBER":
|
|
123
|
+
case "DATE":
|
|
124
|
+
case "DATETIME":
|
|
125
|
+
return rangeCondition(field, draft);
|
|
126
|
+
|
|
127
|
+
case "ENUM":
|
|
128
|
+
case "SELECT": {
|
|
129
|
+
const selected = draft.values ?? [];
|
|
130
|
+
if (selected.length === 0) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const operator = operatorFor(field, PREFERRED_OPERATOR[field.type]);
|
|
134
|
+
if (!operator) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
// Um item só com $IN também funciona, mas $EQ é mais legível na URL.
|
|
138
|
+
return operator === "$IN" && selected.length === 1
|
|
139
|
+
? { $EQ: selected[0] }
|
|
140
|
+
: { [operator]: operator === "$IN" ? selected : selected[0] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
case "BOOLEAN":
|
|
144
|
+
if (isBlank(draft.value)) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return { $EQ: draft.value === "true" };
|
|
148
|
+
|
|
149
|
+
default: {
|
|
150
|
+
if (isBlank(draft.value)) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const operator = operatorFor(field, PREFERRED_OPERATOR[field.type]);
|
|
154
|
+
return operator ? { [operator]: draft.value } : null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Valores do painel → JSON que vai na query (`?filter=`).
|
|
161
|
+
*
|
|
162
|
+
* Devolve `undefined` quando nada está preenchido — e não `"{}"`, que viraria
|
|
163
|
+
* um parâmetro vazio na URL e um filtro sem efeito no backend.
|
|
164
|
+
*/
|
|
165
|
+
export const buildFilter = (
|
|
166
|
+
values: FilterValues,
|
|
167
|
+
schema: FilterField[],
|
|
168
|
+
): string | undefined => {
|
|
169
|
+
const where: Record<string, unknown> = {};
|
|
170
|
+
|
|
171
|
+
for (const field of schema) {
|
|
172
|
+
const draft = values[field.field];
|
|
173
|
+
if (!draft || !hasValue(draft)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const condition = conditionFor(field, draft);
|
|
177
|
+
if (condition) {
|
|
178
|
+
where[field.field] = condition;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return Object.keys(where).length > 0 ? JSON.stringify(where) : undefined;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* JSON → valores do painel, para hidratar a tela a partir da URL.
|
|
187
|
+
*
|
|
188
|
+
* Ignora em silêncio o que não souber ler: a URL é editável à mão, e um filtro
|
|
189
|
+
* estranho não pode impedir a tela de abrir — quem valida de verdade é o backend.
|
|
190
|
+
*/
|
|
191
|
+
export const parseFilterValues = (
|
|
192
|
+
raw: string | null | undefined,
|
|
193
|
+
schema: FilterField[],
|
|
194
|
+
): FilterValues => {
|
|
195
|
+
if (!raw) {
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let parsed: unknown;
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(raw);
|
|
202
|
+
} catch {
|
|
203
|
+
return {};
|
|
204
|
+
}
|
|
205
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
206
|
+
return {};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const values: FilterValues = {};
|
|
210
|
+
for (const field of schema) {
|
|
211
|
+
const condition = (parsed as Record<string, unknown>)[field.field];
|
|
212
|
+
if (typeof condition !== "object" || condition === null) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const draft = draftFrom(field, condition as Record<string, unknown>);
|
|
216
|
+
if (draft) {
|
|
217
|
+
values[field.field] = draft;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return values;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const draftFrom = (
|
|
224
|
+
field: FilterField,
|
|
225
|
+
condition: Record<string, unknown>,
|
|
226
|
+
): FilterDraftValue | null => {
|
|
227
|
+
const asText = (value: unknown): string | undefined =>
|
|
228
|
+
typeof value === "string" || typeof value === "number"
|
|
229
|
+
? String(value)
|
|
230
|
+
: undefined;
|
|
231
|
+
|
|
232
|
+
switch (field.type) {
|
|
233
|
+
case "NUMBER":
|
|
234
|
+
case "DATE":
|
|
235
|
+
case "DATETIME": {
|
|
236
|
+
// Data volta da URL em ISO com hora; o calendário trabalha em dia local.
|
|
237
|
+
const isDate = field.type !== "NUMBER";
|
|
238
|
+
const asBound = (value: unknown): string | undefined => {
|
|
239
|
+
const text = asText(value);
|
|
240
|
+
if (!text || !isDate) {
|
|
241
|
+
return text;
|
|
242
|
+
}
|
|
243
|
+
const parsed = new Date(text);
|
|
244
|
+
return Number.isNaN(parsed.getTime()) ? text : toIsoDay(parsed);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const between = condition.$BETWEEN;
|
|
248
|
+
if (Array.isArray(between) && between.length === 2) {
|
|
249
|
+
return { from: asBound(between[0]), to: asBound(between[1]) };
|
|
250
|
+
}
|
|
251
|
+
const from = asBound(condition.$GTE);
|
|
252
|
+
const to = asBound(condition.$LTE);
|
|
253
|
+
return from || to ? { from, to } : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case "ENUM":
|
|
257
|
+
case "SELECT": {
|
|
258
|
+
const list = condition.$IN;
|
|
259
|
+
if (Array.isArray(list)) {
|
|
260
|
+
return { values: list.filter((item): item is string => typeof item === "string") };
|
|
261
|
+
}
|
|
262
|
+
const single = asText(condition.$EQ);
|
|
263
|
+
return single ? { values: [single] } : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case "BOOLEAN":
|
|
267
|
+
return typeof condition.$EQ === "boolean"
|
|
268
|
+
? { value: String(condition.$EQ) }
|
|
269
|
+
: null;
|
|
270
|
+
|
|
271
|
+
default: {
|
|
272
|
+
// Qualquer operador de texto serve para reidratar a caixa.
|
|
273
|
+
const value =
|
|
274
|
+
asText(condition.$SUBSTRING) ??
|
|
275
|
+
asText(condition.$LIKE) ??
|
|
276
|
+
asText(condition.$STARTSWITH) ??
|
|
277
|
+
asText(condition.$ENDSWITH) ??
|
|
278
|
+
asText(condition.$EQ);
|
|
279
|
+
return value ? { value } : null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/** Resumo de um filtro ativo, para o chip. */
|
|
285
|
+
export const describeValue = (
|
|
286
|
+
field: FilterField,
|
|
287
|
+
draft: FilterDraftValue,
|
|
288
|
+
labels: { from: string; to: string; yes: string; no: string; locale: string },
|
|
289
|
+
): string => {
|
|
290
|
+
const show = (value: string): string =>
|
|
291
|
+
field.type === "DATE" || field.type === "DATETIME"
|
|
292
|
+
? formatDay(value, labels.locale)
|
|
293
|
+
: value;
|
|
294
|
+
|
|
295
|
+
if (field.type === "BOOLEAN") {
|
|
296
|
+
return draft.value === "true" ? labels.yes : labels.no;
|
|
297
|
+
}
|
|
298
|
+
if (draft.values?.length) {
|
|
299
|
+
const optionLabel = (value: string): string =>
|
|
300
|
+
field.options?.find((option) => option.value === value)?.label ?? value;
|
|
301
|
+
return draft.values.map(optionLabel).join(", ");
|
|
302
|
+
}
|
|
303
|
+
if (draft.from && draft.to) {
|
|
304
|
+
return `${show(draft.from)} – ${show(draft.to)}`;
|
|
305
|
+
}
|
|
306
|
+
if (draft.from) {
|
|
307
|
+
return `${labels.from} ${show(draft.from)}`;
|
|
308
|
+
}
|
|
309
|
+
if (draft.to) {
|
|
310
|
+
return `${labels.to} ${show(draft.to)}`;
|
|
311
|
+
}
|
|
312
|
+
return draft.value ?? "";
|
|
313
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISO-8601 com data e hora, do jeito que o backend serializa `Date`.
|
|
3
|
+
* Aceita `Z` e offset (+03:00), com ou sem milissegundos.
|
|
4
|
+
*/
|
|
5
|
+
const ISO_DATE_TIME =
|
|
6
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
7
|
+
|
|
8
|
+
/** Só é data se for string no formato ISO — número solto não conta. */
|
|
9
|
+
export function isIsoDateTime(value: unknown): value is string {
|
|
10
|
+
return typeof value === "string" && ISO_DATE_TIME.test(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Data e hora no formato do locale. Recebe o locale de quem chama porque o
|
|
15
|
+
* idioma é escolha do usuário (I18nProvider), não do navegador.
|
|
16
|
+
*/
|
|
17
|
+
export function formatDateTime(value: string | Date, locale: string): string {
|
|
18
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
19
|
+
if (Number.isNaN(date.getTime())) {
|
|
20
|
+
return String(value);
|
|
21
|
+
}
|
|
22
|
+
return new Intl.DateTimeFormat(locale, {
|
|
23
|
+
dateStyle: "short",
|
|
24
|
+
timeStyle: "medium",
|
|
25
|
+
}).format(date);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Aplica máscara de telefone BR enquanto digita: (00) 00000-0000 */
|
|
29
|
+
export function maskPhoneBR(value: string): string {
|
|
30
|
+
const d = value.replace(/\D/g, "").slice(0, 11);
|
|
31
|
+
if (d.length <= 2) {return d.replace(/(\d{0,2})/, "($1");}
|
|
32
|
+
if (d.length <= 6) {return d.replace(/(\d{2})(\d{0,4})/, "($1) $2");}
|
|
33
|
+
if (d.length <= 10) {return d.replace(/(\d{2})(\d{4})(\d{0,4})/, "($1) $2-$3");}
|
|
34
|
+
return d.replace(/(\d{2})(\d{5})(\d{0,4})/, "($1) $2-$3");
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Iniciais para o fallback do avatar: "Rodrigo Liberti" → "RL".
|
|
3
|
+
*
|
|
4
|
+
* Sem sobrenome, tenta a segunda palavra do próprio nome ("Ana Paula" → "AP");
|
|
5
|
+
* se só houver uma palavra, devolve uma letra — duas letras da mesma palavra
|
|
6
|
+
* ("RO") pareceriam sobrenome que não existe.
|
|
7
|
+
*/
|
|
8
|
+
export function getInitials(
|
|
9
|
+
name?: string | null,
|
|
10
|
+
lastName?: string | null,
|
|
11
|
+
): string {
|
|
12
|
+
const first = name?.trim() ?? "";
|
|
13
|
+
const last = lastName?.trim() ?? "";
|
|
14
|
+
|
|
15
|
+
const firstLetter = first.charAt(0);
|
|
16
|
+
if (!firstLetter) {
|
|
17
|
+
return "?";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const secondSource = last || first.split(/\s+/).slice(1).join(" ");
|
|
21
|
+
const secondLetter = secondSource.charAt(0);
|
|
22
|
+
|
|
23
|
+
return (firstLetter + secondLetter).toUpperCase();
|
|
24
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Espelha STRONG_PASSWORD_REGEX do backend (core/utils/password.constants.ts).
|
|
3
|
+
* Mín. 8 caracteres, com maiúscula, minúscula, número e caractere especial.
|
|
4
|
+
* Ao mudar aqui, mude lá também — o backend é a autoridade final.
|
|
5
|
+
*/
|
|
6
|
+
export const STRONG_PASSWORD_REGEX =
|
|
7
|
+
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*(),.?":{}|<>]).{8,}$/;
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ChevronDown,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
History,
|
|
7
|
+
LayoutDashboard,
|
|
8
|
+
LogOut,
|
|
9
|
+
Menu,
|
|
10
|
+
ScrollText,
|
|
11
|
+
Settings,
|
|
12
|
+
Shield,
|
|
13
|
+
ShieldCheck,
|
|
14
|
+
User,
|
|
15
|
+
Users,
|
|
16
|
+
} from "lucide-react";
|
|
17
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
18
|
+
import type { JSX } from "react";
|
|
19
|
+
import { useEffect, useMemo, useState } from "react";
|
|
20
|
+
|
|
21
|
+
import { Breadcrumbs } from "#core/components/breadcrumbs";
|
|
22
|
+
import { LanguageSelector } from "#core/components/language-selector";
|
|
23
|
+
import { ThemeToggle } from "#core/components/theme-toggle";
|
|
24
|
+
import {
|
|
25
|
+
Button,
|
|
26
|
+
DropdownMenu,
|
|
27
|
+
DropdownMenuContent,
|
|
28
|
+
DropdownMenuItem,
|
|
29
|
+
DropdownMenuLabel,
|
|
30
|
+
DropdownMenuSeparator,
|
|
31
|
+
DropdownMenuTrigger,
|
|
32
|
+
Separator,
|
|
33
|
+
Spinner,
|
|
34
|
+
} from "#core/components/ui";
|
|
35
|
+
import { UserAvatar } from "#core/components/user-avatar";
|
|
36
|
+
import { useAuth, useI18n } from "#core/contexts";
|
|
37
|
+
import { NotificationsCenter } from "#core/features/notifications/notifications-center";
|
|
38
|
+
import { ForcePasswordChange } from "#core/features/profile/force-password-change";
|
|
39
|
+
import { cn } from "#core/lib/utils";
|
|
40
|
+
|
|
41
|
+
const COLLAPSE_KEY = "sidebar-collapsed";
|
|
42
|
+
|
|
43
|
+
interface NavItem {
|
|
44
|
+
key: string;
|
|
45
|
+
href: string;
|
|
46
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
47
|
+
perm: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DASHBOARD: NavItem = {
|
|
51
|
+
key: "nav.dashboard",
|
|
52
|
+
href: "/dashboard",
|
|
53
|
+
icon: LayoutDashboard,
|
|
54
|
+
perm: null,
|
|
55
|
+
};
|
|
56
|
+
const PROFILE: NavItem = {
|
|
57
|
+
key: "nav.profile",
|
|
58
|
+
href: "/perfil",
|
|
59
|
+
icon: User,
|
|
60
|
+
perm: null,
|
|
61
|
+
};
|
|
62
|
+
const ADMIN_CHILDREN: NavItem[] = [
|
|
63
|
+
{ key: "nav.users", href: "/usuarios", icon: Users, perm: "users:read:any" },
|
|
64
|
+
{ key: "nav.rbac", href: "/rbac", icon: ShieldCheck, perm: "roles:read:any" },
|
|
65
|
+
{ key: "nav.logs", href: "/logs", icon: ScrollText, perm: "audit:read:any" },
|
|
66
|
+
// Restrito a administradores — só quem tem audit:read-trail:any (só Super Admin) vê este item.
|
|
67
|
+
{
|
|
68
|
+
key: "nav.audit",
|
|
69
|
+
href: "/auditoria",
|
|
70
|
+
icon: History,
|
|
71
|
+
perm: "audit:read-trail:any",
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export function AppShell({ children }: { children: React.ReactNode }): JSX.Element {
|
|
76
|
+
const { user, loading, logout, hasPermission } = useAuth();
|
|
77
|
+
const { t } = useI18n();
|
|
78
|
+
const router = useRouter();
|
|
79
|
+
const pathname = usePathname();
|
|
80
|
+
|
|
81
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
82
|
+
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
|
83
|
+
if (typeof window === "undefined") {return false;}
|
|
84
|
+
return localStorage.getItem(COLLAPSE_KEY) === "1";
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const adminChildren = useMemo(
|
|
88
|
+
() => ADMIN_CHILDREN.filter((c) => !c.perm || hasPermission(c.perm)),
|
|
89
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
|
+
[user],
|
|
91
|
+
);
|
|
92
|
+
const adminActive = adminChildren.some((c) => pathname === c.href);
|
|
93
|
+
const [adminOpen, setAdminOpen] = useState(adminActive);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!loading && !user) {router.replace("/login");}
|
|
97
|
+
}, [loading, user, router]);
|
|
98
|
+
|
|
99
|
+
if (loading || !user) {
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex min-h-screen items-center justify-center bg-background">
|
|
102
|
+
<Spinner className="h-8 w-8" />
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (user?.passwordExpired || user?.mustChangePassword) {
|
|
108
|
+
return (
|
|
109
|
+
<ForcePasswordChange
|
|
110
|
+
reason={user.passwordExpired ? "expired" : "provisional"}
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const toggleCollapsed = (): void =>
|
|
116
|
+
setCollapsed((v) => {
|
|
117
|
+
const next = !v;
|
|
118
|
+
localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
|
|
119
|
+
return next;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const go = (href: string): void => {
|
|
123
|
+
router.push(href);
|
|
124
|
+
setMobileOpen(false);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const NavLink = ({
|
|
128
|
+
item,
|
|
129
|
+
mini,
|
|
130
|
+
nested,
|
|
131
|
+
}: {
|
|
132
|
+
item: NavItem;
|
|
133
|
+
mini: boolean;
|
|
134
|
+
nested?: boolean;
|
|
135
|
+
}): JSX.Element => {
|
|
136
|
+
const active = pathname === item.href;
|
|
137
|
+
const Icon = item.icon;
|
|
138
|
+
return (
|
|
139
|
+
<button
|
|
140
|
+
onClick={() => go(item.href)}
|
|
141
|
+
title={mini ? t(item.key) : undefined}
|
|
142
|
+
className={cn(
|
|
143
|
+
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
144
|
+
mini && "justify-center px-2",
|
|
145
|
+
nested && !mini && "pl-10",
|
|
146
|
+
active
|
|
147
|
+
? "bg-primary/10 text-primary"
|
|
148
|
+
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
<Icon className="h-5 w-5 shrink-0" />
|
|
152
|
+
{!mini && <span className="truncate">{t(item.key)}</span>}
|
|
153
|
+
</button>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const NavContent = ({ mini }: { mini: boolean }): JSX.Element => (
|
|
158
|
+
<div className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden p-3">
|
|
159
|
+
<NavLink item={DASHBOARD} mini={mini} />
|
|
160
|
+
|
|
161
|
+
{adminChildren.length > 0 &&
|
|
162
|
+
(mini ? (
|
|
163
|
+
<>
|
|
164
|
+
<Separator className="my-2" />
|
|
165
|
+
{adminChildren.map((c) => (
|
|
166
|
+
<NavLink key={c.href} item={c} mini />
|
|
167
|
+
))}
|
|
168
|
+
</>
|
|
169
|
+
) : (
|
|
170
|
+
<div className="mt-2">
|
|
171
|
+
<button
|
|
172
|
+
onClick={() => setAdminOpen((v) => !v)}
|
|
173
|
+
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
174
|
+
>
|
|
175
|
+
<Settings className="h-5 w-5 shrink-0" />
|
|
176
|
+
<span className="flex-1 text-left">{t("nav.admin")}</span>
|
|
177
|
+
<ChevronRight
|
|
178
|
+
className={cn(
|
|
179
|
+
"h-4 w-4 transition-transform",
|
|
180
|
+
adminOpen && "rotate-90",
|
|
181
|
+
)}
|
|
182
|
+
/>
|
|
183
|
+
</button>
|
|
184
|
+
{adminOpen && (
|
|
185
|
+
<div className="mt-1 flex flex-col gap-1">
|
|
186
|
+
{adminChildren.map((c) => (
|
|
187
|
+
<NavLink key={c.href} item={c} mini={false} nested />
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
))}
|
|
193
|
+
|
|
194
|
+
<div className="mt-1">
|
|
195
|
+
<NavLink item={PROFILE} mini={mini} />
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<div className="min-h-screen bg-background">
|
|
202
|
+
{/* Header */}
|
|
203
|
+
<header className="fixed inset-x-0 top-0 z-40 flex h-16 items-center gap-2 border-b border-border bg-card px-3 sm:px-4">
|
|
204
|
+
<Button
|
|
205
|
+
variant="ghost"
|
|
206
|
+
size="icon"
|
|
207
|
+
className="md:hidden"
|
|
208
|
+
onClick={() => setMobileOpen(true)}
|
|
209
|
+
aria-label="menu"
|
|
210
|
+
>
|
|
211
|
+
<Menu className="h-5 w-5" />
|
|
212
|
+
</Button>
|
|
213
|
+
<Button
|
|
214
|
+
variant="ghost"
|
|
215
|
+
size="icon"
|
|
216
|
+
className="hidden md:inline-flex"
|
|
217
|
+
onClick={toggleCollapsed}
|
|
218
|
+
aria-label="menu"
|
|
219
|
+
>
|
|
220
|
+
<Menu className="h-5 w-5" />
|
|
221
|
+
</Button>
|
|
222
|
+
|
|
223
|
+
<div className="flex items-center gap-2">
|
|
224
|
+
<Shield className="h-6 w-6 text-primary" />
|
|
225
|
+
<span className="hidden text-lg font-extrabold sm:block">
|
|
226
|
+
{t("app.name")}
|
|
227
|
+
</span>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div className="flex-1" />
|
|
231
|
+
|
|
232
|
+
<LanguageSelector />
|
|
233
|
+
<ThemeToggle />
|
|
234
|
+
<NotificationsCenter />
|
|
235
|
+
<Separator orientation="vertical" className="mx-1 h-8" />
|
|
236
|
+
|
|
237
|
+
<DropdownMenu>
|
|
238
|
+
<DropdownMenuTrigger asChild>
|
|
239
|
+
<button className="flex items-center gap-2 rounded-lg px-1.5 py-1 transition-colors hover:bg-accent">
|
|
240
|
+
<UserAvatar
|
|
241
|
+
name={user?.name}
|
|
242
|
+
lastName={user?.lastName}
|
|
243
|
+
avatar={user?.avatar}
|
|
244
|
+
className="h-8 w-8"
|
|
245
|
+
fallbackClassName="text-xs"
|
|
246
|
+
/>
|
|
247
|
+
<div className="hidden text-left sm:block">
|
|
248
|
+
<p className="text-sm font-semibold leading-tight">
|
|
249
|
+
{user?.name}
|
|
250
|
+
</p>
|
|
251
|
+
<p className="text-xs text-muted-foreground">
|
|
252
|
+
{user?.roles?.[0] ?? ""}
|
|
253
|
+
</p>
|
|
254
|
+
</div>
|
|
255
|
+
<ChevronDown className="hidden h-4 w-4 text-muted-foreground sm:block" />
|
|
256
|
+
</button>
|
|
257
|
+
</DropdownMenuTrigger>
|
|
258
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
259
|
+
<DropdownMenuLabel>{user?.name}</DropdownMenuLabel>
|
|
260
|
+
<DropdownMenuSeparator />
|
|
261
|
+
<DropdownMenuItem onClick={() => router.push("/perfil")}>
|
|
262
|
+
<User className="h-4 w-4" />
|
|
263
|
+
{t("user.menuProfile")}
|
|
264
|
+
</DropdownMenuItem>
|
|
265
|
+
<DropdownMenuItem onClick={() => logout()}>
|
|
266
|
+
<LogOut className="h-4 w-4" />
|
|
267
|
+
{t("user.logout")}
|
|
268
|
+
</DropdownMenuItem>
|
|
269
|
+
</DropdownMenuContent>
|
|
270
|
+
</DropdownMenu>
|
|
271
|
+
</header>
|
|
272
|
+
|
|
273
|
+
{/* Desktop sidebar */}
|
|
274
|
+
<aside
|
|
275
|
+
className={cn(
|
|
276
|
+
"fixed left-0 top-16 z-30 hidden h-[calc(100vh-4rem)] flex-col border-r border-border bg-card transition-[width] duration-200 md:flex",
|
|
277
|
+
collapsed ? "w-[76px]" : "w-64",
|
|
278
|
+
)}
|
|
279
|
+
>
|
|
280
|
+
<NavContent mini={collapsed} />
|
|
281
|
+
</aside>
|
|
282
|
+
|
|
283
|
+
{/* Mobile sidebar */}
|
|
284
|
+
{mobileOpen && (
|
|
285
|
+
<div className="fixed inset-0 z-50 md:hidden">
|
|
286
|
+
<div
|
|
287
|
+
className="absolute inset-0 bg-black/50"
|
|
288
|
+
onClick={() => setMobileOpen(false)}
|
|
289
|
+
/>
|
|
290
|
+
<aside className="absolute left-0 top-0 flex h-full w-64 flex-col border-r border-border bg-card">
|
|
291
|
+
<div className="flex h-16 items-center gap-2 border-b border-border px-4">
|
|
292
|
+
<Shield className="h-6 w-6 text-primary" />
|
|
293
|
+
<span className="text-lg font-extrabold">{t("app.name")}</span>
|
|
294
|
+
</div>
|
|
295
|
+
<NavContent mini={false} />
|
|
296
|
+
</aside>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{/* Main */}
|
|
301
|
+
<main
|
|
302
|
+
className={cn(
|
|
303
|
+
"pt-16 transition-[margin] duration-200",
|
|
304
|
+
collapsed ? "md:ml-[76px]" : "md:ml-64",
|
|
305
|
+
)}
|
|
306
|
+
>
|
|
307
|
+
<div className="p-4 md:p-8">
|
|
308
|
+
<Breadcrumbs />
|
|
309
|
+
{children}
|
|
310
|
+
</div>
|
|
311
|
+
</main>
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|