@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.
- package/features/README.md +5 -0
- package/features/notifications/README.md +14 -0
- package/features/notifications/migrations/0001_init.sql +59 -0
- package/features/notifications/schema.prisma +46 -0
- package/package.json +3 -1
- package/src/notification/__tests__/notification-service.test.ts +192 -0
- package/src/notification/__tests__/notification-ui.test.tsx +62 -0
- package/src/notification/index.ts +15 -13
- package/src/notification/notification-service.ts +270 -96
- package/src/notification/ui/index.ts +2 -0
- package/src/notification/ui/notification-bell.tsx +244 -0
- package/src/notification/ui/notifications-inbox.tsx +193 -0
- package/src/notification/storage/in-memory.ts +0 -56
- package/src/notification/storage/index.ts +0 -1
- package/src/notification/types.ts +0 -51
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
18
|
+
export type NotificationType =
|
|
19
|
+
| "info"
|
|
20
|
+
| "success"
|
|
21
|
+
| "warning"
|
|
22
|
+
| "error"
|
|
23
|
+
| "approval"
|
|
42
24
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
export
|
|
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
|
-
|
|
120
|
-
|
|
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,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
|
+
}
|