rl-core-front 0.3.0 → 0.5.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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/_services/api/app-error-code.enum.ts +23 -0
  3. package/src/_services/api/schema.d.ts +86 -132
  4. package/src/_services/auth/index.ts +2 -0
  5. package/src/_utils/filter.ts +15 -34
  6. package/src/_utils/password.ts +40 -0
  7. package/src/_utils/permission.ts +14 -0
  8. package/src/_utils/storage.ts +64 -0
  9. package/src/_utils/user.ts +19 -0
  10. package/src/components/app-shell.tsx +85 -11
  11. package/src/components/brand-header.tsx +23 -8
  12. package/src/components/brand-panel.tsx +41 -0
  13. package/src/components/index.ts +1 -0
  14. package/src/components/password-field.tsx +49 -22
  15. package/src/components/ui/alert.tsx +34 -5
  16. package/src/components/ui/badge.tsx +2 -2
  17. package/src/components/ui/button.tsx +5 -0
  18. package/src/components/ui/confirm-dialog.tsx +3 -1
  19. package/src/components/ui/data-table.tsx +13 -4
  20. package/src/components/ui/dialog.tsx +120 -21
  21. package/src/components/ui/filter-sheet.tsx +47 -25
  22. package/src/components/ui/index.ts +3 -1
  23. package/src/components/ui/password-requirements.tsx +105 -0
  24. package/src/components/ui/segmented-control.tsx +75 -0
  25. package/src/components/ui/simple-select.tsx +74 -0
  26. package/src/components/ui/switch.tsx +3 -1
  27. package/src/components/ui/tabs.tsx +4 -8
  28. package/src/components/ui/track.styles.ts +31 -0
  29. package/src/contexts/brand-context.tsx +15 -0
  30. package/src/contexts/color-mode-context.tsx +4 -2
  31. package/src/contexts/i18n-context.tsx +72 -6
  32. package/src/contexts/socket-context.tsx +27 -1
  33. package/src/features/audit/audit-screen.tsx +13 -140
  34. package/src/features/audit/components/audit-diff.tsx +156 -0
  35. package/src/features/audit/components/index.ts +3 -0
  36. package/src/features/audit/components/record-audit-button.tsx +176 -0
  37. package/src/features/audit/enums/audit-data-action.enum.ts +20 -0
  38. package/src/features/audit/hooks/use-audit-trail.ts +9 -2
  39. package/src/features/audit/services/audit.service.ts +43 -1
  40. package/src/features/login/components/backup-codes-dialog.tsx +4 -4
  41. package/src/features/login/components/login-form.tsx +18 -3
  42. package/src/features/login/components/two-factor-setup.tsx +32 -2
  43. package/src/features/login/hooks/use-login-flow.ts +60 -7
  44. package/src/features/login/login-screen.tsx +99 -40
  45. package/src/features/logs/enums/error-type.enum.ts +21 -0
  46. package/src/features/logs/enums/request-outcome.enum.ts +14 -0
  47. package/src/features/logs/hooks/use-request-logs.ts +26 -3
  48. package/src/features/logs/logs-screen.tsx +2 -14
  49. package/src/features/logs/panels/index.ts +0 -1
  50. package/src/features/logs/panels/request-logs-panel.tsx +177 -25
  51. package/src/features/logs/services/logs.service.ts +13 -51
  52. package/src/features/notifications/components/notification-item.tsx +10 -8
  53. package/src/features/notifications/enums/notification-type.enum.ts +8 -0
  54. package/src/features/profile/force-password-change.tsx +18 -3
  55. package/src/features/profile/profile-screen.tsx +10 -2
  56. package/src/features/rbac/panels/groups-panel.tsx +78 -30
  57. package/src/features/rbac/panels/roles-panel.tsx +2 -2
  58. package/src/features/rbac/services/rbac.service.ts +23 -27
  59. package/src/features/recovery/reset-password-screen.tsx +16 -2
  60. package/src/features/users/components/user-form-dialog.tsx +65 -9
  61. package/src/features/users/hooks/use-users.ts +24 -3
  62. package/src/features/users/services/users.service.ts +16 -0
  63. package/src/features/users/users-screen.tsx +17 -12
  64. package/src/hooks/use-column-visibility.ts +13 -12
  65. package/src/hooks/use-countdown.ts +70 -0
  66. package/src/hooks/use-filters.ts +25 -2
  67. package/src/hooks/use-list-query.ts +14 -1
  68. package/src/hooks/use-request.ts +21 -2
  69. package/src/i18n/messages/en.ts +37 -6
  70. package/src/i18n/messages/pt.ts +41 -6
  71. package/src/index.ts +15 -1
  72. package/src/styles/core.css +99 -2
  73. package/src/styles/fonts/poppins-latin-400.woff2 +0 -0
  74. package/src/styles/fonts/poppins-latin-500.woff2 +0 -0
  75. package/src/styles/fonts/poppins-latin-600.woff2 +0 -0
  76. package/src/styles/fonts/poppins-latin-700.woff2 +0 -0
  77. package/tailwind-preset.ts +17 -0
  78. package/src/components/ui/filter-chips.tsx +0 -88
  79. package/src/features/logs/hooks/use-error-logs.ts +0 -65
  80. package/src/features/logs/panels/error-logs-panel.tsx +0 -183
@@ -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
  }
@@ -1,68 +1,30 @@
1
1
  import { api } from "#core/_services/api/axios.factory";
2
2
  import type { ListQuery } from "#core/_services/api/list-query";
3
3
  import { toListParams } from "#core/_services/api/list-query";
4
+ import type { components } from "#core/_services/api/schema";
4
5
 
5
- export type ErrorType =
6
- | "VALIDATION"
7
- | "AUTH"
8
- | "NOT_FOUND"
9
- | "RATE_LIMIT"
10
- | "DATABASE"
11
- | "INTEGRATION"
12
- | "INTERNAL";
13
-
14
- export interface LogUserRef {
15
- id: string;
16
- name: string;
17
- email: string;
18
- }
19
-
20
- export interface RequestLog {
21
- id: string;
22
- createdAt: string;
23
- userId?: string | null;
24
- action: string;
25
- method?: string | null;
26
- path?: string | null;
27
- ipAddress?: string | null;
28
- statusCode?: number | null;
29
- durationMs?: number | null;
30
- requestId?: string | null;
31
- user?: LogUserRef | null;
32
- }
33
-
34
- export interface ErrorLog {
35
- id: string;
36
- createdAt: string;
37
- statusCode?: number | null;
38
- type: ErrorType;
39
- message: string;
40
- stack?: string | null;
41
- method?: string | null;
42
- path?: string | null;
43
- userId?: string | null;
44
- ipAddress?: string | null;
45
- requestId?: string | null;
46
- user?: LogUserRef | null;
47
- }
6
+ /** Tipos derivados do `openapi.json` (§5) — não redigitar a forma da resposta. */
7
+ export type RequestLog = components["schemas"]["RequestLogResponse"];
8
+ export type LogUserRef = components["schemas"]["AuditUserRefResponse"];
48
9
 
49
10
  export interface Paginated<T> {
50
11
  items: T[];
51
- meta: { total: number; page: number; limit: number; totalPages: number };
12
+ meta: components["schemas"]["PaginationMetaResponse"];
52
13
  }
53
14
 
54
15
  export const logsService = {
16
+ /**
17
+ * Log de requisição — uma rota só.
18
+ *
19
+ * Antes eram `/audit/changes` e `/audit/errors`, duas tabelas com nove
20
+ * colunas iguais. Agora é uma linha por requisição, e a distinção entre
21
+ * sucesso e erro é o filtro `outcome`.
22
+ */
55
23
  listRequests(query: ListQuery) {
56
24
  return api
57
- .get<Paginated<RequestLog>>("/audit/changes", {
25
+ .get<Paginated<RequestLog>>("/audit/requests", {
58
26
  params: toListParams(query),
59
27
  })
60
28
  .then((r) => r.data);
61
29
  },
62
-
63
- listErrors(query: ListQuery) {
64
- return api
65
- .get<Paginated<ErrorLog>>("/audit/errors", { params: toListParams(query) })
66
- .then((r) => r.data);
67
- },
68
30
  };
@@ -5,17 +5,19 @@ import type { JSX } from "react";
5
5
 
6
6
  import { formatDateTime } from "#core/_utils/format";
7
7
  import { useI18n } from "#core/contexts";
8
- import type {
9
- NotificationType,
10
- UserNotification,
11
- } from "#core/features/notifications/services/notifications.service";
8
+ import { NotificationType } from "#core/features/notifications/enums/notification-type.enum";
9
+ import type { UserNotification } from "#core/features/notifications/services/notifications.service";
12
10
  import { cn } from "#core/lib/utils";
13
11
 
14
12
  const ICONS: Record<NotificationType, JSX.Element> = {
15
- INFO: <Info className="h-4 w-4 text-sky-500" />,
16
- SUCCESS: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
17
- WARNING: <AlertTriangle className="h-4 w-4 text-amber-500" />,
18
- ERROR: <XCircle className="h-4 w-4 text-destructive" />,
13
+ [NotificationType.INFO]: <Info className="h-4 w-4 text-sky-500" />,
14
+ [NotificationType.SUCCESS]: (
15
+ <CheckCircle2 className="h-4 w-4 text-emerald-500" />
16
+ ),
17
+ [NotificationType.WARNING]: (
18
+ <AlertTriangle className="h-4 w-4 text-amber-500" />
19
+ ),
20
+ [NotificationType.ERROR]: <XCircle className="h-4 w-4 text-destructive" />,
19
21
  };
20
22
 
21
23
  export interface NotificationItemProps {
@@ -0,0 +1,8 @@
1
+ // Severidade da notificação (espelha o NotificationType do backend:
2
+ // notifications.type) — define ícone e cor.
3
+ export enum NotificationType {
4
+ INFO = 0,
5
+ SUCCESS = 1,
6
+ WARNING = 2,
7
+ ERROR = 3,
8
+ }
@@ -2,13 +2,18 @@
2
2
 
3
3
  import { yupResolver } from "@hookform/resolvers/yup";
4
4
  import type { JSX } from "react";
5
- import { useForm } from "react-hook-form";
5
+ import { useForm, useWatch } from "react-hook-form";
6
6
  import * as yup from "yup";
7
7
 
8
8
  import { authService } from "#core/_services/auth";
9
9
  import { STRONG_PASSWORD_REGEX } from "#core/_utils/password";
10
10
  import { PasswordField } from "#core/components";
11
- import { Alert, Button, Card, Field } from "#core/components/ui";
11
+ import {
12
+ Alert,
13
+ Button,
14
+ Card,
15
+ Field,
16
+ } from "#core/components/ui";
12
17
  import { useAuth, useI18n } from "#core/contexts";
13
18
  import { useRequest } from "#core/hooks/use-request";
14
19
 
@@ -36,8 +41,13 @@ export function ForcePasswordChange({
36
41
  const {
37
42
  register,
38
43
  handleSubmit,
44
+ control,
39
45
  formState: { errors },
40
46
  } = useForm({ resolver: yupResolver(schema) });
47
+ const [newPassword = "", confirm = ""] = useWatch({
48
+ control,
49
+ name: ["newPassword", "confirm"],
50
+ });
41
51
 
42
52
  const onSubmit = handleSubmit(async (data) => {
43
53
  const res = await run(
@@ -81,6 +91,7 @@ export function ForcePasswordChange({
81
91
  >
82
92
  <PasswordField
83
93
  error={!!errors.newPassword}
94
+ rulesFor={newPassword}
84
95
  {...register("newPassword")}
85
96
  />
86
97
  </Field>
@@ -88,7 +99,11 @@ export function ForcePasswordChange({
88
99
  label={t("profile.confirmPassword")}
89
100
  error={errors.confirm?.message}
90
101
  >
91
- <PasswordField error={!!errors.confirm} {...register("confirm")} />
102
+ <PasswordField
103
+ error={!!errors.confirm}
104
+ rulesFor={confirm}
105
+ {...register("confirm")}
106
+ />
92
107
  </Field>
93
108
  <Button type="submit" size="lg" disabled={loading}>
94
109
  {loading ? t("common.loading") : t("profile.changePassword")}
@@ -4,7 +4,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
4
4
  import { Camera, ShieldCheck, Trash2 } from "lucide-react";
5
5
  import type { JSX } from "react";
6
6
  import { useEffect, useRef, useState } from "react";
7
- import { useForm } from "react-hook-form";
7
+ import { useForm, useWatch } from "react-hook-form";
8
8
  import * as yup from "yup";
9
9
 
10
10
  import { api } from "#core/_services/api/axios.factory";
@@ -106,8 +106,13 @@ export function ProfileScreen(): JSX.Element {
106
106
  const {
107
107
  register,
108
108
  handleSubmit,
109
+ control,
109
110
  formState: { errors },
110
111
  } = useForm({ resolver: yupResolver(schema) });
112
+ const [newPassword = "", confirm = ""] = useWatch({
113
+ control,
114
+ name: ["newPassword", "confirm"],
115
+ });
111
116
  const onChangePassword = handleSubmit(async (data) => {
112
117
  const res = await password.run(
113
118
  () => authService.changePassword(data.currentPassword, data.newPassword),
@@ -314,6 +319,7 @@ export function ProfileScreen(): JSX.Element {
314
319
  >
315
320
  <PasswordField
316
321
  error={!!errors.newPassword}
322
+ rulesFor={newPassword}
317
323
  {...register("newPassword")}
318
324
  />
319
325
  </Field>
@@ -323,6 +329,7 @@ export function ProfileScreen(): JSX.Element {
323
329
  >
324
330
  <PasswordField
325
331
  error={!!errors.confirm}
332
+ rulesFor={confirm}
326
333
  {...register("confirm")}
327
334
  />
328
335
  </Field>
@@ -339,7 +346,8 @@ export function ProfileScreen(): JSX.Element {
339
346
 
340
347
  {/* diálogo: pede código TOTP para regenerar backup codes */}
341
348
  <Dialog open={regenOpen} onOpenChange={(o) => !o && setRegenOpen(false)}>
342
- <DialogContent className="max-w-sm">
349
+ {/* Confirmação: um campo de seis dígitos não tem o que ampliar. */}
350
+ <DialogContent className="max-w-sm" expandable={false}>
343
351
  <DialogHeader>
344
352
  <DialogTitle>{t("auth.backupTitle")}</DialogTitle>
345
353
  </DialogHeader>
@@ -5,6 +5,7 @@ import { Pencil, Plus, Trash2 } from "lucide-react";
5
5
  import type { JSX } from "react";
6
6
  import { useEffect, useMemo, useState } from "react";
7
7
 
8
+ import { fullName } from "#core/_utils/user";
8
9
  import {
9
10
  Button,
10
11
  Checkbox,
@@ -20,23 +21,28 @@ import {
20
21
  Input,
21
22
  Label,
22
23
  RowActions,
23
- Select,
24
- SelectContent,
25
- SelectItem,
26
- SelectTrigger,
27
- SelectValue,
24
+ SimpleSelect,
28
25
  } from "#core/components/ui";
29
26
  import { useAuth, useI18n } from "#core/contexts";
27
+ import { RecordAuditButton } from "#core/features/audit/components";
30
28
  import {
31
29
  Group,
32
30
  rbacService,
33
31
  Role,
34
32
  } from "#core/features/rbac/services/rbac.service";
33
+ import type { User } from "#core/features/users/services/users.service";
34
+ import { usersService } from "#core/features/users/services/users.service";
35
35
  import { useConfirm } from "#core/hooks/use-confirm";
36
36
  import { RequestOperation, useRequest } from "#core/hooks/use-request";
37
37
 
38
38
  const TYPES = ["company", "department", "team"];
39
39
 
40
+ /**
41
+ * Quantos candidatos a gerente o `<select>` carrega. Lista longa em combo não
42
+ * se navega — passando disso, o caminho é a busca da tela de usuários.
43
+ */
44
+ const MANAGER_OPTIONS_LIMIT = 100;
45
+
40
46
  export function GroupsPanel(): JSX.Element {
41
47
  const { run, loading } = useRequest();
42
48
  const { t } = useI18n();
@@ -44,22 +50,29 @@ export function GroupsPanel(): JSX.Element {
44
50
  const { hasPermission } = useAuth();
45
51
  const [rows, setRows] = useState<Group[]>([]);
46
52
  const [roles, setRoles] = useState<Role[]>([]);
53
+ const [candidates, setCandidates] = useState<User[]>([]);
47
54
  const [open, setOpen] = useState(false);
48
55
  const [editing, setEditing] = useState<Group | null>(null);
49
56
  const [form, setForm] = useState({
50
57
  name: "",
51
58
  description: "",
52
59
  type: "team",
60
+ managerId: "",
53
61
  roleIds: [] as string[],
54
62
  });
55
63
 
56
64
  const load = async (): Promise<void> => {
57
- const [g, r] = await Promise.all([
65
+ const [g, r, u] = await Promise.all([
58
66
  run(() => rbacService.listGroups()),
59
67
  run(() => rbacService.listRoles()),
68
+ // Quem pode virar gerente. Uma página basta: o `<select>` não é lugar de
69
+ // paginar, e quem tem centenas de candidatos gerencia isto pela busca da
70
+ // tela de usuários, não aqui.
71
+ run(() => usersService.list({ page: 0, limit: MANAGER_OPTIONS_LIMIT })),
60
72
  ]);
61
73
  if (g) {setRows(g);}
62
74
  if (r) {setRoles(r);}
75
+ if (u) {setCandidates(u.items);}
63
76
  };
64
77
  useEffect(() => {
65
78
  void load();
@@ -68,7 +81,13 @@ export function GroupsPanel(): JSX.Element {
68
81
 
69
82
  const openNew = (): void => {
70
83
  setEditing(null);
71
- setForm({ name: "", description: "", type: "team", roleIds: [] });
84
+ setForm({
85
+ name: "",
86
+ description: "",
87
+ type: "team",
88
+ managerId: "",
89
+ roleIds: [],
90
+ });
72
91
  setOpen(true);
73
92
  };
74
93
  const openEdit = (g: Group): void => {
@@ -77,17 +96,21 @@ export function GroupsPanel(): JSX.Element {
77
96
  name: g.name,
78
97
  description: g.description ?? "",
79
98
  type: g.type,
99
+ managerId: g.managerId ?? "",
80
100
  roleIds: g.roles?.map((r) => r.id) ?? [],
81
101
  });
82
102
  setOpen(true);
83
103
  };
84
104
 
85
105
  const save = async (): Promise<void> => {
106
+ // `""` é "sem gerente": vai como null explícito, que é o que o backend
107
+ // entende como remover — omitir manteria o gerente atual.
108
+ const payload = { ...form, managerId: form.managerId || null };
86
109
  const res = editing
87
- ? await run(() => rbacService.updateGroup(editing.id, form), {
110
+ ? await run(() => rbacService.updateGroup(editing.id, payload), {
88
111
  success: RequestOperation.Update,
89
112
  })
90
- : await run(() => rbacService.createGroup(form), {
113
+ : await run(() => rbacService.createGroup(payload), {
91
114
  success: RequestOperation.Create,
92
115
  });
93
116
  if (res) {
@@ -134,6 +157,18 @@ export function GroupsPanel(): JSX.Element {
134
157
  meta: { label: t("users.name") },
135
158
  },
136
159
  { accessorKey: "type", header: "Tipo", meta: { label: "Tipo" } },
160
+ {
161
+ id: "manager",
162
+ header: t("rbac.manager"),
163
+ meta: { label: t("rbac.manager") },
164
+ enableSorting: false,
165
+ // Sem gerente, o escopo `team` não alcança ninguém aqui dentro — e é
166
+ // exatamente isso que o traço precisa deixar visível na listagem.
167
+ cell: ({ row }) => {
168
+ const manager = candidates.find((c) => c.id === row.original.managerId);
169
+ return manager ? fullName(manager) : "—";
170
+ },
171
+ },
137
172
  {
138
173
  accessorKey: "description",
139
174
  header: "Descrição",
@@ -217,22 +252,23 @@ export function GroupsPanel(): JSX.Element {
217
252
  }
218
253
  />
219
254
  </Field>
255
+ <Field label={t("rbac.manager")}>
256
+ <SimpleSelect
257
+ value={form.managerId}
258
+ onChange={(managerId) => setForm({ ...form, managerId })}
259
+ emptyLabel={t("rbac.noManager")}
260
+ options={candidates.map((c) => ({
261
+ value: c.id,
262
+ label: fullName(c),
263
+ }))}
264
+ />
265
+ </Field>
220
266
  <Field label="Tipo">
221
- <Select
267
+ <SimpleSelect
222
268
  value={form.type}
223
- onValueChange={(v) => setForm({ ...form, type: v })}
224
- >
225
- <SelectTrigger>
226
- <SelectValue />
227
- </SelectTrigger>
228
- <SelectContent>
229
- {TYPES.map((ty) => (
230
- <SelectItem key={ty} value={ty}>
231
- {ty}
232
- </SelectItem>
233
- ))}
234
- </SelectContent>
235
- </Select>
269
+ onChange={(type) => setForm({ ...form, type })}
270
+ options={TYPES.map((ty) => ({ value: ty, label: ty }))}
271
+ />
236
272
  </Field>
237
273
  <div className="space-y-2">
238
274
  <Label>Papéis</Label>
@@ -251,13 +287,25 @@ export function GroupsPanel(): JSX.Element {
251
287
  ))}
252
288
  </div>
253
289
  </div>
254
- <DialogFooter className="gap-2">
255
- <Button type="button" variant="outline" onClick={() => setOpen(false)}>
256
- {t("common.cancel")}
257
- </Button>
258
- <Button type="submit" disabled={loading}>
259
- {t("common.save")}
260
- </Button>
290
+ <DialogFooter className="gap-2 sm:justify-between">
291
+ {/* na edição: registro que ainda não existe não tem histórico. */}
292
+ <div>
293
+ {editing && (
294
+ <RecordAuditButton entity="Group" entityId={editing.id} />
295
+ )}
296
+ </div>
297
+ <div className="flex gap-2">
298
+ <Button
299
+ type="button"
300
+ variant="outline"
301
+ onClick={() => setOpen(false)}
302
+ >
303
+ {t("common.cancel")}
304
+ </Button>
305
+ <Button type="submit" disabled={loading}>
306
+ {t("common.save")}
307
+ </Button>
308
+ </div>
261
309
  </DialogFooter>
262
310
  </DialogForm>
263
311
  </DialogContent>