Admin action handlers (ban/unban, approve/reject listings, KYC actions) previously swallowed errors silently. Admins now see inline error messages when API calls fail, with dismiss buttons. Co-Authored-By: Paperclip <noreply@paperclip.ing>
484 lines
17 KiB
TypeScript
484 lines
17 KiB
TypeScript
'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 [actionError, setActionError] = useState<string | null>(null);
|
|
|
|
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 (e) {
|
|
setActionError(e instanceof Error ? e.message : 'Không thể tải chi tiết người dùng');
|
|
} 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 (e) {
|
|
setActionError(e instanceof Error ? e.message : 'Thao tác thất bại. Vui lòng thử lại.');
|
|
} 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>
|
|
|
|
{actionError && (
|
|
<div className="flex items-center justify-between rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
|
<span>{actionError}</span>
|
|
<button onClick={() => setActionError(null)} className="ml-2">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</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>
|
|
);
|
|
}
|