rl-core-front 0.3.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 +43 -132
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// O que aconteceu com o registro (espelha o AuditDataAction do backend:
|
|
2
|
+
// audit_data_changes.action).
|
|
3
|
+
export enum AuditDataAction {
|
|
4
|
+
CREATE = 0,
|
|
5
|
+
UPDATE = 1,
|
|
6
|
+
DELETE = 2,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Chave de i18n de cada valor.
|
|
11
|
+
*
|
|
12
|
+
* Aponta a chave e não o texto porque o core é bilíngue (§6): o rótulo mora em
|
|
13
|
+
* `i18n/messages`, e escrevê-lo aqui criaria uma segunda fonte do mesmo texto —
|
|
14
|
+
* que sairia em português também no inglês.
|
|
15
|
+
*/
|
|
16
|
+
export const AuditDataActionLabelKey: Record<AuditDataAction, string> = {
|
|
17
|
+
[AuditDataAction.CREATE]: "auditTrail.actionCreate",
|
|
18
|
+
[AuditDataAction.UPDATE]: "auditTrail.actionUpdate",
|
|
19
|
+
[AuditDataAction.DELETE]: "auditTrail.actionDelete",
|
|
20
|
+
};
|
|
@@ -48,9 +48,14 @@ export function useAuditTrail(): UseAuditTrailResult {
|
|
|
48
48
|
}
|
|
49
49
|
}, [run, list.query]);
|
|
50
50
|
|
|
51
|
+
// Não busca antes da URL ser lida: a query sairia sem o filtro, e essa
|
|
52
|
+
// resposta pode chegar depois da certa e pintar a tela com tudo.
|
|
51
53
|
useEffect(() => {
|
|
54
|
+
if (!list.ready) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
52
57
|
void fetch();
|
|
53
|
-
}, [fetch]);
|
|
58
|
+
}, [fetch, list.ready]);
|
|
54
59
|
|
|
55
60
|
return {
|
|
56
61
|
rows,
|
|
@@ -58,7 +63,9 @@ export function useAuditTrail(): UseAuditTrailResult {
|
|
|
58
63
|
page: list.page,
|
|
59
64
|
pageSize: list.pageSize,
|
|
60
65
|
sorting: list.sorting,
|
|
61
|
-
|
|
66
|
+
// Esperar o catálogo também é carregar: sem isso a tabela pisca
|
|
67
|
+
// "Nenhum registro" antes da primeira busca sair.
|
|
68
|
+
loading: loading || !list.ready,
|
|
62
69
|
setPage: list.setPage,
|
|
63
70
|
setPageSize: list.setPageSize,
|
|
64
71
|
setSorting: list.setSorting,
|
|
@@ -4,16 +4,44 @@ import { toListParams } from "#core/_services/api/list-query";
|
|
|
4
4
|
import type { components } from "#core/_services/api/schema";
|
|
5
5
|
|
|
6
6
|
/** Tipos derivados do `openapi.json` (§5) — não redigitar a forma da resposta. */
|
|
7
|
-
export type AuditAction = components["schemas"]["AuditDataChangeResponse"]["action"];
|
|
8
7
|
export type AuditUserRef = components["schemas"]["AuditUserRefResponse"];
|
|
9
8
|
export type AuditDataChange = components["schemas"]["AuditDataChangeResponse"];
|
|
9
|
+
export type RequestLog = components["schemas"]["RequestLogResponse"];
|
|
10
10
|
|
|
11
11
|
export interface Paginated<T> {
|
|
12
12
|
items: T[];
|
|
13
13
|
meta: components["schemas"]["PaginationMetaResponse"];
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Teto do histórico de um registro.
|
|
18
|
+
*
|
|
19
|
+
* O modal não pagina: quem precisa de mais que isso está fazendo investigação,
|
|
20
|
+
* e aí a tela de auditoria com filtro é o lugar certo.
|
|
21
|
+
*/
|
|
22
|
+
const RECORD_HISTORY_LIMIT = 100;
|
|
23
|
+
|
|
16
24
|
export const auditService = {
|
|
25
|
+
/**
|
|
26
|
+
* Histórico de um registro específico (`entity` + `entityId`).
|
|
27
|
+
*
|
|
28
|
+
* Usa o mesmo endpoint e o mesmo catálogo da listagem geral — a consulta cai
|
|
29
|
+
* no índice `(entity, entity_id, created_at)`, que existe exatamente por isso.
|
|
30
|
+
*/
|
|
31
|
+
listByRecord(entity: string, entityId: string) {
|
|
32
|
+
return api
|
|
33
|
+
.get<Paginated<AuditDataChange>>("/audit/data-changes", {
|
|
34
|
+
params: {
|
|
35
|
+
page: 1,
|
|
36
|
+
limit: RECORD_HISTORY_LIMIT,
|
|
37
|
+
sortBy: "createdAt",
|
|
38
|
+
sortDir: "DESC",
|
|
39
|
+
filter: JSON.stringify({ entity, entityId }),
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
.then((r) => r.data);
|
|
43
|
+
},
|
|
44
|
+
|
|
17
45
|
listDataChanges(query: ListQuery) {
|
|
18
46
|
return api
|
|
19
47
|
.get<Paginated<AuditDataChange>>("/audit/data-changes", {
|
|
@@ -21,4 +49,18 @@ export const auditService = {
|
|
|
21
49
|
})
|
|
22
50
|
.then((r) => r.data);
|
|
23
51
|
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Log de requisição: erro, requisição lenta e ação marcada com `@Auditable`.
|
|
55
|
+
*
|
|
56
|
+
* Uma rota só para o que antes eram `/audit/changes` e `/audit/errors` — a
|
|
57
|
+
* distinção agora é o filtro `outcome`.
|
|
58
|
+
*/
|
|
59
|
+
listRequests(query: ListQuery) {
|
|
60
|
+
return api
|
|
61
|
+
.get<Paginated<RequestLog>>("/audit/requests", {
|
|
62
|
+
params: toListParams(query),
|
|
63
|
+
})
|
|
64
|
+
.then((r) => r.data);
|
|
65
|
+
},
|
|
24
66
|
};
|