feat(web): add Admin module frontend — dashboard, users, moderation, KYC
Build the complete admin panel UI at apps/web/app/(admin)/: - Admin layout with sidebar navigation and ADMIN role guard - Dashboard page with stats cards and revenue chart - User management with search, filters, pagination, detail panel, ban/unban - Listings moderation queue with approve/reject/bulk actions - KYC review page with document viewer and approve/reject flow - New reusable UI components: Dialog, Table Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
412
apps/web/app/(admin)/admin/kyc/page.tsx
Normal file
412
apps/web/app/(admin)/admin/kyc/page.tsx
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { adminApi, type KycQueueItem, type PaginatedResult } from '@/lib/admin-api';
|
||||||
|
|
||||||
|
function kycStatusBadge(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'VERIFIED': return <Badge variant="success">Đã xác minh</Badge>;
|
||||||
|
case 'PENDING': return <Badge variant="warning">Chờ duyệt</Badge>;
|
||||||
|
case 'REJECTED': return <Badge variant="destructive">Bị từ chối</Badge>;
|
||||||
|
default: return <Badge variant="secondary">{status}</Badge>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KycData {
|
||||||
|
idType?: string;
|
||||||
|
idNumber?: string;
|
||||||
|
frontImageUrl?: string;
|
||||||
|
backImageUrl?: string;
|
||||||
|
selfieUrl?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KycDetailView({ item, onApprove, onReject }: {
|
||||||
|
item: KycQueueItem;
|
||||||
|
onApprove: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
}) {
|
||||||
|
const kycData = item.kycData as KycData | null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{item.fullName}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{item.phone}</p>
|
||||||
|
{item.email && (
|
||||||
|
<p className="text-sm text-muted-foreground">{item.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{kycStatusBadge(item.kycStatus)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Vai trò</div>
|
||||||
|
<div className="mt-1 text-sm font-medium">{item.role}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Ngày gửi</div>
|
||||||
|
<div className="mt-1 text-sm font-medium">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString('vi-VN')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kycData && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">Thông tin KYC</h4>
|
||||||
|
{kycData.idType && (
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Loại giấy tờ</div>
|
||||||
|
<div className="mt-1 text-sm font-medium">{kycData.idType}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{kycData.idNumber && (
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Số giấy tờ</div>
|
||||||
|
<div className="mt-1 text-sm font-medium">{kycData.idNumber}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{kycData.frontImageUrl && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">Mặt trước</div>
|
||||||
|
<div className="aspect-video overflow-hidden rounded-md border bg-muted">
|
||||||
|
<img
|
||||||
|
src={kycData.frontImageUrl}
|
||||||
|
alt="Mặt trước giấy tờ"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{kycData.backImageUrl && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">Mặt sau</div>
|
||||||
|
<div className="aspect-video overflow-hidden rounded-md border bg-muted">
|
||||||
|
<img
|
||||||
|
src={kycData.backImageUrl}
|
||||||
|
alt="Mặt sau giấy tờ"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{kycData.selfieUrl && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">Ảnh selfie</div>
|
||||||
|
<div className="aspect-video overflow-hidden rounded-md border bg-muted">
|
||||||
|
<img
|
||||||
|
src={kycData.selfieUrl}
|
||||||
|
alt="Selfie"
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.kycStatus === 'PENDING' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button className="flex-1" onClick={onApprove}>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Duyệt KYC
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" className="flex-1" onClick={onReject}>
|
||||||
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
|
Từ chối
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminKycPage() {
|
||||||
|
const { tokens } = useAuthStore();
|
||||||
|
const [result, setResult] = useState<PaginatedResult<KycQueueItem> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const [selectedItem, setSelectedItem] = useState<KycQueueItem | null>(null);
|
||||||
|
|
||||||
|
// Approve dialog
|
||||||
|
const [approveDialog, setApproveDialog] = useState<string | null>(null);
|
||||||
|
const [approveNotes, setApproveNotes] = useState('');
|
||||||
|
|
||||||
|
// Reject dialog
|
||||||
|
const [rejectDialog, setRejectDialog] = useState<string | null>(null);
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchQueue = useCallback(async () => {
|
||||||
|
if (!tokens?.accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getKycQueue(tokens.accessToken, page, 20);
|
||||||
|
setResult(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Không thể tải hàng đợi KYC');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [tokens?.accessToken, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchQueue();
|
||||||
|
}, [fetchQueue]);
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
if (!tokens?.accessToken || !approveDialog) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminApi.approveKyc(tokens.accessToken, approveDialog, approveNotes || undefined);
|
||||||
|
setApproveDialog(null);
|
||||||
|
setApproveNotes('');
|
||||||
|
setSelectedItem(null);
|
||||||
|
fetchQueue();
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async () => {
|
||||||
|
if (!tokens?.accessToken || !rejectDialog || !rejectReason.trim()) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminApi.rejectKyc(tokens.accessToken, rejectDialog, rejectReason);
|
||||||
|
setRejectDialog(null);
|
||||||
|
setRejectReason('');
|
||||||
|
setSelectedItem(null);
|
||||||
|
fetchQueue();
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Duyệt KYC</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Xác minh danh tính người dùng và đại lý
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchQueue}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Làm mới
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_400px]">
|
||||||
|
{/* Table */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-48 flex-col items-center justify-center gap-2">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchQueue}>
|
||||||
|
Thử lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : !result || result.data.length === 0 ? (
|
||||||
|
<div className="flex h-48 flex-col items-center justify-center gap-2">
|
||||||
|
<ShieldCheck className="h-8 w-8 text-green-500" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Không có yêu cầu KYC nào đang chờ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Họ tên</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">SĐT</TableHead>
|
||||||
|
<TableHead>Vai trò</TableHead>
|
||||||
|
<TableHead>Trạng thái</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">Ngày gửi</TableHead>
|
||||||
|
<TableHead className="w-10"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{result.data.map((item) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.userId}
|
||||||
|
className={`cursor-pointer ${selectedItem?.userId === item.userId ? 'bg-muted/50' : ''}`}
|
||||||
|
onClick={() => setSelectedItem(item)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{item.fullName}</div>
|
||||||
|
{item.email && (
|
||||||
|
<div className="text-xs text-muted-foreground">{item.email}</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell">{item.phone}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{item.role}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{kycStatusBadge(item.kycStatus)}</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString('vi-VN')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{result.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t px-4 py-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Trang {result.page}/{result.totalPages} ({result.total} yêu cầu)
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={page >= result.totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Detail sidebar */}
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
{selectedItem ? (
|
||||||
|
<KycDetailView
|
||||||
|
item={selectedItem}
|
||||||
|
onApprove={() => {
|
||||||
|
setApproveDialog(selectedItem.userId);
|
||||||
|
setApproveNotes('');
|
||||||
|
}}
|
||||||
|
onReject={() => {
|
||||||
|
setRejectDialog(selectedItem.userId);
|
||||||
|
setRejectReason('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Chọn yêu cầu KYC để xem chi tiết
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Approve dialog */}
|
||||||
|
<Dialog open={!!approveDialog} onOpenChange={() => setApproveDialog(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Duyệt KYC</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Xác nhận danh tính người dùng đã được xác minh thành công.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
placeholder="Ghi chú (không bắt buộc)..."
|
||||||
|
value={approveNotes}
|
||||||
|
onChange={(e) => setApproveNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setApproveDialog(null)}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleApprove} disabled={actionLoading}>
|
||||||
|
{actionLoading ? 'Đang xử lý...' : 'Xác nhận duyệt'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Reject dialog */}
|
||||||
|
<Dialog open={!!rejectDialog} onOpenChange={() => setRejectDialog(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Từ chối KYC</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Vui lòng nhập lý do từ chối. Người dùng sẽ cần gửi lại hồ sơ.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
placeholder="Lý do từ chối (bắt buộc)..."
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRejectDialog(null)}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={actionLoading || !rejectReason.trim()}
|
||||||
|
>
|
||||||
|
{actionLoading ? 'Đang xử lý...' : 'Từ chối KYC'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
421
apps/web/app/(admin)/admin/moderation/page.tsx
Normal file
421
apps/web/app/(admin)/admin/moderation/page.tsx
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { adminApi, type ModerationQueueItem, type PaginatedResult } from '@/lib/admin-api';
|
||||||
|
|
||||||
|
function formatPrice(price: number): string {
|
||||||
|
if (price >= 1_000_000_000) {
|
||||||
|
return `${(price / 1_000_000_000).toFixed(1)} tỷ`;
|
||||||
|
}
|
||||||
|
if (price >= 1_000_000) {
|
||||||
|
return `${(price / 1_000_000).toFixed(0)} triệu`;
|
||||||
|
}
|
||||||
|
return price.toLocaleString('vi-VN');
|
||||||
|
}
|
||||||
|
|
||||||
|
function moderationScoreBadge(score: number | null) {
|
||||||
|
if (score === null) return <Badge variant="secondary">N/A</Badge>;
|
||||||
|
if (score >= 80) return <Badge variant="success">{score}</Badge>;
|
||||||
|
if (score >= 50) return <Badge variant="warning">{score}</Badge>;
|
||||||
|
return <Badge variant="destructive">{score}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminModerationPage() {
|
||||||
|
const { tokens } = useAuthStore();
|
||||||
|
const [result, setResult] = useState<PaginatedResult<ModerationQueueItem> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// Selected items for bulk
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Action dialogs
|
||||||
|
const [approveDialog, setApproveDialog] = useState<string | null>(null);
|
||||||
|
const [rejectDialog, setRejectDialog] = useState<string | null>(null);
|
||||||
|
const [approveNotes, setApproveNotes] = useState('');
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
// Bulk action
|
||||||
|
const [bulkAction, setBulkAction] = useState<'approve' | 'reject' | null>(null);
|
||||||
|
const [bulkReason, setBulkReason] = useState('');
|
||||||
|
|
||||||
|
const fetchQueue = useCallback(async () => {
|
||||||
|
if (!tokens?.accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getModerationQueue(tokens.accessToken, page, 20);
|
||||||
|
setResult(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Không thể tải hàng đợi');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [tokens?.accessToken, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchQueue();
|
||||||
|
}, [fetchQueue]);
|
||||||
|
|
||||||
|
const handleApprove = async () => {
|
||||||
|
if (!tokens?.accessToken || !approveDialog) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminApi.approveListing(tokens.accessToken, approveDialog, approveNotes || undefined);
|
||||||
|
setApproveDialog(null);
|
||||||
|
setApproveNotes('');
|
||||||
|
fetchQueue();
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async () => {
|
||||||
|
if (!tokens?.accessToken || !rejectDialog || !rejectReason.trim()) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminApi.rejectListing(tokens.accessToken, rejectDialog, rejectReason);
|
||||||
|
setRejectDialog(null);
|
||||||
|
setRejectReason('');
|
||||||
|
fetchQueue();
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAction = async () => {
|
||||||
|
if (!tokens?.accessToken || !bulkAction || selected.size === 0) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminApi.bulkModerate(
|
||||||
|
tokens.accessToken,
|
||||||
|
Array.from(selected),
|
||||||
|
bulkAction,
|
||||||
|
bulkReason || undefined,
|
||||||
|
);
|
||||||
|
setSelected(new Set());
|
||||||
|
setBulkAction(null);
|
||||||
|
setBulkReason('');
|
||||||
|
fetchQueue();
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (!result) return;
|
||||||
|
if (selected.size === result.data.length) {
|
||||||
|
setSelected(new Set());
|
||||||
|
} else {
|
||||||
|
setSelected(new Set(result.data.map((item) => item.listingId)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Kiểm duyệt tin đăng</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Duyệt hoặc từ chối các tin đăng chờ phê duyệt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setBulkAction('approve'); setBulkReason(''); }}
|
||||||
|
>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Duyệt ({selected.size})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => { setBulkAction('reject'); setBulkReason(''); }}
|
||||||
|
>
|
||||||
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
|
Từ chối ({selected.size})
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchQueue}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Làm mới
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-48 flex-col items-center justify-center gap-2">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchQueue}>
|
||||||
|
Thử lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : !result || result.data.length === 0 ? (
|
||||||
|
<div className="flex h-48 flex-col items-center justify-center gap-2">
|
||||||
|
<CheckCircle className="h-8 w-8 text-green-500" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Không có tin nào chờ kiểm duyệt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.size === result.data.length && result.data.length > 0}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Tiêu đề</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">Loại</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">Giá</TableHead>
|
||||||
|
<TableHead className="hidden lg:table-cell">Người đăng</TableHead>
|
||||||
|
<TableHead>Điểm AI</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">Ngày đăng</TableHead>
|
||||||
|
<TableHead className="text-right">Hành động</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{result.data.map((item) => (
|
||||||
|
<TableRow key={item.listingId}>
|
||||||
|
<TableCell>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(item.listingId)}
|
||||||
|
onChange={() => toggleSelect(item.listingId)}
|
||||||
|
className="rounded border-input"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium max-w-[200px] truncate">
|
||||||
|
{item.propertyTitle}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{item.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell">
|
||||||
|
<Badge variant="outline">{item.propertyType}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
{formatPrice(item.priceVND)} VND
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
<span className="text-sm">{item.sellerName}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{moderationScoreBadge(item.moderationScore)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
|
||||||
|
{new Date(item.createdAt).toLocaleDateString('vi-VN')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Duyệt"
|
||||||
|
onClick={() => {
|
||||||
|
setApproveDialog(item.listingId);
|
||||||
|
setApproveNotes('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
title="Từ chối"
|
||||||
|
onClick={() => {
|
||||||
|
setRejectDialog(item.listingId);
|
||||||
|
setRejectReason('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{result.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t px-4 py-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Trang {result.page}/{result.totalPages} ({result.total} tin)
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={page >= result.totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Approve dialog */}
|
||||||
|
<Dialog open={!!approveDialog} onOpenChange={() => setApproveDialog(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Duyệt tin đăng</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Tin đăng sẽ được hiển thị công khai sau khi duyệt.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
placeholder="Ghi chú (không bắt buộc)..."
|
||||||
|
value={approveNotes}
|
||||||
|
onChange={(e) => setApproveNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setApproveDialog(null)}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleApprove} disabled={actionLoading}>
|
||||||
|
{actionLoading ? 'Đang xử lý...' : 'Duyệt tin'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Reject dialog */}
|
||||||
|
<Dialog open={!!rejectDialog} onOpenChange={() => setRejectDialog(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Từ chối tin đăng</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Vui lòng nhập lý do từ chối. Người đăng sẽ nhận được thông báo.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
placeholder="Lý do từ chối (bắt buộc)..."
|
||||||
|
value={rejectReason}
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRejectDialog(null)}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={actionLoading || !rejectReason.trim()}
|
||||||
|
>
|
||||||
|
{actionLoading ? 'Đang xử lý...' : 'Từ chối'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Bulk action dialog */}
|
||||||
|
<Dialog open={!!bulkAction} onOpenChange={() => setBulkAction(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{bulkAction === 'approve' ? 'Duyệt hàng loạt' : 'Từ chối hàng loạt'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Thao tác sẽ áp dụng cho {selected.size} tin đăng đã chọn.
|
||||||
|
</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{bulkAction === 'reject' && (
|
||||||
|
<Input
|
||||||
|
placeholder="Lý do từ chối (bắt buộc)..."
|
||||||
|
value={bulkReason}
|
||||||
|
onChange={(e) => setBulkReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBulkAction(null)}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={bulkAction === 'reject' ? 'destructive' : 'default'}
|
||||||
|
onClick={handleBulkAction}
|
||||||
|
disabled={actionLoading || (bulkAction === 'reject' && !bulkReason.trim())}
|
||||||
|
>
|
||||||
|
{actionLoading
|
||||||
|
? 'Đang xử lý...'
|
||||||
|
: bulkAction === 'approve'
|
||||||
|
? `Duyệt ${selected.size} tin`
|
||||||
|
: `Từ chối ${selected.size} tin`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
apps/web/app/(admin)/admin/page.tsx
Normal file
226
apps/web/app/(admin)/admin/page.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Home,
|
||||||
|
ClipboardCheck,
|
||||||
|
Clock,
|
||||||
|
UserCheck,
|
||||||
|
ShieldCheck,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
TrendingUp,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { adminApi, type DashboardStats, type RevenueStatsItem } from '@/lib/admin-api';
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
icon: React.ElementType;
|
||||||
|
description?: string;
|
||||||
|
trend?: 'up' | 'down';
|
||||||
|
trendValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, icon: Icon, description, trend, trendValue }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{value.toLocaleString('vi-VN')}</div>
|
||||||
|
{(description || trendValue) && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
||||||
|
{trend === 'up' && <ArrowUpRight className="h-3 w-3 text-green-600" />}
|
||||||
|
{trend === 'down' && <ArrowDownRight className="h-3 w-3 text-red-600" />}
|
||||||
|
{trendValue && (
|
||||||
|
<span className={trend === 'up' ? 'text-green-600' : trend === 'down' ? 'text-red-600' : ''}>
|
||||||
|
{trendValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{description && <span>{description}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevenueChart({ data }: { data: RevenueStatsItem[] }) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Chưa có dữ liệu doanh thu
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRevenue = Math.max(...data.map((d) => d.totalRevenue), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.map((item) => {
|
||||||
|
const pct = (item.totalRevenue / maxRevenue) * 100;
|
||||||
|
return (
|
||||||
|
<div key={item.period} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">{item.period}</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{item.totalRevenue.toLocaleString('vi-VN')} VND
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>Subscription: {item.subscriptionRevenue.toLocaleString('vi-VN')}</span>
|
||||||
|
<span>Listing fee: {item.listingFeeRevenue.toLocaleString('vi-VN')}</span>
|
||||||
|
<span>{item.transactionCount} GD</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboardPage() {
|
||||||
|
const { tokens } = useAuthStore();
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [revenue, setRevenue] = useState<RevenueStatsItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!tokens?.accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const endDate = new Date().toISOString().split('T')[0]!;
|
||||||
|
const startDate = new Date(Date.now() - 180 * 86400000).toISOString().split('T')[0]!;
|
||||||
|
|
||||||
|
const [statsData, revenueData] = await Promise.all([
|
||||||
|
adminApi.getDashboardStats(tokens.accessToken),
|
||||||
|
adminApi.getRevenueStats(tokens.accessToken, startDate, endDate, 'month'),
|
||||||
|
]);
|
||||||
|
setStats(statsData);
|
||||||
|
setRevenue(revenueData);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Không thể tải dữ liệu');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [tokens?.accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-3">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||||
|
Thử lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Tổng quan hệ thống GoodGo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchData}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Làm mới
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title="Tổng người dùng"
|
||||||
|
value={stats.totalUsers}
|
||||||
|
icon={Users}
|
||||||
|
trend="up"
|
||||||
|
trendValue={`+${stats.newUsersLast30Days}`}
|
||||||
|
description="trong 30 ngày"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Tổng tin đăng"
|
||||||
|
value={stats.totalListings}
|
||||||
|
icon={Home}
|
||||||
|
trend="up"
|
||||||
|
trendValue={`+${stats.newListingsLast30Days}`}
|
||||||
|
description="trong 30 ngày"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Tin đang hoạt động"
|
||||||
|
value={stats.activeListings}
|
||||||
|
icon={ClipboardCheck}
|
||||||
|
description="đang hiển thị"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Chờ kiểm duyệt"
|
||||||
|
value={stats.pendingModerationCount}
|
||||||
|
icon={Clock}
|
||||||
|
description="tin đang chờ duyệt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
title="Tổng đại lý"
|
||||||
|
value={stats.totalAgents}
|
||||||
|
icon={UserCheck}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Đại lý đã xác minh"
|
||||||
|
value={stats.verifiedAgents}
|
||||||
|
icon={ShieldCheck}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Tổng giao dịch"
|
||||||
|
value={stats.totalTransactions}
|
||||||
|
icon={TrendingUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revenue chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Doanh thu 6 tháng gần nhất</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RevenueChart data={revenue} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
473
apps/web/app/(admin)/admin/users/page.tsx
Normal file
473
apps/web/app/(admin)/admin/users/page.tsx
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
UserX,
|
||||||
|
UserCheck,
|
||||||
|
Eye,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select } from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import {
|
||||||
|
adminApi,
|
||||||
|
type UserListItem,
|
||||||
|
type UserDetail,
|
||||||
|
type PaginatedResult,
|
||||||
|
} from '@/lib/admin-api';
|
||||||
|
|
||||||
|
function kycBadgeVariant(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'VERIFIED': return 'success' as const;
|
||||||
|
case 'PENDING': return 'warning' as const;
|
||||||
|
case 'REJECTED': return 'destructive' as const;
|
||||||
|
default: return 'secondary' as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleBadgeVariant(role: string) {
|
||||||
|
switch (role) {
|
||||||
|
case 'ADMIN': return 'default' as const;
|
||||||
|
case 'AGENT': return 'info' as const;
|
||||||
|
default: return 'secondary' as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserDetailPanel({
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
onToggleStatus,
|
||||||
|
}: {
|
||||||
|
user: UserDetail;
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleStatus: (userId: string, isActive: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{user.fullName}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{user.phone}</p>
|
||||||
|
{user.email && (
|
||||||
|
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose}>
|
||||||
|
<X className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Vai trò</div>
|
||||||
|
<Badge variant={roleBadgeVariant(user.role)} className="mt-1">{user.role}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">KYC</div>
|
||||||
|
<Badge variant={kycBadgeVariant(user.kycStatus)} className="mt-1">{user.kycStatus}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Trạng thái</div>
|
||||||
|
<Badge variant={user.isActive ? 'success' : 'destructive'} className="mt-1">
|
||||||
|
{user.isActive ? 'Hoạt động' : 'Bị khóa'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">Ngày tạo</div>
|
||||||
|
<div className="mt-1 text-sm font-medium">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString('vi-VN')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="rounded-md border p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold">{user.listingsCount}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Tin đăng</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold">{user.activeListingsCount}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Đang hiển thị</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold">{user.transactionsCount}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Giao dịch</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.subscription && (
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Gói đăng ký</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="info">{user.subscription.planTier}</Badge>
|
||||||
|
<Badge variant={user.subscription.status === 'active' ? 'success' : 'warning'}>
|
||||||
|
{user.subscription.status}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
đến {new Date(user.subscription.currentPeriodEnd).toLocaleDateString('vi-VN')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.recentActivity.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">Hoạt động gần đây</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{user.recentActivity.slice(0, 5).map((activity, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<div className="mt-0.5 h-2 w-2 rounded-full bg-muted-foreground flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span>{activity.description}</span>
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
{new Date(activity.createdAt).toLocaleDateString('vi-VN')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={user.isActive ? 'destructive' : 'default'}
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => onToggleStatus(user.id, !user.isActive)}
|
||||||
|
>
|
||||||
|
{user.isActive ? (
|
||||||
|
<>
|
||||||
|
<UserX className="mr-2 h-4 w-4" /> Khóa tài khoản
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserCheck className="mr-2 h-4 w-4" /> Mở khóa
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminUsersPage() {
|
||||||
|
const { tokens } = useAuthStore();
|
||||||
|
const [result, setResult] = useState<PaginatedResult<UserListItem> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
|
||||||
|
// Detail panel
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserDetail | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
// Ban dialog
|
||||||
|
const [banDialog, setBanDialog] = useState<{ userId: string; isActive: boolean } | null>(null);
|
||||||
|
const [banReason, setBanReason] = useState('');
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
if (!tokens?.accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getUsers(tokens.accessToken, {
|
||||||
|
page,
|
||||||
|
limit: 20,
|
||||||
|
role: roleFilter || undefined,
|
||||||
|
isActive: statusFilter === '' ? undefined : statusFilter === 'active',
|
||||||
|
search: search || undefined,
|
||||||
|
});
|
||||||
|
setResult(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Không thể tải danh sách');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [tokens?.accessToken, page, roleFilter, statusFilter, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, [fetchUsers]);
|
||||||
|
|
||||||
|
const openDetail = async (userId: string) => {
|
||||||
|
if (!tokens?.accessToken) return;
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const detail = await adminApi.getUserDetail(tokens.accessToken, userId);
|
||||||
|
setSelectedUser(detail);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = (userId: string, newActive: boolean) => {
|
||||||
|
setBanDialog({ userId, isActive: newActive });
|
||||||
|
setBanReason('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmToggleStatus = async () => {
|
||||||
|
if (!tokens?.accessToken || !banDialog) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminApi.banUser(
|
||||||
|
tokens.accessToken,
|
||||||
|
banDialog.userId,
|
||||||
|
banReason || 'Admin action',
|
||||||
|
banDialog.isActive, // unban = true if making active
|
||||||
|
);
|
||||||
|
setBanDialog(null);
|
||||||
|
setSelectedUser(null);
|
||||||
|
fetchUsers();
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPage(1);
|
||||||
|
fetchUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Quản lý người dùng</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Danh sách và quản lý tài khoản người dùng
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<form onSubmit={handleSearch} className="flex flex-1 gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Tìm theo tên, SĐT, email..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Tìm
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => { setRoleFilter(e.target.value); setPage(1); }}
|
||||||
|
className="w-40"
|
||||||
|
>
|
||||||
|
<option value="">Tất cả vai trò</option>
|
||||||
|
<option value="USER">Người dùng</option>
|
||||||
|
<option value="AGENT">Đại lý</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||||
|
className="w-40"
|
||||||
|
>
|
||||||
|
<option value="">Tất cả trạng thái</option>
|
||||||
|
<option value="active">Hoạt động</option>
|
||||||
|
<option value="inactive">Bị khóa</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_380px]">
|
||||||
|
{/* Table */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex h-48 flex-col items-center justify-center gap-2">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchUsers}>
|
||||||
|
Thử lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : !result || result.data.length === 0 ? (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Không tìm thấy người dùng nào
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Họ tên</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">SĐT</TableHead>
|
||||||
|
<TableHead>Vai trò</TableHead>
|
||||||
|
<TableHead>KYC</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">Trạng thái</TableHead>
|
||||||
|
<TableHead className="w-10"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{result.data.map((user) => (
|
||||||
|
<TableRow
|
||||||
|
key={user.id}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => openDetail(user.id)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{user.fullName}</div>
|
||||||
|
{user.email && (
|
||||||
|
<div className="text-xs text-muted-foreground">{user.email}</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell">{user.phone}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={roleBadgeVariant(user.role)}>{user.role}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={kycBadgeVariant(user.kycStatus)}>{user.kycStatus}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<Badge variant={user.isActive ? 'success' : 'destructive'}>
|
||||||
|
{user.isActive ? 'Hoạt động' : 'Bị khóa'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{result.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between border-t px-4 py-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Trang {result.page}/{result.totalPages} ({result.total} người dùng)
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={page >= result.totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Detail sidebar */}
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : selectedUser ? (
|
||||||
|
<UserDetailPanel
|
||||||
|
user={selectedUser}
|
||||||
|
onClose={() => setSelectedUser(null)}
|
||||||
|
onToggleStatus={handleToggleStatus}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Chọn người dùng để xem chi tiết
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile detail dialog */}
|
||||||
|
<Dialog open={!!selectedUser && typeof window !== 'undefined' && window.innerWidth < 1024} onOpenChange={() => setSelectedUser(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
{selectedUser && (
|
||||||
|
<UserDetailPanel
|
||||||
|
user={selectedUser}
|
||||||
|
onClose={() => setSelectedUser(null)}
|
||||||
|
onToggleStatus={handleToggleStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Ban/unban confirmation */}
|
||||||
|
<Dialog open={!!banDialog} onOpenChange={() => setBanDialog(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{banDialog?.isActive ? 'Mở khóa tài khoản' : 'Khóa tài khoản'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{banDialog?.isActive
|
||||||
|
? 'Người dùng sẽ có thể đăng nhập và sử dụng hệ thống.'
|
||||||
|
: 'Người dùng sẽ không thể đăng nhập và các tin đăng sẽ bị ẩn.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
placeholder="Lý do..."
|
||||||
|
value={banReason}
|
||||||
|
onChange={(e) => setBanReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setBanDialog(null)}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={banDialog?.isActive ? 'default' : 'destructive'}
|
||||||
|
onClick={confirmToggleStatus}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
{actionLoading ? 'Đang xử lý...' : 'Xác nhận'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
apps/web/app/(admin)/layout.tsx
Normal file
138
apps/web/app/(admin)/layout.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
ClipboardList,
|
||||||
|
ShieldCheck,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const adminNavItems = [
|
||||||
|
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ href: '/admin/users', label: 'Qu\u1EA3n l\u00FD ng\u01B0\u1EDDi d\u00F9ng', icon: Users },
|
||||||
|
{ href: '/admin/moderation', label: 'Ki\u1EC3m duy\u1EC7t tin', icon: ClipboardList },
|
||||||
|
{ href: '/admin/kyc', label: 'Duy\u1EC7t KYC', icon: ShieldCheck },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.role !== 'ADMIN') {
|
||||||
|
router.replace('/dashboard');
|
||||||
|
}
|
||||||
|
}, [user, router]);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<div className="text-muted-foreground">Đang tải...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== 'ADMIN') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background">
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-y-0 left-0 z-50 w-64 border-r bg-card transition-transform lg:static lg:translate-x-0',
|
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-14 items-center border-b px-4">
|
||||||
|
<Link href="/admin" className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-bold text-primary">GoodGo</span>
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs font-semibold text-primary">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="ml-auto lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-1 p-3">
|
||||||
|
{adminNavItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive =
|
||||||
|
item.href === '/admin'
|
||||||
|
? pathname === '/admin'
|
||||||
|
: pathname.startsWith(item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto border-t p-3">
|
||||||
|
<div className="mb-2 px-3 text-xs text-muted-foreground truncate">
|
||||||
|
{user.fullName}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start gap-2"
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Đăng xuất
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<header className="sticky top-0 z-30 flex h-14 items-center border-b bg-background/95 px-4 backdrop-blur lg:hidden">
|
||||||
|
<button onClick={() => setSidebarOpen(true)}>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<span className="ml-3 text-sm font-semibold">GoodGo Admin</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 p-4 md:p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
apps/web/components/ui/dialog.tsx
Normal file
84
apps/web/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dialog({ open, onOpenChange, children }: DialogProps) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/80 animate-in fade-in-0"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg animate-in fade-in-0 zoom-in-95',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = 'DialogContent';
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return (
|
||||||
|
<h2 className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return (
|
||||||
|
<p className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter };
|
||||||
74
apps/web/components/ui/table.tsx
Normal file
74
apps/web/components/ui/table.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn('w-full caption-bottom text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Table.displayName = 'Table';
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = 'TableHeader';
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||||
|
));
|
||||||
|
TableBody.displayName = 'TableBody';
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TableRow.displayName = 'TableRow';
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = 'TableHead';
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = 'TableCell';
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
|
||||||
183
apps/web/lib/admin-api.ts
Normal file
183
apps/web/lib/admin-api.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { apiClient } from './api-client';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalUsers: number;
|
||||||
|
totalListings: number;
|
||||||
|
activeListings: number;
|
||||||
|
pendingModerationCount: number;
|
||||||
|
totalAgents: number;
|
||||||
|
verifiedAgents: number;
|
||||||
|
totalTransactions: number;
|
||||||
|
newUsersLast30Days: number;
|
||||||
|
newListingsLast30Days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueStatsItem {
|
||||||
|
period: string;
|
||||||
|
totalRevenue: number;
|
||||||
|
subscriptionRevenue: number;
|
||||||
|
listingFeeRevenue: number;
|
||||||
|
featuredListingRevenue: number;
|
||||||
|
transactionCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModerationQueueItem {
|
||||||
|
listingId: string;
|
||||||
|
propertyTitle: string;
|
||||||
|
propertyType: string;
|
||||||
|
transactionType: string;
|
||||||
|
priceVND: number;
|
||||||
|
sellerName: string;
|
||||||
|
sellerId: string;
|
||||||
|
moderationScore: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserListItem {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string;
|
||||||
|
fullName: string;
|
||||||
|
role: string;
|
||||||
|
kycStatus: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDetail {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string;
|
||||||
|
fullName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
role: string;
|
||||||
|
kycStatus: string;
|
||||||
|
kycData: unknown;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
listingsCount: number;
|
||||||
|
activeListingsCount: number;
|
||||||
|
transactionsCount: number;
|
||||||
|
subscription: {
|
||||||
|
planTier: string;
|
||||||
|
status: string;
|
||||||
|
currentPeriodEnd: string;
|
||||||
|
} | null;
|
||||||
|
recentActivity: Array<{
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KycQueueItem {
|
||||||
|
userId: string;
|
||||||
|
fullName: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string;
|
||||||
|
role: string;
|
||||||
|
kycStatus: string;
|
||||||
|
kycData: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API ──
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
// Dashboard
|
||||||
|
getDashboardStats: (token: string) =>
|
||||||
|
apiClient.authGet<DashboardStats>('/admin/dashboard', token),
|
||||||
|
|
||||||
|
getRevenueStats: (token: string, startDate: string, endDate: string, groupBy: 'day' | 'month' = 'month') =>
|
||||||
|
apiClient.authGet<RevenueStatsItem[]>(
|
||||||
|
`/admin/revenue?startDate=${startDate}&endDate=${endDate}&groupBy=${groupBy}`,
|
||||||
|
token,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Moderation
|
||||||
|
getModerationQueue: (token: string, page = 1, limit = 20) =>
|
||||||
|
apiClient.authGet<PaginatedResult<ModerationQueueItem>>(
|
||||||
|
`/admin/moderation?page=${page}&limit=${limit}`,
|
||||||
|
token,
|
||||||
|
),
|
||||||
|
|
||||||
|
approveListing: (token: string, listingId: string, moderationNotes?: string) =>
|
||||||
|
apiClient.authPost<{ success: boolean }>('/admin/moderation/approve', token, {
|
||||||
|
listingId,
|
||||||
|
moderationNotes,
|
||||||
|
}),
|
||||||
|
|
||||||
|
rejectListing: (token: string, listingId: string, reason: string) =>
|
||||||
|
apiClient.authPost<{ success: boolean }>('/admin/moderation/reject', token, {
|
||||||
|
listingId,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
|
||||||
|
bulkModerate: (token: string, listingIds: string[], action: 'approve' | 'reject', reason?: string) =>
|
||||||
|
apiClient.authPost<{ success: boolean }>('/admin/moderation/bulk', token, {
|
||||||
|
listingIds,
|
||||||
|
action,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Users
|
||||||
|
getUsers: (token: string, params: { page?: number; limit?: number; role?: string; isActive?: boolean; search?: string } = {}) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.page) query.set('page', String(params.page));
|
||||||
|
if (params.limit) query.set('limit', String(params.limit));
|
||||||
|
if (params.role) query.set('role', params.role);
|
||||||
|
if (params.isActive !== undefined) query.set('isActive', String(params.isActive));
|
||||||
|
if (params.search) query.set('search', params.search);
|
||||||
|
return apiClient.authGet<PaginatedResult<UserListItem>>(
|
||||||
|
`/admin/users?${query.toString()}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
getUserDetail: (token: string, userId: string) =>
|
||||||
|
apiClient.authGet<UserDetail>(`/admin/users/${userId}`, token),
|
||||||
|
|
||||||
|
updateUserStatus: (token: string, userId: string, isActive: boolean, reason?: string) =>
|
||||||
|
apiClient.authPost<{ success: boolean }>('/admin/users/status', token, {
|
||||||
|
userId,
|
||||||
|
isActive,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
|
||||||
|
banUser: (token: string, userId: string, reason: string, unban = false) =>
|
||||||
|
apiClient.authPost<{ success: boolean }>('/admin/users/ban', token, {
|
||||||
|
userId,
|
||||||
|
reason,
|
||||||
|
unban,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// KYC
|
||||||
|
getKycQueue: (token: string, page = 1, limit = 20) =>
|
||||||
|
apiClient.authGet<PaginatedResult<KycQueueItem>>(
|
||||||
|
`/admin/kyc?page=${page}&limit=${limit}`,
|
||||||
|
token,
|
||||||
|
),
|
||||||
|
|
||||||
|
approveKyc: (token: string, userId: string, notes?: string) =>
|
||||||
|
apiClient.authPost<{ success: boolean }>('/admin/kyc/approve', token, {
|
||||||
|
userId,
|
||||||
|
notes,
|
||||||
|
}),
|
||||||
|
|
||||||
|
rejectKyc: (token: string, userId: string, reason: string) =>
|
||||||
|
apiClient.authPost<{ success: boolean }>('/admin/kyc/reject', token, {
|
||||||
|
userId,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user