dptxa-proxy / src /pages /admin /nguoi-dung.astro
TXAVLOG
Deploy DPTXA to Hugging Face Spaces
4bea261
Raw
History Blame Contribute Delete
45.1 kB
---
import AdminLayout from '../../layouts/AdminLayout.astro';
import { txasupabase } from '../../lib/txasupabase.js';
// 1. Bảo mật: Kiểm tra quyền truy cập Admin trước khi render trang
const token = Astro.cookies.get('auth_token')?.value;
let currentUser = null;
if (token) {
try {
currentUser = JSON.parse(Buffer.from(token, 'base64').toString('utf-8'));
} catch (e) {}
}
if (!currentUser || currentUser.role !== 'admin') {
return Astro.redirect('/dang-nhap');
}
// 2. Lấy danh sách người dùng từ database Supabase
const usersList = await txasupabase.getUsers();
// Thống kê nhanh ban đầu
const total = usersList.length;
const admins = usersList.filter(u => u.role === 'admin').length;
const normals = usersList.filter(u => u.role === 'user').length;
const newLast7Days = usersList.filter(u => {
if (!u.created_at) return false;
const d = new Date(u.created_at);
const diffTime = Math.abs(Date.now() - d.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays <= 7;
}).length;
---
<AdminLayout title="Quản lý Người Dùng & Phân Quyền">
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4 animate-fade-in">
<div>
<h1 class="text-4xl font-black tracking-tighter text-white">Quản lý Thành Viên</h1>
<p class="mt-2 text-gray-500">Quản lý tài khoản đăng ký, phân quyền quản trị và định vị thành viên trên hệ thống TPHIMX.</p>
</div>
<div class="flex flex-wrap gap-3">
<button id="create-user-btn" class="flex items-center gap-2 rounded-2xl bg-primary px-6 py-3.5 text-xs font-black uppercase tracking-widest text-dark hover:scale-105 transition-all shadow-[0_0_20px_var(--primary)] cursor-pointer">
<i class="fas fa-plus"></i>
Thêm người dùng mới
</button>
</div>
</div>
<!-- Thống kê Stats Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8 animate-fade-in">
<div class="glass-card rounded-[24px] border border-white/5 p-6 relative overflow-hidden">
<div class="absolute -right-6 -bottom-6 text-white/5 text-6xl font-black rotate-12"><i class="fas fa-users"></i></div>
<span class="text-[10px] font-black uppercase tracking-wider text-gray-500 block">Tổng thành viên</span>
<span class="text-3xl font-black text-white mt-1 block" id="stats-total">{total}</span>
</div>
<div class="glass-card rounded-[24px] border border-white/5 p-6 relative overflow-hidden">
<div class="absolute -right-6 -bottom-6 text-violet-500/5 text-6xl font-black rotate-12"><i class="fas fa-user-shield"></i></div>
<span class="text-[10px] font-black uppercase tracking-wider text-violet-500/60 block">Quản trị viên</span>
<span class="text-3xl font-black text-violet-400 mt-1 block" id="stats-admins">{admins}</span>
</div>
<div class="glass-card rounded-[24px] border border-white/5 p-6 relative overflow-hidden">
<div class="absolute -right-6 -bottom-6 text-blue-500/5 text-6xl font-black rotate-12"><i class="fas fa-user"></i></div>
<span class="text-[10px] font-black uppercase tracking-wider text-blue-500/60 block">Thành viên thường</span>
<span class="text-3xl font-black text-blue-400 mt-1 block" id="stats-normals">{normals}</span>
</div>
<div class="glass-card rounded-[24px] border border-white/5 p-6 relative overflow-hidden">
<div class="absolute -right-6 -bottom-6 text-emerald-500/5 text-6xl font-black rotate-12"><i class="fas fa-user-plus"></i></div>
<span class="text-[10px] font-black uppercase tracking-wider text-emerald-500/60 block">Mới (7 ngày)</span>
<span class="text-3xl font-black text-emerald-400 mt-1 block" id="stats-new">{newLast7Days}</span>
</div>
</div>
<!-- Danh sách chi tiết -->
<div class="glass-card rounded-[35px] border border-white/5 p-6 md:p-8 space-y-6 animate-fade-in">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-white/5 pb-6">
<span class="text-xs font-black uppercase tracking-widest text-primary"><i class="fas fa-user-cog"></i> Danh sách tài khoản người dùng</span>
<!-- Search & Filter Controls -->
<div class="flex flex-wrap items-center gap-3">
<div class="relative w-full sm:w-64">
<i class="fas fa-search absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></i>
<input type="text" id="user-search" placeholder="Tìm tên tài khoản, email..." class="w-full bg-[#12141d] border border-white/5 rounded-2xl pl-11 pr-4 py-2.5 text-xs text-white focus:outline-none focus:border-primary/50 transition-all font-bold" />
</div>
<select id="role-filter" class="bg-[#12141d] border border-white/5 rounded-2xl px-4 py-2.5 text-xs text-gray-400 font-bold focus:outline-none focus:border-primary/50 cursor-pointer">
<option value="all">Tất cả vai trò</option>
<option value="admin">Quản trị viên</option>
<option value="user">Người dùng thường</option>
</select>
</div>
</div>
<!-- Table Container -->
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse min-w-[900px]">
<thead>
<tr class="border-b border-white/5 text-[10px] font-black uppercase tracking-widest text-gray-500">
<th class="pb-4 w-12 text-center pl-4">
<label class="relative flex items-center justify-center cursor-pointer" data-txatooltip="Chọn toàn bộ danh sách">
<input type="checkbox" id="chk-all" class="peer sr-only" />
<div class="h-5 w-5 rounded-lg border border-white/10 bg-[#12141d] flex items-center justify-center transition-all peer-checked:bg-primary peer-checked:border-primary">
<i class="fas fa-check text-[10px] text-dark opacity-0 peer-checked:opacity-100 transition-opacity"></i>
</div>
</label>
</th>
<th class="pb-4">Tài khoản</th>
<th class="pb-4">Email</th>
<th class="pb-4">Thành Phố / Xã Phường</th>
<th class="pb-4">Ngày tham gia</th>
<th class="pb-4">Vai trò</th>
<th class="pb-4 text-right pr-4">Hành động</th>
</tr>
</thead>
<tbody id="user-tbody" class="divide-y divide-white/5 text-xs font-medium">
<!-- Nạp động qua JS -->
</tbody>
</table>
</div>
<!-- Empty state -->
<div id="user-empty" class="hidden py-16 text-center text-gray-500 flex flex-col items-center justify-center">
<div class="h-16 w-16 rounded-full bg-white/5 flex items-center justify-center mb-4 border border-white/5">
<i class="fas fa-users-slash text-2xl opacity-20"></i>
</div>
<p class="text-xs font-black uppercase tracking-widest">Không tìm thấy thành viên nào</p>
<p class="text-[10px] text-gray-600 mt-2">Thử thay đổi bộ lọc hoặc từ khóa tìm kiếm của bạn.</p>
</div>
</div>
<!-- Floating Bulk Action Pill (Viên thuốc nổi xử lý đồng loạt) -->
<div id="bulk-pill" class="fixed bottom-10 left-1/2 -translate-x-1/2 z-50 transform translate-y-28 opacity-0 pointer-events-none transition-all duration-500 ease-out bg-[#0d0e15]/95 border border-primary/30 rounded-full py-4 px-6 flex items-center gap-6 shadow-[0_0_50px_rgba(124,58,237,0.35)] backdrop-blur-xl">
<div class="flex items-center gap-2 border-r border-white/10 pr-4">
<span class="h-2.5 w-2.5 rounded-full bg-primary animate-ping"></span>
<span class="text-xs font-black uppercase tracking-widest text-primary">Đã chọn: <span id="selected-count-pill" class="text-white font-mono font-black text-sm">0</span></span>
</div>
<div class="flex items-center gap-2">
<button id="pill-role-admin" class="flex items-center gap-2 rounded-full bg-violet-500/10 hover:bg-violet-500 border border-violet-500/20 text-violet-400 hover:text-white px-5 py-2.5 text-[9px] font-black uppercase tracking-widest transition-all active:scale-95 cursor-pointer" data-txatooltip="Nâng cấp toàn bộ thành viên được chọn lên Quản trị viên">
<i class="fas fa-user-shield" id="pill-role-admin-icon"></i> <span id="pill-role-admin-text">Lên Admin</span>
</button>
<button id="pill-role-user" class="flex items-center gap-2 rounded-full bg-blue-500/10 hover:bg-blue-500 border border-blue-500/20 text-blue-400 hover:text-white px-5 py-2.5 text-[9px] font-black uppercase tracking-widest transition-all active:scale-95 cursor-pointer" data-txatooltip="Hạ cấp toàn bộ thành viên được chọn xuống Thành viên thường">
<i class="fas fa-user" id="pill-role-user-icon"></i> <span id="pill-role-user-text">Xuống User</span>
</button>
<button id="pill-delete" class="flex items-center gap-2 rounded-full bg-red-500/10 hover:bg-red-500 border border-red-500/20 text-red-400 hover:text-white px-5 py-2.5 text-[9px] font-black uppercase tracking-widest transition-all active:scale-95 cursor-pointer" data-txatooltip="Xóa vĩnh viễn toàn bộ tài khoản đã chọn khỏi hệ thống">
<i class="fas fa-trash-alt" id="pill-delete-icon"></i> <span id="pill-delete-text">Xóa tài khoản</span>
</button>
</div>
<button id="pill-cancel" class="text-gray-400 hover:text-white text-[10px] font-black uppercase tracking-widest transition-all border border-transparent hover:border-white/10 px-4 py-2 rounded-full cursor-pointer" data-txatooltip="Hủy chọn toàn bộ">Hủy</button>
</div>
</AdminLayout>
<script is:inline define:vars={{ initialUsers: usersList, currentAdminUsername: currentUser.username }}>
let usersListLocal = initialUsers || [];
let filterSearch = '';
let filterRole = 'all';
let locationsCache = null;
// Tải danh sách tỉnh thành và lưu cache
async function fetchLocations() {
if (locationsCache) return locationsCache;
try {
const res = await fetch('/locations.json');
locationsCache = await res.json();
return locationsCache;
} catch (err) {
console.error('Lỗi khi tải locations.json:', err);
return null;
}
}
// Hàm dự phòng định dạng thời gian nếu thư viện txaformat tải chậm
function formatTimeFallback(isoString) {
if (!isoString) return '';
const date = new Date(isoString);
return date.toLocaleString('vi-VN', {
hour: '2-digit',
minute: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
// Cập nhật các thống kê trực tiếp trên UI
function updateStats() {
const total = usersListLocal.length;
const admins = usersListLocal.filter(u => u.role === 'admin').length;
const normals = usersListLocal.filter(u => u.role === 'user').length;
// Thống kê thành viên mới trong 7 ngày qua
const newLast7Days = usersListLocal.filter(u => {
if (!u.created_at) return false;
const d = new Date(u.created_at);
const diffTime = Math.abs(Date.now() - d.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays <= 7;
}).length;
const elTotal = document.getElementById('stats-total');
const elAdmins = document.getElementById('stats-admins');
const elNormals = document.getElementById('stats-normals');
const elNew = document.getElementById('stats-new');
if (elTotal) elTotal.textContent = total;
if (elAdmins) elAdmins.textContent = admins;
if (elNormals) elNormals.textContent = normals;
if (elNew) elNew.textContent = newLast7Days;
}
function renderTable() {
const tbody = document.getElementById('user-tbody');
const emptyDiv = document.getElementById('user-empty');
if (!tbody || !emptyDiv) return;
// Bộ lọc logic
const filtered = usersListLocal.filter(item => {
const matchSearch = item.username.toLowerCase().includes(filterSearch.toLowerCase()) ||
(item.email && item.email.toLowerCase().includes(filterSearch.toLowerCase()));
const matchRole = filterRole === 'all' || item.role === filterRole;
return matchSearch && matchRole;
});
// Sắp xếp: Admin hiển thị lên trước, sau đó sắp xếp theo ngày tham gia mới nhất
filtered.sort((a, b) => {
if (a.role === 'admin' && b.role !== 'admin') return -1;
if (a.role !== 'admin' && b.role === 'admin') return 1;
return new Date(b.created_at) - new Date(a.created_at);
});
if (filtered.length === 0) {
tbody.innerHTML = '';
emptyDiv.classList.remove('hidden');
updateBulkPill();
return;
}
emptyDiv.classList.add('hidden');
tbody.innerHTML = filtered.map(item => {
let roleBadge = '';
if (item.role === 'admin') {
roleBadge = `
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border bg-violet-500/10 border-violet-500/20 text-violet-400 shadow-[0_0_12px_rgba(139,92,246,0.15)]">
<i class="fas fa-user-shield"></i> Admin
</span>
`;
} else {
roleBadge = `
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-[9px] font-black uppercase tracking-widest border bg-blue-500/10 border-blue-500/20 text-blue-400">
<i class="fas fa-user"></i> User
</span>
`;
}
// Format khu vực (tỉnh, xã)
const location = [item.ward, item.province].filter(Boolean).join(', ') || 'Chưa cấu hình';
// Sử dụng txaformat để hiển thị thời gian đăng ký
const relativeTime = window.txaformat ? window.txaformat.relativeTime(item.created_at) : formatTimeFallback(item.created_at);
const fullTime = window.txaformat ? window.txaformat.dateTime(item.created_at) : formatTimeFallback(item.created_at);
// Check xem có phải là tài khoản Admin hiện tại hay không để khóa thao tác tự xóa/tự hạ quyền
const isSelf = item.username === currentAdminUsername;
// Nút chỉnh sửa (Edit - Khôi phục CRUD)
const editBtn = `
<button
class="action-btn-edit btn-edit h-8 w-8 rounded-xl bg-amber-500/10 hover:bg-amber-500 hover:text-black flex items-center justify-center text-amber-500 border border-amber-500/20 active:scale-95 transition-all cursor-pointer"
data-username="${item.username}"
data-email="${item.email || ''}"
data-province="${item.province || ''}"
data-ward="${item.ward || ''}"
data-role="${item.role || 'user'}"
data-avatar="${item.avatar_url || ''}"
data-txatooltip="Chỉnh sửa chi tiết tài khoản này"
>
<i class="fas fa-edit text-xs"></i>
</button>
`;
// Nút đổi vai trò nhanh
const roleBtn = !isSelf ? `
<button
class="action-btn btn-role h-8 w-8 rounded-xl bg-violet-500/10 hover:bg-violet-500 hover:text-white flex items-center justify-center text-violet-400 border border-violet-500/20 active:scale-95 transition-all cursor-pointer"
data-username="${item.username}"
data-action="change_role"
data-role="${item.role === 'admin' ? 'user' : 'admin'}"
data-txatooltip="Thay đổi quyền thành: ${item.role === 'admin' ? 'Thành viên thường' : 'Quản trị viên'}"
>
<i class="fas ${item.role === 'admin' ? 'fa-user-minus' : 'fa-user-shield'} text-xs" id="icon-role-${item.username}"></i>
</button>
` : `
<button
class="h-8 w-8 rounded-xl bg-white/5 flex items-center justify-center text-gray-600 border border-white/5 cursor-not-allowed opacity-40"
data-txatooltip="Bạn không thể tự hạ quyền quản trị của chính mình!"
disabled
>
<i class="fas fa-lock text-xs"></i>
</button>
`;
// Nút xóa tài khoản
const deleteBtn = !isSelf ? `
<button
class="action-btn btn-delete h-8 w-8 rounded-xl bg-red-500/10 hover:bg-red-500 hover:text-white flex items-center justify-center text-red-400 border border-red-500/20 active:scale-95 transition-all cursor-pointer"
data-username="${item.username}"
data-action="delete"
data-txatooltip="Xóa vĩnh viễn tài khoản người dùng này"
>
<i class="fas fa-trash-alt text-xs" id="icon-delete-${item.username}"></i>
</button>
` : `
<button
class="h-8 w-8 rounded-xl bg-white/5 flex items-center justify-center text-gray-600 border border-white/5 cursor-not-allowed opacity-40"
data-txatooltip="Bạn không thể tự xóa tài khoản của chính mình!"
disabled
>
<i class="fas fa-lock text-xs"></i>
</button>
`;
// Avatar hiển thị cao cấp
const avatarHtml = item.avatar_url ? `
<img src="${item.avatar_url}" class="h-8 w-8 rounded-full object-cover border border-white/10" />
` : `
<div class="h-8 w-8 rounded-full bg-primary/20 border border-primary/30 flex items-center justify-center text-primary font-black uppercase text-xs">
${item.username[0]}
</div>
`;
return `
<tr class="hover:bg-white/[0.01] transition-colors duration-300">
<td class="py-4 pl-4 text-center">
<label class="relative flex items-center justify-center cursor-pointer">
<input type="checkbox" class="chk-user peer sr-only" data-username="${item.username}" ${isSelf ? 'disabled' : ''} />
<div class="h-5 w-5 rounded-lg border border-white/10 bg-[#12141d] flex items-center justify-center transition-all peer-checked:bg-primary peer-checked:border-primary peer-disabled:opacity-30 peer-disabled:cursor-not-allowed">
<i class="fas fa-check text-[10px] text-dark opacity-0 peer-checked:opacity-100 transition-opacity"></i>
</div>
</label>
</td>
<td class="py-4 font-black text-white text-sm">
<div class="flex items-center gap-3">
${avatarHtml}
<div class="flex flex-col">
<span class="font-black text-white">${item.username}</span>
${isSelf ? '<span class="text-[9px] font-black uppercase tracking-widest text-primary mt-0.5">Tài khoản của bạn</span>' : ''}
</div>
</div>
</td>
<td class="py-4 text-gray-300 font-medium text-xs">${item.email || 'Chưa cung cấp'}</td>
<td class="py-4 text-gray-400 font-semibold text-xs truncate max-w-[200px]" data-txatooltip="${location}">${location}</td>
<td class="py-4 text-gray-500 text-[11px]" data-txatooltip="Ngày đăng ký: ${fullTime}">${relativeTime}</td>
<td class="py-4">${roleBadge}</td>
<td class="py-4 text-right">
<div class="flex items-center justify-end gap-2 pr-2">
${editBtn}
${roleBtn}
${deleteBtn}
</div>
</td>
</tr>
`;
}).join('');
bindTableEvents();
updateBulkPill();
// Khởi tạo lại txatooltip cho danh sách mới nạp động
if (window.initTxaTooltips) {
setTimeout(() => window.initTxaTooltips(), 50);
}
}
// Xử lý gọi API AJAX thay đổi tài khoản đơn lẻ
async function handleAction(username, action, role = null, clickedButton = null) {
if (action === 'delete' && !confirm(`Bạn chắc chắn muốn xóa vĩnh viễn tài khoản "${username}"?`)) {
return;
}
let originalHtml = '';
let iconEl = null;
// Đổi nút bấm sang trạng thái loading
if (clickedButton) {
clickedButton.disabled = true;
clickedButton.style.cursor = 'not-allowed';
iconEl = clickedButton.querySelector('i');
if (iconEl) {
originalHtml = iconEl.className;
iconEl.className = "fas fa-spinner fa-spin text-xs";
}
}
try {
const res = await fetch('/api/admin/nguoi-dung', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, action, role })
});
const data = await res.json();
if (res.ok) {
if (window.txatoast) window.txatoast.success(data.message);
// Cập nhật bộ nhớ cục bộ
if (action === 'delete') {
usersListLocal = usersListLocal.filter(u => u.username !== username);
} else {
usersListLocal = usersListLocal.map(u => {
if (u.username === username) {
return { ...u, role };
}
return u;
});
}
updateStats();
renderTable();
} else {
if (window.txatoast) window.txatoast.error(data.error || 'Có lỗi xảy ra!');
// Khôi phục lại nút khi lỗi
if (clickedButton) {
clickedButton.disabled = false;
clickedButton.style.cursor = 'pointer';
if (iconEl) iconEl.className = originalHtml;
}
}
} catch (err) {
console.error(err);
if (window.txatoast) window.txatoast.error('Lỗi kết nối tới máy chủ!');
if (clickedButton) {
clickedButton.disabled = false;
clickedButton.style.cursor = 'pointer';
if (iconEl) iconEl.className = originalHtml;
}
}
}
// Xử lý gọi API AJAX Bulk Action đồng loạt
async function handleBulkAction(action, selectedUsernames, role = null, pillBtn = null) {
let confirmMsg = '';
if (action === 'delete') {
confirmMsg = `Bạn chắc chắn muốn xóa vĩnh viễn ${selectedUsernames.length} tài khoản đã chọn?`;
} else {
confirmMsg = `Bạn chắc chắn muốn đổi vai trò của ${selectedUsernames.length} tài khoản thành "${role === 'admin' ? 'Quản trị viên' : 'Thành viên thường'}"?`;
}
if (!confirm(confirmMsg)) return;
let originalIconClass = '';
let originalText = '';
let iconEl = null;
let textEl = null;
// Thay đổi trạng thái nút trong viên thuốc thành Loading
if (pillBtn) {
iconEl = pillBtn.querySelector('i');
textEl = pillBtn.querySelector('span');
// Khóa tất cả các nút trong Pill chống double-click
document.querySelectorAll('#bulk-pill button').forEach(b => {
b.disabled = true;
b.style.cursor = 'not-allowed';
});
if (iconEl) {
originalIconClass = iconEl.className;
iconEl.className = "fas fa-spinner fa-spin";
}
if (textEl) {
originalText = textEl.textContent;
textEl.textContent = "Đang xử lý...";
}
}
try {
const res = await fetch('/api/admin/nguoi-dung', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ usernames: selectedUsernames, action, role })
});
const data = await res.json();
if (res.ok) {
if (window.txatoast) window.txatoast.success(data.message);
// Cập nhật cục bộ hàng loạt
if (action === 'delete') {
usersListLocal = usersListLocal.filter(u => !selectedUsernames.includes(u.username));
} else {
usersListLocal = usersListLocal.map(u => {
if (selectedUsernames.includes(u.username)) {
return { ...u, role };
}
return u;
});
}
// Bỏ tích tất cả checkboxes
const chkAll = document.getElementById('chk-all');
if (chkAll) chkAll.checked = false;
updateStats();
renderTable();
} else {
if (window.txatoast) window.txatoast.error(data.error || 'Có lỗi xảy ra khi xử lý đồng loạt!');
restoreBulkButtons();
}
} catch (err) {
console.error(err);
if (window.txatoast) window.txatoast.error('Lỗi đường truyền tới máy chủ!');
restoreBulkButtons();
}
function restoreBulkButtons() {
document.querySelectorAll('#bulk-pill button').forEach(b => {
b.disabled = false;
b.style.cursor = 'pointer';
});
if (iconEl) iconEl.className = originalIconClass;
if (textEl) textEl.textContent = originalText;
}
}
// Đồng bộ trạng thái của Viên thuốc (Bulk Action Pill)
function updateBulkPill() {
const checked = document.querySelectorAll('.chk-user:checked');
const totalCount = document.querySelectorAll('.chk-user:not(:disabled)').length;
const chkAll = document.getElementById('chk-all');
const pill = document.getElementById('bulk-pill');
const countSpan = document.getElementById('selected-count-pill');
if (chkAll) {
chkAll.checked = totalCount > 0 && checked.length === totalCount;
}
if (checked.length > 0) {
if (pill) {
pill.classList.remove('translate-y-28', 'opacity-0', 'pointer-events-none');
pill.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto');
}
if (countSpan) countSpan.textContent = checked.length;
} else {
if (pill) {
pill.classList.add('translate-y-28', 'opacity-0', 'pointer-events-none');
pill.classList.remove('translate-y-0', 'opacity-100', 'pointer-events-auto');
}
}
}
function bindTableEvents() {
// Sự kiện tích chọn checkboxes từng dòng
document.querySelectorAll('.chk-user').forEach(chk => {
chk.onchange = () => {
updateBulkPill();
};
});
// Sự kiện các nút hành động nhanh đơn lẻ (đổi vai trò & xóa)
document.querySelectorAll('.action-btn').forEach(btn => {
btn.onclick = () => {
const username = btn.getAttribute('data-username');
const action = btn.getAttribute('data-action');
const role = btn.getAttribute('data-role');
handleAction(username, action, role, btn);
};
});
// Sự kiện nút sửa chi tiết người dùng (Edit Modal - Khôi phục CRUD)
document.querySelectorAll('.action-btn-edit').forEach(btn => {
btn.onclick = () => {
const username = btn.getAttribute('data-username');
const email = btn.getAttribute('data-email');
const province = btn.getAttribute('data-province');
const ward = btn.getAttribute('data-ward');
const role = btn.getAttribute('data-role');
const avatarUrl = btn.getAttribute('data-avatar');
const isSelf = username === currentAdminUsername;
window.txamodal.show({
title: `Chỉnh sửa tài khoản: ${username}`,
message: `
<div class="space-y-4 text-left">
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Email <span class="text-red-500">*</span></label>
<input type="email" id="edit-user-email" value="${email}" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50 font-bold" />
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Mật khẩu mới (Để trống nếu giữ nguyên)</label>
<input type="password" id="edit-user-password" placeholder="Nhập mật khẩu mới cho tài khoản" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Thành Phố</label>
<select id="edit-user-province" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-xs text-white focus:outline-none focus:border-primary/50 font-bold cursor-pointer">
<option value="" disabled selected>Chọn Thành Phố</option>
</select>
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Xã/Phường</label>
<select id="edit-user-ward" disabled class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-xs text-white focus:outline-none focus:border-primary/50 font-bold cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed">
<option value="" disabled selected>Chọn Xã/Phường</option>
</select>
</div>
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Ảnh đại diện (URL)</label>
<input type="text" id="edit-user-avatar" value="${avatarUrl}" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50" />
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Vai trò</label>
<select id="edit-user-role" ${isSelf ? 'disabled' : ''} class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50 font-bold disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer">
<option value="user" ${role === 'user' ? 'selected' : ''}>Người dùng thường (User)</option>
<option value="admin" ${role === 'admin' ? 'selected' : ''}>Quản trị viên (Admin)</option>
</select>
${isSelf ? '<span class="text-[9px] text-primary/70 font-semibold mt-1 block">* Bạn không thể tự thay đổi vai trò của chính mình.</span>' : ''}
</div>
</div>`,
type: 'warning',
confirmText: 'Lưu thay đổi',
onConfirm: async () => {
const emailVal = document.getElementById('edit-user-email')?.value.trim();
const passwordVal = document.getElementById('edit-user-password')?.value.trim();
const provinceVal = document.getElementById('edit-user-province')?.value;
const wardVal = document.getElementById('edit-user-ward')?.value;
const avatarVal = document.getElementById('edit-user-avatar')?.value.trim();
const roleVal = document.getElementById('edit-user-role')?.value;
if (!emailVal) {
window.txatoast.error('Email không được để trống!');
return false;
}
try {
const res = await fetch('/api/admin/nguoi-dung', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'update',
username,
email: emailVal,
password: passwordVal,
province: provinceVal || '',
ward: wardVal || '',
avatar_url: avatarVal,
role: isSelf ? 'admin' : roleVal
})
});
const data = await res.json();
if (res.ok) {
usersListLocal = usersListLocal.map(u => {
if (u.username === username) {
return data.user;
}
return u;
});
updateStats();
renderTable();
window.txatoast.success(data.message);
return true;
} else {
window.txatoast.error(data.error);
return false;
}
} catch(e) {
console.error(e);
window.txatoast.error('Lỗi khi lưu thông tin chỉnh sửa!');
return false;
}
}
});
// Tải vị trí địa lý cho Edit Modal
setTimeout(async () => {
const locs = await fetchLocations();
if (!locs) return;
const provinceSelect = document.getElementById('edit-user-province');
const wardSelect = document.getElementById('edit-user-ward');
if (provinceSelect && wardSelect) {
// Nạp danh sách Tỉnh/Thành
Object.keys(locs).sort().forEach(prov => {
const opt = document.createElement('option');
opt.value = prov;
opt.textContent = prov;
if (prov === province) {
opt.selected = true;
}
provinceSelect.appendChild(opt);
});
// Nạp danh sách Xã/Phường nếu tỉnh đã có giá trị ban đầu
if (province && locs[province]) {
wardSelect.disabled = false;
locs[province].forEach(item => {
const opt = document.createElement('option');
opt.value = item.txa_name;
opt.textContent = item.txa_name;
if (item.txa_name === ward) {
opt.selected = true;
}
wardSelect.appendChild(opt);
});
}
// Lắng nghe sự kiện đổi Tỉnh/Thành
provinceSelect.addEventListener('change', (e) => {
const selectedProv = e.target.value;
wardSelect.innerHTML = '<option value="" disabled selected>Chọn Xã/Phường</option>';
wardSelect.disabled = !selectedProv;
if (selectedProv && locs[selectedProv]) {
locs[selectedProv].forEach(item => {
const opt = document.createElement('option');
opt.value = item.txa_name;
opt.textContent = item.txa_name;
wardSelect.appendChild(opt);
});
}
});
}
}, 80);
};
});
}
function init() {
renderTable();
// Lắng nghe thanh tìm kiếm
const searchInput = document.getElementById('user-search');
if (searchInput) {
searchInput.oninput = (e) => {
filterSearch = e.target.value.trim();
renderTable();
};
}
// Lắng nghe bộ lọc Vai trò
const selectFilter = document.getElementById('role-filter');
if (selectFilter) {
selectFilter.onchange = (e) => {
filterRole = e.target.value;
renderTable();
};
}
// Checkbox chọn tất cả các dòng
const chkAll = document.getElementById('chk-all');
if (chkAll) {
chkAll.onchange = () => {
document.querySelectorAll('.chk-user:not(:disabled)').forEach(chk => {
chk.checked = chkAll.checked;
});
updateBulkPill();
};
}
// Nút mở modal thêm tài khoản mới
const createBtn = document.getElementById('create-user-btn');
if (createBtn) {
createBtn.onclick = () => {
window.txamodal.show({
title: 'Thêm người dùng mới',
message: `
<div class="space-y-4 text-left">
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Tên tài khoản <span class="text-red-500">*</span></label>
<input type="text" id="new-user-username" placeholder="Ví dụ: nguyen_van_a" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50 font-bold" />
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Email <span class="text-red-500">*</span></label>
<input type="email" id="new-user-email" placeholder="Ví dụ: anguyen@gmail.com" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50 font-bold" />
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Mật khẩu <span class="text-red-500">*</span></label>
<input type="password" id="new-user-password" placeholder="Nhập mật khẩu tài khoản" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Thành Phố</label>
<select id="new-user-province" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-xs text-white focus:outline-none focus:border-primary/50 font-bold cursor-pointer">
<option value="" disabled selected>Chọn Thành Phố</option>
</select>
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Xã/Phường</label>
<select id="new-user-ward" disabled class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-xs text-white focus:outline-none focus:border-primary/50 font-bold cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed">
<option value="" disabled selected>Chọn Xã/Phường</option>
</select>
</div>
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Ảnh đại diện (URL - Tùy chọn)</label>
<input type="text" id="new-user-avatar" placeholder="https://domain.com/avatar.jpg" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50" />
</div>
<div>
<label class="block text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Vai trò</label>
<select id="new-user-role" class="w-full bg-[#12141d] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-primary/50 font-bold cursor-pointer">
<option value="user" selected>Người dùng thường (User)</option>
<option value="admin">Quản trị viên (Admin)</option>
</select>
</div>
</div>`,
type: 'success',
confirmText: 'Tạo tài khoản',
onConfirm: async () => {
const username = document.getElementById('new-user-username')?.value.trim();
const email = document.getElementById('new-user-email')?.value.trim();
const password = document.getElementById('new-user-password')?.value.trim();
const province = document.getElementById('new-user-province')?.value;
const ward = document.getElementById('new-user-ward')?.value;
const avatarUrl = document.getElementById('new-user-avatar')?.value.trim();
const role = document.getElementById('new-user-role')?.value;
if (!username || !email || !password) {
window.txatoast.error('Vui lòng nhập đầy đủ các trường bắt buộc (*)!');
return false;
}
try {
const res = await fetch('/api/admin/nguoi-dung', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'create',
username,
email,
password,
province: province || '',
ward: ward || '',
avatar_url: avatarUrl,
role
})
});
const data = await res.json();
if (res.ok) {
usersListLocal.push(data.user);
updateStats();
renderTable();
window.txatoast.success(data.message);
return true;
} else {
window.txatoast.error(data.error);
return false;
}
} catch(e) {
console.error(e);
window.txatoast.error('Lỗi khi kết nối máy chủ để tạo tài khoản!');
return false;
}
}
});
// Tải danh mục địa lý cho Create Modal
setTimeout(async () => {
const locs = await fetchLocations();
if (!locs) return;
const provinceSelect = document.getElementById('new-user-province');
const wardSelect = document.getElementById('new-user-ward');
if (provinceSelect && wardSelect) {
// Nạp danh sách Tỉnh/Thành
Object.keys(locs).sort().forEach(prov => {
const opt = document.createElement('option');
opt.value = prov;
opt.textContent = prov;
provinceSelect.appendChild(opt);
});
// Lắng nghe sự kiện đổi Tỉnh/Thành
provinceSelect.addEventListener('change', (e) => {
const selectedProv = e.target.value;
wardSelect.innerHTML = '<option value="" disabled selected>Chọn Xã/Phường</option>';
wardSelect.disabled = !selectedProv;
if (selectedProv && locs[selectedProv]) {
locs[selectedProv].forEach(item => {
const opt = document.createElement('option');
opt.value = item.txa_name;
opt.textContent = item.txa_name;
wardSelect.appendChild(opt);
});
}
});
}
}, 80);
};
}
// --- SỰ KIỆN NÚT TRÊN VIÊN THUỐC ĐỒNG LOẠT (BULK ACTIONS) ---
// Nút hủy chọn hàng loạt
const btnPillCancel = document.getElementById('pill-cancel');
if (btnPillCancel) {
btnPillCancel.onclick = () => {
document.querySelectorAll('.chk-user').forEach(chk => chk.checked = false);
if (chkAll) chkAll.checked = false;
updateBulkPill();
};
}
// Nâng lên Admin hàng loạt
const btnPillRoleAdmin = document.getElementById('pill-role-admin');
if (btnPillRoleAdmin) {
btnPillRoleAdmin.onclick = () => {
const checked = document.querySelectorAll('.chk-user:checked');
const usernames = Array.from(checked).map(chk => chk.getAttribute('data-username'));
handleBulkAction('change_role', usernames, 'admin', btnPillRoleAdmin);
};
}
// Hạ xuống Thành viên thường hàng loạt
const btnPillRoleUser = document.getElementById('pill-role-user');
if (btnPillRoleUser) {
btnPillRoleUser.onclick = () => {
const checked = document.querySelectorAll('.chk-user:checked');
const usernames = Array.from(checked).map(chk => chk.getAttribute('data-username'));
handleBulkAction('change_role', usernames, 'user', btnPillRoleUser);
};
}
// Xóa hàng loạt
const btnPillDelete = document.getElementById('pill-delete');
if (btnPillDelete) {
btnPillDelete.onclick = () => {
const checked = document.querySelectorAll('.chk-user:checked');
const usernames = Array.from(checked).map(chk => chk.getAttribute('data-username'));
handleBulkAction('delete', usernames, null, btnPillDelete);
};
}
}
// Khởi chạy khi tải trang Astro kết thúc
document.addEventListener('astro:page-load', init);
</script>
<style>
.glass-card {
background: rgba(18, 20, 29, 0.4);
backdrop-filter: blur(40px) saturate(200%);
}
</style>