Spaces:
Sleeping
Sleeping
| --- | |
| 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> | |