@goplusvn/core 0.1.54 → 0.1.55
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 +6 -0
- package/features/audit-logs/README.md +18 -0
- package/features/audit-logs/migrations/0001_init.sql +33 -0
- package/features/audit-logs/schema.prisma +20 -0
- package/package.json +3 -1
- package/src/audit/__tests__/audit-context.test.ts +174 -0
- package/src/audit/__tests__/prisma-audit-extension.test.ts +426 -0
- package/src/audit/audit-actor.ts +42 -0
- package/src/audit/audit-context.ts +47 -0
- package/src/audit/entity-audit.ts +71 -0
- package/src/audit/index.ts +29 -10
- package/src/audit/prisma-audit-extension.ts +572 -0
- package/src/system/pages/__tests__/system-audit-page.test.tsx +140 -0
- package/src/system/pages/system-audit-page.tsx +532 -0
- package/src/ui/management/audit-log-page.tsx +12 -207
- package/src/audit/audit-manager.ts +0 -139
- package/src/audit/memory-audit-logger.ts +0 -86
- package/src/audit/types.ts +0 -50
|
@@ -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
|
+
}
|