rl-core-front 0.1.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 (112) hide show
  1. package/README.md +129 -0
  2. package/package.json +113 -0
  3. package/src/_services/api/axios.factory.ts +97 -0
  4. package/src/_services/api/list-query.ts +26 -0
  5. package/src/_services/api/schema.d.ts +2527 -0
  6. package/src/_services/auth/index.ts +127 -0
  7. package/src/_utils/advanced-filter.ts +386 -0
  8. package/src/_utils/calendar.ts +103 -0
  9. package/src/_utils/crop-image.ts +65 -0
  10. package/src/_utils/filter.ts +313 -0
  11. package/src/_utils/format.ts +35 -0
  12. package/src/_utils/initials.ts +24 -0
  13. package/src/_utils/password.ts +7 -0
  14. package/src/components/app-shell.tsx +314 -0
  15. package/src/components/breadcrumbs.tsx +55 -0
  16. package/src/components/index.ts +7 -0
  17. package/src/components/language-selector.tsx +51 -0
  18. package/src/components/password-field.tsx +40 -0
  19. package/src/components/theme-toggle.tsx +27 -0
  20. package/src/components/ui/alert.tsx +27 -0
  21. package/src/components/ui/avatar.tsx +50 -0
  22. package/src/components/ui/badge.tsx +36 -0
  23. package/src/components/ui/button.tsx +62 -0
  24. package/src/components/ui/card.tsx +86 -0
  25. package/src/components/ui/checkbox.tsx +30 -0
  26. package/src/components/ui/column-visibility-modal.tsx +110 -0
  27. package/src/components/ui/confirm-dialog.tsx +69 -0
  28. package/src/components/ui/data-table.tsx +401 -0
  29. package/src/components/ui/date-range-picker.tsx +250 -0
  30. package/src/components/ui/dialog.tsx +138 -0
  31. package/src/components/ui/dropdown-menu.tsx +108 -0
  32. package/src/components/ui/field.tsx +20 -0
  33. package/src/components/ui/filter-chips.tsx +88 -0
  34. package/src/components/ui/filter-field.tsx +137 -0
  35. package/src/components/ui/filter-group.tsx +155 -0
  36. package/src/components/ui/filter-rule.tsx +221 -0
  37. package/src/components/ui/filter-sheet.tsx +201 -0
  38. package/src/components/ui/index.ts +30 -0
  39. package/src/components/ui/input.tsx +25 -0
  40. package/src/components/ui/label.tsx +23 -0
  41. package/src/components/ui/row-actions.tsx +32 -0
  42. package/src/components/ui/select.tsx +97 -0
  43. package/src/components/ui/separator.tsx +31 -0
  44. package/src/components/ui/sheet.tsx +119 -0
  45. package/src/components/ui/sonner.tsx +28 -0
  46. package/src/components/ui/spinner.tsx +12 -0
  47. package/src/components/ui/switch.tsx +29 -0
  48. package/src/components/ui/table.tsx +84 -0
  49. package/src/components/ui/tabs.tsx +52 -0
  50. package/src/components/user-avatar.tsx +54 -0
  51. package/src/contexts/auth-context.tsx +90 -0
  52. package/src/contexts/color-mode-context.tsx +105 -0
  53. package/src/contexts/i18n-context.tsx +76 -0
  54. package/src/contexts/index.ts +6 -0
  55. package/src/contexts/socket-context.tsx +153 -0
  56. package/src/contexts/toast-context.tsx +40 -0
  57. package/src/features/audit/audit-screen.tsx +299 -0
  58. package/src/features/audit/hooks/use-audit-trail.ts +68 -0
  59. package/src/features/audit/services/audit.service.ts +24 -0
  60. package/src/features/dashboard/dashboard-screen.tsx +73 -0
  61. package/src/features/login/components/backup-codes-dialog.tsx +66 -0
  62. package/src/features/login/components/index.ts +5 -0
  63. package/src/features/login/components/login-form.tsx +62 -0
  64. package/src/features/login/components/two-factor-setup.tsx +71 -0
  65. package/src/features/login/components/two-factor-verify.tsx +56 -0
  66. package/src/features/login/hooks/use-login-flow.ts +119 -0
  67. package/src/features/login/login-screen.tsx +83 -0
  68. package/src/features/login/validation/schemas.ts +30 -0
  69. package/src/features/logs/hooks/use-error-logs.ts +65 -0
  70. package/src/features/logs/hooks/use-request-logs.ts +65 -0
  71. package/src/features/logs/logs-screen.tsx +31 -0
  72. package/src/features/logs/panels/error-logs-panel.tsx +183 -0
  73. package/src/features/logs/panels/index.ts +3 -0
  74. package/src/features/logs/panels/request-logs-panel.tsx +124 -0
  75. package/src/features/logs/services/logs.service.ts +68 -0
  76. package/src/features/notifications/components/notification-item.tsx +67 -0
  77. package/src/features/notifications/hooks/use-notifications.ts +78 -0
  78. package/src/features/notifications/notifications-center.tsx +119 -0
  79. package/src/features/notifications/services/notifications.service.ts +38 -0
  80. package/src/features/profile/components/avatar-crop-dialog.tsx +133 -0
  81. package/src/features/profile/components/index.ts +2 -0
  82. package/src/features/profile/force-password-change.tsx +114 -0
  83. package/src/features/profile/profile-screen.tsx +385 -0
  84. package/src/features/rbac/panels/actions-panel.tsx +20 -0
  85. package/src/features/rbac/panels/catalog-panel.tsx +243 -0
  86. package/src/features/rbac/panels/groups-panel.tsx +269 -0
  87. package/src/features/rbac/panels/index.ts +8 -0
  88. package/src/features/rbac/panels/permissions-panel.tsx +204 -0
  89. package/src/features/rbac/panels/resources-panel.tsx +20 -0
  90. package/src/features/rbac/panels/roles-panel.tsx +224 -0
  91. package/src/features/rbac/panels/scopes-panel.tsx +20 -0
  92. package/src/features/rbac/rbac-screen.tsx +80 -0
  93. package/src/features/rbac/services/rbac.service.ts +110 -0
  94. package/src/features/recovery/forgot-password-screen.tsx +69 -0
  95. package/src/features/recovery/reset-password-screen.tsx +163 -0
  96. package/src/features/users/components/user-form-dialog.tsx +187 -0
  97. package/src/features/users/hooks/use-users.ts +181 -0
  98. package/src/features/users/services/users.service.ts +83 -0
  99. package/src/features/users/users-screen.tsx +331 -0
  100. package/src/hooks/use-column-visibility.ts +54 -0
  101. package/src/hooks/use-confirm.tsx +70 -0
  102. package/src/hooks/use-filter-schema.ts +85 -0
  103. package/src/hooks/use-filters.ts +148 -0
  104. package/src/hooks/use-list-query.ts +69 -0
  105. package/src/hooks/use-request.ts +114 -0
  106. package/src/i18n/messages/en.ts +296 -0
  107. package/src/i18n/messages/pt.ts +305 -0
  108. package/src/index.ts +60 -0
  109. package/src/lib/utils.ts +6 -0
  110. package/src/middleware.ts +42 -0
  111. package/src/styles/core.css +142 -0
  112. package/tailwind-preset.ts +103 -0
@@ -0,0 +1,183 @@
1
+ "use client";
2
+
3
+ import type {
4
+ ColumnDef,
5
+ OnChangeFn,
6
+ PaginationState,
7
+ SortingState,
8
+ } from "@tanstack/react-table";
9
+ import { Eye } from "lucide-react";
10
+ import type { JSX } from "react";
11
+ import { useMemo, useState } from "react";
12
+
13
+ import {
14
+ Badge,
15
+ Button,
16
+ DataTable,
17
+ DataTableFeatures,
18
+ Dialog,
19
+ DialogContent,
20
+ DialogHeader,
21
+ DialogTitle,
22
+ RowActions,
23
+ } from "#core/components/ui";
24
+ import { useI18n } from "#core/contexts";
25
+ import { useErrorLogs } from "#core/features/logs/hooks/use-error-logs";
26
+ import { ErrorLog, ErrorType } from "#core/features/logs/services/logs.service";
27
+
28
+ function typeVariant(type: ErrorType): "warning" | "destructive" | "secondary" {
29
+ if (type === "DATABASE" || type === "INTEGRATION" || type === "INTERNAL")
30
+ {return "destructive";}
31
+ if (type === "VALIDATION") {return "secondary";}
32
+ return "warning";
33
+ }
34
+
35
+ export function ErrorLogsPanel(): JSX.Element {
36
+ const { t } = useI18n();
37
+ const logs = useErrorLogs();
38
+ const [detail, setDetail] = useState<ErrorLog | null>(null);
39
+
40
+ const columns = useMemo<ColumnDef<DataTableFeatures, ErrorLog, unknown>[]>(
41
+ () => [
42
+ {
43
+ accessorKey: "createdAt",
44
+ header: t("logs.date"),
45
+ meta: { label: t("logs.date") },
46
+ cell: ({ getValue }) => new Date(String(getValue())).toLocaleString(),
47
+ },
48
+ {
49
+ accessorKey: "type",
50
+ header: t("logs.type"),
51
+ meta: { label: t("logs.type") },
52
+ cell: ({ getValue }) => {
53
+ const type = getValue() as ErrorType;
54
+ return <Badge variant={typeVariant(type)}>{type}</Badge>;
55
+ },
56
+ },
57
+ {
58
+ accessorKey: "statusCode",
59
+ header: t("logs.status"),
60
+ meta: { label: t("logs.status") },
61
+ },
62
+ {
63
+ accessorKey: "message",
64
+ header: t("logs.message"),
65
+ meta: { label: t("logs.message") },
66
+ enableSorting: false,
67
+ cell: ({ getValue }) => (
68
+ <span className="line-clamp-1 max-w-md">{String(getValue())}</span>
69
+ ),
70
+ },
71
+ {
72
+ id: "user",
73
+ header: t("logs.user"),
74
+ meta: { label: t("logs.user") },
75
+ enableSorting: false,
76
+ cell: ({ row }) => row.original.user?.name ?? t("logs.systemUser"),
77
+ },
78
+ {
79
+ id: "actions",
80
+ header: () => <div className="text-center">{t("common.actions")}</div>,
81
+ meta: { shrink: true },
82
+ enableHiding: false,
83
+ enableSorting: false,
84
+ cell: ({ row }) => (
85
+ <RowActions>
86
+ <Button
87
+ variant="ghost"
88
+ size="sm"
89
+ onClick={() => setDetail(row.original)}
90
+ >
91
+ <Eye className="h-4 w-4" />
92
+ {t("logs.viewDetails")}
93
+ </Button>
94
+ </RowActions>
95
+ ),
96
+ },
97
+ ],
98
+
99
+ [t],
100
+ );
101
+
102
+ const pagination: PaginationState = {
103
+ pageIndex: logs.page,
104
+ pageSize: logs.pageSize,
105
+ };
106
+ const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
107
+ const next = typeof updater === "function" ? updater(pagination) : updater;
108
+ if (next.pageSize !== logs.pageSize) {
109
+ logs.setPageSize(next.pageSize);
110
+ logs.setPage(0);
111
+ } else if (next.pageIndex !== logs.page) {
112
+ logs.setPage(next.pageIndex);
113
+ }
114
+ };
115
+ const onSortingChange: OnChangeFn<SortingState> = (updater) => {
116
+ const next =
117
+ typeof updater === "function" ? updater(logs.sorting) : updater;
118
+ logs.setSorting(next);
119
+ logs.setPage(0);
120
+ };
121
+
122
+ return (
123
+ <div>
124
+ <DataTable
125
+ columns={columns}
126
+ data={logs.rows}
127
+ getRowId={(r) => r.id}
128
+ storageKey="logs-errors"
129
+ loading={logs.loading}
130
+ manualPagination
131
+ rowCount={logs.total}
132
+ pagination={pagination}
133
+ onPaginationChange={onPaginationChange}
134
+ manualSorting
135
+ sorting={logs.sorting}
136
+ onSortingChange={onSortingChange}
137
+ filters={logs.filters}
138
+ />
139
+
140
+ <Dialog open={!!detail} onOpenChange={(o) => !o && setDetail(null)}>
141
+ <DialogContent className="max-w-2xl">
142
+ <DialogHeader>
143
+ <DialogTitle>
144
+ {t("logs.detailsTitle")} — {detail?.type} ({detail?.statusCode})
145
+ </DialogTitle>
146
+ </DialogHeader>
147
+ {detail && (
148
+ <div className="flex min-w-0 flex-col gap-3 text-sm">
149
+ <div>
150
+ <span className="font-medium">{t("logs.route")}: </span>
151
+ <span className="font-mono text-xs">
152
+ {detail.method} {detail.path}
153
+ </span>
154
+ </div>
155
+ <div>
156
+ <span className="font-medium">{t("logs.message")}: </span>
157
+ {detail.message}
158
+ </div>
159
+ <div>
160
+ <span className="font-medium">{t("logs.user")}: </span>
161
+ {detail.user?.name ?? t("logs.systemUser")}
162
+ </div>
163
+ <div>
164
+ <span className="font-medium">{t("logs.requestId")}: </span>
165
+ <span className="font-mono text-xs">
166
+ {detail.requestId ?? "—"}
167
+ </span>
168
+ </div>
169
+ {detail.stack && (
170
+ <div className="min-w-0">
171
+ <p className="mb-1 font-medium">{t("logs.stackTrace")}</p>
172
+ <pre className="max-h-64 min-w-0 max-w-full overflow-auto rounded-md bg-muted p-3 text-xs">
173
+ {detail.stack}
174
+ </pre>
175
+ </div>
176
+ )}
177
+ </div>
178
+ )}
179
+ </DialogContent>
180
+ </Dialog>
181
+ </div>
182
+ );
183
+ }
@@ -0,0 +1,3 @@
1
+ // Barrel da pasta — importe daqui de fora; dentro da pasta use o caminho direto.
2
+ export * from "./error-logs-panel";
3
+ export * from "./request-logs-panel";
@@ -0,0 +1,124 @@
1
+ "use client";
2
+
3
+ import type {
4
+ ColumnDef,
5
+ OnChangeFn,
6
+ PaginationState,
7
+ SortingState,
8
+ } from "@tanstack/react-table";
9
+ import type { JSX } from "react";
10
+ import { useMemo } from "react";
11
+
12
+ import { Badge, DataTable, DataTableFeatures } from "#core/components/ui";
13
+ import { useI18n } from "#core/contexts";
14
+ import { useRequestLogs } from "#core/features/logs/hooks/use-request-logs";
15
+ import { RequestLog } from "#core/features/logs/services/logs.service";
16
+
17
+ function statusVariant(
18
+ status?: number | null,
19
+ ): "success" | "warning" | "destructive" | "secondary" {
20
+ if (!status) {return "secondary";}
21
+ if (status >= 500) {return "destructive";}
22
+ if (status >= 400) {return "warning";}
23
+ if (status >= 200 && status < 300) {return "success";}
24
+ return "secondary";
25
+ }
26
+
27
+ export function RequestLogsPanel(): JSX.Element {
28
+ const { t } = useI18n();
29
+ const logs = useRequestLogs();
30
+
31
+ const columns = useMemo<ColumnDef<DataTableFeatures, RequestLog, unknown>[]>(
32
+ () => [
33
+ {
34
+ accessorKey: "createdAt",
35
+ header: t("logs.date"),
36
+ meta: { label: t("logs.date") },
37
+ cell: ({ getValue }) => new Date(String(getValue())).toLocaleString(),
38
+ },
39
+ {
40
+ accessorKey: "action",
41
+ header: t("logs.route"),
42
+ meta: { label: t("logs.route") },
43
+ enableSorting: false,
44
+ cell: ({ getValue }) => (
45
+ <span className="font-mono text-xs">{String(getValue())}</span>
46
+ ),
47
+ },
48
+ {
49
+ accessorKey: "statusCode",
50
+ header: t("logs.status"),
51
+ meta: { label: t("logs.status") },
52
+ cell: ({ getValue }) => {
53
+ const status = getValue() as number | null;
54
+ return <Badge variant={statusVariant(status)}>{status ?? "—"}</Badge>;
55
+ },
56
+ },
57
+ {
58
+ accessorKey: "durationMs",
59
+ header: t("logs.duration"),
60
+ meta: { label: t("logs.duration") },
61
+ cell: ({ getValue }) => {
62
+ const ms = getValue() as number | null;
63
+ return ms != null ? `${ms}ms` : "—";
64
+ },
65
+ },
66
+ {
67
+ id: "user",
68
+ header: t("logs.user"),
69
+ meta: { label: t("logs.user") },
70
+ enableSorting: false,
71
+ cell: ({ row }) => row.original.user?.name ?? t("logs.systemUser"),
72
+ },
73
+ {
74
+ accessorKey: "ipAddress",
75
+ header: t("logs.ip"),
76
+ meta: { label: t("logs.ip") },
77
+ enableSorting: false,
78
+ cell: ({ getValue }) => (
79
+ <span className="font-mono text-xs">{String(getValue() ?? "—")}</span>
80
+ ),
81
+ },
82
+ ],
83
+
84
+ [t],
85
+ );
86
+
87
+ const pagination: PaginationState = {
88
+ pageIndex: logs.page,
89
+ pageSize: logs.pageSize,
90
+ };
91
+ const onPaginationChange: OnChangeFn<PaginationState> = (updater) => {
92
+ const next = typeof updater === "function" ? updater(pagination) : updater;
93
+ if (next.pageSize !== logs.pageSize) {
94
+ logs.setPageSize(next.pageSize);
95
+ logs.setPage(0);
96
+ } else if (next.pageIndex !== logs.page) {
97
+ logs.setPage(next.pageIndex);
98
+ }
99
+ };
100
+ const onSortingChange: OnChangeFn<SortingState> = (updater) => {
101
+ const next =
102
+ typeof updater === "function" ? updater(logs.sorting) : updater;
103
+ logs.setSorting(next);
104
+ logs.setPage(0);
105
+ };
106
+
107
+ 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}
121
+ filters={logs.filters}
122
+ />
123
+ );
124
+ }
@@ -0,0 +1,68 @@
1
+ import { api } from "#core/_services/api/axios.factory";
2
+ import type { ListQuery } from "#core/_services/api/list-query";
3
+ import { toListParams } from "#core/_services/api/list-query";
4
+
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
+ }
48
+
49
+ export interface Paginated<T> {
50
+ items: T[];
51
+ meta: { total: number; page: number; limit: number; totalPages: number };
52
+ }
53
+
54
+ export const logsService = {
55
+ listRequests(query: ListQuery) {
56
+ return api
57
+ .get<Paginated<RequestLog>>("/audit/changes", {
58
+ params: toListParams(query),
59
+ })
60
+ .then((r) => r.data);
61
+ },
62
+
63
+ listErrors(query: ListQuery) {
64
+ return api
65
+ .get<Paginated<ErrorLog>>("/audit/errors", { params: toListParams(query) })
66
+ .then((r) => r.data);
67
+ },
68
+ };
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import { AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react";
4
+ import type { JSX } from "react";
5
+
6
+ import { formatDateTime } from "#core/_utils/format";
7
+ import { useI18n } from "#core/contexts";
8
+ import type {
9
+ NotificationType,
10
+ UserNotification,
11
+ } from "#core/features/notifications/services/notifications.service";
12
+ import { cn } from "#core/lib/utils";
13
+
14
+ 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" />,
19
+ };
20
+
21
+ export interface NotificationItemProps {
22
+ notification: UserNotification;
23
+ onSelect: (notification: UserNotification) => void;
24
+ }
25
+
26
+ export function NotificationItem({
27
+ notification,
28
+ onSelect,
29
+ }: NotificationItemProps): JSX.Element {
30
+ const { locale } = useI18n();
31
+
32
+ return (
33
+ <button
34
+ type="button"
35
+ onClick={() => onSelect(notification)}
36
+ className={cn(
37
+ "flex w-full gap-3 border-b border-border px-4 py-3 text-left transition-colors hover:bg-accent",
38
+ !notification.read && "bg-accent/40",
39
+ )}
40
+ >
41
+ <span className="mt-0.5 shrink-0">{ICONS[notification.type]}</span>
42
+
43
+ <span className="min-w-0 flex-1">
44
+ <span className="flex items-center gap-2">
45
+ <span
46
+ className={cn(
47
+ "truncate text-sm",
48
+ notification.read ? "text-foreground" : "font-semibold",
49
+ )}
50
+ >
51
+ {notification.title}
52
+ </span>
53
+ {!notification.read && (
54
+ <span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
55
+ )}
56
+ </span>
57
+
58
+ <span className="mt-0.5 block text-xs text-muted-foreground">
59
+ {notification.body}
60
+ </span>
61
+ <span className="mt-1 block text-[11px] text-muted-foreground">
62
+ {formatDateTime(notification.createdAt, locale)}
63
+ </span>
64
+ </span>
65
+ </button>
66
+ );
67
+ }
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ import { useSocket } from "#core/contexts/socket-context";
6
+ import {
7
+ NOTIFICATION_CREATED_EVENT,
8
+ notificationsService,
9
+ UserNotification,
10
+ } from "#core/features/notifications/services/notifications.service";
11
+ import { useRequest } from "#core/hooks/use-request";
12
+
13
+ /** Quantas o sino carrega de uma vez. O histórico completo não cabe no popover. */
14
+ const PAGE_SIZE = 20;
15
+
16
+ export interface UseNotificationsResult {
17
+ items: UserNotification[];
18
+ unread: number;
19
+ loading: boolean;
20
+ fetch: () => Promise<void>;
21
+ markAsRead: (id: string) => Promise<void>;
22
+ markAllAsRead: () => Promise<void>;
23
+ }
24
+
25
+ export function useNotifications(): UseNotificationsResult {
26
+ const { run, loading } = useRequest();
27
+ const { on, reconnectCount } = useSocket();
28
+
29
+ const [items, setItems] = useState<UserNotification[]>([]);
30
+
31
+ const unread = items.filter((item) => !item.read).length;
32
+
33
+ const fetch = useCallback(async () => {
34
+ const page = await run(() => notificationsService.list(1, PAGE_SIZE));
35
+ if (page) {
36
+ setItems(page.items);
37
+ }
38
+ }, [run]);
39
+
40
+ // Carga inicial e, de novo, a cada reconexão: enquanto o socket esteve fora
41
+ // chegaram notificações que ninguém empurrou para esta aba.
42
+ useEffect(() => {
43
+ void fetch();
44
+ }, [fetch, reconnectCount]);
45
+
46
+ // Sem toast aqui: o badge do sino já é o aviso, e disparar os dois pelo mesmo
47
+ // evento faz o usuário descartar a mesma informação duas vezes.
48
+ useEffect(() => {
49
+ return on<UserNotification>(NOTIFICATION_CREATED_EVENT, (notification) => {
50
+ // O evento já traz a notificação inteira: inserir no topo evita refazer
51
+ // a listagem a cada chegada.
52
+ setItems((current) => {
53
+ if (current.some((item) => item.id === notification.id)) {
54
+ return current;
55
+ }
56
+ return [notification, ...current];
57
+ });
58
+ });
59
+ }, [on]);
60
+
61
+ const markAsRead = useCallback(
62
+ async (id: string) => {
63
+ // Otimista: o badge responde ao clique, e o servidor confirma depois.
64
+ setItems((current) =>
65
+ current.map((item) => (item.id === id ? { ...item, read: true } : item)),
66
+ );
67
+ await run(() => notificationsService.markAsRead(id));
68
+ },
69
+ [run],
70
+ );
71
+
72
+ const markAllAsRead = useCallback(async () => {
73
+ setItems((current) => current.map((item) => ({ ...item, read: true })));
74
+ await run(() => notificationsService.markAllAsRead());
75
+ }, [run]);
76
+
77
+ return { items, unread, loading, fetch, markAsRead, markAllAsRead };
78
+ }
@@ -0,0 +1,119 @@
1
+ "use client";
2
+
3
+ import { Bell } from "lucide-react";
4
+ import { useRouter } from "next/navigation";
5
+ import type { JSX } from "react";
6
+ import { useState } from "react";
7
+
8
+ import {
9
+ Button,
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuTrigger,
13
+ Spinner,
14
+ } from "#core/components/ui";
15
+ import { useI18n } from "#core/contexts";
16
+ import { NotificationItem } from "#core/features/notifications/components/notification-item";
17
+ import { useNotifications } from "#core/features/notifications/hooks/use-notifications";
18
+ import type { UserNotification } from "#core/features/notifications/services/notifications.service";
19
+ import { cn } from "#core/lib/utils";
20
+
21
+ type Tab = "all" | "unread";
22
+
23
+ /** Acima disso o número não cabe no badge sem empurrar o layout. */
24
+ const BADGE_LIMIT = 99;
25
+
26
+ export function NotificationsCenter(): JSX.Element {
27
+ const { t } = useI18n();
28
+ const router = useRouter();
29
+ const { items, unread, loading, markAsRead, markAllAsRead } =
30
+ useNotifications();
31
+ const [tab, setTab] = useState<Tab>("all");
32
+
33
+ const visible = tab === "unread" ? items.filter((i) => !i.read) : items;
34
+
35
+ const onSelect = (notification: UserNotification): void => {
36
+ if (!notification.read) {
37
+ void markAsRead(notification.id);
38
+ }
39
+ if (notification.route) {
40
+ // router.push preserva o estado do app; window.location recarregaria tudo.
41
+ router.push(notification.route);
42
+ }
43
+ };
44
+
45
+ return (
46
+ <DropdownMenu>
47
+ <DropdownMenuTrigger asChild>
48
+ <Button
49
+ variant="ghost"
50
+ size="icon"
51
+ className="relative"
52
+ aria-label={t("notifications.title")}
53
+ >
54
+ <Bell className="h-5 w-5" />
55
+ {unread > 0 && (
56
+ <span className="absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-destructive-foreground">
57
+ {unread > BADGE_LIMIT ? `${BADGE_LIMIT}+` : unread}
58
+ </span>
59
+ )}
60
+ </Button>
61
+ </DropdownMenuTrigger>
62
+
63
+ <DropdownMenuContent align="end" className="w-96 p-0">
64
+ <div className="flex items-center justify-between border-b border-border px-4 py-2">
65
+ <div className="flex gap-3">
66
+ {(["all", "unread"] as const).map((value) => (
67
+ <button
68
+ key={value}
69
+ type="button"
70
+ onClick={() => setTab(value)}
71
+ className={cn(
72
+ "border-b-2 pb-1 text-sm transition-colors",
73
+ tab === value
74
+ ? "border-primary font-medium text-foreground"
75
+ : "border-transparent text-muted-foreground hover:text-foreground",
76
+ )}
77
+ >
78
+ {value === "all"
79
+ ? t("notifications.all")
80
+ : t("notifications.unread")}
81
+ </button>
82
+ ))}
83
+ </div>
84
+
85
+ <button
86
+ type="button"
87
+ onClick={() => void markAllAsRead()}
88
+ disabled={unread === 0}
89
+ className="text-xs font-medium text-primary hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground disabled:no-underline"
90
+ >
91
+ {t("notifications.markAllRead")}
92
+ </button>
93
+ </div>
94
+
95
+ <div className="max-h-96 overflow-y-auto">
96
+ {loading && items.length === 0 && (
97
+ <div className="flex justify-center py-8">
98
+ <Spinner />
99
+ </div>
100
+ )}
101
+
102
+ {!loading && visible.length === 0 && (
103
+ <p className="px-4 py-8 text-center text-sm text-muted-foreground">
104
+ {t("notifications.empty")}
105
+ </p>
106
+ )}
107
+
108
+ {visible.map((notification) => (
109
+ <NotificationItem
110
+ key={notification.id}
111
+ notification={notification}
112
+ onSelect={onSelect}
113
+ />
114
+ ))}
115
+ </div>
116
+ </DropdownMenuContent>
117
+ </DropdownMenu>
118
+ );
119
+ }
@@ -0,0 +1,38 @@
1
+ import { api } from "#core/_services/api/axios.factory";
2
+ import type { components } from "#core/_services/api/schema";
3
+
4
+ /** Tipos derivados do `openapi.json` (§5) — não redigitar a forma da resposta. */
5
+ export type UserNotification = components["schemas"]["UserNotificationResponse"];
6
+ export type NotificationType = UserNotification["type"];
7
+
8
+ export interface PaginatedNotifications {
9
+ items: UserNotification[];
10
+ meta: components["schemas"]["PaginationMetaResponse"];
11
+ }
12
+
13
+ /** Evento de push do socket — mesma forma da notificação da listagem. */
14
+ export const NOTIFICATION_CREATED_EVENT = "notification:created";
15
+
16
+ export const notificationsService = {
17
+ list(page: number, limit: number) {
18
+ return api
19
+ .get<PaginatedNotifications>("/notifications", {
20
+ params: { page, limit },
21
+ })
22
+ .then((r) => r.data);
23
+ },
24
+
25
+ unreadCount() {
26
+ return api
27
+ .get<{ unread: number }>("/notifications/unread-count")
28
+ .then((r) => r.data.unread);
29
+ },
30
+
31
+ markAsRead(id: string) {
32
+ return api.patch(`/notifications/${id}/read`, {}).then((r) => r.data);
33
+ },
34
+
35
+ markAllAsRead() {
36
+ return api.patch("/notifications/read-all", {}).then((r) => r.data);
37
+ },
38
+ };