rl-core-front 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/_services/api/schema.d.ts +238 -117
- package/src/_utils/filter.ts +15 -34
- package/src/components/app-shell.tsx +51 -1
- package/src/components/brand-header.tsx +23 -8
- package/src/components/brand-panel.tsx +41 -0
- package/src/components/index.ts +1 -0
- package/src/components/ui/button.tsx +5 -0
- package/src/components/ui/confirm-dialog.tsx +3 -1
- package/src/components/ui/data-table.tsx +13 -4
- package/src/components/ui/dialog.tsx +120 -21
- package/src/components/ui/filter-sheet.tsx +47 -25
- package/src/components/ui/index.ts +1 -1
- package/src/components/ui/segmented-control.tsx +74 -0
- package/src/contexts/brand-context.tsx +15 -0
- package/src/contexts/i18n-context.tsx +69 -4
- package/src/features/audit/audit-screen.tsx +13 -140
- package/src/features/audit/components/audit-diff.tsx +156 -0
- package/src/features/audit/components/index.ts +3 -0
- package/src/features/audit/components/record-audit-button.tsx +176 -0
- package/src/features/audit/enums/audit-data-action.enum.ts +20 -0
- package/src/features/audit/hooks/use-audit-trail.ts +9 -2
- package/src/features/audit/services/audit.service.ts +43 -1
- package/src/features/login/login-screen.tsx +86 -40
- package/src/features/logs/enums/error-type.enum.ts +21 -0
- package/src/features/logs/enums/request-outcome.enum.ts +14 -0
- package/src/features/logs/hooks/use-request-logs.ts +26 -3
- package/src/features/logs/logs-screen.tsx +2 -14
- package/src/features/logs/panels/index.ts +0 -1
- package/src/features/logs/panels/request-logs-panel.tsx +177 -25
- package/src/features/logs/services/logs.service.ts +13 -51
- package/src/features/notifications/components/notification-item.tsx +10 -8
- package/src/features/notifications/enums/notification-type.enum.ts +8 -0
- package/src/features/profile/profile-screen.tsx +2 -1
- package/src/features/rbac/panels/groups-panel.tsx +20 -7
- package/src/features/users/components/user-form-dialog.tsx +16 -7
- package/src/features/users/hooks/use-users.ts +9 -2
- package/src/hooks/use-filters.ts +25 -2
- package/src/hooks/use-list-query.ts +14 -1
- package/src/i18n/messages/en.ts +17 -5
- package/src/i18n/messages/pt.ts +18 -5
- package/src/index.ts +12 -1
- package/src/components/ui/filter-chips.tsx +0 -88
- package/src/features/logs/hooks/use-error-logs.ts +0 -65
- package/src/features/logs/panels/error-logs-panel.tsx +0 -183
|
@@ -33,6 +33,18 @@ export interface Brand {
|
|
|
33
33
|
logo?: ReactNode;
|
|
34
34
|
/** Frase curta sob a logo na tela de login. Opcional. */
|
|
35
35
|
tagline?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Onde a marca fica na tela de login.
|
|
38
|
+
*
|
|
39
|
+
* - `"top"` (padrão): logo pequena acima do formulário, tudo num cartão só.
|
|
40
|
+
* - `"right"` / `"left"`: a marca ocupa metade da tela, com um divisor entre
|
|
41
|
+
* ela e o formulário. É para logo grande — espremida no topo do cartão ela
|
|
42
|
+
* sai ilegível, e aumentar o cartão para caber estragaria o formulário.
|
|
43
|
+
*
|
|
44
|
+
* Em tela estreita os dois lados viram empilhado com a marca em cima: meia
|
|
45
|
+
* tela de logo num celular não deixaria o formulário aparecer.
|
|
46
|
+
*/
|
|
47
|
+
loginLayout?: "top" | "left" | "right";
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
/** O que os componentes do core consomem: sem opcionais, tudo já resolvido. */
|
|
@@ -41,6 +53,7 @@ export interface BrandContextType {
|
|
|
41
53
|
mark: ReactNode;
|
|
42
54
|
logo: ReactNode;
|
|
43
55
|
tagline: string | null;
|
|
56
|
+
loginLayout: "top" | "left" | "right";
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
const BrandContext = createContext<BrandContextType | undefined>(undefined);
|
|
@@ -64,6 +77,7 @@ export function BrandProvider({
|
|
|
64
77
|
mark,
|
|
65
78
|
logo: brand?.logo ?? mark,
|
|
66
79
|
tagline: brand?.tagline ?? null,
|
|
80
|
+
loginLayout: brand?.loginLayout ?? "top",
|
|
67
81
|
};
|
|
68
82
|
}, [brand, t]);
|
|
69
83
|
|
|
@@ -88,6 +102,7 @@ export function useBrand(): BrandContextType {
|
|
|
88
102
|
mark: defaultMark,
|
|
89
103
|
logo: defaultMark,
|
|
90
104
|
tagline: null,
|
|
105
|
+
loginLayout: "top",
|
|
91
106
|
}
|
|
92
107
|
);
|
|
93
108
|
}
|
|
@@ -14,9 +14,45 @@ import { en } from "#core/i18n/messages/en";
|
|
|
14
14
|
import { pt } from "#core/i18n/messages/pt";
|
|
15
15
|
|
|
16
16
|
export type Locale = "pt" | "en";
|
|
17
|
-
const MESSAGES = { pt, en } as const;
|
|
18
17
|
const STORAGE_KEY = "locale";
|
|
19
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Dicionário de um idioma: objeto aninhado de strings, como `i18n/messages`.
|
|
21
|
+
*
|
|
22
|
+
* O tipo é aberto de propósito — o projeto acrescenta as seções dele, e travar
|
|
23
|
+
* a forma aqui obrigaria o core a conhecer as chaves de quem o instala.
|
|
24
|
+
*/
|
|
25
|
+
export type Messages = Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
/** Dicionário do projeto, por idioma. Cada um é opcional. */
|
|
28
|
+
export type ProjectMessages = Partial<Record<Locale, Messages>>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mescla o dicionário do projeto sobre o do core.
|
|
32
|
+
*
|
|
33
|
+
* É profundo porque as seções coincidem: um projeto que acrescenta
|
|
34
|
+
* `nav.tasks` não pode apagar o resto de `nav`, que é o que o shell usa. Em
|
|
35
|
+
* conflito, o projeto vence — é o que permite renomear "Logs" sem tocar no
|
|
36
|
+
* pacote.
|
|
37
|
+
*/
|
|
38
|
+
function deepMerge(base: Messages, override: Messages): Messages {
|
|
39
|
+
const result: Messages = { ...base };
|
|
40
|
+
for (const [key, value] of Object.entries(override)) {
|
|
41
|
+
const current = result[key];
|
|
42
|
+
const bothObjects =
|
|
43
|
+
current !== null &&
|
|
44
|
+
typeof current === "object" &&
|
|
45
|
+
!Array.isArray(current) &&
|
|
46
|
+
value !== null &&
|
|
47
|
+
typeof value === "object" &&
|
|
48
|
+
!Array.isArray(value);
|
|
49
|
+
result[key] = bothObjects
|
|
50
|
+
? deepMerge(current as Messages, value as Messages)
|
|
51
|
+
: value;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
20
56
|
export interface I18nContextType {
|
|
21
57
|
locale: Locale;
|
|
22
58
|
setLocale: (l: Locale) => void;
|
|
@@ -34,9 +70,35 @@ function resolve(obj: unknown, path: string): string | undefined {
|
|
|
34
70
|
}, obj) as string | undefined;
|
|
35
71
|
}
|
|
36
72
|
|
|
37
|
-
export
|
|
73
|
+
export interface I18nProviderProps {
|
|
74
|
+
children: React.ReactNode;
|
|
75
|
+
/**
|
|
76
|
+
* Dicionário do projeto, mesclado sobre o do core (§14: o core não sabe os
|
|
77
|
+
* textos de quem o instala).
|
|
78
|
+
*
|
|
79
|
+
* ```tsx
|
|
80
|
+
* <I18nProvider messages={{ pt: { nav: { tasks: "Tarefas" } } }}>
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
messages?: ProjectMessages;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function I18nProvider({
|
|
87
|
+
children,
|
|
88
|
+
messages,
|
|
89
|
+
}: I18nProviderProps): JSX.Element {
|
|
38
90
|
const [locale, setLocaleState] = useState<Locale>("pt");
|
|
39
91
|
|
|
92
|
+
// Mescla uma vez por dicionário, não a cada tradução: `t` roda em todo render
|
|
93
|
+
// de toda tela que usa texto.
|
|
94
|
+
const dictionary = useMemo(
|
|
95
|
+
() => ({
|
|
96
|
+
pt: messages?.pt ? deepMerge(pt, messages.pt) : (pt as Messages),
|
|
97
|
+
en: messages?.en ? deepMerge(en, messages.en) : (en as Messages),
|
|
98
|
+
}),
|
|
99
|
+
[messages],
|
|
100
|
+
);
|
|
101
|
+
|
|
40
102
|
useEffect(() => {
|
|
41
103
|
const saved = (localStorage.getItem(STORAGE_KEY) as Locale) || null;
|
|
42
104
|
if (saved === "pt" || saved === "en") {setLocaleState(saved);}
|
|
@@ -50,15 +112,18 @@ export function I18nProvider({ children }: { children: React.ReactNode }): JSX.E
|
|
|
50
112
|
|
|
51
113
|
const t = useCallback(
|
|
52
114
|
(key: string, params?: Record<string, string | number>) => {
|
|
115
|
+
// Sem tradução no idioma corrente, cai no português; sem nenhuma, devolve
|
|
116
|
+
// a própria chave — é o que deixa `label: "Tarefas"` funcionar como texto
|
|
117
|
+
// cru em quem não quer cadastrar dicionário.
|
|
53
118
|
const raw =
|
|
54
|
-
resolve(
|
|
119
|
+
resolve(dictionary[locale], key) ?? resolve(dictionary.pt, key) ?? key;
|
|
55
120
|
if (!params) {return raw;}
|
|
56
121
|
return Object.entries(params).reduce(
|
|
57
122
|
(str, [k, v]) => str.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)),
|
|
58
123
|
raw,
|
|
59
124
|
);
|
|
60
125
|
},
|
|
61
|
-
[locale],
|
|
126
|
+
[locale, dictionary],
|
|
62
127
|
);
|
|
63
128
|
|
|
64
129
|
const value = useMemo(
|
|
@@ -10,11 +10,10 @@ import { Eye } from "lucide-react";
|
|
|
10
10
|
import type { JSX } from "react";
|
|
11
11
|
import { useMemo, useState } from "react";
|
|
12
12
|
|
|
13
|
-
import { formatDateTime
|
|
13
|
+
import { formatDateTime } from "#core/_utils/format";
|
|
14
14
|
import {
|
|
15
15
|
Badge,
|
|
16
16
|
Button,
|
|
17
|
-
Checkbox,
|
|
18
17
|
DataTable,
|
|
19
18
|
DataTableFeatures,
|
|
20
19
|
Dialog,
|
|
@@ -22,88 +21,21 @@ import {
|
|
|
22
21
|
DialogHeader,
|
|
23
22
|
DialogTitle,
|
|
24
23
|
RowActions,
|
|
25
|
-
Table,
|
|
26
|
-
TableBody,
|
|
27
|
-
TableCell,
|
|
28
|
-
TableHead,
|
|
29
|
-
TableHeader,
|
|
30
|
-
TableRow,
|
|
31
24
|
} from "#core/components/ui";
|
|
32
25
|
import { useI18n } from "#core/contexts";
|
|
33
|
-
import {
|
|
26
|
+
import { AuditDiff } from "#core/features/audit/components/audit-diff";
|
|
34
27
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
} from "#core/features/audit/
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
* Campos de controle interno: aparecem no diff porque a auditoria versiona a
|
|
41
|
-
* linha inteira, mas não são informação de negócio. Ficam escondidos por padrão.
|
|
42
|
-
*/
|
|
43
|
-
const TECHNICAL_FIELDS = new Set([
|
|
44
|
-
"failedLoginAttempts",
|
|
45
|
-
"createdAt",
|
|
46
|
-
"updatedAt",
|
|
47
|
-
"deletedAt",
|
|
48
|
-
"passwordChangedAt",
|
|
49
|
-
"password",
|
|
50
|
-
"twoFactorSecret",
|
|
51
|
-
"backupCodes",
|
|
52
|
-
"mustChangePassword",
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
|
-
interface DiffRow {
|
|
56
|
-
field: string;
|
|
57
|
-
before: string;
|
|
58
|
-
after: string;
|
|
59
|
-
technical: boolean;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
type Translate = (key: string) => string;
|
|
63
|
-
|
|
64
|
-
function formatValue(
|
|
65
|
-
value: unknown,
|
|
66
|
-
locale: string,
|
|
67
|
-
t: Translate,
|
|
68
|
-
): string {
|
|
69
|
-
if (value === null || value === undefined) {
|
|
70
|
-
return "—";
|
|
71
|
-
}
|
|
72
|
-
if (typeof value === "boolean") {
|
|
73
|
-
return value ? t("common.yes") : t("common.no");
|
|
74
|
-
}
|
|
75
|
-
if (isIsoDateTime(value)) {
|
|
76
|
-
return formatDateTime(value, locale);
|
|
77
|
-
}
|
|
78
|
-
if (typeof value === "object") {
|
|
79
|
-
// Indentado: objeto e array em uma linha só ficavam ilegíveis no modal.
|
|
80
|
-
return JSON.stringify(value, null, 2);
|
|
81
|
-
}
|
|
82
|
-
return String(value);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function diffRows(
|
|
86
|
-
item: AuditDataChange,
|
|
87
|
-
locale: string,
|
|
88
|
-
t: Translate,
|
|
89
|
-
): DiffRow[] {
|
|
90
|
-
const keys = new Set([
|
|
91
|
-
...Object.keys(item.oldData ?? {}),
|
|
92
|
-
...Object.keys(item.newData ?? {}),
|
|
93
|
-
]);
|
|
94
|
-
return Array.from(keys).map((field) => ({
|
|
95
|
-
field,
|
|
96
|
-
before: formatValue(item.oldData?.[field], locale, t),
|
|
97
|
-
after: formatValue(item.newData?.[field], locale, t),
|
|
98
|
-
technical: TECHNICAL_FIELDS.has(field),
|
|
99
|
-
}));
|
|
100
|
-
}
|
|
28
|
+
AuditDataAction,
|
|
29
|
+
AuditDataActionLabelKey,
|
|
30
|
+
} from "#core/features/audit/enums/audit-data-action.enum";
|
|
31
|
+
import { useAuditTrail } from "#core/features/audit/hooks/use-audit-trail";
|
|
32
|
+
import { AuditDataChange } from "#core/features/audit/services/audit.service";
|
|
101
33
|
|
|
102
34
|
function actionVariant(
|
|
103
|
-
action:
|
|
35
|
+
action: AuditDataAction,
|
|
104
36
|
): "success" | "warning" | "destructive" {
|
|
105
|
-
if (action ===
|
|
106
|
-
if (action ===
|
|
37
|
+
if (action === AuditDataAction.CREATE) {return "success";}
|
|
38
|
+
if (action === AuditDataAction.DELETE) {return "destructive";}
|
|
107
39
|
return "warning";
|
|
108
40
|
}
|
|
109
41
|
|
|
@@ -111,21 +43,9 @@ export function AuditScreen(): JSX.Element {
|
|
|
111
43
|
const { t, locale } = useI18n();
|
|
112
44
|
const trail = useAuditTrail();
|
|
113
45
|
const [detail, setDetail] = useState<AuditDataChange | null>(null);
|
|
114
|
-
const [showTechnical, setShowTechnical] = useState(false);
|
|
115
46
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const key = `auditFields.${field}`;
|
|
119
|
-
const label = t(key);
|
|
120
|
-
return label === key ? field : label;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const actionLabel = (action: AuditAction): string =>
|
|
124
|
-
action === "CREATE"
|
|
125
|
-
? t("auditTrail.actionCreate")
|
|
126
|
-
: action === "DELETE"
|
|
127
|
-
? t("auditTrail.actionDelete")
|
|
128
|
-
: t("auditTrail.actionUpdate");
|
|
47
|
+
const actionLabel = (action: AuditDataAction): string =>
|
|
48
|
+
t(AuditDataActionLabelKey[action]);
|
|
129
49
|
|
|
130
50
|
const columns = useMemo<
|
|
131
51
|
ColumnDef<DataTableFeatures, AuditDataChange, unknown>[]
|
|
@@ -193,10 +113,6 @@ export function AuditScreen(): JSX.Element {
|
|
|
193
113
|
[t, locale],
|
|
194
114
|
);
|
|
195
115
|
|
|
196
|
-
const allRows = detail ? diffRows(detail, locale, t) : [];
|
|
197
|
-
const rows = showTechnical ? allRows : allRows.filter((r) => !r.technical);
|
|
198
|
-
const hiddenCount = allRows.length - rows.length;
|
|
199
|
-
|
|
200
116
|
const pagination: PaginationState = {
|
|
201
117
|
pageIndex: trail.page,
|
|
202
118
|
pageSize: trail.pageSize,
|
|
@@ -248,50 +164,7 @@ export function AuditScreen(): JSX.Element {
|
|
|
248
164
|
{detail && actionLabel(detail.action)})
|
|
249
165
|
</DialogTitle>
|
|
250
166
|
</DialogHeader>
|
|
251
|
-
{detail &&
|
|
252
|
-
<div className="max-h-[60vh] overflow-y-auto">
|
|
253
|
-
{rows.length === 0 ? (
|
|
254
|
-
<p className="text-sm text-muted-foreground">
|
|
255
|
-
{t("auditTrail.noFields")}
|
|
256
|
-
</p>
|
|
257
|
-
) : (
|
|
258
|
-
<Table>
|
|
259
|
-
<TableHeader>
|
|
260
|
-
<TableRow>
|
|
261
|
-
<TableHead>{t("auditTrail.field")}</TableHead>
|
|
262
|
-
<TableHead>{t("auditTrail.before")}</TableHead>
|
|
263
|
-
<TableHead>{t("auditTrail.after")}</TableHead>
|
|
264
|
-
</TableRow>
|
|
265
|
-
</TableHeader>
|
|
266
|
-
<TableBody>
|
|
267
|
-
{rows.map((d) => (
|
|
268
|
-
<TableRow key={d.field}>
|
|
269
|
-
<TableCell className="font-medium">
|
|
270
|
-
{fieldLabel(d.field)}
|
|
271
|
-
</TableCell>
|
|
272
|
-
<TableCell className="whitespace-pre-wrap text-destructive/90">
|
|
273
|
-
{d.before}
|
|
274
|
-
</TableCell>
|
|
275
|
-
<TableCell className="whitespace-pre-wrap text-success">
|
|
276
|
-
{d.after}
|
|
277
|
-
</TableCell>
|
|
278
|
-
</TableRow>
|
|
279
|
-
))}
|
|
280
|
-
</TableBody>
|
|
281
|
-
</Table>
|
|
282
|
-
)}
|
|
283
|
-
</div>
|
|
284
|
-
)}
|
|
285
|
-
{detail && (hiddenCount > 0 || showTechnical) && (
|
|
286
|
-
<label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
|
287
|
-
<Checkbox
|
|
288
|
-
checked={showTechnical}
|
|
289
|
-
onCheckedChange={(checked) => setShowTechnical(!!checked)}
|
|
290
|
-
/>
|
|
291
|
-
{t("auditTrail.showTechnical")}
|
|
292
|
-
{hiddenCount > 0 && ` (${hiddenCount})`}
|
|
293
|
-
</label>
|
|
294
|
-
)}
|
|
167
|
+
{detail && <AuditDiff change={detail} />}
|
|
295
168
|
</DialogContent>
|
|
296
169
|
</Dialog>
|
|
297
170
|
</div>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { formatDateTime, isIsoDateTime } from "#core/_utils/format";
|
|
7
|
+
import {
|
|
8
|
+
Checkbox,
|
|
9
|
+
Table,
|
|
10
|
+
TableBody,
|
|
11
|
+
TableCell,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableHeader,
|
|
14
|
+
TableRow,
|
|
15
|
+
} from "#core/components/ui";
|
|
16
|
+
import { useI18n } from "#core/contexts";
|
|
17
|
+
import { AuditDataChange } from "#core/features/audit/services/audit.service";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Campos de controle interno: aparecem no diff porque a auditoria versiona a
|
|
21
|
+
* linha inteira, mas não são informação de negócio. Ficam escondidos por padrão.
|
|
22
|
+
*/
|
|
23
|
+
const TECHNICAL_FIELDS = new Set([
|
|
24
|
+
"failedLoginAttempts",
|
|
25
|
+
"createdAt",
|
|
26
|
+
"updatedAt",
|
|
27
|
+
"deletedAt",
|
|
28
|
+
"passwordChangedAt",
|
|
29
|
+
"password",
|
|
30
|
+
"twoFactorSecret",
|
|
31
|
+
"backupCodes",
|
|
32
|
+
"mustChangePassword",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
interface DiffRow {
|
|
36
|
+
field: string;
|
|
37
|
+
before: string;
|
|
38
|
+
after: string;
|
|
39
|
+
technical: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Translate = (key: string) => string;
|
|
43
|
+
|
|
44
|
+
function formatValue(value: unknown, locale: string, t: Translate): string {
|
|
45
|
+
if (value === null || value === undefined) {
|
|
46
|
+
return "—";
|
|
47
|
+
}
|
|
48
|
+
if (typeof value === "boolean") {
|
|
49
|
+
return value ? t("common.yes") : t("common.no");
|
|
50
|
+
}
|
|
51
|
+
if (isIsoDateTime(value)) {
|
|
52
|
+
return formatDateTime(value, locale);
|
|
53
|
+
}
|
|
54
|
+
if (typeof value === "object") {
|
|
55
|
+
// Indentado: objeto e array em uma linha só ficavam ilegíveis no modal.
|
|
56
|
+
return JSON.stringify(value, null, 2);
|
|
57
|
+
}
|
|
58
|
+
return String(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function diffRows(
|
|
62
|
+
item: AuditDataChange,
|
|
63
|
+
locale: string,
|
|
64
|
+
t: Translate,
|
|
65
|
+
): DiffRow[] {
|
|
66
|
+
const keys = new Set([
|
|
67
|
+
...Object.keys(item.oldData ?? {}),
|
|
68
|
+
...Object.keys(item.newData ?? {}),
|
|
69
|
+
]);
|
|
70
|
+
return Array.from(keys).map((field) => ({
|
|
71
|
+
field,
|
|
72
|
+
before: formatValue(item.oldData?.[field], locale, t),
|
|
73
|
+
after: formatValue(item.newData?.[field], locale, t),
|
|
74
|
+
technical: TECHNICAL_FIELDS.has(field),
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AuditDiffProps {
|
|
79
|
+
change: AuditDataChange;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Diff de uma alteração: campo, antes e depois.
|
|
84
|
+
*
|
|
85
|
+
* Mora aqui, e não na tela de auditoria, porque os dois lugares mostram o mesmo
|
|
86
|
+
* diff — a listagem geral e o histórico de um registro. Duas cópias divergiriam
|
|
87
|
+
* na primeira vez que alguém acrescentasse um campo técnico (§6).
|
|
88
|
+
*/
|
|
89
|
+
export function AuditDiff({ change }: AuditDiffProps): JSX.Element {
|
|
90
|
+
const { t, locale } = useI18n();
|
|
91
|
+
const [showTechnical, setShowTechnical] = useState(false);
|
|
92
|
+
|
|
93
|
+
const allRows = useMemo(
|
|
94
|
+
() => diffRows(change, locale, t),
|
|
95
|
+
[change, locale, t],
|
|
96
|
+
);
|
|
97
|
+
const rows = showTechnical
|
|
98
|
+
? allRows
|
|
99
|
+
: allRows.filter((row) => !row.technical);
|
|
100
|
+
const hiddenCount = allRows.length - rows.length;
|
|
101
|
+
|
|
102
|
+
/** Rótulo do campo; sem tradução cadastrada, mostra o nome cru da coluna. */
|
|
103
|
+
const fieldLabel = (field: string): string => {
|
|
104
|
+
const key = `auditFields.${field}`;
|
|
105
|
+
const label = t(key);
|
|
106
|
+
return label === key ? field : label;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<>
|
|
111
|
+
<div className="max-h-[60vh] overflow-y-auto">
|
|
112
|
+
{rows.length === 0 ? (
|
|
113
|
+
<p className="text-sm text-muted-foreground">
|
|
114
|
+
{t("auditTrail.noFields")}
|
|
115
|
+
</p>
|
|
116
|
+
) : (
|
|
117
|
+
<Table>
|
|
118
|
+
<TableHeader>
|
|
119
|
+
<TableRow>
|
|
120
|
+
<TableHead>{t("auditTrail.field")}</TableHead>
|
|
121
|
+
<TableHead>{t("auditTrail.before")}</TableHead>
|
|
122
|
+
<TableHead>{t("auditTrail.after")}</TableHead>
|
|
123
|
+
</TableRow>
|
|
124
|
+
</TableHeader>
|
|
125
|
+
<TableBody>
|
|
126
|
+
{rows.map((row) => (
|
|
127
|
+
<TableRow key={row.field}>
|
|
128
|
+
<TableCell className="font-medium">
|
|
129
|
+
{fieldLabel(row.field)}
|
|
130
|
+
</TableCell>
|
|
131
|
+
<TableCell className="whitespace-pre-wrap text-destructive/90">
|
|
132
|
+
{row.before}
|
|
133
|
+
</TableCell>
|
|
134
|
+
<TableCell className="whitespace-pre-wrap text-success">
|
|
135
|
+
{row.after}
|
|
136
|
+
</TableCell>
|
|
137
|
+
</TableRow>
|
|
138
|
+
))}
|
|
139
|
+
</TableBody>
|
|
140
|
+
</Table>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{(hiddenCount > 0 || showTechnical) && (
|
|
145
|
+
<label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
|
146
|
+
<Checkbox
|
|
147
|
+
checked={showTechnical}
|
|
148
|
+
onCheckedChange={(checked) => setShowTechnical(!!checked)}
|
|
149
|
+
/>
|
|
150
|
+
{t("auditTrail.showTechnical")}
|
|
151
|
+
{hiddenCount > 0 && ` (${hiddenCount})`}
|
|
152
|
+
</label>
|
|
153
|
+
)}
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Eye, History } from "lucide-react";
|
|
4
|
+
import type { JSX } from "react";
|
|
5
|
+
import { useCallback, useEffect, useState } from "react";
|
|
6
|
+
|
|
7
|
+
import { formatDateTime } from "#core/_utils/format";
|
|
8
|
+
import {
|
|
9
|
+
Badge,
|
|
10
|
+
Button,
|
|
11
|
+
Dialog,
|
|
12
|
+
DialogContent,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
Spinner,
|
|
16
|
+
Table,
|
|
17
|
+
TableBody,
|
|
18
|
+
TableCell,
|
|
19
|
+
TableHead,
|
|
20
|
+
TableHeader,
|
|
21
|
+
TableRow,
|
|
22
|
+
} from "#core/components/ui";
|
|
23
|
+
import { useAuth, useI18n } from "#core/contexts";
|
|
24
|
+
import { AuditDiff } from "#core/features/audit/components/audit-diff";
|
|
25
|
+
import {
|
|
26
|
+
AuditDataAction,
|
|
27
|
+
AuditDataActionLabelKey,
|
|
28
|
+
} from "#core/features/audit/enums/audit-data-action.enum";
|
|
29
|
+
import {
|
|
30
|
+
AuditDataChange,
|
|
31
|
+
auditService,
|
|
32
|
+
} from "#core/features/audit/services/audit.service";
|
|
33
|
+
import { useRequest } from "#core/hooks/use-request";
|
|
34
|
+
|
|
35
|
+
/** Mesma permissão da tela de auditoria: ver a trilha é ler dado de todos. */
|
|
36
|
+
const TRAIL_PERMISSION = "audit:read-trail:any";
|
|
37
|
+
|
|
38
|
+
function actionVariant(
|
|
39
|
+
action: AuditDataAction,
|
|
40
|
+
): "success" | "warning" | "destructive" {
|
|
41
|
+
if (action === AuditDataAction.CREATE) {return "success";}
|
|
42
|
+
if (action === AuditDataAction.DELETE) {return "destructive";}
|
|
43
|
+
return "warning";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RecordAuditButtonProps {
|
|
47
|
+
/**
|
|
48
|
+
* Nome da **classe** da entidade, como o backend a grava: `"User"`, `"Role"`,
|
|
49
|
+
* `"Group"` — não o nome da tabela.
|
|
50
|
+
*/
|
|
51
|
+
entity: string;
|
|
52
|
+
entityId: string;
|
|
53
|
+
/** Texto do botão. Ausente, usa "Histórico". */
|
|
54
|
+
label?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Histórico de alterações de um registro, onde ele é editado.
|
|
59
|
+
*
|
|
60
|
+
* Consulta a mesma trilha da tela de auditoria, filtrada por `entity` +
|
|
61
|
+
* `entityId`. Some por completo para quem não tem permissão de ler a trilha —
|
|
62
|
+
* botão que só entrega 403 é pior que botão nenhum.
|
|
63
|
+
*
|
|
64
|
+
* ```tsx
|
|
65
|
+
* <RecordAuditButton entity="User" entityId={user.id} />
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function RecordAuditButton({
|
|
69
|
+
entity,
|
|
70
|
+
entityId,
|
|
71
|
+
label,
|
|
72
|
+
}: RecordAuditButtonProps): JSX.Element | null {
|
|
73
|
+
const { t, locale } = useI18n();
|
|
74
|
+
const { hasPermission } = useAuth();
|
|
75
|
+
const { run, loading } = useRequest();
|
|
76
|
+
|
|
77
|
+
const [open, setOpen] = useState(false);
|
|
78
|
+
const [items, setItems] = useState<AuditDataChange[]>([]);
|
|
79
|
+
const [detail, setDetail] = useState<AuditDataChange | null>(null);
|
|
80
|
+
|
|
81
|
+
const fetch = useCallback(async () => {
|
|
82
|
+
const res = await run(() => auditService.listByRecord(entity, entityId));
|
|
83
|
+
if (res) {
|
|
84
|
+
setItems(res.items);
|
|
85
|
+
}
|
|
86
|
+
}, [run, entity, entityId]);
|
|
87
|
+
|
|
88
|
+
// Busca só ao abrir: a tela de edição não paga a consulta de quem nunca
|
|
89
|
+
// clica no histórico.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (open) {
|
|
92
|
+
void fetch();
|
|
93
|
+
}
|
|
94
|
+
}, [open, fetch]);
|
|
95
|
+
|
|
96
|
+
if (!hasPermission(TRAIL_PERMISSION)) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<>
|
|
102
|
+
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
|
103
|
+
<History className="h-4 w-4" />
|
|
104
|
+
{label ?? t("auditTrail.recordHistory")}
|
|
105
|
+
</Button>
|
|
106
|
+
|
|
107
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
108
|
+
<DialogContent className="max-w-3xl">
|
|
109
|
+
<DialogHeader>
|
|
110
|
+
<DialogTitle>{t("auditTrail.recordHistory")}</DialogTitle>
|
|
111
|
+
</DialogHeader>
|
|
112
|
+
|
|
113
|
+
{loading ? (
|
|
114
|
+
<div className="flex justify-center py-8">
|
|
115
|
+
<Spinner className="h-6 w-6" />
|
|
116
|
+
</div>
|
|
117
|
+
) : items.length === 0 ? (
|
|
118
|
+
<p className="py-4 text-sm text-muted-foreground">
|
|
119
|
+
{t("auditTrail.noRecordHistory")}
|
|
120
|
+
</p>
|
|
121
|
+
) : (
|
|
122
|
+
<div className="max-h-[60vh] overflow-y-auto">
|
|
123
|
+
<Table>
|
|
124
|
+
<TableHeader>
|
|
125
|
+
<TableRow>
|
|
126
|
+
<TableHead>{t("auditTrail.action")}</TableHead>
|
|
127
|
+
<TableHead>{t("auditTrail.user")}</TableHead>
|
|
128
|
+
<TableHead>{t("auditTrail.date")}</TableHead>
|
|
129
|
+
<TableHead />
|
|
130
|
+
</TableRow>
|
|
131
|
+
</TableHeader>
|
|
132
|
+
<TableBody>
|
|
133
|
+
{items.map((item) => (
|
|
134
|
+
<TableRow key={item.id}>
|
|
135
|
+
<TableCell>
|
|
136
|
+
<Badge variant={actionVariant(item.action)}>
|
|
137
|
+
{t(AuditDataActionLabelKey[item.action])}
|
|
138
|
+
</Badge>
|
|
139
|
+
</TableCell>
|
|
140
|
+
<TableCell>
|
|
141
|
+
{item.user?.name ?? t("auditTrail.systemUser")}
|
|
142
|
+
</TableCell>
|
|
143
|
+
<TableCell>{formatDateTime(item.createdAt, locale)}</TableCell>
|
|
144
|
+
<TableCell className="text-right">
|
|
145
|
+
<Button
|
|
146
|
+
variant="ghost"
|
|
147
|
+
size="sm"
|
|
148
|
+
onClick={() => setDetail(item)}
|
|
149
|
+
>
|
|
150
|
+
<Eye className="h-4 w-4" />
|
|
151
|
+
{t("auditTrail.viewChanges")}
|
|
152
|
+
</Button>
|
|
153
|
+
</TableCell>
|
|
154
|
+
</TableRow>
|
|
155
|
+
))}
|
|
156
|
+
</TableBody>
|
|
157
|
+
</Table>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</DialogContent>
|
|
161
|
+
</Dialog>
|
|
162
|
+
|
|
163
|
+
<Dialog open={!!detail} onOpenChange={(o) => !o && setDetail(null)}>
|
|
164
|
+
<DialogContent className="max-w-2xl">
|
|
165
|
+
<DialogHeader>
|
|
166
|
+
<DialogTitle>
|
|
167
|
+
{t("auditTrail.changesTitle")} — {entity} (
|
|
168
|
+
{detail && t(AuditDataActionLabelKey[detail.action])})
|
|
169
|
+
</DialogTitle>
|
|
170
|
+
</DialogHeader>
|
|
171
|
+
{detail && <AuditDiff change={detail} />}
|
|
172
|
+
</DialogContent>
|
|
173
|
+
</Dialog>
|
|
174
|
+
</>
|
|
175
|
+
);
|
|
176
|
+
}
|