@goplusvn/core 0.1.54 → 0.1.56

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 (39) hide show
  1. package/PLATFORM.md +44 -0
  2. package/features/README.md +6 -0
  3. package/features/audit-logs/README.md +18 -0
  4. package/features/audit-logs/migrations/0001_init.sql +33 -0
  5. package/features/audit-logs/schema.prisma +20 -0
  6. package/package.json +5 -1
  7. package/src/audit/__tests__/audit-context.test.ts +174 -0
  8. package/src/audit/__tests__/prisma-audit-extension.test.ts +426 -0
  9. package/src/audit/audit-actor.ts +42 -0
  10. package/src/audit/audit-context.ts +47 -0
  11. package/src/audit/entity-audit.ts +71 -0
  12. package/src/audit/index.ts +29 -10
  13. package/src/audit/prisma-audit-extension.ts +572 -0
  14. package/src/crud/components/crud-import-dialog.tsx +23 -421
  15. package/src/crud/components/crud-page.tsx +7 -12
  16. package/src/import/__tests__/import-dialog.test.tsx +141 -0
  17. package/src/import/__tests__/import-engine.test.ts +235 -0
  18. package/src/import/import-dialog.tsx +570 -0
  19. package/src/import/import-engine.ts +357 -0
  20. package/src/import/index.ts +39 -0
  21. package/src/import/types.ts +73 -0
  22. package/src/import/use-import.ts +156 -0
  23. package/src/system/pages/__tests__/system-audit-page.test.tsx +140 -0
  24. package/src/system/pages/system-audit-page.tsx +532 -0
  25. package/src/ui/filters/__tests__/advanced-filter-builder.test.tsx +194 -0
  26. package/src/ui/filters/advanced-filter-builder.tsx +380 -0
  27. package/src/ui/filters/index.ts +7 -0
  28. package/src/ui/index.tsx +1 -0
  29. package/src/ui/management/audit-log-page.tsx +12 -207
  30. package/src/audit/audit-manager.ts +0 -139
  31. package/src/audit/memory-audit-logger.ts +0 -86
  32. package/src/audit/types.ts +0 -50
  33. package/src/crud/crud-filters/checkbox-filter.tsx +0 -87
  34. package/src/crud/crud-filters/datetime-filter.tsx +0 -82
  35. package/src/crud/crud-filters/filter-builder.tsx +0 -64
  36. package/src/crud/crud-filters/index.tsx +0 -78
  37. package/src/crud/crud-filters/radio-filter.tsx +0 -79
  38. package/src/crud/crud-filters/select-filter.tsx +0 -148
  39. package/src/crud/crud-filters/text-filter.tsx +0 -81
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Smoke render trang "Nhật ký hệ thống": mount thật (jsdom) với fetch giả.
3
+ * Trang chỉ mở được sau đăng nhập quyền admin nên không kiểm được bằng HTTP
4
+ * ẩn danh — test này bắt lỗi import/hook trước khi app tiêu thụ, và chốt hai
5
+ * điều dễ vỡ khi app truyền cấu hình riêng: nhãn hành động của app phải thắng,
6
+ * và bộ lọc đối tượng phải BIẾN MẤT khi app không khai `resources`.
7
+ */
8
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
9
+ import { afterEach, describe, expect, it, vi } from "vitest";
10
+
11
+ import { SystemAuditPage } from "../system-audit-page";
12
+
13
+ const LOG = {
14
+ id: "log-1",
15
+ action: "update",
16
+ resource: "customer",
17
+ resourceId: "cust-1",
18
+ userId: "u1",
19
+ description: "[Kế toán] Cập nhật customer",
20
+ oldData: { name: "Tên cũ" },
21
+ newData: { name: "Tên mới" },
22
+ createdAt: "2026-08-01T06:00:00.000Z",
23
+ user: null,
24
+ };
25
+
26
+ function mockFetch(logs: unknown[] = [LOG], total = logs.length) {
27
+ const calls: string[] = [];
28
+ global.fetch = vi.fn(async (input: RequestInfo | URL) => {
29
+ calls.push(String(input));
30
+ return new Response(
31
+ JSON.stringify({
32
+ data: logs,
33
+ meta: { total, skip: 0, take: 30, hasMore: total > 30 },
34
+ }),
35
+ { status: 200, headers: { "Content-Type": "application/json" } },
36
+ );
37
+ }) as unknown as typeof fetch;
38
+ return calls;
39
+ }
40
+
41
+ describe("SystemAuditPage", () => {
42
+ afterEach(() => vi.restoreAllMocks());
43
+
44
+ it("render nhật ký từ API, bóc tên người ra khỏi tiền tố [Tên]", async () => {
45
+ mockFetch();
46
+ render(<SystemAuditPage />);
47
+
48
+ await waitFor(() => expect(screen.getByText("Cập nhật")).toBeDefined());
49
+ // "[Kế toán]" là tiền tố trong description → cột Người thực hiện, và mô tả
50
+ // hiển thị đã cắt tiền tố.
51
+ expect(screen.getByText("Kế toán")).toBeDefined();
52
+ expect(screen.getByText("Cập nhật customer")).toBeDefined();
53
+ expect(screen.getByText("cust-1")).toBeDefined();
54
+ });
55
+
56
+ it("bung dòng mới hiện diff cũ → mới", async () => {
57
+ mockFetch();
58
+ render(<SystemAuditPage />);
59
+
60
+ await waitFor(() => expect(screen.getByText("Cập nhật")).toBeDefined());
61
+ expect(screen.queryByText("Tên cũ")).toBeNull();
62
+
63
+ const toggle = screen
64
+ .getAllByRole("button")
65
+ .find((b) => b.className.includes("h-6 w-6"))!;
66
+ fireEvent.click(toggle);
67
+
68
+ expect(screen.getByText("Tên cũ")).toBeDefined();
69
+ expect(screen.getByText("Tên mới")).toBeDefined();
70
+ });
71
+
72
+ it("nhãn/màu hành động của app thắng mặc định của core", async () => {
73
+ mockFetch([{ ...LOG, action: "confirm-payment" }]);
74
+ render(
75
+ <SystemAuditPage
76
+ actions={[
77
+ {
78
+ value: "confirm-payment",
79
+ label: "Xác nhận thanh toán",
80
+ color: "bg-amber-500",
81
+ },
82
+ ]}
83
+ />,
84
+ );
85
+
86
+ await waitFor(() =>
87
+ expect(screen.getByText("Xác nhận thanh toán")).toBeDefined(),
88
+ );
89
+ });
90
+
91
+ it("không khai resources thì ẩn hẳn bộ lọc đối tượng", async () => {
92
+ mockFetch();
93
+ const { rerender } = render(<SystemAuditPage />);
94
+
95
+ await waitFor(() => expect(screen.getByText("Cập nhật")).toBeDefined());
96
+ expect(screen.queryByText("Tất cả đối tượng")).toBeNull();
97
+
98
+ rerender(
99
+ <SystemAuditPage
100
+ resources={[{ value: "customer", label: "Khách hàng" }]}
101
+ />,
102
+ );
103
+ await waitFor(() =>
104
+ expect(screen.getByText("Tất cả đối tượng")).toBeDefined(),
105
+ );
106
+ });
107
+
108
+ it("gửi bộ lọc + phân trang XUỐNG SERVER (bảng này hàng triệu dòng)", async () => {
109
+ const calls = mockFetch([LOG], 100);
110
+ render(<SystemAuditPage apiUrl="/api/audit" />);
111
+
112
+ await waitFor(() => expect(calls.length).toBeGreaterThan(0));
113
+ expect(calls[0]).toContain("/api/audit?");
114
+ expect(calls[0]).toContain("skip=0");
115
+ expect(calls[0]).toContain("take=30");
116
+
117
+ // total 100 > PAGE_SIZE → có phân trang, sang trang 2 phải gọi lại API.
118
+ fireEvent.click(
119
+ screen.getAllByRole("button").find((b) => b.className.includes("h-7"))!,
120
+ );
121
+ fireEvent.click(
122
+ screen
123
+ .getAllByRole("button")
124
+ .filter((b) => b.className.includes("h-7"))[1],
125
+ );
126
+
127
+ await waitFor(() =>
128
+ expect(calls.some((u) => u.includes("skip=30"))).toBe(true),
129
+ );
130
+ });
131
+
132
+ it("rỗng thì báo không tìm thấy, không vỡ layout", async () => {
133
+ mockFetch([], 0);
134
+ render(<SystemAuditPage />);
135
+
136
+ await waitFor(() =>
137
+ expect(screen.getByText("Không tìm thấy nhật ký nào")).toBeDefined(),
138
+ );
139
+ });
140
+ });
@@ -0,0 +1,532 @@
1
+ "use client";
2
+
3
+ import {
4
+ CalendarDays,
5
+ ChevronDown,
6
+ ChevronLeft,
7
+ ChevronRight,
8
+ ChevronUp,
9
+ Filter,
10
+ Loader2,
11
+ RefreshCw,
12
+ ScrollText,
13
+ Search,
14
+ X,
15
+ } from "lucide-react";
16
+ import { useCallback, useEffect, useRef, useState } from "react";
17
+
18
+ import {
19
+ Badge,
20
+ Button,
21
+ Card,
22
+ CardContent,
23
+ CardHeader,
24
+ CardTitle,
25
+ Input,
26
+ Select,
27
+ SelectContent,
28
+ SelectItem,
29
+ SelectTrigger,
30
+ SelectValue,
31
+ Table,
32
+ TableBody,
33
+ TableCell,
34
+ TableHead,
35
+ TableHeader,
36
+ TableRow,
37
+ } from "../../ui";
38
+
39
+ /**
40
+ * Trang "Nhật ký hệ thống" — đọc bảng `audit_logs` (feature `audit-logs`).
41
+ * Lọc SERVER-SIDE hết (search/action/resource/khoảng ngày + phân trang): bảng
42
+ * này nhanh chóng lên hàng triệu dòng, lọc ở client là tải chết trình duyệt.
43
+ *
44
+ * `actions`/`resources` là DỮ LIỆU CỦA APP (nhãn tiếng Việt + màu badge cho
45
+ * hành động nghiệp vụ riêng) — core chỉ thủ sẵn create/update/delete.
46
+ */
47
+
48
+ export interface AuditActionOption {
49
+ value: string;
50
+ label: string;
51
+ /** Class nền badge, vd "bg-emerald-500". */
52
+ color?: string;
53
+ }
54
+
55
+ export interface AuditResourceOption {
56
+ value: string;
57
+ label: string;
58
+ }
59
+
60
+ /** Ba hành động extension tự sinh — app nào cũng có. */
61
+ export const BASE_AUDIT_ACTIONS: AuditActionOption[] = [
62
+ { value: "create", label: "Tạo mới", color: "bg-emerald-500" },
63
+ { value: "update", label: "Cập nhật", color: "bg-blue-500" },
64
+ { value: "delete", label: "Xóa", color: "bg-red-500" },
65
+ ];
66
+
67
+ interface AuditLogEntry {
68
+ id: string;
69
+ action: string;
70
+ resource: string;
71
+ resourceId: string | null;
72
+ userId: string | null;
73
+ description: string | null;
74
+ oldData: Record<string, unknown> | null;
75
+ newData: Record<string, unknown> | null;
76
+ createdAt: string;
77
+ user?: { id: string; name: string | null; email: string | null } | null;
78
+ }
79
+
80
+ interface AuditResponse {
81
+ data: AuditLogEntry[];
82
+ meta: { total: number; skip: number; take: number; hasMore: boolean };
83
+ }
84
+
85
+ const PAGE_SIZE = 30;
86
+
87
+ function formatTime(dateStr: string) {
88
+ return new Date(dateStr).toLocaleString("vi-VN", {
89
+ day: "2-digit",
90
+ month: "2-digit",
91
+ year: "numeric",
92
+ hour: "2-digit",
93
+ minute: "2-digit",
94
+ second: "2-digit",
95
+ });
96
+ }
97
+
98
+ function DataChanges({
99
+ oldData,
100
+ newData,
101
+ }: {
102
+ oldData: Record<string, unknown> | null;
103
+ newData: Record<string, unknown> | null;
104
+ }) {
105
+ if (!oldData && !newData) return null;
106
+ const hideKeys = new Set([
107
+ "id",
108
+ "createdAt",
109
+ "updatedAt",
110
+ "updatedBy",
111
+ "createdBy",
112
+ ]);
113
+ const allKeys = Array.from(
114
+ new Set([...Object.keys(oldData || {}), ...Object.keys(newData || {})]),
115
+ ).filter((k) => !hideKeys.has(k));
116
+
117
+ const changedKeys = allKeys.filter((k) => {
118
+ const ov = oldData?.[k],
119
+ nv = newData?.[k];
120
+ if (JSON.stringify(ov) === JSON.stringify(nv)) return false;
121
+ if (typeof ov === "object" && typeof nv === "object") return false;
122
+ return true;
123
+ });
124
+
125
+ if (changedKeys.length === 0) return null;
126
+
127
+ return (
128
+ <div className="space-y-0.5 mt-1">
129
+ {changedKeys.slice(0, 8).map((key) => {
130
+ const ov = oldData?.[key],
131
+ nv = newData?.[key];
132
+ return (
133
+ <div key={key} className="flex items-start gap-1.5 text-xs">
134
+ <span className="text-muted-foreground font-medium min-w-[80px] shrink-0">
135
+ {key}:
136
+ </span>
137
+ {ov !== undefined && nv !== undefined ? (
138
+ <span>
139
+ <span className="text-destructive line-through mr-1">
140
+ {String(ov ?? "—")}
141
+ </span>
142
+ <span className="text-muted-foreground mx-0.5">→</span>
143
+ <span className="text-emerald-600 font-medium">
144
+ {String(nv ?? "—")}
145
+ </span>
146
+ </span>
147
+ ) : (
148
+ <span className="text-muted-foreground">
149
+ {String(nv ?? ov ?? "—")}
150
+ </span>
151
+ )}
152
+ </div>
153
+ );
154
+ })}
155
+ {changedKeys.length > 8 && (
156
+ <span className="text-xs text-muted-foreground">
157
+ +{changedKeys.length - 8} trường khác
158
+ </span>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ export interface SystemAuditPageProps {
165
+ /** Endpoint danh sách nhật ký. Mặc định /api/admin/system/audit. */
166
+ apiUrl?: string;
167
+ /** Hành động của app (nhãn + màu). Mặc định chỉ create/update/delete. */
168
+ actions?: AuditActionOption[];
169
+ /** Đối tượng của app. Rỗng thì ẩn hẳn bộ lọc đối tượng. */
170
+ resources?: AuditResourceOption[];
171
+ }
172
+
173
+ export function SystemAuditPage({
174
+ apiUrl = "/api/admin/system/audit",
175
+ actions = BASE_AUDIT_ACTIONS,
176
+ resources = [],
177
+ }: SystemAuditPageProps = {}) {
178
+ const [data, setData] = useState<AuditResponse | null>(null);
179
+ const [isLoading, setIsLoading] = useState(true);
180
+ const [page, setPage] = useState(0);
181
+ const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
182
+
183
+ // Bộ lọc — tất cả đẩy xuống server
184
+ const [search, setSearch] = useState("");
185
+ const [searchInput, setSearchInput] = useState(""); // đệm debounce
186
+ const [actionFilter, setActionFilter] = useState("all");
187
+ const [resourceFilter, setResourceFilter] = useState("all");
188
+ const [startDate, setStartDate] = useState("");
189
+ const [endDate, setEndDate] = useState("");
190
+
191
+ const actionMap = new Map(actions.map((a) => [a.value, a]));
192
+ const resourceMap = new Map(resources.map((r) => [r.value, r.label]));
193
+
194
+ const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
195
+ const handleSearchInput = (val: string) => {
196
+ setSearchInput(val);
197
+ if (searchTimer.current) clearTimeout(searchTimer.current);
198
+ searchTimer.current = setTimeout(() => {
199
+ setSearch(val);
200
+ setPage(0);
201
+ }, 400);
202
+ };
203
+
204
+ const fetchLogs = useCallback(async () => {
205
+ setIsLoading(true);
206
+ try {
207
+ const params = new URLSearchParams();
208
+ params.set("skip", String(page * PAGE_SIZE));
209
+ params.set("take", String(PAGE_SIZE));
210
+ if (search) params.set("search", search);
211
+ if (actionFilter !== "all") params.set("action", actionFilter);
212
+ if (resourceFilter !== "all") params.set("resource", resourceFilter);
213
+ if (startDate) params.set("startDate", new Date(startDate).toISOString());
214
+ if (endDate)
215
+ params.set("endDate", new Date(endDate + "T23:59:59").toISOString());
216
+
217
+ const res = await fetch(`${apiUrl}?${params}`);
218
+ if (res.ok) setData(await res.json());
219
+ } catch (e) {
220
+ console.error(e);
221
+ } finally {
222
+ setIsLoading(false);
223
+ }
224
+ }, [apiUrl, page, search, actionFilter, resourceFilter, startDate, endDate]);
225
+
226
+ useEffect(() => {
227
+ fetchLogs();
228
+ }, [fetchLogs]);
229
+
230
+ const clearFilters = () => {
231
+ setSearchInput("");
232
+ setSearch("");
233
+ setActionFilter("all");
234
+ setResourceFilter("all");
235
+ setStartDate("");
236
+ setEndDate("");
237
+ setPage(0);
238
+ };
239
+
240
+ const hasActiveFilters =
241
+ search ||
242
+ actionFilter !== "all" ||
243
+ resourceFilter !== "all" ||
244
+ startDate ||
245
+ endDate;
246
+
247
+ const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0;
248
+
249
+ return (
250
+ <div className="p-4 sm:p-6 space-y-4 h-full flex flex-col">
251
+ {/* Header */}
252
+ <div className="flex items-center justify-between">
253
+ <div>
254
+ <h1 className="text-xl font-bold tracking-tight flex items-center gap-2">
255
+ <ScrollText className="h-5 w-5" />
256
+ Nhật ký hệ thống
257
+ </h1>
258
+ <p className="text-sm text-muted-foreground">
259
+ Theo dõi mọi thay đổi dữ liệu
260
+ {data && (
261
+ <span className="ml-1 text-xs">
262
+ ({data.meta.total.toLocaleString()} bản ghi)
263
+ </span>
264
+ )}
265
+ </p>
266
+ </div>
267
+ <Button
268
+ variant="outline"
269
+ size="sm"
270
+ onClick={fetchLogs}
271
+ disabled={isLoading}
272
+ >
273
+ <RefreshCw
274
+ className={`h-4 w-4 mr-1 ${isLoading ? "animate-spin" : ""}`}
275
+ />
276
+ Làm mới
277
+ </Button>
278
+ </div>
279
+
280
+ {/* Bộ lọc */}
281
+ <Card>
282
+ <CardHeader className="py-3">
283
+ <CardTitle className="text-sm font-medium flex items-center justify-between">
284
+ <span className="flex items-center gap-1.5">
285
+ <Filter className="h-3.5 w-3.5" />
286
+ Bộ lọc
287
+ </span>
288
+ {hasActiveFilters && (
289
+ <Button
290
+ variant="ghost"
291
+ size="sm"
292
+ className="h-6 text-xs"
293
+ onClick={clearFilters}
294
+ >
295
+ <X className="h-3 w-3 mr-1" /> Xóa bộ lọc
296
+ </Button>
297
+ )}
298
+ </CardTitle>
299
+ </CardHeader>
300
+ <CardContent className="pb-3">
301
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
302
+ <div className="relative sm:col-span-2 lg:col-span-1">
303
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
304
+ <Input
305
+ placeholder="Tìm theo mô tả, ID, resource..."
306
+ value={searchInput}
307
+ onChange={(e) => handleSearchInput(e.target.value)}
308
+ className="h-8 text-sm pl-8"
309
+ />
310
+ </div>
311
+
312
+ <Select
313
+ value={actionFilter}
314
+ onValueChange={(v) => {
315
+ setActionFilter(v);
316
+ setPage(0);
317
+ }}
318
+ >
319
+ <SelectTrigger className="h-8 text-sm">
320
+ <SelectValue placeholder="Tất cả hành động" />
321
+ </SelectTrigger>
322
+ <SelectContent>
323
+ <SelectItem value="all">Tất cả hành động</SelectItem>
324
+ {actions.map((a) => (
325
+ <SelectItem key={a.value} value={a.value}>
326
+ {a.label}
327
+ </SelectItem>
328
+ ))}
329
+ </SelectContent>
330
+ </Select>
331
+
332
+ {resources.length > 0 && (
333
+ <Select
334
+ value={resourceFilter}
335
+ onValueChange={(v) => {
336
+ setResourceFilter(v);
337
+ setPage(0);
338
+ }}
339
+ >
340
+ <SelectTrigger className="h-8 text-sm">
341
+ <SelectValue placeholder="Tất cả đối tượng" />
342
+ </SelectTrigger>
343
+ <SelectContent>
344
+ <SelectItem value="all">Tất cả đối tượng</SelectItem>
345
+ {resources.map((r) => (
346
+ <SelectItem key={r.value} value={r.value}>
347
+ {r.label}
348
+ </SelectItem>
349
+ ))}
350
+ </SelectContent>
351
+ </Select>
352
+ )}
353
+
354
+ <div className="flex items-center gap-2 sm:col-span-2 lg:col-span-1">
355
+ <CalendarDays className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
356
+ <Input
357
+ type="date"
358
+ value={startDate}
359
+ onChange={(e) => {
360
+ setStartDate(e.target.value);
361
+ setPage(0);
362
+ }}
363
+ className="h-8 text-xs flex-1"
364
+ />
365
+ <span className="text-muted-foreground text-xs">—</span>
366
+ <Input
367
+ type="date"
368
+ value={endDate}
369
+ onChange={(e) => {
370
+ setEndDate(e.target.value);
371
+ setPage(0);
372
+ }}
373
+ className="h-8 text-xs flex-1"
374
+ />
375
+ </div>
376
+ </div>
377
+ </CardContent>
378
+ </Card>
379
+
380
+ {/* Bảng */}
381
+ <Card className="flex-1 flex flex-col min-h-0">
382
+ <CardContent className="flex-1 overflow-auto p-0">
383
+ {isLoading ? (
384
+ <div className="flex items-center justify-center py-16">
385
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
386
+ <span className="ml-2 text-sm text-muted-foreground">
387
+ Đang tải nhật ký...
388
+ </span>
389
+ </div>
390
+ ) : (
391
+ <Table>
392
+ <TableHeader>
393
+ <TableRow>
394
+ <TableHead className="w-[150px]">Thời gian</TableHead>
395
+ <TableHead className="w-[130px]">Hành động</TableHead>
396
+ <TableHead className="w-[140px]">Đối tượng</TableHead>
397
+ <TableHead className="w-[140px]">Người thực hiện</TableHead>
398
+ <TableHead>Chi tiết</TableHead>
399
+ <TableHead className="w-[36px]" />
400
+ </TableRow>
401
+ </TableHeader>
402
+ <TableBody>
403
+ {(!data?.data || data.data.length === 0) && (
404
+ <TableRow>
405
+ <TableCell
406
+ colSpan={6}
407
+ className="text-center h-24 text-muted-foreground"
408
+ >
409
+ <ScrollText className="h-8 w-8 mx-auto mb-2 opacity-40" />
410
+ Không tìm thấy nhật ký nào
411
+ </TableCell>
412
+ </TableRow>
413
+ )}
414
+ {data?.data.map((log) => {
415
+ const actionCfg = actionMap.get(log.action);
416
+ const isExpanded = expandedIds.has(log.id);
417
+ const hasDetails =
418
+ (log.oldData && Object.keys(log.oldData).length > 0) ||
419
+ (log.newData && Object.keys(log.newData).length > 0);
420
+
421
+ // Tên người: quan hệ user trước, không có thì bóc tiền tố
422
+ // "[Tên]" mà logEntityAction/extension đã nhét vào mô tả.
423
+ let userName = "Hệ thống";
424
+ if (log.user?.name) userName = log.user.name;
425
+ else if (log.user?.email) userName = log.user.email;
426
+ else if (log.description) {
427
+ const m = log.description.match(/^\[(.*?)\]/);
428
+ if (m) userName = m[1];
429
+ }
430
+
431
+ const desc =
432
+ log.description?.replace(/^\[.*?\]\s*/, "") || "";
433
+
434
+ return (
435
+ <TableRow key={log.id} className="align-top">
436
+ <TableCell className="font-mono text-xs text-muted-foreground whitespace-nowrap">
437
+ {formatTime(log.createdAt)}
438
+ </TableCell>
439
+ <TableCell>
440
+ <Badge
441
+ className={`text-[10px] text-white ${actionCfg?.color || "bg-slate-400"}`}
442
+ >
443
+ {actionCfg?.label || log.action}
444
+ </Badge>
445
+ </TableCell>
446
+ <TableCell className="text-sm">
447
+ <div>
448
+ {resourceMap.get(log.resource) || log.resource}
449
+ </div>
450
+ {log.resourceId && (
451
+ <div
452
+ className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px]"
453
+ title={log.resourceId}
454
+ >
455
+ {log.resourceId}
456
+ </div>
457
+ )}
458
+ </TableCell>
459
+ <TableCell className="text-sm">{userName}</TableCell>
460
+ <TableCell className="text-sm">
461
+ <div className="text-muted-foreground">{desc}</div>
462
+ {isExpanded && hasDetails && (
463
+ <DataChanges
464
+ oldData={log.oldData}
465
+ newData={log.newData}
466
+ />
467
+ )}
468
+ </TableCell>
469
+ <TableCell>
470
+ {hasDetails && (
471
+ <Button
472
+ variant="ghost"
473
+ size="sm"
474
+ className="h-6 w-6 p-0"
475
+ onClick={() =>
476
+ setExpandedIds((prev) => {
477
+ const next = new Set(prev);
478
+ if (next.has(log.id)) next.delete(log.id);
479
+ else next.add(log.id);
480
+ return next;
481
+ })
482
+ }
483
+ >
484
+ {isExpanded ? (
485
+ <ChevronUp className="h-3 w-3" />
486
+ ) : (
487
+ <ChevronDown className="h-3 w-3" />
488
+ )}
489
+ </Button>
490
+ )}
491
+ </TableCell>
492
+ </TableRow>
493
+ );
494
+ })}
495
+ </TableBody>
496
+ </Table>
497
+ )}
498
+ </CardContent>
499
+
500
+ {/* Phân trang */}
501
+ {totalPages > 1 && (
502
+ <div className="flex items-center justify-between px-4 py-2 border-t">
503
+ <span className="text-xs text-muted-foreground">
504
+ Trang {page + 1}/{totalPages} ·{" "}
505
+ {data?.meta.total.toLocaleString()} bản ghi
506
+ </span>
507
+ <div className="flex gap-1">
508
+ <Button
509
+ variant="outline"
510
+ size="sm"
511
+ className="h-7"
512
+ disabled={page === 0}
513
+ onClick={() => setPage((p) => p - 1)}
514
+ >
515
+ <ChevronLeft className="h-3 w-3" />
516
+ </Button>
517
+ <Button
518
+ variant="outline"
519
+ size="sm"
520
+ className="h-7"
521
+ disabled={!data?.meta.hasMore}
522
+ onClick={() => setPage((p) => p + 1)}
523
+ >
524
+ <ChevronRight className="h-3 w-3" />
525
+ </Button>
526
+ </div>
527
+ </div>
528
+ )}
529
+ </Card>
530
+ </div>
531
+ );
532
+ }