@goplusvn/core 0.1.52 → 0.1.53

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.
@@ -1,120 +1,294 @@
1
- import type {
2
- CreateNotificationInput,
3
- Notification,
4
- NotificationServiceOptions,
5
- NotificationStorage,
6
- } from "./types";
7
- import { InMemoryStorage } from "./storage/in-memory";
8
- import { createLogger } from "../infrastructure/logger";
9
- import { eventBus } from "../infrastructure/event-bus";
10
-
11
- const logger = createLogger("NotificationService");
12
-
13
1
  /**
14
- * NotificationService - sends and manages user notifications
15
- *
16
- * @example
17
- * ```typescript
18
- * import { notificationService } from '@goerp/core/notification';
19
- *
20
- * // Send notification
21
- * await notificationService.send({
22
- * userId: 'manager-123',
23
- * title: 'Purchase Order cần duyệt',
24
- * content: 'PO-001 chờ phê duyệt',
25
- * type: 'approval',
26
- * link: '/purchase-orders/PO-001'
27
- * });
2
+ * Thông báo in-app cho NHÂN VIÊN engine dùng chung mọi app goerp (bảng
3
+ * `notifications` + `push_subscriptions` ship qua `goerp-features sync`,
4
+ * feature notifications). Thay bản mock InMemoryStorage cũ (đã xóa — không
5
+ * app nào dùng); đây là bản tôi luyện từ vinhhoa.
28
6
  *
29
- * // Get unread notifications
30
- * const unread = await notificationService.getUnread('manager-123');
7
+ * Triết lý: mọi hàm ghi (`notify`) là BEST-EFFORT và KHÔNG BAO GIỜ throw —
8
+ * thông báo lỗi không được phép chặn luồng nghiệp vụ (đơn/duyệt/thanh toán
9
+ * vẫn phải chạy). Fan-out ra nhiều người nhận ở tầng này (mỗi người 1 row).
31
10
  *
32
- * // Mark as read
33
- * await notificationService.markAsRead('notification-id');
34
- * ```
11
+ * App cắm qua `configureNotificationService({ db, afterNotify? })` (cùng
12
+ * khuôn configureTaskRunner): db là Prisma client có các model
13
+ * notification / rolePermission / userRole / userBranch / user (schema RBAC
14
+ * chung của goerp); `afterNotify` là seam fan-out thêm kênh (web push, email…)
15
+ * — chạy fire-and-forget sau khi ghi DB.
35
16
  */
36
- class NotificationServiceImpl {
37
- private storage: NotificationStorage;
38
17
 
39
- constructor(options: NotificationServiceOptions = {}) {
40
- this.storage = options.storage || new InMemoryStorage();
41
- }
18
+ export type NotificationType =
19
+ | "info"
20
+ | "success"
21
+ | "warning"
22
+ | "error"
23
+ | "approval"
42
24
 
43
- /** Set custom storage implementation */
44
- setStorage(storage: NotificationStorage): void {
45
- this.storage = storage;
46
- }
25
+ export interface NotifyInput {
26
+ /** Gửi trực tiếp tới các user id (bỏ qua giá trị falsy). */
27
+ userIds?: Array<string | null | undefined>
28
+ /** Gửi tới mọi user mang một trong các vai trò này. */
29
+ roleCodes?: string[]
30
+ /**
31
+ * Gửi tới mọi user mang các vai trò này BẤT KỂ chi nhánh — cho vai trò tập
32
+ * trung tại trụ sở (vd ACCOUNTANT) cần biết sự kiện của mọi CN.
33
+ */
34
+ roleCodesAllBranches?: string[]
35
+ /** Gửi tới mọi user CÓ QUYỀN resource:action (vd payment-request:approve_l2). */
36
+ permission?: { resourceCode: string; actionCode: string }
37
+ /**
38
+ * Giới hạn người nhận suy ra từ `roleCodes`/`permission` về đúng chi nhánh.
39
+ * KHÔNG áp cho `userIds` (đích chỉ định tường minh) và `roleCodesAllBranches`.
40
+ */
41
+ branchId?: string | null
42
+ /**
43
+ * Cách áp `branchId` cho nhóm suy-ra-từ-vai-trò:
44
+ * - "member" (mặc định): user ĐƯỢC GÁN chi nhánh đó.
45
+ * - "default": chi nhánh MẶC ĐỊNH của user (UserBranch.isDefault — nơi làm
46
+ * việc chính) — chỉ báo đúng người PHỤ TRÁCH chi nhánh của chứng từ.
47
+ */
48
+ branchScope?: "member" | "default"
47
49
 
48
- /** Send a notification */
49
- async send(input: CreateNotificationInput): Promise<Notification> {
50
- const notification: Notification = {
51
- id: crypto.randomUUID(),
52
- userId: input.userId,
53
- title: input.title,
54
- content: input.content,
55
- type: input.type || "info",
56
- channel: input.channel || "in-app",
57
- status: "sent",
58
- link: input.link,
59
- metadata: input.metadata,
60
- createdAt: new Date(),
61
- };
62
-
63
- await this.storage.save(notification);
64
-
65
- logger.debug("Notification sent", {
66
- id: notification.id,
67
- userId: notification.userId,
68
- type: notification.type,
69
- });
70
-
71
- // Emit event for real-time updates
72
- eventBus.emit("notification.sent", { notification });
73
-
74
- return notification;
75
- }
50
+ type?: NotificationType
51
+ category?: string
52
+ title: string
53
+ content: string
54
+ url?: string
55
+ iconName?: string
56
+ resourceType?: string
57
+ resourceId?: string
58
+ /** Actor gây ra sự kiện (null = hệ thống). */
59
+ createdBy?: string | null
60
+ /** Đừng tự gửi thông báo cho chính người vừa thao tác. */
61
+ excludeUserId?: string | null
62
+ meta?: unknown
63
+ }
76
64
 
77
- /** Get all notifications for a user */
78
- async getAll(userId: string): Promise<Notification[]> {
79
- return this.storage.findByUserId(userId);
65
+ /**
66
+ * Delegate Prisma tối thiểu — structural, args `any` CÓ CHỦ ĐÍCH (client
67
+ * Prisma sinh ra hẹp hơn structural type — bài học SettingsDb/TaskDb).
68
+ */
69
+ export interface NotificationDb {
70
+ notification: {
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
+ createMany(args: any): Promise<{ count: number }>
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
74
+ findMany(args: any): Promise<any[]>
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
+ count(args: any): Promise<number>
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ updateMany(args: any): Promise<{ count: number }>
79
+ }
80
+ rolePermission: {
81
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
82
+ findMany(args: any): Promise<Array<{ roleCode: string }>>
83
+ }
84
+ userRole: {
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ findMany(args: any): Promise<Array<{ userId: string }>>
87
+ }
88
+ userBranch: {
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ findMany(args: any): Promise<Array<{ userId: string }>>
80
91
  }
92
+ user: {
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ findMany(args: any): Promise<Array<{ id: string }>>
95
+ }
96
+ }
97
+
98
+ interface NotificationConfig {
99
+ db: NotificationDb
100
+ /**
101
+ * Fan-out kênh phụ (web push, email…) SAU khi ghi DB — fire-and-forget,
102
+ * lỗi tự nuốt (engine đã catch + log).
103
+ */
104
+ afterNotify?: (recipients: string[], input: NotifyInput) => Promise<unknown>
105
+ }
106
+
107
+ let config: NotificationConfig | null = null
81
108
 
82
- /** Get unread notifications for a user */
83
- async getUnread(userId: string): Promise<Notification[]> {
84
- return this.storage.findByUserId(userId, { unreadOnly: true });
109
+ export function configureNotificationService(next: NotificationConfig): void {
110
+ config = next
111
+ }
112
+
113
+ function requireConfig(): NotificationConfig {
114
+ if (!config) {
115
+ throw new Error(
116
+ "[notification] chưa configureNotificationService({ db, afterNotify? }) — gọi 1 lần lúc khởi tạo app.",
117
+ )
85
118
  }
119
+ return config
120
+ }
121
+
122
+ /**
123
+ * Suy ra danh sách userId người nhận (distinct, chỉ user đang hoạt động).
124
+ * Gộp: userIds tường minh ∪ (role/permission [∩ chi nhánh nếu có]).
125
+ */
126
+ async function resolveRecipients(input: NotifyInput): Promise<string[]> {
127
+ const { db } = requireConfig()
128
+ const explicit = (input.userIds ?? []).filter(
129
+ (id): id is string => typeof id === "string" && id.length > 0,
130
+ )
86
131
 
87
- /** Get unread count for a user */
88
- async getUnreadCount(userId: string): Promise<number> {
89
- const unread = await this.getUnread(userId);
90
- return unread.length;
132
+ // Gom các roleCode: từ input.roleCodes + từ permission (resource:action).
133
+ const roleCodes = new Set<string>(input.roleCodes ?? [])
134
+ if (input.permission) {
135
+ const rp = await db.rolePermission.findMany({
136
+ where: {
137
+ resourceCode: input.permission.resourceCode,
138
+ actionCode: input.permission.actionCode,
139
+ },
140
+ select: { roleCode: true },
141
+ })
142
+ rp.forEach((r) => roleCodes.add(r.roleCode))
91
143
  }
92
144
 
93
- /** Get a notification by ID */
94
- async getById(id: string): Promise<Notification | null> {
95
- return this.storage.findById(id);
145
+ let roleDerived: string[] = []
146
+ if (roleCodes.size > 0) {
147
+ const userRoles = await db.userRole.findMany({
148
+ where: { roleCode: { in: [...roleCodes] } },
149
+ select: { userId: true },
150
+ })
151
+ roleDerived = userRoles.map((u) => u.userId)
152
+
153
+ // Lọc theo chi nhánh nếu yêu cầu (chỉ áp cho nhóm suy-ra-từ-vai-trò).
154
+ if (input.branchId && roleDerived.length > 0) {
155
+ const inBranch = await db.userBranch.findMany({
156
+ where: {
157
+ branchId: input.branchId,
158
+ userId: { in: roleDerived },
159
+ ...(input.branchScope === "default" ? { isDefault: true } : {}),
160
+ },
161
+ select: { userId: true },
162
+ })
163
+ const allowed = new Set(inBranch.map((b) => b.userId))
164
+ roleDerived = roleDerived.filter((id) => allowed.has(id))
165
+ }
96
166
  }
97
167
 
98
- /** Mark a notification as read */
99
- async markAsRead(id: string): Promise<void> {
100
- await this.storage.markAsRead(id);
101
- eventBus.emit("notification.read", { id });
168
+ // Vai trò toàn cục (trụ sở): nhận bất kể chi nhánh, không qua lọc branchId.
169
+ let globalRoleDerived: string[] = []
170
+ if (input.roleCodesAllBranches?.length) {
171
+ const rows = await db.userRole.findMany({
172
+ where: { roleCode: { in: input.roleCodesAllBranches } },
173
+ select: { userId: true },
174
+ })
175
+ globalRoleDerived = rows.map((u) => u.userId)
102
176
  }
103
177
 
104
- /** Mark all notifications as read for a user */
105
- async markAllAsRead(userId: string): Promise<void> {
106
- await this.storage.markAllAsRead(userId);
107
- eventBus.emit("notification.allRead", { userId });
178
+ const candidates = [
179
+ ...new Set([...explicit, ...roleDerived, ...globalRoleDerived]),
180
+ ]
181
+ if (candidates.length === 0) return []
182
+
183
+ // Chỉ giữ user còn tồn tại & đang hoạt động (loại id rác / user bị khoá).
184
+ const valid = await db.user.findMany({
185
+ where: { id: { in: candidates }, isActive: true },
186
+ select: { id: true },
187
+ })
188
+ let ids = valid.map((u) => u.id)
189
+ if (input.excludeUserId) ids = ids.filter((id) => id !== input.excludeUserId)
190
+ return ids
191
+ }
192
+
193
+ /**
194
+ * Ghi thông báo cho (nhiều) người nhận. Trả về số row đã tạo. KHÔNG throw.
195
+ */
196
+ export async function notify(input: NotifyInput): Promise<number> {
197
+ try {
198
+ const { db, afterNotify } = requireConfig()
199
+ const recipients = await resolveRecipients(input)
200
+ if (recipients.length === 0) return 0
201
+
202
+ const now = new Date()
203
+ await db.notification.createMany({
204
+ data: recipients.map((userId) => ({
205
+ userId,
206
+ type: input.type ?? "info",
207
+ category: input.category ?? null,
208
+ title: input.title,
209
+ content: input.content,
210
+ url: input.url ?? null,
211
+ iconName: input.iconName ?? null,
212
+ resourceType: input.resourceType ?? null,
213
+ resourceId: input.resourceId ?? null,
214
+ createdBy: input.createdBy ?? null,
215
+ meta: input.meta ?? undefined,
216
+ createdAt: now,
217
+ })),
218
+ })
219
+
220
+ // Kênh phụ (web push, email…) — fire-and-forget qua seam.
221
+ if (afterNotify) {
222
+ void afterNotify(recipients, input).catch((e) =>
223
+ console.error("[notification] afterNotify lỗi:", e),
224
+ )
225
+ }
226
+
227
+ return recipients.length
228
+ } catch (e) {
229
+ console.error("[notification] notify lỗi:", e)
230
+ return 0
108
231
  }
232
+ }
233
+
234
+ export async function getUnreadCount(userId: string): Promise<number> {
235
+ const { db } = requireConfig()
236
+ return db.notification.count({ where: { userId, isRead: false } })
237
+ }
238
+
239
+ export interface ListParams {
240
+ cursor?: string
241
+ take?: number
242
+ filter?: "all" | "unread"
243
+ }
109
244
 
110
- /** Delete a notification */
111
- async delete(id: string): Promise<void> {
112
- await this.storage.delete(id);
245
+ /**
246
+ * Danh sách thông báo của CHÍNH user (cursor pagination theo createdAt desc).
247
+ * `nextCursor` = id của row cuối trang (null nếu hết).
248
+ */
249
+ export async function listNotifications(userId: string, params: ListParams) {
250
+ const { db } = requireConfig()
251
+ const take = Math.min(Math.max(params.take ?? 20, 1), 50)
252
+ const where = {
253
+ userId,
254
+ ...(params.filter === "unread" ? { isRead: false } : {}),
113
255
  }
256
+
257
+ const rows = await db.notification.findMany({
258
+ where,
259
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
260
+ take: take + 1, // lấy dư 1 để biết còn trang sau
261
+ ...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
262
+ })
263
+
264
+ const hasMore = rows.length > take
265
+ const items = hasMore ? rows.slice(0, take) : rows
266
+ const nextCursor = hasMore ? (items[items.length - 1]?.id ?? null) : null
267
+
268
+ const [total, unreadCount] = await Promise.all([
269
+ db.notification.count({ where }),
270
+ db.notification.count({ where: { userId, isRead: false } }),
271
+ ])
272
+
273
+ return { items, nextCursor, total, unreadCount }
114
274
  }
115
275
 
116
- // Singleton instance
117
- export const notificationService = new NotificationServiceImpl();
276
+ /** Đánh dấu đã đọc một số tin (scope theo user để không đọc hộ người khác). */
277
+ export async function markRead(userId: string, ids: string[]): Promise<number> {
278
+ const { db } = requireConfig()
279
+ if (!ids.length) return 0
280
+ const res = await db.notification.updateMany({
281
+ where: { userId, id: { in: ids }, isRead: false },
282
+ data: { isRead: true, readAt: new Date() },
283
+ })
284
+ return res.count
285
+ }
118
286
 
119
- // Export class for testing
120
- export { NotificationServiceImpl };
287
+ export async function markAllRead(userId: string): Promise<number> {
288
+ const { db } = requireConfig()
289
+ const res = await db.notification.updateMany({
290
+ where: { userId, isRead: false },
291
+ data: { isRead: true, readAt: new Date() },
292
+ })
293
+ return res.count
294
+ }
@@ -0,0 +1,2 @@
1
+ export { NotificationBell } from "./notification-bell"
2
+ export { NotificationsInbox } from "./notifications-inbox"
@@ -0,0 +1,244 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef, useState } from "react"
4
+ import type React from "react"
5
+ import { useParams, useRouter } from "next/navigation"
6
+ import {
7
+ Badge,
8
+ Button,
9
+ DynamicIcon,
10
+ Popover,
11
+ PopoverContent,
12
+ PopoverTrigger,
13
+ ScrollArea,
14
+ } from "../../ui/primitives"
15
+ import { toast } from "sonner"
16
+ import useSWR from "swr"
17
+ import { Bell, CheckCheck } from "lucide-react"
18
+
19
+
20
+ /**
21
+ * Chuông thông báo in-app dùng chung mọi app goerp — truyền vào MainLayout qua
22
+ * prop `notificationSlot`. Engine + bảng: @goerp/core/notification + feature
23
+ * notifications; app cung cấp API mỏng (mẫu vinhhoa /api/notifications*).
24
+ * Tự fetch dữ liệu app:
25
+ * - badge: poll GET /api/notifications/unread-count mỗi 45s
26
+ * - list: fetch GET /api/notifications khi MỞ dropdown
27
+ * - toast khi số chưa đọc tăng giữa 2 lần poll (tin mới)
28
+ * Không dùng SSE (nhất quán codebase — polling).
29
+ */
30
+
31
+ interface NotiItem {
32
+ id: string
33
+ type: string
34
+ category: string | null
35
+ title: string
36
+ content: string
37
+ url: string | null
38
+ iconName: string | null
39
+ isRead: boolean
40
+ createdAt: string
41
+ }
42
+ interface ListResp {
43
+ items: NotiItem[]
44
+ nextCursor: string | null
45
+ total: number
46
+ unreadCount: number
47
+ }
48
+
49
+ const fetcher = (url: string) =>
50
+ fetch(url).then((r) => {
51
+ if (!r.ok) throw new Error(String(r.status))
52
+ return r.json()
53
+ })
54
+
55
+ function timeAgo(d: string): string {
56
+ const s = Math.floor((Date.now() - new Date(d).getTime()) / 1000)
57
+ if (s < 60) return "vừa xong"
58
+ const m = Math.floor(s / 60)
59
+ if (m < 60) return `${m} phút trước`
60
+ const h = Math.floor(m / 60)
61
+ if (h < 24) return `${h} giờ trước`
62
+ const day = Math.floor(h / 24)
63
+ if (day < 7) return `${day} ngày trước`
64
+ return new Date(d).toLocaleDateString("vi-VN")
65
+ }
66
+
67
+ export function NotificationBell({
68
+ settingsSlot,
69
+ }: {
70
+ /** Slot cài đặt dưới danh sách (vd toggle Web Push của app). */
71
+ settingsSlot?: React.ReactNode
72
+ }) {
73
+ const router = useRouter()
74
+ const params = useParams()
75
+ const lang = (params?.lang as string) || "vi"
76
+ const [open, setOpen] = useState(false)
77
+
78
+ // Badge: poll số chưa đọc (nhẹ) — chạy nền kể cả khi đóng.
79
+ const { data: countData, mutate: mutateCount } = useSWR<{ count: number }>(
80
+ "/api/notifications/unread-count",
81
+ fetcher,
82
+ { refreshInterval: 45_000, revalidateOnFocus: true }
83
+ )
84
+ const unread = countData?.count ?? 0
85
+
86
+ // List: chỉ fetch khi mở dropdown.
87
+ const { data: listData, mutate: mutateList } = useSWR<ListResp>(
88
+ open ? "/api/notifications?take=10&filter=all" : null,
89
+ fetcher
90
+ )
91
+ const items = listData?.items ?? []
92
+
93
+ // Toast khi có tin mới (số chưa đọc tăng).
94
+ const prevUnread = useRef<number | null>(null)
95
+ useEffect(() => {
96
+ if (countData?.count == null) return
97
+ const cur = countData.count
98
+ if (prevUnread.current != null && cur > prevUnread.current) {
99
+ toast.info("Bạn có thông báo mới", {
100
+ description: `${cur} thông báo chưa đọc`,
101
+ })
102
+ if (open) void mutateList()
103
+ }
104
+ prevUnread.current = cur
105
+ }, [countData?.count, open, mutateList])
106
+
107
+ // Badge số trên icon PWA (iOS 16.4+/Android khi đã cài) — best-effort.
108
+ useEffect(() => {
109
+ if (!("setAppBadge" in navigator)) return
110
+ if (unread > 0) void navigator.setAppBadge(unread).catch(() => {})
111
+ else void navigator.clearAppBadge?.().catch(() => {})
112
+ }, [unread])
113
+
114
+ async function markRead(ids: string[]) {
115
+ try {
116
+ await fetch("/api/notifications/read", {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json" },
119
+ body: JSON.stringify({ ids }),
120
+ })
121
+ void mutateCount()
122
+ void mutateList()
123
+ } catch {
124
+ /* best-effort */
125
+ }
126
+ }
127
+
128
+ async function markAllRead() {
129
+ try {
130
+ await fetch("/api/notifications/read", {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify({ all: true }),
134
+ })
135
+ void mutateCount()
136
+ void mutateList()
137
+ } catch {
138
+ /* best-effort */
139
+ }
140
+ }
141
+
142
+ function onItemClick(n: NotiItem) {
143
+ if (!n.isRead) void markRead([n.id])
144
+ setOpen(false)
145
+ if (n.url) router.push(`/${lang}${n.url}`)
146
+ }
147
+
148
+ return (
149
+ <Popover open={open} onOpenChange={setOpen} modal>
150
+ <PopoverTrigger asChild>
151
+ <Button
152
+ variant="ghost"
153
+ size="icon"
154
+ className="relative"
155
+ aria-label="Thông báo"
156
+ >
157
+ <Bell className="size-4" />
158
+ {unread > 0 && (
159
+ <Badge
160
+ // top-0 (không thò LÊN khỏi nút) để header h-11 trong SidebarInset
161
+ // overflow-hidden KHÔNG cắt mất phần trên của badge; vẫn nằm góc
162
+ // trên-phải nút, thò nhẹ sang phải.
163
+ className="absolute top-0 -end-1 h-4 min-w-4 justify-center px-1 text-[10px] leading-none"
164
+ aria-live="polite"
165
+ role="status"
166
+ >
167
+ {unread > 99 ? "99+" : unread}
168
+ </Badge>
169
+ )}
170
+ </Button>
171
+ </PopoverTrigger>
172
+ <PopoverContent align="end" className="w-[380px] p-0">
173
+ <div className="flex items-center justify-between border-b border-border p-3">
174
+ <h3 className="text-sm font-semibold">Thông báo</h3>
175
+ {unread > 0 && (
176
+ <Button
177
+ variant="link"
178
+ className="h-auto gap-1 p-0 text-xs text-primary"
179
+ onClick={markAllRead}
180
+ >
181
+ <CheckCheck className="size-3.5" />
182
+ Đánh dấu đã đọc tất cả
183
+ </Button>
184
+ )}
185
+ </div>
186
+
187
+ <ScrollArea className="max-h-[340px]">
188
+ {items.length === 0 ? (
189
+ <p className="px-6 py-10 text-center text-sm text-muted-foreground">
190
+ Chưa có thông báo
191
+ </p>
192
+ ) : (
193
+ <ul className="divide-y divide-border">
194
+ {items.map((n) => (
195
+ <li key={n.id}>
196
+ <button
197
+ type="button"
198
+ onClick={() => onItemClick(n)}
199
+ className="flex w-full items-start gap-3 px-4 py-3 text-left hover:bg-accent hover:text-accent-foreground"
200
+ >
201
+ <span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
202
+ <DynamicIcon
203
+ name={(n.iconName as any) || "Bell"}
204
+ className="size-4"
205
+ />
206
+ </span>
207
+ <span className="min-w-0 flex-1">
208
+ <span className="block truncate text-sm font-medium">
209
+ {n.title}
210
+ </span>
211
+ <span className="mt-0.5 block text-xs text-muted-foreground line-clamp-2">
212
+ {n.content}
213
+ </span>
214
+ <span className="mt-1 block text-[11px] text-muted-foreground">
215
+ {timeAgo(n.createdAt)}
216
+ </span>
217
+ </span>
218
+ {!n.isRead && (
219
+ <span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />
220
+ )}
221
+ </button>
222
+ </li>
223
+ ))}
224
+ </ul>
225
+ )}
226
+ </ScrollArea>
227
+
228
+ <div className="border-t border-border p-2">
229
+ {settingsSlot}
230
+ <Button
231
+ variant="ghost"
232
+ className="w-full text-sm text-primary"
233
+ onClick={() => {
234
+ setOpen(false)
235
+ router.push(`/${lang}/notifications`)
236
+ }}
237
+ >
238
+ Xem tất cả thông báo
239
+ </Button>
240
+ </div>
241
+ </PopoverContent>
242
+ </Popover>
243
+ )
244
+ }