mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 18:23:11 +07:00
thực hiện phân quyền admin giáo viên và học sinh xong
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import type { User, UserRole, UserStatus } from "@/types";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_LABELS: Record<UserStatus, string> = {
|
||||
pending: "Chờ duyệt",
|
||||
approved: "Đã duyệt",
|
||||
rejected: "Từ chối",
|
||||
};
|
||||
|
||||
const ROLE_LABELS: Record<UserRole, string> = {
|
||||
admin: "Admin",
|
||||
teacher: "Giáo viên",
|
||||
student: "Học viên",
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<UserStatus, string> = {
|
||||
pending: "bg-yellow-100 text-yellow-800",
|
||||
approved: "bg-green-100 text-green-800",
|
||||
rejected: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
const ROLE_COLORS: Record<UserRole, string> = {
|
||||
admin: "bg-purple-100 text-purple-800",
|
||||
teacher: "bg-blue-100 text-blue-800",
|
||||
student: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
const [me, setMe] = useState<User | null>(null);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState<number | null>(null); // userId being mutated
|
||||
const [filterStatus, setFilterStatus] = useState<UserStatus | "all">("all");
|
||||
const [filterRole, setFilterRole] = useState<UserRole | "all">("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// ── Auth guard: admin only ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
const me = await api.me();
|
||||
if (me.role !== "admin") { router.replace("/dashboard"); return; }
|
||||
setMe(me);
|
||||
const list = await api.adminListUsers();
|
||||
setUsers(list);
|
||||
} catch {
|
||||
router.replace("/login");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [router]);
|
||||
|
||||
// ── Mutate helpers ─────────────────────────────────────────────────────────
|
||||
const updateUser = useCallback((updated: User) => {
|
||||
setUsers(prev => prev.map(u => u.id === updated.id ? updated : u));
|
||||
}, []);
|
||||
|
||||
const handleStatusChange = useCallback(async (userId: number, status: UserStatus) => {
|
||||
setBusy(userId);
|
||||
try {
|
||||
const updated = await api.adminUpdateStatus(userId, status);
|
||||
updateUser(updated);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update status.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [updateUser]);
|
||||
|
||||
const handleRoleChange = useCallback(async (userId: number, role: UserRole) => {
|
||||
setBusy(userId);
|
||||
try {
|
||||
const updated = await api.adminUpdateRole(userId, role);
|
||||
updateUser(updated);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update role.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [updateUser]);
|
||||
|
||||
const handleDelete = useCallback(async (userId: number, username: string) => {
|
||||
if (!confirm(`Xoá tài khoản "${username}" và toàn bộ dữ liệu? Không thể hoàn tác.`)) return;
|
||||
setBusy(userId);
|
||||
try {
|
||||
await api.adminDeleteUser(userId);
|
||||
setUsers(prev => prev.filter(u => u.id !== userId));
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to delete user.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Filtered list ──────────────────────────────────────────────────────────
|
||||
const filtered = users.filter(u => {
|
||||
if (filterStatus !== "all" && u.status !== filterStatus) return false;
|
||||
if (filterRole !== "all" && u.role !== filterRole) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
if (!u.username.toLowerCase().includes(q) && !u.email.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const pendingCount = users.filter(u => u.status === "pending").length;
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<svg className="animate-spin h-8 w-8 text-blue-500" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* ── Navbar ──────────────────────────────────────────────────────────── */}
|
||||
<header className="bg-white border-b shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="text-gray-400 hover:text-blue-600 transition"
|
||||
title="Về Dashboard"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="font-bold text-lg text-blue-600">PDF LMS</span>
|
||||
<span className="text-xs bg-purple-100 text-purple-700 font-semibold px-2 py-0.5 rounded-full">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500 hidden sm:block">{me?.username}</span>
|
||||
<button
|
||||
onClick={async () => { await api.logout(); router.replace("/login"); }}
|
||||
className="text-sm text-gray-500 hover:text-red-600 transition"
|
||||
>
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* ── Page title + stats ─────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Quản lý người dùng</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{users.length} tài khoản tổng cộng</p>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-2 bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-2 rounded-lg text-sm font-medium cursor-pointer hover:bg-yellow-100 transition"
|
||||
onClick={() => setFilterStatus("pending")}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
{pendingCount} tài khoản chờ duyệt
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Error banner ──────────────────────────────────────────────────── */}
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-lg flex items-center justify-between">
|
||||
{error}
|
||||
<button onClick={() => setError("")} className="ml-3 text-red-400 hover:text-red-600">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filters ───────────────────────────────────────────────────────── */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 mb-4 flex flex-col sm:flex-row gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tìm theo username / email…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="flex-1 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={e => setFilterStatus(e.target.value as UserStatus | "all")}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="all">Tất cả trạng thái</option>
|
||||
<option value="pending">Chờ duyệt</option>
|
||||
<option value="approved">Đã duyệt</option>
|
||||
<option value="rejected">Từ chối</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterRole}
|
||||
onChange={e => setFilterRole(e.target.value as UserRole | "all")}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="all">Tất cả vai trò</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="teacher">Giáo viên</option>
|
||||
<option value="student">Học viên</option>
|
||||
</select>
|
||||
{(filterStatus !== "all" || filterRole !== "all" || search) && (
|
||||
<button
|
||||
onClick={() => { setFilterStatus("all"); setFilterRole("all"); setSearch(""); }}
|
||||
className="text-sm text-gray-500 hover:text-red-600 transition px-2"
|
||||
>
|
||||
Xoá lọc
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Table ────────────────────────────────────────────────────────── */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center text-gray-400 text-sm py-16">Không tìm thấy tài khoản nào.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-4 py-3">ID</th>
|
||||
<th className="px-4 py-3">Người dùng</th>
|
||||
<th className="px-4 py-3">Vai trò</th>
|
||||
<th className="px-4 py-3">Trạng thái</th>
|
||||
<th className="px-4 py-3">Ngày đăng ký</th>
|
||||
<th className="px-4 py-3 text-right">Hành động</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filtered.map(u => {
|
||||
const isSelf = u.id === me?.id;
|
||||
const isLoading = busy === u.id;
|
||||
return (
|
||||
<tr key={u.id} className="hover:bg-gray-50 transition">
|
||||
{/* ID */}
|
||||
<td className="px-4 py-3 text-gray-400 tabular-nums">{u.id}</td>
|
||||
|
||||
{/* User info */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900">{u.username}</div>
|
||||
<div className="text-xs text-gray-400">{u.email}</div>
|
||||
</td>
|
||||
|
||||
{/* Role selector */}
|
||||
<td className="px-4 py-3">
|
||||
{isSelf ? (
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${ROLE_COLORS[u.role]}`}>
|
||||
{ROLE_LABELS[u.role]}
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
value={u.role}
|
||||
disabled={isLoading}
|
||||
onChange={e => handleRoleChange(u.id, e.target.value as UserRole)}
|
||||
className={`text-xs border rounded-lg px-2 py-1 focus:outline-none focus:border-blue-500 disabled:opacity-50 ${ROLE_COLORS[u.role]} border-transparent`}
|
||||
>
|
||||
<option value="student">Học viên</option>
|
||||
<option value="teacher">Giáo viên</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Status selector */}
|
||||
<td className="px-4 py-3">
|
||||
{isSelf ? (
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${STATUS_COLORS[u.status]}`}>
|
||||
{STATUS_LABELS[u.status]}
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
value={u.status}
|
||||
disabled={isLoading}
|
||||
onChange={e => handleStatusChange(u.id, e.target.value as UserStatus)}
|
||||
className={`text-xs border rounded-lg px-2 py-1 focus:outline-none focus:border-blue-500 disabled:opacity-50 ${STATUS_COLORS[u.status]} border-transparent`}
|
||||
>
|
||||
<option value="pending">Chờ duyệt</option>
|
||||
<option value="approved">Duyệt</option>
|
||||
<option value="rejected">Từ chối</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Created at */}
|
||||
<td className="px-4 py-3 text-gray-400 tabular-nums whitespace-nowrap">
|
||||
{new Date(u.created_at).toLocaleDateString("vi-VN")}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isLoading ? (
|
||||
<svg className="animate-spin h-4 w-4 text-blue-500 ml-auto" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : isSelf ? (
|
||||
<span className="text-xs text-gray-300">—</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleDelete(u.id, u.username)}
|
||||
className="text-xs text-red-400 hover:text-red-600 hover:bg-red-50 px-2 py-1 rounded transition"
|
||||
>
|
||||
Xoá
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user