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
@@ -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
- loading,
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
  };
@@ -3,9 +3,14 @@
3
3
  import type { JSX } from "react";
4
4
  import { useEffect } from "react";
5
5
 
6
- import { BrandHeader, LanguageSelector, ThemeToggle } from "#core/components";
7
- import { Card } from "#core/components/ui";
8
- import { useI18n } from "#core/contexts";
6
+ import {
7
+ BrandHeader,
8
+ BrandPanel,
9
+ LanguageSelector,
10
+ ThemeToggle,
11
+ } from "#core/components";
12
+ import { Card, Separator } from "#core/components/ui";
13
+ import { useBrand, useI18n } from "#core/contexts";
9
14
  import {
10
15
  BackupCodesDialog,
11
16
  LoginForm,
@@ -13,6 +18,7 @@ import {
13
18
  TwoFactorVerify,
14
19
  } from "#core/features/login/components";
15
20
  import { useLoginFlow } from "#core/features/login/hooks/use-login-flow";
21
+ import { cn } from "#core/lib/utils";
16
22
 
17
23
  export function LoginScreen(): JSX.Element {
18
24
  const flow = useLoginFlow();
@@ -30,50 +36,90 @@ export function LoginScreen(): JSX.Element {
30
36
  // eslint-disable-next-line react-hooks/exhaustive-deps
31
37
  }, [flow.step]);
32
38
 
33
- return (
34
- <div className="relative flex min-h-screen items-center justify-center bg-background p-4">
35
- <div className="absolute right-3 top-3 flex gap-1">
36
- <LanguageSelector />
37
- <ThemeToggle />
38
- </div>
39
+ const { loginLayout } = useBrand();
40
+ const aoLado = loginLayout !== "top";
39
41
 
40
- <Card className="w-full max-w-md p-8">
41
- {/* A marca vem antes do título do passo: é a primeira tela do sistema,
42
- e quem chega aqui precisa reconhecer onde está antes de logar. */}
43
- <BrandHeader title={titles[flow.step]} />
42
+ const conteudo = (
43
+ <>
44
+ {/* A marca vem antes do título do passo: é a primeira tela do sistema, e
45
+ quem chega aqui precisa reconhecer onde está antes de logar. Com a
46
+ marca ao lado, só o título — repeti-la aqui seria dizer duas vezes. */}
47
+ <BrandHeader title={titles[flow.step]} showBrand={!aoLado} />
44
48
 
45
- {flow.error && (
46
- <div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
47
- {flow.error}
48
- </div>
49
- )}
49
+ {flow.error && (
50
+ <div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
51
+ {flow.error}
52
+ </div>
53
+ )}
50
54
 
51
- {flow.step === "credentials" && (
52
- <LoginForm loading={flow.loading} onSubmit={flow.submitCredentials} />
53
- )}
55
+ {flow.step === "credentials" && (
56
+ <LoginForm loading={flow.loading} onSubmit={flow.submitCredentials} />
57
+ )}
54
58
 
55
- {flow.step === "setup" && flow.setup && (
56
- <TwoFactorSetup
57
- setup={flow.setup}
58
- loading={flow.loading}
59
- onConfirm={(code) => flow.submitCode(code, "totp")}
60
- />
61
- )}
59
+ {flow.step === "setup" && flow.setup && (
60
+ <TwoFactorSetup
61
+ setup={flow.setup}
62
+ loading={flow.loading}
63
+ onConfirm={(code) => flow.submitCode(code, "totp")}
64
+ />
65
+ )}
66
+
67
+ {flow.step === "verify" && (
68
+ <TwoFactorVerify
69
+ loading={flow.loading}
70
+ onVerify={flow.submitCode}
71
+ onRequestEmail={flow.requestEmailCode}
72
+ />
73
+ )}
74
+
75
+ <BackupCodesDialog
76
+ open={flow.step === "backup"}
77
+ codes={flow.backupCodes}
78
+ onClose={flow.finishBackup}
79
+ />
80
+ </>
81
+ );
62
82
 
63
- {flow.step === "verify" && (
64
- <TwoFactorVerify
65
- loading={flow.loading}
66
- onVerify={flow.submitCode}
67
- onRequestEmail={flow.requestEmailCode}
83
+ const seletores = (
84
+ <div className="absolute right-3 top-3 z-10 flex gap-1">
85
+ <LanguageSelector />
86
+ <ThemeToggle />
87
+ </div>
88
+ );
89
+
90
+ if (aoLado) {
91
+ return (
92
+ <div className="relative min-h-screen bg-background">
93
+ {seletores}
94
+ {/* Empilhado em tela estreita, lado a lado a partir do `lg`. No DOM a
95
+ marca vem primeiro — no celular é ela que deve abrir a tela, nos
96
+ dois casos. Quem troca os lados no desktop é o `order`, e o divisor
97
+ precisa do dele para não sobrar na ponta. */}
98
+ <div className="flex min-h-screen flex-col lg:flex-row">
99
+ <BrandPanel
100
+ className={loginLayout === "right" ? "lg:order-3" : "lg:order-1"}
101
+ />
102
+ <Separator
103
+ orientation="horizontal"
104
+ className="lg:order-2 lg:h-auto lg:w-px lg:self-stretch"
68
105
  />
69
- )}
106
+ <div
107
+ className={cn(
108
+ "flex flex-1 items-center justify-center p-4",
109
+ loginLayout === "right" ? "lg:order-1" : "lg:order-3",
110
+ )}
111
+ >
112
+ <Card className="w-full max-w-md p-8">{conteudo}</Card>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ );
117
+ }
70
118
 
71
- <BackupCodesDialog
72
- open={flow.step === "backup"}
73
- codes={flow.backupCodes}
74
- onClose={flow.finishBackup}
75
- />
76
- </Card>
119
+ return (
120
+ <div className="relative flex min-h-screen items-center justify-center bg-background p-4">
121
+ {seletores}
122
+ <Card className="w-full max-w-md p-8">{conteudo}</Card>
77
123
  </div>
78
124
  );
79
125
  }
@@ -0,0 +1,21 @@
1
+ // Categoria do erro (espelha o ErrorType do backend: request_logs.error_type).
2
+ export enum ErrorType {
3
+ VALIDATION = 0,
4
+ AUTH = 1,
5
+ NOT_FOUND = 2,
6
+ RATE_LIMIT = 3,
7
+ DATABASE = 4,
8
+ INTEGRATION = 5,
9
+ INTERNAL = 6,
10
+ }
11
+
12
+ /** Chave de i18n de cada valor — o texto mora em `i18n/messages` (§6). */
13
+ export const ErrorTypeLabelKey: Record<ErrorType, string> = {
14
+ [ErrorType.VALIDATION]: "logs.errorValidation",
15
+ [ErrorType.AUTH]: "logs.errorAuth",
16
+ [ErrorType.NOT_FOUND]: "logs.errorNotFound",
17
+ [ErrorType.RATE_LIMIT]: "logs.errorRateLimit",
18
+ [ErrorType.DATABASE]: "logs.errorDatabase",
19
+ [ErrorType.INTEGRATION]: "logs.errorIntegration",
20
+ [ErrorType.INTERNAL]: "logs.errorInternal",
21
+ };
@@ -0,0 +1,14 @@
1
+ // Desfecho da requisição (espelha o RequestOutcome do backend:
2
+ // request_logs.outcome).
3
+ export enum RequestOutcome {
4
+ SUCCESS = 0,
5
+ ERROR = 1,
6
+ ABORTED = 2,
7
+ }
8
+
9
+ /** Chave de i18n de cada valor — o texto mora em `i18n/messages` (§6). */
10
+ export const RequestOutcomeLabelKey: Record<RequestOutcome, string> = {
11
+ [RequestOutcome.SUCCESS]: "logs.outcomeSuccess",
12
+ [RequestOutcome.ERROR]: "logs.outcomeError",
13
+ [RequestOutcome.ABORTED]: "logs.outcomeAborted",
14
+ };
@@ -9,11 +9,23 @@ import {
9
9
  useState,
10
10
  } from "react";
11
11
 
12
+ import type { FilterValues } from "#core/_utils/filter";
13
+ import { RequestOutcome } from "#core/features/logs/enums/request-outcome.enum";
12
14
  import { logsService, RequestLog } from "#core/features/logs/services/logs.service";
13
15
  import type { FiltersState } from "#core/hooks/use-filters";
14
16
  import { useListQuery } from "#core/hooks/use-list-query";
15
17
  import { useRequest } from "#core/hooks/use-request";
16
18
 
19
+ /**
20
+ * A tela abre no sucesso, não em tudo.
21
+ *
22
+ * Fora do módulo para não recriar o objeto a cada render: ele é dependência do
23
+ * efeito de hidratação, e um literal novo por render o refaria sem parar.
24
+ */
25
+ const DEFAULT_FILTER: FilterValues = {
26
+ outcome: { values: [String(RequestOutcome.SUCCESS)] },
27
+ };
28
+
17
29
  export interface UseRequestLogsResult {
18
30
  rows: RequestLog[];
19
31
  total: number;
@@ -32,7 +44,11 @@ export interface UseRequestLogsResult {
32
44
  export function useRequestLogs(): UseRequestLogsResult {
33
45
  const { run, loading } = useRequest();
34
46
 
35
- const list = useListQuery("/audit/changes/filter-schema", 20);
47
+ const list = useListQuery(
48
+ "/audit/requests/filter-schema",
49
+ 20,
50
+ DEFAULT_FILTER,
51
+ );
36
52
 
37
53
  const [rows, setRows] = useState<RequestLog[]>([]);
38
54
  const [total, setTotal] = useState(0);
@@ -45,9 +61,14 @@ export function useRequestLogs(): UseRequestLogsResult {
45
61
  }
46
62
  }, [run, list.query]);
47
63
 
64
+ // Não busca antes da URL ser lida: a query sairia sem o filtro, e essa
65
+ // resposta pode chegar depois da certa e pintar a tela com tudo.
48
66
  useEffect(() => {
67
+ if (!list.ready) {
68
+ return;
69
+ }
49
70
  void fetch();
50
- }, [fetch]);
71
+ }, [fetch, list.ready]);
51
72
 
52
73
  return {
53
74
  rows,
@@ -55,7 +76,9 @@ export function useRequestLogs(): UseRequestLogsResult {
55
76
  page: list.page,
56
77
  pageSize: list.pageSize,
57
78
  sorting: list.sorting,
58
- loading,
79
+ // Esperar o catálogo também é carregar: sem isso a tabela pisca
80
+ // "Nenhum registro" antes da primeira busca sair.
81
+ loading: loading || !list.ready,
59
82
  setPage: list.setPage,
60
83
  setPageSize: list.setPageSize,
61
84
  setSorting: list.setSorting,
@@ -2,9 +2,8 @@
2
2
 
3
3
  import type { JSX } from "react";
4
4
 
5
- import { Tabs, TabsContent, TabsList, TabsTrigger } from "#core/components/ui";
6
5
  import { useI18n } from "#core/contexts";
7
- import { ErrorLogsPanel, RequestLogsPanel } from "#core/features/logs/panels";
6
+ import { RequestLogsPanel } from "#core/features/logs/panels";
8
7
 
9
8
  export function LogsScreen(): JSX.Element {
10
9
  const { t } = useI18n();
@@ -14,18 +13,7 @@ export function LogsScreen(): JSX.Element {
14
13
  <h1 className="mb-1 text-3xl font-bold">{t("logs.title")}</h1>
15
14
  <p className="mb-4 text-sm text-muted-foreground">{t("logs.subtitle")}</p>
16
15
 
17
- <Tabs defaultValue="requests">
18
- <TabsList>
19
- <TabsTrigger value="requests">{t("logs.tabRequests")}</TabsTrigger>
20
- <TabsTrigger value="errors">{t("logs.tabErrors")}</TabsTrigger>
21
- </TabsList>
22
- <TabsContent value="requests">
23
- <RequestLogsPanel />
24
- </TabsContent>
25
- <TabsContent value="errors">
26
- <ErrorLogsPanel />
27
- </TabsContent>
28
- </Tabs>
16
+ <RequestLogsPanel />
29
17
  </div>
30
18
  );
31
19
  }
@@ -1,3 +1,2 @@
1
1
  // Barrel da pasta — importe daqui de fora; dentro da pasta use o caminho direto.
2
- export * from "./error-logs-panel";
3
2
  export * from "./request-logs-panel";
@@ -6,11 +6,29 @@ import type {
6
6
  PaginationState,
7
7
  SortingState,
8
8
  } from "@tanstack/react-table";
9
+ import { Eye } from "lucide-react";
9
10
  import type { JSX } from "react";
10
- import { useMemo } from "react";
11
+ import { useMemo, useState } from "react";
11
12
 
12
- import { Badge, DataTable, DataTableFeatures } from "#core/components/ui";
13
+ import {
14
+ Badge,
15
+ Button,
16
+ DataTable,
17
+ DataTableFeatures,
18
+ Dialog,
19
+ DialogContent,
20
+ DialogHeader,
21
+ DialogTitle,
22
+ RowActions,
23
+ SegmentedControl,
24
+ SegmentedOption,
25
+ } from "#core/components/ui";
13
26
  import { useI18n } from "#core/contexts";
27
+ import {
28
+ ErrorType,
29
+ ErrorTypeLabelKey,
30
+ } from "#core/features/logs/enums/error-type.enum";
31
+ import { RequestOutcomeLabelKey } from "#core/features/logs/enums/request-outcome.enum";
14
32
  import { useRequestLogs } from "#core/features/logs/hooks/use-request-logs";
15
33
  import { RequestLog } from "#core/features/logs/services/logs.service";
16
34
 
@@ -24,9 +42,46 @@ function statusVariant(
24
42
  return "secondary";
25
43
  }
26
44
 
45
+ /**
46
+ * Nenhum segmento aceso.
47
+ *
48
+ * Não é opção do controle — some do filtro só quem limpar o desfecho pelo
49
+ * painel lateral, e aí nenhum segmento fica marcado.
50
+ */
51
+ const NO_OUTCOME = "";
52
+
53
+ /**
54
+ * Log de requisição — um painel só.
55
+ *
56
+ * Eram dois (requisições e erros) enquanto eram duas tabelas. Hoje é uma linha
57
+ * por requisição, e quem separa sucesso de erro é o desfecho na barra.
58
+ */
27
59
  export function RequestLogsPanel(): JSX.Element {
28
60
  const { t } = useI18n();
29
61
  const logs = useRequestLogs();
62
+ const [detail, setDetail] = useState<RequestLog | null>(null);
63
+
64
+ // O atalho não é um segundo caminho de filtro (§11): ele escreve no mesmo
65
+ // estado do painel lateral, então o chip aparece, a URL guarda e o painel
66
+ // mostra o mesmo valor. É atalho para o campo do catálogo, não um filtro
67
+ // paralelo.
68
+ const outcome = logs.filters.values.outcome?.values?.[0] ?? NO_OUTCOME;
69
+ const setOutcome = (value: string): void => {
70
+ logs.filters.setValues({
71
+ ...logs.filters.values,
72
+ outcome: { values: [value] },
73
+ });
74
+ logs.setPage(0);
75
+ };
76
+
77
+ const outcomeOptions = useMemo<SegmentedOption[]>(
78
+ () =>
79
+ Object.entries(RequestOutcomeLabelKey).map(([value, key]) => ({
80
+ value,
81
+ label: t(key),
82
+ })),
83
+ [t],
84
+ );
30
85
 
31
86
  const columns = useMemo<ColumnDef<DataTableFeatures, RequestLog, unknown>[]>(
32
87
  () => [
@@ -37,12 +92,17 @@ export function RequestLogsPanel(): JSX.Element {
37
92
  cell: ({ getValue }) => new Date(String(getValue())).toLocaleString(),
38
93
  },
39
94
  {
40
- accessorKey: "action",
95
+ id: "route",
41
96
  header: t("logs.route"),
42
97
  meta: { label: t("logs.route") },
43
98
  enableSorting: false,
44
- cell: ({ getValue }) => (
45
- <span className="font-mono text-xs">{String(getValue())}</span>
99
+ // A ação nomeada (`@Auditable("Login")`) diz mais que o caminho; sem
100
+ // ela, o caminho é o que identifica a requisição.
101
+ cell: ({ row }) => (
102
+ <span className="font-mono text-xs">
103
+ {row.original.action ??
104
+ `${row.original.method ?? ""} ${row.original.path ?? "—"}`}
105
+ </span>
46
106
  ),
47
107
  },
48
108
  {
@@ -54,6 +114,16 @@ export function RequestLogsPanel(): JSX.Element {
54
114
  return <Badge variant={statusVariant(status)}>{status ?? "—"}</Badge>;
55
115
  },
56
116
  },
117
+ {
118
+ accessorKey: "errorType",
119
+ header: t("logs.type"),
120
+ meta: { label: t("logs.type") },
121
+ enableSorting: false,
122
+ cell: ({ getValue }) => {
123
+ const type = getValue() as ErrorType | null;
124
+ return type == null ? "—" : t(ErrorTypeLabelKey[type]);
125
+ },
126
+ },
57
127
  {
58
128
  accessorKey: "durationMs",
59
129
  header: t("logs.duration"),
@@ -71,16 +141,26 @@ export function RequestLogsPanel(): JSX.Element {
71
141
  cell: ({ row }) => row.original.user?.name ?? t("logs.systemUser"),
72
142
  },
73
143
  {
74
- accessorKey: "ipAddress",
75
- header: t("logs.ip"),
76
- meta: { label: t("logs.ip") },
144
+ id: "actions",
145
+ header: () => <div className="text-center">{t("common.actions")}</div>,
146
+ meta: { shrink: true },
147
+ enableHiding: false,
77
148
  enableSorting: false,
78
- cell: ({ getValue }) => (
79
- <span className="font-mono text-xs">{String(getValue() ?? "—")}</span>
149
+ cell: ({ row }) => (
150
+ <RowActions>
151
+ <Button
152
+ variant="ghost"
153
+ size="sm"
154
+ onClick={() => setDetail(row.original)}
155
+ >
156
+ <Eye className="h-4 w-4" />
157
+ {t("logs.viewDetails")}
158
+ </Button>
159
+ </RowActions>
80
160
  ),
81
161
  },
82
162
  ],
83
-
163
+
84
164
  [t],
85
165
  );
86
166
 
@@ -105,20 +185,92 @@ export function RequestLogsPanel(): JSX.Element {
105
185
  };
106
186
 
107
187
  return (
108
- <DataTable
109
- columns={columns}
110
- data={logs.rows}
111
- getRowId={(r) => r.id}
112
- storageKey="logs-requests"
113
- loading={logs.loading}
114
- manualPagination
115
- rowCount={logs.total}
116
- pagination={pagination}
117
- onPaginationChange={onPaginationChange}
118
- manualSorting
119
- sorting={logs.sorting}
120
- onSortingChange={onSortingChange}
188
+ <>
189
+ <DataTable
190
+ columns={columns}
191
+ data={logs.rows}
192
+ getRowId={(r) => r.id}
193
+ storageKey="logs-requests"
194
+ loading={logs.loading}
195
+ manualPagination
196
+ rowCount={logs.total}
197
+ pagination={pagination}
198
+ onPaginationChange={onPaginationChange}
199
+ manualSorting
200
+ sorting={logs.sorting}
201
+ onSortingChange={onSortingChange}
121
202
  filters={logs.filters}
122
- />
203
+ // O desfecho é escolhido na barra: no painel e no chip seria a mesma
204
+ // coisa em três lugares. Continua no catálogo, que é quem serializa.
205
+ hiddenFilterFields={["outcome"]}
206
+ toolbar={
207
+ <SegmentedControl
208
+ // Altura dos botões `sm` de Filtros/Colunas, que dividem a linha.
209
+ className="h-9"
210
+ label={t("logs.outcome")}
211
+ options={outcomeOptions}
212
+ value={outcome}
213
+ onValueChange={setOutcome}
214
+ />
215
+ }
216
+ />
217
+
218
+ <Dialog open={!!detail} onOpenChange={(o) => !o && setDetail(null)}>
219
+ <DialogContent className="max-w-2xl">
220
+ <DialogHeader>
221
+ <DialogTitle>
222
+ {t("logs.detailsTitle")}
223
+ {detail?.statusCode != null && ` (${detail.statusCode})`}
224
+ </DialogTitle>
225
+ </DialogHeader>
226
+ {detail && (
227
+ <div className="flex min-w-0 flex-col gap-3 text-sm">
228
+ <div>
229
+ <span className="font-medium">{t("logs.route")}: </span>
230
+ <span className="font-mono text-xs">
231
+ {detail.method} {detail.path}
232
+ </span>
233
+ </div>
234
+ {detail.errorMessage && (
235
+ <div>
236
+ <span className="font-medium">{t("logs.message")}: </span>
237
+ {detail.errorMessage}
238
+ </div>
239
+ )}
240
+ {detail.errorCode && (
241
+ <div>
242
+ <span className="font-medium">{t("logs.errorCode")}: </span>
243
+ <span className="font-mono text-xs">{detail.errorCode}</span>
244
+ </div>
245
+ )}
246
+ <div>
247
+ <span className="font-medium">{t("logs.user")}: </span>
248
+ {detail.user?.name ?? t("logs.systemUser")}
249
+ </div>
250
+ <div>
251
+ <span className="font-medium">{t("logs.requestId")}: </span>
252
+ <span className="font-mono text-xs">
253
+ {detail.requestId ?? "—"}
254
+ </span>
255
+ </div>
256
+ {detail.userAgent && (
257
+ <div className="min-w-0">
258
+ <span className="font-medium">{t("logs.userAgent")}: </span>
259
+ <span className="font-mono text-xs">{detail.userAgent}</span>
260
+ </div>
261
+ )}
262
+ {detail.errorStack && (
263
+ <div className="min-w-0">
264
+ <p className="mb-1 font-medium">{t("logs.stackTrace")}</p>
265
+ <pre className="max-h-64 min-w-0 max-w-full overflow-auto rounded-md bg-muted p-3 text-xs">
266
+ {detail.errorStack}
267
+ </pre>
268
+ </div>
269
+ )}
270
+ </div>
271
+ )}
272
+ </DialogContent>
273
+ </Dialog>
274
+ </>
123
275
  );
124
276
  }