@goplusvn/core 0.1.51 → 0.1.52

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.
@@ -29,5 +29,9 @@ GENERATED — đừng sửa tay) và tạo thư mục migration
29
29
  ## Feature hiện có
30
30
 
31
31
  - `background-tasks` — bảng `background_tasks` cho trung tâm tác vụ nền
32
- (export/import chạy nền). Runtime hiện ở app (vinhhoa `src/server/tasks`);
33
- sẽ dời lên core ở đợt F1.
32
+ (export/import chạy nền). Runtime: `@goerp/core/tasks`
33
+ (`configureTaskRunner`) + UI `@goerp/core/tasks/ui`.
34
+ - `error-logs` — bảng `error_logs` cho hệ ghi lỗi server
35
+ (`createErrorLogger`/`buildServerError` ở `errors/server-error`) + trang
36
+ admin `system/pages/error-logs-page` (app cung cấp API, mẫu vinhhoa
37
+ /api/error-logs).
@@ -0,0 +1,32 @@
1
+ -- goerp feature: error-logs — bước 0001 (idempotent). Bảng cho createErrorLogger
2
+ -- (@goerp/core/errors/server-error) + trang admin ErrorLogsPage.
3
+ -- gen_random_uuid() là hàm sẵn của PostgreSQL 13+.
4
+ CREATE TABLE IF NOT EXISTS "error_logs" (
5
+ "id" TEXT NOT NULL DEFAULT gen_random_uuid(),
6
+ "error_id" TEXT NOT NULL,
7
+ "fingerprint" TEXT,
8
+ "code" TEXT,
9
+ "message" TEXT NOT NULL,
10
+ "detail" TEXT,
11
+ "context" TEXT,
12
+ "module" TEXT,
13
+ "user_id" TEXT,
14
+ "url" TEXT,
15
+ "user_agent" TEXT,
16
+ "severity" TEXT NOT NULL DEFAULT 'error',
17
+ "resolved" BOOLEAN NOT NULL DEFAULT false,
18
+ "occurrence_count" INTEGER NOT NULL DEFAULT 1,
19
+ "last_seen_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
20
+ "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
21
+
22
+ CONSTRAINT "error_logs_pkey" PRIMARY KEY ("id")
23
+ );
24
+
25
+ CREATE INDEX IF NOT EXISTS "idx_error_logs_code" ON "error_logs"("code");
26
+ CREATE INDEX IF NOT EXISTS "idx_error_logs_created_at" ON "error_logs"("created_at");
27
+ CREATE INDEX IF NOT EXISTS "idx_error_logs_error_id" ON "error_logs"("error_id");
28
+ CREATE INDEX IF NOT EXISTS "idx_error_logs_fingerprint" ON "error_logs"("fingerprint");
29
+ CREATE INDEX IF NOT EXISTS "idx_error_logs_module" ON "error_logs"("module");
30
+ CREATE INDEX IF NOT EXISTS "idx_error_logs_resolved" ON "error_logs"("resolved");
31
+ CREATE INDEX IF NOT EXISTS "idx_error_logs_severity" ON "error_logs"("severity");
32
+ CREATE INDEX IF NOT EXISTS "idx_error_logs_user_id" ON "error_logs"("user_id");
@@ -0,0 +1,28 @@
1
+ model ErrorLog {
2
+ id String @id @default(dbgenerated("gen_random_uuid()")) @map("id")
3
+ errorId String @map("error_id")
4
+ fingerprint String? @map("fingerprint")
5
+ code String? @map("code")
6
+ message String @map("message")
7
+ detail String? @map("detail")
8
+ context String? @map("context")
9
+ module String? @map("module")
10
+ userId String? @map("user_id")
11
+ url String? @map("url")
12
+ userAgent String? @map("user_agent")
13
+ severity String @default("error") @map("severity")
14
+ resolved Boolean @default(false) @map("resolved")
15
+ occurrenceCount Int @default(1) @map("occurrence_count")
16
+ lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz(6)
17
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
18
+
19
+ @@index([code], map: "idx_error_logs_code")
20
+ @@index([createdAt], map: "idx_error_logs_created_at")
21
+ @@index([errorId], map: "idx_error_logs_error_id")
22
+ @@index([fingerprint], map: "idx_error_logs_fingerprint")
23
+ @@index([module], map: "idx_error_logs_module")
24
+ @@index([resolved], map: "idx_error_logs_resolved")
25
+ @@index([severity], map: "idx_error_logs_severity")
26
+ @@index([userId], map: "idx_error_logs_user_id")
27
+ @@map("error_logs")
28
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.51",
4
+ "version": "0.1.52",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -77,6 +77,7 @@
77
77
  "./system/services/system-category-service": "./src/system/services/system-category-service.ts",
78
78
  "./system/pages/system-settings-page": "./src/system/pages/system-settings-page.tsx",
79
79
  "./system/pages/system-category-page": "./src/system/pages/system-category-page.tsx",
80
+ "./system/pages/error-logs-page": "./src/system/pages/error-logs-page.tsx",
80
81
  "./rbac/role-service": "./src/rbac/role-service.ts",
81
82
  "./rbac/resource-service": "./src/rbac/resource-service.ts",
82
83
  "./infrastructure/cron/cron-manager": "./src/infrastructure/cron/cron-manager.ts",
@@ -0,0 +1,652 @@
1
+ "use client"
2
+
3
+ // Trang admin "Nhật ký lỗi" dùng chung mọi app goerp (F1 batch 2) — đọc/ghi
4
+ // qua API app cung cấp (mẫu: vinhhoa /api/error-logs). Engine ghi lỗi + bảng
5
+ // error_logs ship sẵn: createErrorLogger (errors/server-error) + feature
6
+ // `error-logs` (goerp-features sync).
7
+
8
+ import { useCallback, useEffect, useState } from "react"
9
+ import { useDebounce } from "../../hooks"
10
+ import { toast } from "sonner"
11
+ import {
12
+ Badge,
13
+ Button,
14
+ Card,
15
+ CardContent,
16
+ Dialog,
17
+ DialogContent,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ Input,
21
+ Select,
22
+ SelectContent,
23
+ SelectItem,
24
+ SelectTrigger,
25
+ SelectValue,
26
+ Table,
27
+ TableBody,
28
+ TableCell,
29
+ TableHead,
30
+ TableHeader,
31
+ TableRow,
32
+ } from "../../ui/primitives"
33
+ import {
34
+ AlertTriangle,
35
+ Bug,
36
+ CalendarDays,
37
+ Check,
38
+ CheckCircle2,
39
+ ChevronLeft,
40
+ ChevronRight,
41
+ Copy,
42
+ Filter,
43
+ Layers,
44
+ Link2,
45
+ RefreshCw,
46
+ Search,
47
+ Trash2,
48
+ User,
49
+ X,
50
+ } from "lucide-react"
51
+
52
+ interface ErrorLogItem {
53
+ id: string
54
+ errorId: string
55
+ code: string | null
56
+ message: string
57
+ detail: string | null
58
+ context: string | null
59
+ module: string | null
60
+ userId: string | null
61
+ userName?: string | null
62
+ url: string | null
63
+ severity: string
64
+ resolved: boolean
65
+ occurrenceCount: number
66
+ lastSeenAt: string
67
+ createdAt: string
68
+ }
69
+
70
+ interface ModuleStat { module: string; count: number }
71
+
72
+ interface Meta {
73
+ total: number; skip: number; take: number; hasMore: boolean
74
+ todayCount: number; unresolvedCount: number
75
+ moduleStats: ModuleStat[]
76
+ }
77
+
78
+ // Parse stack trace string into structured frames
79
+ function parseStackTrace(detail: string): { frame: string; isApp: boolean }[] {
80
+ return detail
81
+ .split("\n")
82
+ .map((line) => line.trim())
83
+ .filter(Boolean)
84
+ .map((line) => ({
85
+ frame: line,
86
+ isApp: line.includes("src/") || line.includes("app/") || line.includes("pages/"),
87
+ }))
88
+ }
89
+
90
+ function StackTraceViewer({ detail }: { detail: string }) {
91
+ const frames = parseStackTrace(detail)
92
+ // Try to parse as JSON first (structured error)
93
+ try {
94
+ const json = JSON.parse(detail)
95
+ return (
96
+ <pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto whitespace-pre-wrap max-h-[240px] overflow-y-auto">
97
+ {JSON.stringify(json, null, 2)}
98
+ </pre>
99
+ )
100
+ } catch {
101
+ // Not JSON — render as stack trace
102
+ }
103
+
104
+ return (
105
+ <div className="rounded-md bg-slate-950 text-slate-100 text-xs font-mono p-3 max-h-[240px] overflow-y-auto space-y-0.5">
106
+ {frames.map((f, i) => (
107
+ <div
108
+ key={i}
109
+ className={`leading-5 ${
110
+ i === 0 ? "text-red-400 font-bold" :
111
+ f.isApp ? "text-amber-300" : "text-slate-400"
112
+ }`}
113
+ >
114
+ {f.frame}
115
+ </div>
116
+ ))}
117
+ </div>
118
+ )
119
+ }
120
+
121
+ function SeverityBadge({ sev }: { sev: string }) {
122
+ const map: Record<string, string> = {
123
+ critical: "bg-danger text-white",
124
+ error: "bg-danger-subtle text-danger-text",
125
+ warning: "bg-warning-subtle text-warning-text",
126
+ }
127
+ return (
128
+ <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${map[sev] || "bg-muted text-muted-foreground"}`}>
129
+ {sev}
130
+ </span>
131
+ )
132
+ }
133
+
134
+ export function ErrorLogsPage({
135
+ initialSearch,
136
+ apiUrl = "/api/error-logs",
137
+ }: {
138
+ /** Deep-link từ dashboard vận hành: ?search=<errorId> */
139
+ initialSearch?: string
140
+ /** Endpoint app cung cấp (GET list/DELETE purge + [id] PATCH resolve). */
141
+ apiUrl?: string
142
+ }) {
143
+ const [logs, setLogs] = useState<ErrorLogItem[]>([])
144
+ const [meta, setMeta] = useState<Meta>({
145
+ total: 0, skip: 0, take: 30, hasMore: false,
146
+ todayCount: 0, unresolvedCount: 0, moduleStats: [],
147
+ })
148
+ const [loading, setLoading] = useState(true)
149
+ const [search, setSearch] = useState(initialSearch ?? "")
150
+ const [severity, setSeverity] = useState("all")
151
+ // Khi deep-link theo errorId thì không lọc resolved — link luôn tìm ra dòng
152
+ const [resolved, setResolved] = useState(initialSearch ? "all" : "false")
153
+ const [moduleFilter, setModuleFilter] = useState("all")
154
+ const [startDate, setStartDate] = useState("")
155
+ const [endDate, setEndDate] = useState("")
156
+ const [selectedLog, setSelectedLog] = useState<ErrorLogItem | null>(null)
157
+ const [page, setPage] = useState(0)
158
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false)
159
+ const [deleting, setDeleting] = useState(false)
160
+ const [showFilters, setShowFilters] = useState(false)
161
+
162
+ const PAGE_SIZE = 30
163
+
164
+ const debouncedSearch = useDebounce(search, 350)
165
+
166
+ const fetchLogs = useCallback(async (signal?: AbortSignal) => {
167
+ setLoading(true)
168
+ try {
169
+ const params = new URLSearchParams()
170
+ if (debouncedSearch) params.set("search", debouncedSearch)
171
+ if (severity !== "all") params.set("severity", severity)
172
+ if (resolved !== "all") params.set("resolved", resolved)
173
+ if (moduleFilter !== "all") params.set("module", moduleFilter)
174
+ if (startDate) params.set("startDate", startDate)
175
+ if (endDate) params.set("endDate", new Date(endDate + "T23:59:59").toISOString())
176
+ params.set("skip", String(page * PAGE_SIZE))
177
+ params.set("take", String(PAGE_SIZE))
178
+
179
+ const res = await fetch(`${apiUrl}?${params}`, { signal })
180
+ if (!res.ok) return
181
+ const data = await res.json()
182
+ if (data.data) {
183
+ setLogs(data.data)
184
+ setMeta(data.meta)
185
+ }
186
+ } catch (e) {
187
+ if ((e as any)?.name === "AbortError") return
188
+ /* silent */
189
+ } finally {
190
+ setLoading(false)
191
+ }
192
+ }, [debouncedSearch, severity, resolved, moduleFilter, startDate, endDate, page])
193
+
194
+ useEffect(() => {
195
+ const controller = new AbortController()
196
+ fetchLogs(controller.signal)
197
+ return () => controller.abort()
198
+ }, [fetchLogs])
199
+
200
+ const handleToggleResolved = async (log: ErrorLogItem) => {
201
+ try {
202
+ await fetch(`${apiUrl}/${log.id}`, {
203
+ method: "PATCH",
204
+ headers: { "Content-Type": "application/json" },
205
+ body: JSON.stringify({ resolved: !log.resolved }),
206
+ })
207
+ toast.success(log.resolved ? "Đánh dấu chưa xử lý" : "Đánh dấu đã xử lý")
208
+ fetchLogs()
209
+ if (selectedLog?.id === log.id) {
210
+ setSelectedLog({ ...log, resolved: !log.resolved })
211
+ }
212
+ } catch {
213
+ toast.error("Lỗi cập nhật trạng thái")
214
+ }
215
+ }
216
+
217
+ const handleDeleteLogs = async (mode: "older_than" | "resolved", days?: number) => {
218
+ setDeleting(true)
219
+ try {
220
+ const params = new URLSearchParams({ mode })
221
+ if (days) params.set("days", String(days))
222
+ const res = await fetch(`${apiUrl}?${params}`, { method: "DELETE" })
223
+ const data = await res.json()
224
+ if (data.success) {
225
+ toast.success(`Đã xóa ${data.deleted} bản ghi`)
226
+ setShowDeleteDialog(false)
227
+ fetchLogs()
228
+ } else {
229
+ toast.error("Xóa log thất bại")
230
+ }
231
+ } catch {
232
+ toast.error("Xóa log thất bại")
233
+ } finally {
234
+ setDeleting(false)
235
+ }
236
+ }
237
+
238
+ const formatDate = (s: string) =>
239
+ new Date(s).toLocaleString("vi-VN", {
240
+ timeZone: "Asia/Ho_Chi_Minh",
241
+ day: "2-digit", month: "2-digit", year: "numeric",
242
+ hour: "2-digit", minute: "2-digit",
243
+ })
244
+
245
+ const hasActiveFilters = search || severity !== "all" || resolved !== "all" ||
246
+ moduleFilter !== "all" || startDate || endDate
247
+
248
+ const clearFilters = () => {
249
+ setSearch(""); setSeverity("all"); setResolved("false")
250
+ setModuleFilter("all"); setStartDate(""); setEndDate(""); setPage(0)
251
+ }
252
+
253
+ const allModules = Array.from(
254
+ new Set([
255
+ ...meta.moduleStats.map((m) => m.module),
256
+ ...logs.map((l) => l.module).filter(Boolean),
257
+ ])
258
+ ).filter(Boolean) as string[]
259
+
260
+ return (
261
+ <div className="flex h-full flex-col space-y-4 p-4 md:p-6">
262
+ {/* Header */}
263
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
264
+ <div>
265
+ <h2 className="text-xl md:text-2xl font-bold tracking-tight flex items-center gap-2">
266
+ <Bug className="h-5 w-5 md:h-6 md:w-6" />
267
+ Nhật ký lỗi
268
+ </h2>
269
+ <p className="text-muted-foreground text-sm hidden sm:block">
270
+ Theo dõi và quản lý lỗi hệ thống
271
+ </p>
272
+ </div>
273
+ <div className="flex gap-2">
274
+ <Button
275
+ variant={showFilters ? "default" : "outline"}
276
+ size="sm"
277
+ onClick={() => setShowFilters((v) => !v)}
278
+ >
279
+ <Filter className="mr-1.5 h-4 w-4" />
280
+ Bộ lọc
281
+ {hasActiveFilters && (
282
+ <span className="ml-1.5 rounded-full bg-primary-foreground text-primary w-4 h-4 text-[10px] flex items-center justify-center font-bold">
283
+ !
284
+ </span>
285
+ )}
286
+ </Button>
287
+ <Button variant="outline" size="sm" onClick={() => setShowDeleteDialog(true)}>
288
+ <Trash2 className="mr-1.5 h-4 w-4" />
289
+ Xóa cũ
290
+ </Button>
291
+ <Button variant="outline" size="sm" onClick={() => fetchLogs()} disabled={loading}>
292
+ <RefreshCw className={`mr-1.5 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
293
+ Làm mới
294
+ </Button>
295
+ </div>
296
+ </div>
297
+
298
+ {/* Stats bar */}
299
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
300
+ <Card>
301
+ <CardContent className="p-3 text-center">
302
+ <p className="text-2xl font-bold text-destructive">{meta.unresolvedCount}</p>
303
+ <p className="text-xs text-muted-foreground mt-0.5">Chưa xử lý</p>
304
+ </CardContent>
305
+ </Card>
306
+ <Card>
307
+ <CardContent className="p-3 text-center">
308
+ <p className="text-2xl font-bold">{meta.todayCount}</p>
309
+ <p className="text-xs text-muted-foreground mt-0.5">Hôm nay</p>
310
+ </CardContent>
311
+ </Card>
312
+ {meta.moduleStats.slice(0, 2).map((m) => (
313
+ <Card key={m.module}>
314
+ <CardContent className="p-3 text-center">
315
+ <p className="text-2xl font-bold text-amber-600">{m.count}</p>
316
+ <p className="text-xs text-muted-foreground mt-0.5 truncate">{m.module}</p>
317
+ </CardContent>
318
+ </Card>
319
+ ))}
320
+ </div>
321
+
322
+ {/* Filters (collapsible) */}
323
+ {showFilters && (
324
+ <Card>
325
+ <CardContent className="p-3">
326
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
327
+ <div className="relative sm:col-span-2 lg:col-span-1">
328
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
329
+ <Input
330
+ placeholder="Tìm message, errorId, context..."
331
+ value={search}
332
+ onChange={(e) => { setSearch(e.target.value); setPage(0) }}
333
+ className="pl-8"
334
+ />
335
+ </div>
336
+ <Select value={severity} onValueChange={(v) => { setSeverity(v); setPage(0) }}>
337
+ <SelectTrigger>
338
+ <AlertTriangle className="h-3.5 w-3.5 mr-1.5 text-muted-foreground" />
339
+ <SelectValue placeholder="Mức độ" />
340
+ </SelectTrigger>
341
+ <SelectContent>
342
+ <SelectItem value="all">Tất cả mức độ</SelectItem>
343
+ <SelectItem value="critical">Critical</SelectItem>
344
+ <SelectItem value="error">Error</SelectItem>
345
+ <SelectItem value="warning">Warning</SelectItem>
346
+ </SelectContent>
347
+ </Select>
348
+ <Select value={resolved} onValueChange={(v) => { setResolved(v); setPage(0) }}>
349
+ <SelectTrigger>
350
+ <SelectValue placeholder="Trạng thái" />
351
+ </SelectTrigger>
352
+ <SelectContent>
353
+ <SelectItem value="all">Tất cả</SelectItem>
354
+ <SelectItem value="false">Chưa xử lý</SelectItem>
355
+ <SelectItem value="true">Đã xử lý</SelectItem>
356
+ </SelectContent>
357
+ </Select>
358
+ <Select value={moduleFilter} onValueChange={(v) => { setModuleFilter(v); setPage(0) }}>
359
+ <SelectTrigger>
360
+ <Layers className="h-3.5 w-3.5 mr-1.5 text-muted-foreground" />
361
+ <SelectValue placeholder="Module" />
362
+ </SelectTrigger>
363
+ <SelectContent>
364
+ <SelectItem value="all">Tất cả module</SelectItem>
365
+ {allModules.map((m) => (
366
+ <SelectItem key={m} value={m}>{m}</SelectItem>
367
+ ))}
368
+ </SelectContent>
369
+ </Select>
370
+ <div className="flex items-center gap-2 sm:col-span-2 lg:col-span-1">
371
+ <CalendarDays className="h-4 w-4 text-muted-foreground shrink-0" />
372
+ <Input
373
+ type="date" value={startDate}
374
+ onChange={(e) => { setStartDate(e.target.value); setPage(0) }}
375
+ className="flex-1"
376
+ />
377
+ <span className="text-muted-foreground text-sm">→</span>
378
+ <Input
379
+ type="date" value={endDate}
380
+ onChange={(e) => { setEndDate(e.target.value); setPage(0) }}
381
+ className="flex-1"
382
+ />
383
+ </div>
384
+ {hasActiveFilters && (
385
+ <Button variant="ghost" size="sm" className="w-fit" onClick={clearFilters}>
386
+ <X className="h-3.5 w-3.5 mr-1" /> Xóa bộ lọc
387
+ </Button>
388
+ )}
389
+ </div>
390
+ </CardContent>
391
+ </Card>
392
+ )}
393
+
394
+ {/* Table */}
395
+ <Card className="flex-1">
396
+ <CardContent className="p-0">
397
+ <div className="rounded-md border overflow-x-auto">
398
+ <Table>
399
+ <TableHeader>
400
+ <TableRow>
401
+ <TableHead className="w-[130px]">Thời gian</TableHead>
402
+ <TableHead className="w-[85px]">Mức độ</TableHead>
403
+ <TableHead className="w-[100px] hidden md:table-cell">Module</TableHead>
404
+ <TableHead>Thông báo lỗi</TableHead>
405
+ <TableHead className="w-[60px] text-center">Lần</TableHead>
406
+ <TableHead className="w-[80px]">Trạng thái</TableHead>
407
+ <TableHead className="w-[50px] text-right hidden sm:table-cell" />
408
+ </TableRow>
409
+ </TableHeader>
410
+ <TableBody>
411
+ {loading ? (
412
+ <TableRow>
413
+ <TableCell colSpan={7} className="text-center py-8">
414
+ <RefreshCw className="h-5 w-5 animate-spin mx-auto text-muted-foreground" />
415
+ </TableCell>
416
+ </TableRow>
417
+ ) : logs.length === 0 ? (
418
+ <TableRow>
419
+ <TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
420
+ <CheckCircle2 className="h-8 w-8 mx-auto mb-2 text-green-500" />
421
+ Không có lỗi nào
422
+ </TableCell>
423
+ </TableRow>
424
+ ) : (
425
+ logs.map((log) => (
426
+ <TableRow
427
+ key={log.id}
428
+ className="cursor-pointer hover:bg-muted/50"
429
+ onClick={() => setSelectedLog(log)}
430
+ >
431
+ <TableCell className="text-xs text-muted-foreground font-mono">
432
+ {formatDate(log.lastSeenAt || log.createdAt)}
433
+ </TableCell>
434
+ <TableCell>
435
+ <SeverityBadge sev={log.severity} />
436
+ </TableCell>
437
+ <TableCell className="hidden md:table-cell">
438
+ {log.module ? (
439
+ <span className="text-xs bg-muted text-foreground px-1.5 py-0.5 rounded font-mono">
440
+ {log.module}
441
+ </span>
442
+ ) : (
443
+ <span className="text-xs text-muted-foreground">—</span>
444
+ )}
445
+ </TableCell>
446
+ <TableCell
447
+ className="max-w-[200px] md:max-w-[300px] truncate text-sm"
448
+ title={log.message}
449
+ >
450
+ {log.message}
451
+ </TableCell>
452
+ <TableCell className="text-center">
453
+ {log.occurrenceCount > 1 ? (
454
+ <span className="text-xs font-bold text-amber-600 bg-amber-50 rounded-full px-1.5 py-0.5">
455
+ ×{log.occurrenceCount}
456
+ </span>
457
+ ) : (
458
+ <span className="text-xs text-muted-foreground">1</span>
459
+ )}
460
+ </TableCell>
461
+ <TableCell>
462
+ {log.resolved ? (
463
+ <Badge variant="secondary" className="bg-success-subtle text-success-text text-xs">
464
+ Đã xử lý
465
+ </Badge>
466
+ ) : (
467
+ <Badge variant="outline" className="text-destructive text-xs">
468
+ Chưa xử lý
469
+ </Badge>
470
+ )}
471
+ </TableCell>
472
+ <TableCell className="text-right hidden sm:table-cell">
473
+ <Button
474
+ variant="ghost" size="sm"
475
+ onClick={(e) => { e.stopPropagation(); handleToggleResolved(log) }}
476
+ title={log.resolved ? "Đánh dấu chưa xử lý" : "Đánh dấu đã xử lý"}
477
+ >
478
+ <Check className="h-4 w-4" />
479
+ </Button>
480
+ </TableCell>
481
+ </TableRow>
482
+ ))
483
+ )}
484
+ </TableBody>
485
+ </Table>
486
+ </div>
487
+ </CardContent>
488
+ </Card>
489
+
490
+ {/* Pagination */}
491
+ <div className="flex items-center justify-between text-sm">
492
+ <p className="text-muted-foreground">
493
+ {logs.length} / {meta.total} bản ghi
494
+ </p>
495
+ <div className="flex gap-2">
496
+ <Button variant="outline" size="sm" disabled={page === 0}
497
+ onClick={() => setPage((p) => p - 1)}>
498
+ <ChevronLeft className="h-4 w-4" />
499
+ </Button>
500
+ <Button variant="outline" size="sm" disabled={!meta.hasMore}
501
+ onClick={() => setPage((p) => p + 1)}>
502
+ <ChevronRight className="h-4 w-4" />
503
+ </Button>
504
+ </div>
505
+ </div>
506
+
507
+ {/* Detail Dialog */}
508
+ <Dialog open={!!selectedLog} onOpenChange={() => setSelectedLog(null)}>
509
+ <DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
510
+ <DialogHeader>
511
+ <DialogTitle className="flex items-center gap-2">
512
+ <Bug className="h-5 w-5" />
513
+ Chi tiết lỗi
514
+ </DialogTitle>
515
+ </DialogHeader>
516
+ {selectedLog && (
517
+ <div className="space-y-4">
518
+ {/* Meta grid */}
519
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
520
+ <div>
521
+ <p className="text-muted-foreground text-xs mb-1">Mức độ</p>
522
+ <SeverityBadge sev={selectedLog.severity} />
523
+ </div>
524
+ <div>
525
+ <p className="text-muted-foreground text-xs mb-1">Số lần</p>
526
+ <p className="font-bold text-amber-600">
527
+ ×{selectedLog.occurrenceCount}
528
+ {selectedLog.occurrenceCount > 1 && (
529
+ <span className="text-xs text-muted-foreground font-normal ml-1">lần</span>
530
+ )}
531
+ </p>
532
+ </div>
533
+ <div>
534
+ <p className="text-muted-foreground text-xs mb-1">Module</p>
535
+ <p className="font-mono text-xs">{selectedLog.module || "—"}</p>
536
+ </div>
537
+ <div>
538
+ <p className="text-muted-foreground text-xs mb-1">Lần đầu</p>
539
+ <p className="text-xs">{formatDate(selectedLog.createdAt)}</p>
540
+ </div>
541
+ <div>
542
+ <p className="text-muted-foreground text-xs mb-1">Lần cuối</p>
543
+ <p className="text-xs">{formatDate(selectedLog.lastSeenAt)}</p>
544
+ </div>
545
+ <div>
546
+ <p className="text-muted-foreground text-xs mb-1">Error ID</p>
547
+ <p className="font-mono text-xs">{selectedLog.errorId}</p>
548
+ </div>
549
+ {selectedLog.code && (
550
+ <div>
551
+ <p className="text-muted-foreground text-xs mb-1">Code</p>
552
+ <p className="font-mono text-xs">{selectedLog.code}</p>
553
+ </div>
554
+ )}
555
+ {selectedLog.context && (
556
+ <div className="col-span-2">
557
+ <p className="text-muted-foreground text-xs mb-1">Context</p>
558
+ <p className="font-mono text-xs">{selectedLog.context}</p>
559
+ </div>
560
+ )}
561
+ {selectedLog.url && (
562
+ <div className="col-span-2 sm:col-span-3">
563
+ <p className="text-muted-foreground text-xs mb-1 flex items-center gap-1">
564
+ <Link2 className="h-3 w-3" /> URL
565
+ </p>
566
+ <p className="text-xs font-mono break-all">{selectedLog.url}</p>
567
+ </div>
568
+ )}
569
+ {selectedLog.userId && (
570
+ <div>
571
+ <p className="text-muted-foreground text-xs mb-1 flex items-center gap-1">
572
+ <User className="h-3 w-3" /> Người dùng
573
+ </p>
574
+ <p className="font-mono text-xs">{selectedLog.userName || selectedLog.userId}</p>
575
+ </div>
576
+ )}
577
+ </div>
578
+
579
+ {/* Message */}
580
+ <div>
581
+ <p className="text-sm text-muted-foreground mb-1">Thông báo lỗi</p>
582
+ <p className="font-medium">{selectedLog.message}</p>
583
+ </div>
584
+
585
+ {/* Stack trace */}
586
+ {selectedLog.detail && (
587
+ <div>
588
+ <div className="flex items-center justify-between mb-1">
589
+ <p className="text-sm text-muted-foreground">Chi tiết kỹ thuật</p>
590
+ <Button
591
+ variant="ghost" size="sm"
592
+ onClick={() => {
593
+ navigator.clipboard.writeText(selectedLog.detail!)
594
+ toast.success("Đã copy")
595
+ }}
596
+ >
597
+ <Copy className="h-3 w-3 mr-1" /> Copy
598
+ </Button>
599
+ </div>
600
+ <StackTraceViewer detail={selectedLog.detail} />
601
+ </div>
602
+ )}
603
+
604
+ {/* Actions */}
605
+ <div className="flex justify-between pt-2 border-t">
606
+ <Button
607
+ variant={selectedLog.resolved ? "outline" : "default"}
608
+ size="sm"
609
+ onClick={() => handleToggleResolved(selectedLog)}
610
+ >
611
+ {selectedLog.resolved ? "Đánh dấu chưa xử lý" : "Đánh dấu đã xử lý"}
612
+ </Button>
613
+ </div>
614
+ </div>
615
+ )}
616
+ </DialogContent>
617
+ </Dialog>
618
+
619
+ {/* Delete Dialog */}
620
+ <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
621
+ <DialogContent className="max-w-sm">
622
+ <DialogHeader>
623
+ <DialogTitle className="flex items-center gap-2">
624
+ <Trash2 className="h-5 w-5 text-destructive" />
625
+ Xóa log lỗi
626
+ </DialogTitle>
627
+ </DialogHeader>
628
+ <div className="grid gap-2">
629
+ {[7, 30, 90].map((d) => (
630
+ <Button
631
+ key={d} variant="outline" size="sm" className="justify-start"
632
+ disabled={deleting}
633
+ onClick={() => handleDeleteLogs("older_than", d)}
634
+ >
635
+ Xóa log cũ hơn {d} ngày
636
+ </Button>
637
+ ))}
638
+ <Button
639
+ variant="outline" size="sm"
640
+ className="justify-start text-green-700"
641
+ disabled={deleting}
642
+ onClick={() => handleDeleteLogs("resolved")}
643
+ >
644
+ <CheckCircle2 className="mr-2 h-4 w-4" />
645
+ Xóa tất cả log đã xử lý
646
+ </Button>
647
+ </div>
648
+ </DialogContent>
649
+ </Dialog>
650
+ </div>
651
+ )
652
+ }