diff --git a/apps/web/app/[locale]/(admin)/admin/audit-log/page.tsx b/apps/web/app/[locale]/(admin)/admin/audit-log/page.tsx new file mode 100644 index 0000000..c5ab258 --- /dev/null +++ b/apps/web/app/[locale]/(admin)/admin/audit-log/page.tsx @@ -0,0 +1,330 @@ +'use client'; + +import { + RefreshCw, + ChevronLeft, + ChevronRight, + X, + Filter, + AlertTriangle, + Info, + ShieldAlert, + ChevronDown, + ChevronUp, +} from 'lucide-react'; +import { useEffect, useState, useCallback } from 'react'; +import { Signal } from '@/components/design-system/signal'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { adminApi, type AuditLogItem, type PaginatedResult } from '@/lib/admin-api'; + +const SEVERITY_CONFIG = { + info: { label: 'Thông tin', icon: Info, dir: 'neutral' as const }, + warning: { label: 'Cảnh báo', icon: AlertTriangle, dir: 'neutral' as const }, + critical: { label: 'Nghiêm trọng', icon: ShieldAlert, dir: 'down' as const }, +}; + +const MODULE_LABELS: Record = { + auth: 'Xác thực', + listings: 'Tin đăng', + payments: 'Thanh toán', + subscriptions: 'Gói dịch vụ', + admin: 'Quản trị', + analytics: 'Phân tích', + search: 'Tìm kiếm', + notifications: 'Thông báo', + agents: 'Đại lý', + kyc: 'KYC', + users: 'Người dùng', + moderation: 'Kiểm duyệt', +}; + +function SeverityPill({ severity }: { severity: AuditLogItem['severity'] }) { + const cfg = SEVERITY_CONFIG[severity]; + return ; +} + +function DiffToggle({ before, after }: { before: unknown; after: unknown }) { + const [open, setOpen] = useState(false); + if (!before && !after) return null; + + return ( +
+ + {open && ( +
+ {before != null && ( +
+              {JSON.stringify(before, null, 2)}
+            
+ )} + {after != null && ( +
+              {JSON.stringify(after, null, 2)}
+            
+ )} +
+ )} +
+ ); +} + +export default function AdminAuditLogPage() { + const [result, setResult] = useState | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + + // Filters + const [filterModule, setFilterModule] = useState(''); + const [filterActor, setFilterActor] = useState(''); + const [filterSeverity, setFilterSeverity] = useState(''); + const [filterFrom, setFilterFrom] = useState(''); + const [filterTo, setFilterTo] = useState(''); + const [showFilters, setShowFilters] = useState(false); + + const fetchLogs = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await adminApi.getAuditLogs({ + page, + limit: 50, + module: filterModule || undefined, + actorId: filterActor || undefined, + severity: filterSeverity || undefined, + from: filterFrom || undefined, + to: filterTo || undefined, + }); + setResult(data); + } catch (e) { + setError(e instanceof Error ? e.message : 'Không thể tải nhật ký kiểm toán'); + } finally { + setLoading(false); + } + }, [page, filterModule, filterActor, filterSeverity, filterFrom, filterTo]); + + useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + const handleFilterApply = () => { + setPage(1); + fetchLogs(); + }; + + const handleFilterReset = () => { + setFilterModule(''); + setFilterActor(''); + setFilterSeverity(''); + setFilterFrom(''); + setFilterTo(''); + setPage(1); + }; + + const activeFiltersCount = [filterModule, filterActor, filterSeverity, filterFrom, filterTo].filter(Boolean).length; + + return ( +
+ {/* Header */} +
+
+

Nhật ký kiểm toán

+

Lịch sử hành động hệ thống theo thời gian thực

+
+
+ + +
+
+ + {/* Filters */} + {showFilters && ( + + +
+
+ + +
+
+ + +
+
+ + setFilterActor(e.target.value)} + className="h-8 text-sm" + /> +
+
+ + setFilterFrom(e.target.value)} + className="h-8 text-sm" + /> +
+
+ + setFilterTo(e.target.value)} + className="h-8 text-sm" + /> +
+
+
+ + +
+
+
+ )} + + {/* Table */} + + + {loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+ +
+ ) : !result || result.data.length === 0 ? ( +
+ +

Không có nhật ký nào phù hợp

+
+ ) : ( +
+ + + + Thời gian + Actor + Hành động + Module + Mục tiêu + Mức độ + IP + Diff + + + + {result.data.map((log) => ( + + + {new Date(log.createdAt).toLocaleString('vi-VN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + +
{log.actorName}
+
{log.actorRole}
+
+ + {log.action} + + + + {MODULE_LABELS[log.module] ?? log.module} + + + + {log.targetId ?? '—'} + + + + + + {log.ipAddress ?? '—'} + + + + +
+ ))} +
+
+ + {result.totalPages > 1 && ( +
+ + Trang {result.page}/{result.totalPages} · {result.total} bản ghi + +
+ + +
+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/[locale]/(admin)/admin/kyc/page.tsx b/apps/web/app/[locale]/(admin)/admin/kyc/page.tsx index f2fb16f..304af8d 100644 --- a/apps/web/app/[locale]/(admin)/admin/kyc/page.tsx +++ b/apps/web/app/[locale]/(admin)/admin/kyc/page.tsx @@ -6,13 +6,13 @@ import { RefreshCw, ChevronLeft, ChevronRight, - FileText, ShieldCheck, X, + User, } from 'lucide-react'; import Image from 'next/image'; import { useEffect, useState, useCallback } from 'react'; -import { Badge } from '@/components/ui/badge'; +import { StatusChip } from '@/components/design-system/status-chip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { @@ -27,15 +27,6 @@ import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { adminApi, type KycQueueItem, type PaginatedResult } from '@/lib/admin-api'; -function kycStatusBadge(status: string) { - switch (status) { - case 'VERIFIED': return Đã xác minh; - case 'PENDING': return Chờ duyệt; - case 'REJECTED': return Bị từ chối; - default: return {status}; - } -} - interface KycData { idType?: string; idNumber?: string; @@ -45,7 +36,13 @@ interface KycData { [key: string]: unknown; } -function KycDetailView({ item, onApprove, onReject }: { +function kycStatusToPropertyStatus(status: string): 'active' | 'pending' | 'rejected' { + if (status === 'VERIFIED') return 'active'; + if (status === 'REJECTED') return 'rejected'; + return 'pending'; +} + +function KycDetailPanel({ item, onApprove, onReject }: { item: KycQueueItem; onApprove: () => void; onReject: () => void; @@ -53,102 +50,83 @@ function KycDetailView({ item, onApprove, onReject }: { const kycData = item.kycData as KycData | null; return ( -
-
+
+ {/* Identity */} +
-

{item.fullName}

-

{item.phone}

- {item.email && ( -

{item.email}

- )} +
{item.fullName}
+
{item.phone}
+ {item.email &&
{item.email}
}
- {kycStatusBadge(item.kycStatus)} +
-
-
-
Vai trò
-
{item.role}
+ {/* Meta grid */} +
+
+
Vai trò
+
{item.role}
-
-
Ngày gửi
-
- {new Date(item.createdAt).toLocaleDateString('vi-VN')} -
+
+
Ngày gửi
+
{new Date(item.createdAt).toLocaleDateString('vi-VN')}
+ {/* KYC data */} {kycData && ( -
-

Thông tin KYC

- {kycData.idType && ( -
-
Loại giấy tờ
-
{kycData.idType}
-
- )} - {kycData.idNumber && ( -
-
Số giấy tờ
-
{kycData.idNumber}
+
+
Tài liệu KYC
+ + {(kycData.idType || kycData.idNumber) && ( +
+ {kycData.idType && ( +
+
Loại giấy tờ
+
{kycData.idType}
+
+ )} + {kycData.idNumber && ( +
+
Số giấy tờ
+
{kycData.idNumber}
+
+ )}
)} -
- {kycData.frontImageUrl && ( -
-
Mặt trước
-
+ {[ + { url: kycData.frontImageUrl, label: 'Mặt trước CCCD/CMND' }, + { url: kycData.backImageUrl, label: 'Mặt sau CCCD/CMND' }, + { url: kycData.selfieUrl, label: 'Ảnh selfie xác nhận' }, + ].map(({ url, label }) => + url ? ( +
+
{label}
+
Mặt trước giấy tờ
- )} - {kycData.backImageUrl && ( -
-
Mặt sau
-
- Mặt sau giấy tờ -
-
- )} - {kycData.selfieUrl && ( -
-
Ảnh selfie
-
- Selfie -
-
- )} -
+ ) : null, + )}
)} + {/* Actions */} {item.kycStatus === 'PENDING' && ( -
- -
@@ -165,11 +143,9 @@ export default function AdminKycPage() { const [selectedItem, setSelectedItem] = useState(null); - // Approve dialog const [approveDialog, setApproveDialog] = useState(null); const [approveNotes, setApproveNotes] = useState(''); - // Reject dialog const [rejectDialog, setRejectDialog] = useState(null); const [rejectReason, setRejectReason] = useState(''); @@ -226,7 +202,7 @@ export default function AdminKycPage() { }; return ( -
+
{actionError && (
{actionError} @@ -238,101 +214,97 @@ export default function AdminKycPage() {
-

Duyệt KYC

-

- Xác minh danh tính người dùng và đại lý -

+

Duyệt KYC

+

Xác minh danh tính người dùng và đại lý

-
-
+
{/* Table */} - + {loading ? (
- +
) : error ? (

{error}

- +
) : !result || result.data.length === 0 ? (
- -

- Không có yêu cầu KYC nào đang chờ -

+ +

Không có yêu cầu KYC nào đang chờ

) : ( <> - - - - Họ tên - SĐT - Vai trò - Trạng thái - Ngày gửi - - - - - {result.data.map((item) => ( - setSelectedItem(item)} - > - -
{item.fullName}
- {item.email && ( -
{item.email}
- )} -
- {item.phone} - - {item.role} - - {kycStatusBadge(item.kycStatus)} - - {new Date(item.createdAt).toLocaleDateString('vi-VN')} - - - - +
+
+ + + Họ tên + SĐT + Vai trò + Trạng thái + Ngày gửi + - ))} - -
+ + + {result.data.map((item) => ( + setSelectedItem(item)} + className={`h-row-compact cursor-pointer border-b border-border transition-colors ${ + selectedItem?.userId === item.userId + ? 'bg-background-surface' + : 'hover:bg-background-surface' + }`} + > + +
{item.fullName}
+ {item.email && ( +
{item.email}
+ )} +
+ + {item.phone} + + + + {item.role} + + + + + + + {new Date(item.createdAt).toLocaleDateString('vi-VN')} + + + + +
+ ))} +
+ +
{result.totalPages > 1 && ( -
- - Trang {result.page}/{result.totalPages} ({result.total} yêu cầu) +
+ + Trang {result.page}/{result.totalPages} · {result.total} yêu cầu
- -
@@ -343,24 +315,18 @@ export default function AdminKycPage() { - {/* Detail sidebar */} + {/* Detail panel */}
- - + + {selectedItem ? ( - { - setApproveDialog(selectedItem.userId); - setApproveNotes(''); - }} - onReject={() => { - setRejectDialog(selectedItem.userId); - setRejectReason(''); - }} + onApprove={() => { setApproveDialog(selectedItem.userId); setApproveNotes(''); }} + onReject={() => { setRejectDialog(selectedItem.userId); setRejectReason(''); }} /> ) : ( -
+
Chọn yêu cầu KYC để xem chi tiết
)} @@ -374,9 +340,7 @@ export default function AdminKycPage() { Duyệt KYC - - Xác nhận danh tính người dùng đã được xác minh thành công. - + Xác nhận danh tính người dùng đã được xác minh thành công. setApproveNotes(e.target.value)} /> - + @@ -399,9 +361,7 @@ export default function AdminKycPage() { Từ chối KYC - - Vui lòng nhập lý do từ chối. Người dùng sẽ cần gửi lại hồ sơ. - + Vui lòng nhập lý do từ chối. Người dùng sẽ cần gửi lại hồ sơ. setRejectReason(e.target.value)} /> - + - )} -
- + {/* Tabs */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Table */} + {loading ? (
- +
) : error ? (

{error}

- +
) : !result || result.data.length === 0 ? (
- -

- Không có tin nào chờ kiểm duyệt -

+ +

Không có tin nào trong hàng đợi này

) : ( - <> +
- - - - 0} - onChange={toggleSelectAll} - className="rounded border-input" - aria-label="Chọn tất cả tin đăng" - /> - - Tiêu đề - Loại - Giá - Người đăng - Điểm AI - Ngày đăng - Hành động + + + {isActionable && ( + + 0} + onChange={toggleSelectAll} + className="rounded border-border" + aria-label="Chọn tất cả tin đăng" + /> + + )} + Tiêu đề + Loại + Giá (VND) + Người đăng + Điểm AI + Trạng thái + Ngày đăng + {isActionable && Hành động} {result.data.map((item) => ( - + + {isActionable && ( + + toggleSelect(item.listingId)} + className="rounded border-border" + aria-label={`Chọn tin: ${item.propertyTitle}`} + /> + + )} - toggleSelect(item.listingId)} - className="rounded border-input" - aria-label={`Chọn tin: ${item.propertyTitle}`} - /> - - -
- {item.propertyTitle} -
-
+
{item.propertyTitle}
+
{item.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'}
- {item.propertyType} + + {item.propertyType} + - - {formatPrice(item.priceVND)} VND + + {new Intl.NumberFormat('vi-VN').format(item.priceVND)} - - {item.sellerName} + + {item.sellerName} + + + - {moderationScoreBadge(item.moderationScore)} + - + {new Date(item.createdAt).toLocaleDateString('vi-VN')} - -
- - -
-
+ {isActionable && ( + +
+ + + +
+
+ )} ))}
{result.totalPages > 1 && ( -
- - Trang {result.page}/{result.totalPages} ({result.total} tin) +
+ + Trang {result.page}/{result.totalPages} · {result.total} tin
- -
)} - +
)} + {/* Bulk action bar (sticky bottom) */} + {isActionable && selected.size > 0 && ( +
+ + Đã chọn {selected.size} tin + +
+ + + +
+
+ )} + {/* Approve dialog */} setApproveDialog(null)}> Duyệt tin đăng - - Tin đăng sẽ được hiển thị công khai sau khi duyệt. - + Tin đăng sẽ được hiển thị công khai sau khi duyệt. setApproveNotes(e.target.value)} /> - + @@ -353,9 +402,7 @@ export default function AdminModerationPage() { Từ chối tin đăng - - Vui lòng nhập lý do từ chối. Người đăng sẽ nhận được thông báo. - + Vui lòng nhập lý do từ chối. Người đăng sẽ nhận được thông báo. setRejectReason(e.target.value)} /> - - + @@ -399,9 +440,7 @@ export default function AdminModerationPage() { /> )} - +