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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/_services/api/schema.d.ts +238 -117
  3. package/src/_utils/filter.ts +15 -34
  4. package/src/components/app-shell.tsx +51 -1
  5. package/src/components/brand-header.tsx +23 -8
  6. package/src/components/brand-panel.tsx +41 -0
  7. package/src/components/index.ts +1 -0
  8. package/src/components/ui/button.tsx +5 -0
  9. package/src/components/ui/confirm-dialog.tsx +3 -1
  10. package/src/components/ui/data-table.tsx +13 -4
  11. package/src/components/ui/dialog.tsx +120 -21
  12. package/src/components/ui/filter-sheet.tsx +47 -25
  13. package/src/components/ui/index.ts +1 -1
  14. package/src/components/ui/segmented-control.tsx +74 -0
  15. package/src/contexts/brand-context.tsx +15 -0
  16. package/src/contexts/i18n-context.tsx +69 -4
  17. package/src/features/audit/audit-screen.tsx +13 -140
  18. package/src/features/audit/components/audit-diff.tsx +156 -0
  19. package/src/features/audit/components/index.ts +3 -0
  20. package/src/features/audit/components/record-audit-button.tsx +176 -0
  21. package/src/features/audit/enums/audit-data-action.enum.ts +20 -0
  22. package/src/features/audit/hooks/use-audit-trail.ts +9 -2
  23. package/src/features/audit/services/audit.service.ts +43 -1
  24. package/src/features/login/login-screen.tsx +86 -40
  25. package/src/features/logs/enums/error-type.enum.ts +21 -0
  26. package/src/features/logs/enums/request-outcome.enum.ts +14 -0
  27. package/src/features/logs/hooks/use-request-logs.ts +26 -3
  28. package/src/features/logs/logs-screen.tsx +2 -14
  29. package/src/features/logs/panels/index.ts +0 -1
  30. package/src/features/logs/panels/request-logs-panel.tsx +177 -25
  31. package/src/features/logs/services/logs.service.ts +13 -51
  32. package/src/features/notifications/components/notification-item.tsx +10 -8
  33. package/src/features/notifications/enums/notification-type.enum.ts +8 -0
  34. package/src/features/profile/profile-screen.tsx +2 -1
  35. package/src/features/rbac/panels/groups-panel.tsx +20 -7
  36. package/src/features/users/components/user-form-dialog.tsx +16 -7
  37. package/src/features/users/hooks/use-users.ts +9 -2
  38. package/src/hooks/use-filters.ts +25 -2
  39. package/src/hooks/use-list-query.ts +14 -1
  40. package/src/i18n/messages/en.ts +17 -5
  41. package/src/i18n/messages/pt.ts +18 -5
  42. package/src/index.ts +12 -1
  43. package/src/components/ui/filter-chips.tsx +0 -88
  44. package/src/features/logs/hooks/use-error-logs.ts +0 -65
  45. 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 function I18nProvider({ children }: { children: React.ReactNode }): JSX.Element {
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(MESSAGES[locale], key) ?? resolve(MESSAGES.pt, key) ?? key;
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, isIsoDateTime } from "#core/_utils/format";
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 { useAuditTrail } from "#core/features/audit/hooks/use-audit-trail";
26
+ import { AuditDiff } from "#core/features/audit/components/audit-diff";
34
27
  import {
35
- AuditAction,
36
- AuditDataChange,
37
- } from "#core/features/audit/services/audit.service";
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: AuditAction,
35
+ action: AuditDataAction,
104
36
  ): "success" | "warning" | "destructive" {
105
- if (action === "CREATE") {return "success";}
106
- if (action === "DELETE") {return "destructive";}
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
- /** Rótulo do campo; sem tradução cadastrada, mostra o nome cru da coluna. */
117
- const fieldLabel = (field: string): string => {
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,3 @@
1
+ // Barrel da pasta — importe daqui de fora; dentro da pasta use o caminho direto.
2
+ export * from "./audit-diff";
3
+ export * from "./record-audit-button";
@@ -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
+ }