@goplusvn/core 0.1.52 → 0.1.54

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.
@@ -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
+ }
@@ -0,0 +1,193 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { useParams, useRouter } from "next/navigation"
5
+ import { Badge, Button, DynamicIcon } from "../../ui/primitives"
6
+ import useSWRInfinite from "swr/infinite"
7
+ import { CheckCheck, Inbox } from "lucide-react"
8
+
9
+ /**
10
+ * Trang hộp thư thông báo đầy đủ (Phase 1 — bản gọn để "Xem tất cả" trong chuông
11
+ * hoạt động). Lọc tất cả/chưa đọc, tải thêm (cursor), đánh dấu đã đọc.
12
+ */
13
+
14
+ interface NotiItem {
15
+ id: string
16
+ type: string
17
+ category: string | null
18
+ title: string
19
+ content: string
20
+ url: string | null
21
+ iconName: string | null
22
+ isRead: boolean
23
+ createdAt: string
24
+ }
25
+ interface ListResp {
26
+ items: NotiItem[]
27
+ nextCursor: string | null
28
+ total: number
29
+ unreadCount: number
30
+ }
31
+
32
+ const fetcher = (url: string) =>
33
+ fetch(url).then((r) => {
34
+ if (!r.ok) throw new Error(String(r.status))
35
+ return r.json()
36
+ })
37
+
38
+ function timeAgo(d: string): string {
39
+ const s = Math.floor((Date.now() - new Date(d).getTime()) / 1000)
40
+ if (s < 60) return "vừa xong"
41
+ const m = Math.floor(s / 60)
42
+ if (m < 60) return `${m} phút trước`
43
+ const h = Math.floor(m / 60)
44
+ if (h < 24) return `${h} giờ trước`
45
+ const day = Math.floor(h / 24)
46
+ if (day < 7) return `${day} ngày trước`
47
+ return new Date(d).toLocaleDateString("vi-VN")
48
+ }
49
+
50
+ const makeGetKey =
51
+ (filter: "all" | "unread") => (index: number, prev: ListResp | null) => {
52
+ if (prev && !prev.nextCursor) return null // hết trang
53
+ const cursor = index === 0 ? "" : prev?.nextCursor
54
+ return `/api/notifications?take=20&filter=${filter}${cursor ? `&cursor=${cursor}` : ""}`
55
+ }
56
+
57
+ export function NotificationsInbox() {
58
+ const router = useRouter()
59
+ const params = useParams()
60
+ const lang = (params?.lang as string) || "vi"
61
+ const [tab, setTab] = useState<"all" | "unread">("all")
62
+
63
+ // Hai bộ SWRInfinite riêng cho 2 tab → chuyển tab tức thì, không lẫn cache.
64
+ const all = useSWRInfinite<ListResp>(makeGetKey("all"), fetcher)
65
+ const unread = useSWRInfinite<ListResp>(makeGetKey("unread"), fetcher)
66
+ const swr = tab === "all" ? all : unread
67
+
68
+ const pages = swr.data ?? []
69
+ const items = pages.flatMap((p) => p.items)
70
+ const unreadCount = (all.data ?? [])[0]?.unreadCount ?? 0
71
+ const hasMore =
72
+ pages.length > 0 && pages[pages.length - 1]?.nextCursor != null
73
+ const loading = swr.isLoading || swr.isValidating
74
+
75
+ function refresh() {
76
+ void all.mutate()
77
+ void unread.mutate()
78
+ }
79
+
80
+ async function markAllRead() {
81
+ await fetch("/api/notifications/read", {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({ all: true }),
85
+ }).catch(() => {})
86
+ refresh()
87
+ }
88
+
89
+ async function onItemClick(n: NotiItem) {
90
+ if (!n.isRead) {
91
+ await fetch("/api/notifications/read", {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify({ ids: [n.id] }),
95
+ }).catch(() => {})
96
+ refresh()
97
+ }
98
+ if (n.url) router.push(`/${lang}${n.url}`)
99
+ }
100
+
101
+ return (
102
+ <div className="mx-auto w-full max-w-3xl">
103
+ <div className="mb-4 flex items-center justify-between">
104
+ <div>
105
+ <h1 className="text-xl font-semibold">Thông báo</h1>
106
+ <p className="text-sm text-muted-foreground">
107
+ {unreadCount > 0
108
+ ? `${unreadCount} thông báo chưa đọc`
109
+ : "Đã đọc hết"}
110
+ </p>
111
+ </div>
112
+ {unreadCount > 0 && (
113
+ <Button variant="outline" size="sm" onClick={markAllRead}>
114
+ <CheckCheck className="mr-1 size-4" />
115
+ Đánh dấu đã đọc tất cả
116
+ </Button>
117
+ )}
118
+ </div>
119
+
120
+ <div className="mb-3 flex gap-2">
121
+ <Button
122
+ variant={tab === "all" ? "default" : "ghost"}
123
+ size="sm"
124
+ onClick={() => setTab("all")}
125
+ >
126
+ Tất cả
127
+ </Button>
128
+ <Button
129
+ variant={tab === "unread" ? "default" : "ghost"}
130
+ size="sm"
131
+ onClick={() => setTab("unread")}
132
+ >
133
+ Chưa đọc
134
+ {unreadCount > 0 && <Badge className="ml-1">{unreadCount}</Badge>}
135
+ </Button>
136
+ </div>
137
+
138
+ <div className="rounded-lg border border-border bg-card">
139
+ {items.length === 0 && !loading ? (
140
+ <div className="flex flex-col items-center gap-2 py-16 text-muted-foreground">
141
+ <Inbox className="size-8" />
142
+ <p className="text-sm">Chưa có thông báo</p>
143
+ </div>
144
+ ) : (
145
+ <ul className="divide-y divide-border">
146
+ {items.map((n) => (
147
+ <li key={n.id}>
148
+ <button
149
+ type="button"
150
+ onClick={() => onItemClick(n)}
151
+ className={`flex w-full items-start gap-3 px-4 py-3 text-left hover:bg-accent hover:text-accent-foreground ${
152
+ n.isRead ? "" : "bg-primary/5"
153
+ }`}
154
+ >
155
+ <span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
156
+ <DynamicIcon
157
+ name={(n.iconName as any) || "Bell"}
158
+ className="size-4"
159
+ />
160
+ </span>
161
+ <span className="min-w-0 flex-1">
162
+ <span className="block text-sm font-medium">{n.title}</span>
163
+ <span className="mt-0.5 block text-sm text-muted-foreground">
164
+ {n.content}
165
+ </span>
166
+ <span className="mt-1 block text-xs text-muted-foreground">
167
+ {timeAgo(n.createdAt)}
168
+ </span>
169
+ </span>
170
+ {!n.isRead && (
171
+ <span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />
172
+ )}
173
+ </button>
174
+ </li>
175
+ ))}
176
+ </ul>
177
+ )}
178
+ </div>
179
+
180
+ {hasMore && (
181
+ <div className="mt-4 flex justify-center">
182
+ <Button
183
+ variant="outline"
184
+ onClick={() => swr.setSize(swr.size + 1)}
185
+ disabled={loading}
186
+ >
187
+ {loading ? "Đang tải…" : "Tải thêm"}
188
+ </Button>
189
+ </div>
190
+ )}
191
+ </div>
192
+ )
193
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Smoke render trang "Tác vụ định kỳ": mount thật (jsdom) với fetch giả — bắt
3
+ * lỗi import/hook trước khi app tiêu thụ. Trang này chỉ mở được sau đăng nhập
4
+ * (quyền admin) nên không kiểm được bằng cách gọi HTTP ẩn danh.
5
+ */
6
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+
9
+ import { SystemJobsPage } from "../system-jobs-page";
10
+
11
+ const JOB = {
12
+ name: "zns-dispatch",
13
+ cronTime: "* * * * *",
14
+ isRunning: true,
15
+ nextDate: null,
16
+ enabled: true,
17
+ status: "idle" as const,
18
+ lastRun: "2026-08-01T06:00:00.000Z",
19
+ nextRun: "2026-08-01T06:01:00.000Z",
20
+ error: null,
21
+ inMemory: true,
22
+ };
23
+
24
+ const LOG = {
25
+ id: "log1",
26
+ jobName: "zns-dispatch",
27
+ startedAt: "2026-08-01T06:00:00.000Z",
28
+ finishedAt: "2026-08-01T06:00:02.000Z",
29
+ durationMs: 2000,
30
+ status: "success" as const,
31
+ error: null,
32
+ summary: null,
33
+ actions: [{ time: "2026-08-01T06:00:01.000Z", action: "Gửi 3 tin ZNS" }],
34
+ };
35
+
36
+ function mockFetch(jobs = [JOB]) {
37
+ const calls: string[] = [];
38
+ global.fetch = vi.fn(async (input: RequestInfo | URL) => {
39
+ const url = String(input);
40
+ calls.push(url);
41
+ const body = url.includes("/history")
42
+ ? { data: [LOG], meta: { total: 1 } }
43
+ : { data: jobs };
44
+ return new Response(JSON.stringify(body), {
45
+ status: 200,
46
+ headers: { "Content-Type": "application/json" },
47
+ });
48
+ }) as unknown as typeof fetch;
49
+ return calls;
50
+ }
51
+
52
+ describe("SystemJobsPage", () => {
53
+ afterEach(() => vi.restoreAllMocks());
54
+
55
+ it("render job từ API kèm lịch chạy", async () => {
56
+ mockFetch();
57
+ render(<SystemJobsPage />);
58
+
59
+ await waitFor(() => expect(screen.getByText("zns-dispatch")).toBeDefined());
60
+ expect(screen.getByText("* * * * *")).toBeDefined();
61
+ expect(screen.getByText("Chờ")).toBeDefined();
62
+ });
63
+
64
+ it("bung dòng → gọi endpoint lịch sử và hiện actions của lần chạy", async () => {
65
+ const calls = mockFetch();
66
+ render(<SystemJobsPage apiUrl="/api/jobs" />);
67
+
68
+ await waitFor(() => expect(screen.getByText("zns-dispatch")).toBeDefined());
69
+ fireEvent.click(screen.getByText("zns-dispatch"));
70
+
71
+ await waitFor(() => expect(screen.getByText("Thành công")).toBeDefined());
72
+ expect(
73
+ calls.some((u) => u.startsWith("/api/jobs/zns-dispatch/history?")),
74
+ ).toBe(true);
75
+
76
+ // Bung tiếp một lần chạy mới thấy nhật ký actions.
77
+ fireEvent.click(screen.getByText("Thành công"));
78
+ expect(screen.getByText("Gửi 3 tin ZNS")).toBeDefined();
79
+ });
80
+
81
+ it("job chỉ-còn-trong-DB: khoá nút chạy/tạm dừng", async () => {
82
+ mockFetch([{ ...JOB, inMemory: false }]);
83
+ render(<SystemJobsPage />);
84
+
85
+ await waitFor(() => expect(screen.getByText("Chỉ lịch sử")).toBeDefined());
86
+ const actionButtons = screen
87
+ .getAllByRole("button")
88
+ .filter((b) => b.getAttribute("title")?.includes("Không khả dụng"));
89
+ expect(actionButtons).toHaveLength(2);
90
+ actionButtons.forEach((b) => expect(b.hasAttribute("disabled")).toBe(true));
91
+ });
92
+ });