Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Admin Panel — AttendAI Pro</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <link rel="stylesheet" href="css/main.css"> | |
| <link rel="stylesheet" href="css/teacher.css"> | |
| <link rel="stylesheet" href="css/admin.css"> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> | |
| <script src="https://accounts.google.com/gsi/client" async defer></script> | |
| </head> | |
| <body> | |
| <!-- Admin Auth Wall (shown if not logged in as admin) --> | |
| <div id="adminAuthWall" class="admin-auth-wall"> | |
| <div class="admin-auth-card"> | |
| <div class="admin-auth-header"> | |
| <div class="admin-shield">🛡️</div> | |
| <h1>Developer / Admin Access</h1> | |
| <p>Restricted area. Only authorized administrators can access this panel.</p> | |
| </div> | |
| <div id="adminAuthAlert" style="margin-bottom:16px;display:none"></div> | |
| <div class="role-reminder admin-reminder"> | |
| <div class="reminder-icon">⚠️</div> | |
| <div> | |
| <strong>Restricted Access</strong> | |
| <p>Your Google account must be registered with admin role. All access attempts are logged.</p> | |
| </div> | |
| </div> | |
| <div style="margin:24px 0; display: flex; justify-content: center;"> | |
| <div id="adminGoogleBtnContainer"></div> | |
| </div> | |
| <div id="adminLoadingState" class="auth-loading hidden"> | |
| <div class="spinner spinner-dark"></div> | |
| <span>Verifying admin credentials...</span> | |
| </div> | |
| <div class="auth-notice-box"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg> | |
| <span>All admin actions are logged with IP, timestamp, and user identity.</span> | |
| </div> | |
| <div style="text-align:center;margin-top:16px"> | |
| <a href="index.html" style="font-size:13px;color:var(--gray)">← Return to Home</a> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Admin Dashboard --> | |
| <div id="adminDashboard" style="display:none"> | |
| <!-- Sidebar --> | |
| <aside class="sidebar" id="sidebar"> | |
| <div class="sidebar-logo"> | |
| <span class="logo-icon">🛡️</span> | |
| Admin<span>Panel</span> | |
| </div> | |
| <nav class="sidebar-nav"> | |
| <div class="nav-section-title">System</div> | |
| <button class="nav-item active" onclick="showSection('dashboard', this)"><span class="nav-icon">📊</span> Dashboard</button> | |
| <button class="nav-item" onclick="showSection('users', this)"><span class="nav-icon">👥</span> User Management</button> | |
| <button class="nav-item" onclick="showSection('sessions', this)"><span class="nav-icon">📱</span> Sessions</button> | |
| <button class="nav-item" onclick="showSection('attendance', this)"><span class="nav-icon">✅</span> Attendance</button> | |
| <div class="nav-section-title">Security</div> | |
| <button class="nav-item" onclick="showSection('fraud', this)"> | |
| <span class="nav-icon">🚨</span> Fraud Alerts | |
| <span class="notif-badge" id="fraudBadge" style="position:relative;margin-left:auto;font-size:10px;background:var(--danger)">0</span> | |
| </button> | |
| <button class="nav-item" onclick="showSection('logs', this)"><span class="nav-icon">📋</span> Audit Logs</button> | |
| <div class="nav-section-title">Configuration</div> | |
| <button class="nav-item" onclick="showSection('config', this)"><span class="nav-icon">⚙️</span> System Config</button> | |
| <button class="nav-item" onclick="showSection('analytics', this)"><span class="nav-icon">📈</span> Global Analytics</button> | |
| </nav> | |
| <div class="sidebar-footer"> | |
| <div class="sidebar-user"> | |
| <img id="adminSidebarAvatar" src="" alt="" class="avatar avatar-sm"> | |
| <div class="sidebar-user-info"> | |
| <div class="sidebar-user-name" id="adminSidebarName">—</div> | |
| <div class="sidebar-user-role" style="color:#F59E0B">⚡ Administrator</div> | |
| </div> | |
| </div> | |
| <button class="nav-item" onclick="logout()" style="color:rgba(239,68,68,0.8)"><span class="nav-icon">🚪</span> Sign Out</button> | |
| </div> | |
| </aside> | |
| <div class="mobile-overlay" id="mobileOverlay" onclick="closeSidebar()"></div> | |
| <main class="main-content"> | |
| <div class="topbar"> | |
| <div style="display:flex;align-items:center;gap:12px"> | |
| <button class="hamburger" onclick="toggleSidebar()">☰</button> | |
| <div> | |
| <div class="topbar-title" id="topbarTitle">System Dashboard</div> | |
| <div class="text-xs text-gray" id="adminTopbarSub">AttendAI Pro — Administrator View</div> | |
| </div> | |
| </div> | |
| <div class="topbar-actions"> | |
| <div class="admin-badge">⚡ ADMIN</div> | |
| <button class="btn btn-sm btn-secondary" onclick="refreshAll()">🔄 Refresh</button> | |
| </div> | |
| </div> | |
| <div class="page-content"> | |
| <!-- ===== DASHBOARD ===== --> | |
| <div class="section active fade-in" id="section-dashboard"> | |
| <div class="stats-grid" id="systemStats"> | |
| <div class="stat-card blue"><div class="stat-icon">👥</div><div class="stat-value" id="sys-users">—</div><div class="stat-label">Total Users</div></div> | |
| <div class="stat-card green"><div class="stat-icon">🎓</div><div class="stat-value" id="sys-students">—</div><div class="stat-label">Students</div></div> | |
| <div class="stat-card purple"><div class="stat-icon">👨🏫</div><div class="stat-value" id="sys-teachers">—</div><div class="stat-label">Teachers</div></div> | |
| <div class="stat-card yellow"><div class="stat-icon">📱</div><div class="stat-value" id="sys-sessions">—</div><div class="stat-label">Total Sessions</div></div> | |
| <div class="stat-card green"><div class="stat-icon">✅</div><div class="stat-value" id="sys-att">—</div><div class="stat-label">Attendance Records</div></div> | |
| <div class="stat-card red"><div class="stat-icon">🚨</div><div class="stat-value" id="sys-fraud">—</div><div class="stat-label">Fraud Alerts</div></div> | |
| </div> | |
| <!-- Live Sessions --> | |
| <div class="card"> | |
| <div class="section-header"><div class="section-title">🔴 Live Sessions</div><span class="live-dot">LIVE</span></div> | |
| <div id="liveSessionsList"> | |
| <div class="empty-state" style="padding:30px"><div class="empty-state-icon">📱</div><h3>No Active Sessions</h3><p>No teachers are currently running attendance sessions.</p></div> | |
| </div> | |
| </div> | |
| <!-- Recent Logs --> | |
| <div class="card"> | |
| <div class="section-header"><div class="section-title">📋 Recent Activity</div><a href="#" onclick="showSection('logs',document.querySelector('[onclick*=logs]'))" class="btn btn-sm btn-secondary">View All</a></div> | |
| <div id="recentLogs"> | |
| <div class="empty-state" style="padding:30px"><div class="empty-state-icon">📋</div><p>No recent activity</p></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== USER MANAGEMENT ===== --> | |
| <div class="section hidden" id="section-users"> | |
| <div class="section-header" style="margin-bottom:20px"> | |
| <div> | |
| <h2>User Management</h2> | |
| <p class="text-sm text-gray">Add, edit, and manage all system users. Only pre-registered users can log in.</p> | |
| </div> | |
| <button class="btn btn-primary" onclick="openAddUserModal()">+ Add User</button> | |
| </div> | |
| <!-- Tabs --> | |
| <div class="user-tabs"> | |
| <button class="utab active" onclick="filterUsers('all', this)">All Users</button> | |
| <button class="utab" onclick="filterUsers('student', this)">Students</button> | |
| <button class="utab" onclick="filterUsers('teacher', this)">Teachers</button> | |
| <button class="utab" onclick="filterUsers('admin', this)">Admins</button> | |
| </div> | |
| <!-- Search --> | |
| <div style="margin:16px 0;display:flex;gap:12px"> | |
| <input type="text" class="form-control" id="userSearchInput" placeholder="Search by name, email, or roll number..." oninput="searchUsers()" style="flex:1"> | |
| <button class="btn btn-secondary" onclick="exportUsers()">⬇️ Export</button> | |
| </div> | |
| <div class="card p-0"> | |
| <div class="table-wrapper"> | |
| <table class="table"> | |
| <thead> | |
| <tr><th>Name</th><th>Email</th><th>Role</th><th>Roll/Dept</th><th>Institution</th><th>Status</th><th>Last Login</th><th>Actions</th></tr> | |
| </thead> | |
| <tbody id="usersTableBody"> | |
| <tr><td colspan="8" style="text-align:center;padding:32px;color:var(--gray)">Loading users...</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== SESSIONS ===== --> | |
| <div class="section hidden" id="section-sessions"> | |
| <div class="section-header" style="margin-bottom:20px"> | |
| <h2>All Sessions</h2> | |
| <button class="btn btn-sm btn-danger" id="forceEndBtn" onclick="forceEndAllSessions()" style="display:none">Force End All Live Sessions</button> | |
| </div> | |
| <div class="card p-0"> | |
| <div class="table-wrapper"> | |
| <table class="table"> | |
| <thead><tr><th>Subject</th><th>Class</th><th>Teacher</th><th>Started</th><th>Status</th><th>Students</th><th>Actions</th></tr></thead> | |
| <tbody id="adminSessionsBody"> | |
| <tr><td colspan="7" style="text-align:center;padding:32px;color:var(--gray)">Loading sessions...</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== ATTENDANCE ===== --> | |
| <div class="section hidden" id="section-attendance"> | |
| <div class="section-header" style="margin-bottom:20px"> | |
| <div><h2>All Attendance Records</h2><p class="text-sm text-gray">Complete verified attendance database</p></div> | |
| <button class="btn btn-success" onclick="exportAllAttendance()">⬇️ Export All</button> | |
| </div> | |
| <div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap"> | |
| <select class="form-control form-select" id="adminAttFilter" onchange="loadAdminAttendance()" style="width:auto"> | |
| <option value="all">All Time</option> | |
| <option value="today">Today</option> | |
| <option value="week">This Week</option> | |
| <option value="month">This Month</option> | |
| </select> | |
| <select class="form-control form-select" id="adminAttStatus" onchange="loadAdminAttendance()" style="width:auto"> | |
| <option value="">All Statuses</option> | |
| <option value="present">Present</option> | |
| <option value="absent">Absent</option> | |
| <option value="late">Late</option> | |
| <option value="fraudulent">Fraudulent</option> | |
| </select> | |
| </div> | |
| <div class="card p-0"> | |
| <div class="table-wrapper"> | |
| <table class="table"> | |
| <thead><tr><th>Roll No.</th><th>Student</th><th>Session</th><th>Status</th><th>Time</th><th>GPS</th><th>Face</th><th>Fraud Flags</th></tr></thead> | |
| <tbody id="adminAttBody"> | |
| <tr><td colspan="8" style="text-align:center;padding:32px;color:var(--gray)">Loading...</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== FRAUD ALERTS ===== --> | |
| <div class="section hidden" id="section-fraud"> | |
| <div class="section-header" style="margin-bottom:20px"> | |
| <h2>🚨 Fraud Detection Center</h2> | |
| <button class="btn btn-sm btn-secondary" onclick="loadAdminFraud()">🔄 Refresh</button> | |
| </div> | |
| <div id="adminFraudList"> | |
| <div class="empty-state" style="padding:60px"><div class="empty-state-icon">🛡️</div><h3>No Alerts</h3><p>No fraud detected in the system.</p></div> | |
| </div> | |
| </div> | |
| <!-- ===== AUDIT LOGS ===== --> | |
| <div class="section hidden" id="section-logs"> | |
| <div class="section-header" style="margin-bottom:20px"> | |
| <h2>📋 Audit Logs</h2> | |
| <div style="display:flex;gap:8px"> | |
| <select class="form-control form-select" id="logFilter" onchange="loadAuditLogs()" style="width:auto"> | |
| <option value="">All Actions</option> | |
| <option value="LOGIN_SUCCESS">Logins</option> | |
| <option value="LOGIN_FAILED">Failed Logins</option> | |
| <option value="ATTENDANCE_MARKED">Attendance</option> | |
| <option value="SESSION_CREATED">Sessions</option> | |
| <option value="FRAUD_ATTEMPT">Fraud Attempts</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="card p-0"> | |
| <div class="table-wrapper"> | |
| <table class="table"> | |
| <thead><tr><th>Timestamp</th><th>User</th><th>Action</th><th>Resource</th><th>Status</th></tr></thead> | |
| <tbody id="logsTableBody"> | |
| <tr><td colspan="5" style="text-align:center;padding:32px;color:var(--gray)">Loading logs...</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== SYSTEM CONFIG ===== --> | |
| <div class="section hidden" id="section-config"> | |
| <h2 style="margin-bottom:24px">⚙️ System Configuration</h2> | |
| <div class="grid-2"> | |
| <div class="card"> | |
| <h3 style="margin-bottom:20px">Attendance Rules</h3> | |
| <div class="form-group"> | |
| <label class="form-label">QR Rotation Interval (seconds)</label> | |
| <input type="number" class="form-control" id="cfg-qr" value="60" min="10" max="300"> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Default Geofence Radius (meters)</label> | |
| <input type="number" class="form-control" id="cfg-geo" value="100" min="10" max="5000"> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Attendance Warning Threshold (%)</label> | |
| <input type="number" class="form-control" id="cfg-threshold" value="75" min="1" max="100"> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Face Match Confidence (0-1)</label> | |
| <input type="number" class="form-control" id="cfg-face" value="0.6" min="0.1" max="1" step="0.05"> | |
| </div> | |
| <button class="btn btn-primary" onclick="saveConfig()">💾 Save Settings</button> | |
| </div> | |
| <div class="card"> | |
| <h3 style="margin-bottom:20px">Institution Settings</h3> | |
| <div class="form-group"> | |
| <label class="form-label">Institution Name</label> | |
| <input type="text" class="form-control" id="cfg-institution" placeholder="University Name"> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Google Client ID</label> | |
| <input type="text" class="form-control" id="cfg-oauth" placeholder="xxx.apps.googleusercontent.com"> | |
| <div class="form-hint">Required for Google OAuth login</div> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Allowed Student Email Domains</label> | |
| <input type="text" class="form-control" id="cfg-domains" placeholder="university.edu, college.ac.in (comma separated)"> | |
| <div class="form-hint">Leave blank to allow all domains</div> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Max Attendance Attempts per Session</label> | |
| <input type="number" class="form-control" id="cfg-attempts" value="1" min="1" max="5"> | |
| </div> | |
| <button class="btn btn-primary" onclick="saveConfig()">💾 Save Settings</button> | |
| </div> | |
| </div> | |
| <!-- Danger Zone --> | |
| <div class="card" style="margin-top:20px;border-color:rgba(239,68,68,0.3)"> | |
| <h3 style="color:var(--danger);margin-bottom:16px">⚠️ Danger Zone</h3> | |
| <div style="display:flex;gap:12px;flex-wrap:wrap"> | |
| <button class="btn btn-danger" onclick="confirmAction('clear_fraud', 'Mark all fraud alerts as resolved?')">Clear All Fraud Alerts</button> | |
| <button class="btn btn-danger" onclick="confirmAction('end_sessions', 'Force end ALL active sessions?')">Force End All Sessions</button> | |
| </div> | |
| <p class="text-sm text-gray" style="margin-top:12px">These actions are irreversible and will be logged.</p> | |
| </div> | |
| </div> | |
| <!-- ===== ANALYTICS ===== --> | |
| <div class="section hidden" id="section-analytics"> | |
| <h2 style="margin-bottom:24px">📈 Global System Analytics</h2> | |
| <div class="grid-2"> | |
| <div class="card"><div class="section-title" style="margin-bottom:16px">Daily Attendance Trend</div><div style="height:280px"><canvas id="adminTrendChart"></canvas></div></div> | |
| <div class="card"><div class="section-title" style="margin-bottom:16px">User Distribution</div><div style="height:280px"><canvas id="adminUserChart"></canvas></div></div> | |
| </div> | |
| <div class="card" style="margin-top:20px"> | |
| <div class="section-title" style="margin-bottom:16px">Fraud Detection Summary</div> | |
| <div style="height:250px"><canvas id="adminFraudChart"></canvas></div> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| </div><!-- /adminDashboard --> | |
| <!-- Add User Modal --> | |
| <div class="modal-overlay" id="addUserModal"> | |
| <div class="modal" style="max-width:540px"> | |
| <div class="modal-header"> | |
| <h3>➕ Add New User</h3> | |
| <button class="modal-close" onclick="closeModal('addUserModal')">×</button> | |
| </div> | |
| <div class="modal-body"> | |
| <div id="addUserAlert" style="margin-bottom:12px;display:none"></div> | |
| <div class="form-group"> | |
| <label class="form-label">Full Name *</label> | |
| <input type="text" class="form-control" id="nu-name" placeholder="John Doe"> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Google Email *</label> | |
| <input type="email" class="form-control" id="nu-email" placeholder="student@university.edu"> | |
| <div class="form-hint">Must match their Google account email exactly</div> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Role *</label> | |
| <select class="form-control form-select" id="nu-role" onchange="toggleRoleFields()"> | |
| <option value="student">Student</option> | |
| <option value="teacher">Teacher</option> | |
| <option value="admin">Administrator</option> | |
| </select> | |
| </div> | |
| <div id="studentFields"> | |
| <div class="form-group"> | |
| <label class="form-label">Roll Number *</label> | |
| <input type="text" class="form-control" id="nu-roll" placeholder="CS2024001"> | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Department</label> | |
| <input type="text" class="form-control" id="nu-dept" placeholder="Computer Science"> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Institution</label> | |
| <input type="text" class="form-control" id="nu-institution" placeholder="University Name"> | |
| </div> | |
| </div> | |
| <div class="modal-footer"> | |
| <button class="btn btn-secondary" onclick="closeModal('addUserModal')">Cancel</button> | |
| <button class="btn btn-primary" onclick="addUser()">Add User</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="toast-container" id="toastContainer"></div> | |
| <script src="js/config.js"></script> | |
| <script> | |
| let currentAdmin = null; | |
| let allUsers = []; | |
| let currentUserFilter = 'all'; | |
| let adminTrendChart = null, adminUserChart = null, adminFraudChart = null; | |
| /* ===== Auth ===== */ | |
| async function checkAdminAuth() { | |
| const user = AuthState.get(); | |
| if (user && user.role === 'admin') { | |
| currentAdmin = user; | |
| showAdminDashboard(); | |
| } | |
| // If not logged in, show auth wall | |
| } | |
| function showAdminDashboard() { | |
| document.getElementById('adminAuthWall').style.display = 'none'; | |
| document.getElementById('adminDashboard').style.display = 'block'; | |
| document.getElementById('adminSidebarAvatar').src = currentAdmin.picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(currentAdmin.name)}&background=F59E0B&color=fff`; | |
| document.getElementById('adminSidebarName').textContent = currentAdmin.name; | |
| loadDashboard(); | |
| loadAllUsers(); | |
| loadAdminSessions(); | |
| loadAdminAttendance(); | |
| loadAdminFraud(); | |
| loadAuditLogs(); | |
| } | |
| function handleGoogleCredential(response) { | |
| try { | |
| const parts = response.credential.split('.'); | |
| const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); | |
| verifyAdminInDB({ sub: payload.sub, email: payload.email, name: payload.name, picture: payload.picture }); | |
| } catch(e) { | |
| showAdminAuthAlert('Authentication failed. Please try again.', 'danger'); | |
| } | |
| } | |
| async function verifyAdminInDB(googleUser) { | |
| document.getElementById('adminLoadingState').classList.remove('hidden'); | |
| document.getElementById('adminGoogleBtn').disabled = true; | |
| try { | |
| // Exact email lookup | |
| const result = await API.get('users', { email: googleUser.email }); | |
| const users = result.data || []; | |
| const user = users.find(u => u.email && u.email.toLowerCase() === googleUser.email.toLowerCase()); | |
| if (!user) { | |
| showAdminAuthAlert('Your email is not registered in the system.', 'danger'); | |
| return; | |
| } | |
| if (user.role !== 'admin') { | |
| showAdminAuthAlert(`Your account role is "${user.role}", not admin. Please use the correct portal.`, 'danger'); | |
| await Logger.log('ADMIN_ACCESS_DENIED', 'auth', { email: googleUser.email, role: user.role }, 'failure'); | |
| return; | |
| } | |
| if (user.is_active === false) { | |
| showAdminAuthAlert('Your admin account is deactivated.', 'danger'); | |
| return; | |
| } | |
| currentAdmin = { ...user, name: googleUser.name || user.name, picture: googleUser.picture || '' }; | |
| AuthState.save(currentAdmin); | |
| await API.patch('users', user.id, { last_login: Date.now() }); | |
| await Logger.log('ADMIN_LOGIN', 'auth', { email: googleUser.email }, 'success'); | |
| showAdminDashboard(); | |
| } catch(e) { | |
| showAdminAuthAlert('System error during verification. Please try again.', 'danger'); | |
| } finally { | |
| document.getElementById('adminLoadingState').classList.add('hidden'); | |
| // Official button is managed by Google | |
| } | |
| } | |
| function showAdminAuthAlert(msg, type) { | |
| const el = document.getElementById('adminAuthAlert'); | |
| el.style.display = 'block'; | |
| el.innerHTML = `<div class="alert alert-${type}"><span>${type==='danger'?'❌':'⚠️'}</span><div>${msg}</div></div>`; | |
| } | |
| function initiateAdminLogin() { | |
| if (APP_CONFIG.googleClientId === 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') { | |
| showAdminAuthAlert('<strong>Google OAuth Not Configured</strong><br>Set up Google OAuth Client ID in js/config.js to enable login.', 'warning'); | |
| return; | |
| } | |
| if (typeof google !== 'undefined' && google.accounts) google.accounts.id.prompt(); | |
| else showAdminAuthAlert('Google auth unavailable.', 'warning'); | |
| } | |
| /* ===== Navigation ===== */ | |
| function showSection(name, el) { | |
| document.querySelectorAll('.section').forEach(s => { s.classList.remove('active'); s.classList.add('hidden'); }); | |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); | |
| const sec = document.getElementById('section-' + name); | |
| if (sec) { sec.classList.remove('hidden'); sec.classList.add('active', 'fade-in'); } | |
| if (el) el.classList.add('active'); | |
| const titles = { dashboard:'System Dashboard', users:'User Management', sessions:'All Sessions', attendance:'Attendance Records', fraud:'Fraud Detection', logs:'Audit Logs', config:'System Config', analytics:'Global Analytics' }; | |
| document.getElementById('topbarTitle').textContent = titles[name] || name; | |
| if (name === 'analytics') loadAnalytics(); | |
| closeSidebar(); | |
| } | |
| function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); document.getElementById('mobileOverlay').classList.toggle('open'); } | |
| function closeSidebar() { document.getElementById('sidebar').classList.remove('open'); document.getElementById('mobileOverlay').classList.remove('open'); } | |
| /* ===== Dashboard ===== */ | |
| async function loadDashboard() { | |
| try { | |
| const [uRes, sRes, aRes, fRes] = await Promise.all([ | |
| API.get('users', { limit: 500 }), | |
| API.get('sessions', { limit: 200 }), | |
| API.get('attendance', { limit: 1000 }), | |
| API.get('fraud_alerts', { limit: 200 }) | |
| ]); | |
| const users = uRes.data || []; | |
| const sessions = sRes.data || []; | |
| const att = aRes.data || []; | |
| const fraud = fRes.data || []; | |
| document.getElementById('sys-users').textContent = users.length; | |
| document.getElementById('sys-students').textContent = users.filter(u => u.role === 'student').length; | |
| document.getElementById('sys-teachers').textContent = users.filter(u => u.role === 'teacher').length; | |
| document.getElementById('sys-sessions').textContent = sessions.length; | |
| document.getElementById('sys-att').textContent = att.length; | |
| document.getElementById('sys-fraud').textContent = fraud.filter(f => !f.resolved).length; | |
| document.getElementById('fraudBadge').textContent = fraud.filter(f => !f.resolved).length; | |
| // Live sessions | |
| const liveSessions = sessions.filter(s => s.is_active); | |
| document.getElementById('forceEndBtn').style.display = liveSessions.length ? 'block' : 'none'; | |
| const liveEl = document.getElementById('liveSessionsList'); | |
| if (!liveSessions.length) { | |
| liveEl.innerHTML = `<div class="empty-state" style="padding:30px"><div class="empty-state-icon">📱</div><h3>No Active Sessions</h3><p>No teachers are currently running attendance sessions.</p></div>`; | |
| } else { | |
| const userMap = {}; | |
| users.forEach(u => { userMap[u.id] = u; }); | |
| liveEl.innerHTML = liveSessions.map(s => { | |
| const teacher = userMap[s.teacher_id] || {}; | |
| return `<div class="live-session-row"><div class="live-dot">LIVE</div><div style="flex:1"><strong>${s.subject}</strong> — ${s.class_name}</div><div class="text-sm text-gray">by ${teacher.name||'Unknown'}</div><div class="text-xs text-gray">${Utils.timeAgo(s.started_at||s.created_at)}</div></div>`; | |
| }).join(''); | |
| } | |
| } catch(e) { console.error('Dashboard load error:', e); } | |
| } | |
| /* ===== User Management ===== */ | |
| async function loadAllUsers() { | |
| try { | |
| const result = await API.get('users', { limit: 500 }); | |
| allUsers = result.data || []; | |
| renderUsersTable(allUsers); | |
| } catch(e) {} | |
| } | |
| function filterUsers(role, el) { | |
| document.querySelectorAll('.utab').forEach(t => t.classList.remove('active')); | |
| if (el) el.classList.add('active'); | |
| currentUserFilter = role; | |
| const filtered = role === 'all' ? allUsers : allUsers.filter(u => u.role === role); | |
| renderUsersTable(filtered); | |
| } | |
| function searchUsers() { | |
| const q = document.getElementById('userSearchInput').value.toLowerCase(); | |
| let filtered = currentUserFilter === 'all' ? allUsers : allUsers.filter(u => u.role === currentUserFilter); | |
| if (q) filtered = filtered.filter(u => (u.name||'').toLowerCase().includes(q) || (u.email||'').toLowerCase().includes(q) || (u.roll_number||'').toLowerCase().includes(q)); | |
| renderUsersTable(filtered); | |
| } | |
| function renderUsersTable(users) { | |
| const tbody = document.getElementById('usersTableBody'); | |
| if (!users.length) { | |
| tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:40px;color:var(--gray)"><div class="empty-state-icon">👥</div><p>No users found. Add users to get started.</p></td></tr>`; | |
| return; | |
| } | |
| const roleColors = { student: 'info', teacher: 'primary', admin: 'warning' }; | |
| tbody.innerHTML = users.map(u => `<tr> | |
| <td><div style="display:flex;align-items:center;gap:10px"><img src="${u.picture||`https://ui-avatars.com/api/?name=${encodeURIComponent(u.name||'?')}&background=4F46E5&color=fff`}" class="avatar avatar-sm">${u.name||'—'}</div></td> | |
| <td class="text-sm text-gray">${u.email||'—'}</td> | |
| <td><span class="badge badge-${roleColors[u.role]||'gray'}">${u.role||'—'}</span></td> | |
| <td class="text-sm">${u.roll_number||u.department||'—'}</td> | |
| <td class="text-sm">${u.institution||'—'}</td> | |
| <td><span class="badge badge-${u.is_active!==false?'success':'danger'}">${u.is_active!==false?'Active':'Inactive'}</span></td> | |
| <td class="text-sm text-gray">${u.last_login ? Utils.timeAgo(u.last_login) : 'Never'}</td> | |
| <td> | |
| <div style="display:flex;gap:6px"> | |
| <button class="btn btn-sm btn-secondary" onclick="toggleUserStatus('${u.id}', ${u.is_active!==false})">${u.is_active!==false?'Disable':'Enable'}</button> | |
| <button class="btn btn-sm btn-danger" onclick="deleteUser('${u.id}', '${u.name}')">Delete</button> | |
| </div> | |
| </td> | |
| </tr>`).join(''); | |
| } | |
| async function toggleUserStatus(id, isActive) { | |
| if (!confirm(`${isActive ? 'Disable' : 'Enable'} this user? ${isActive ? 'They will not be able to log in.' : ''}`)) return; | |
| try { | |
| await API.patch('users', id, { is_active: !isActive }); | |
| await Logger.log(isActive ? 'USER_DISABLED' : 'USER_ENABLED', 'users', { user_id: id }); | |
| Toast.success(`User ${isActive ? 'disabled' : 'enabled'}`); | |
| await loadAllUsers(); | |
| filterUsers(currentUserFilter, null); | |
| } catch(e) { Toast.error('Error updating user'); } | |
| } | |
| async function deleteUser(id, name) { | |
| if (!confirm(`Permanently delete user "${name}"? This cannot be undone.`)) return; | |
| try { | |
| await API.delete('users', id); | |
| await Logger.log('USER_DELETED', 'users', { user_id: id, name }); | |
| Toast.success('User deleted'); | |
| await loadAllUsers(); | |
| filterUsers(currentUserFilter, null); | |
| } catch(e) { Toast.error('Error deleting user'); } | |
| } | |
| function openAddUserModal() { openModal('addUserModal'); } | |
| function toggleRoleFields() { | |
| const role = document.getElementById('nu-role').value; | |
| document.getElementById('studentFields').style.display = role === 'student' ? 'block' : 'none'; | |
| } | |
| async function addUser() { | |
| const name = document.getElementById('nu-name').value.trim(); | |
| const email = document.getElementById('nu-email').value.trim().toLowerCase(); | |
| const role = document.getElementById('nu-role').value; | |
| const roll = document.getElementById('nu-roll').value.trim().toUpperCase(); | |
| const dept = document.getElementById('nu-dept').value.trim(); | |
| const institution = document.getElementById('nu-institution').value.trim(); | |
| if (!name || !email || !role) { | |
| showModalAlert('Please fill in all required fields.', 'danger', 'addUserAlert'); | |
| return; | |
| } | |
| if (role === 'student' && !roll) { | |
| showModalAlert('Roll number is required for students.', 'danger', 'addUserAlert'); | |
| return; | |
| } | |
| // Check for duplicate email | |
| const existing = allUsers.find(u => u.email && u.email.toLowerCase() === email); | |
| if (existing) { | |
| showModalAlert('A user with this email already exists.', 'danger', 'addUserAlert'); | |
| return; | |
| } | |
| try { | |
| const newUser = { | |
| id: Utils.generateUUID(), | |
| email, | |
| name, | |
| role, | |
| roll_number: role === 'student' ? roll : null, | |
| department: dept || null, | |
| institution: institution || null, | |
| is_active: true, | |
| face_data: null, | |
| picture: null, | |
| last_login: null | |
| }; | |
| await API.post('users', newUser); | |
| await Logger.log('USER_ADDED', 'users', { email, role, name }); | |
| Toast.success(`User "${name}" added as ${role}`); | |
| closeModal('addUserModal'); | |
| document.getElementById('nu-name').value = ''; | |
| document.getElementById('nu-email').value = ''; | |
| document.getElementById('nu-roll').value = ''; | |
| document.getElementById('nu-dept').value = ''; | |
| document.getElementById('nu-institution').value = ''; | |
| await loadAllUsers(); | |
| filterUsers(currentUserFilter, null); | |
| } catch(e) { showModalAlert('Error adding user. Please try again.', 'danger', 'addUserAlert'); } | |
| } | |
| /* ===== Sessions Admin ===== */ | |
| async function loadAdminSessions() { | |
| try { | |
| const [sRes, uRes] = await Promise.all([API.get('sessions', { limit: 200 }), API.get('users', { limit: 200 })]); | |
| const sessions = (sRes.data||[]).sort((a,b) => (b.started_at||b.created_at)-(a.started_at||a.created_at)); | |
| const userMap = {}; | |
| (uRes.data||[]).forEach(u => { userMap[u.id] = u; }); | |
| const tbody = document.getElementById('adminSessionsBody'); | |
| if (!sessions.length) { | |
| tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;padding:32px;color:var(--gray)"><p>No sessions yet</p></td></tr>`; | |
| return; | |
| } | |
| tbody.innerHTML = sessions.map(s => { | |
| const teacher = userMap[s.teacher_id] || {}; | |
| return `<tr> | |
| <td><strong>${s.subject}</strong></td> | |
| <td>${s.class_name}</td> | |
| <td>${teacher.name||'Unknown'}</td> | |
| <td class="text-sm">${Utils.formatDateTime(s.started_at||s.created_at)}</td> | |
| <td><span class="badge badge-${s.is_active?'danger':'gray'}">${s.is_active?'🔴 LIVE':'Ended'}</span></td> | |
| <td>${s.total_students||'—'}</td> | |
| <td>${s.is_active ? `<button class="btn btn-sm btn-danger" onclick="forceEndSession('${s.id}')">Force End</button>` : '—'}</td> | |
| </tr>`; | |
| }).join(''); | |
| } catch(e) {} | |
| } | |
| async function forceEndSession(id) { | |
| if (!confirm('Force end this session?')) return; | |
| try { | |
| await API.patch('sessions', id, { is_active: false, ended_at: Date.now() }); | |
| await Logger.log('SESSION_FORCE_ENDED', 'sessions', { session_id: id }); | |
| Toast.success('Session force-ended'); | |
| await loadAdminSessions(); | |
| } catch(e) { Toast.error('Error ending session'); } | |
| } | |
| async function forceEndAllSessions() { | |
| if (!confirm('Force end ALL active sessions? This will stop all ongoing attendance marking.')) return; | |
| try { | |
| const result = await API.get('sessions', { limit: 100 }); | |
| const live = (result.data||[]).filter(s => s.is_active); | |
| await Promise.all(live.map(s => API.patch('sessions', s.id, { is_active: false, ended_at: Date.now() }))); | |
| Toast.success(`${live.length} sessions ended`); | |
| await loadAdminSessions(); | |
| await loadDashboard(); | |
| } catch(e) { Toast.error('Error ending sessions'); } | |
| } | |
| /* ===== Attendance Admin ===== */ | |
| async function loadAdminAttendance() { | |
| try { | |
| const [aRes, uRes, sRes] = await Promise.all([ | |
| API.get('attendance', { limit: 1000 }), | |
| API.get('users', { limit: 500 }), | |
| API.get('sessions', { limit: 200 }) | |
| ]); | |
| const userMap = {}; | |
| (uRes.data||[]).forEach(u => { userMap[u.id] = u; }); | |
| const sessMap = {}; | |
| (sRes.data||[]).forEach(s => { sessMap[s.id] = s; }); | |
| let records = aRes.data || []; | |
| const rangeFilter = document.getElementById('adminAttFilter')?.value; | |
| const statusFilter = document.getElementById('adminAttStatus')?.value; | |
| const ranges = { today: Date.now()-86400000, week: Date.now()-7*86400000, month: Date.now()-30*86400000, all: 0 }; | |
| const cutoff = ranges[rangeFilter] || 0; | |
| records = records.filter(r => (r.marked_at||r.created_at) >= cutoff); | |
| if (statusFilter) records = records.filter(r => r.status === statusFilter); | |
| records.sort((a,b) => (b.marked_at||b.created_at) - (a.marked_at||a.created_at)); | |
| const tbody = document.getElementById('adminAttBody'); | |
| if (!records.length) { | |
| tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:32px;color:var(--gray)"><div class="empty-state-icon">📋</div><p>No records for selected filters</p></td></tr>`; | |
| return; | |
| } | |
| tbody.innerHTML = records.map(r => { | |
| const u = userMap[r.student_id] || {}; | |
| const s = sessMap[r.session_id] || {}; | |
| const flags = r.fraud_flags && r.fraud_flags.length ? `<span class="badge badge-danger">${r.fraud_flags.join(',')}</span>` : '—'; | |
| return `<tr> | |
| <td><strong>${r.roll_number||'—'}</strong></td> | |
| <td class="text-sm">${u.name||'Unknown'}</td> | |
| <td class="text-sm">${s.subject||'—'}</td> | |
| <td><span class="badge badge-${r.status==='present'||r.status==='late'?'success':r.status==='fraudulent'?'primary':'danger'}">${r.status}</span></td> | |
| <td class="text-sm">${Utils.formatDateTime(r.marked_at||r.created_at)}</td> | |
| <td class="text-sm text-gray">${r.gps_lat?`${r.gps_lat.toFixed(4)},${r.gps_lng.toFixed(4)}`:'—'}</td> | |
| <td class="text-sm">${r.face_match_score!=null?`${(r.face_match_score*100).toFixed(0)}%`:'—'}</td> | |
| <td>${flags}</td> | |
| </tr>`; | |
| }).join(''); | |
| } catch(e) { console.error('Attendance admin error:', e); } | |
| } | |
| /* ===== Fraud Admin ===== */ | |
| async function loadAdminFraud() { | |
| try { | |
| const [fRes, uRes] = await Promise.all([API.get('fraud_alerts', { limit: 500 }), API.get('users', { limit: 200 })]); | |
| const alerts = (fRes.data||[]).sort((a,b) => (b.detected_at||b.created_at)-(a.detected_at||a.created_at)); | |
| const userMap = {}; | |
| (uRes.data||[]).forEach(u => { userMap[u.id] = u; }); | |
| document.getElementById('fraudBadge').textContent = alerts.filter(a => !a.resolved).length; | |
| const el = document.getElementById('adminFraudList'); | |
| if (!alerts.length) { | |
| el.innerHTML = `<div class="empty-state" style="padding:60px"><div class="empty-state-icon">🛡️</div><h3>No Fraud Alerts</h3><p>System is clean. No suspicious activity detected.</p></div>`; | |
| return; | |
| } | |
| el.innerHTML = alerts.map(a => { | |
| const s = userMap[a.student_id] || {}; | |
| return `<div class="fraud-alert-card" style="margin-bottom:12px"> | |
| <div class="fraud-alert-icon">🚨</div> | |
| <div class="fraud-alert-content" style="flex:1"> | |
| <h4>${a.alert_type||'Suspicious Activity'}</h4> | |
| <p>${a.description}</p> | |
| <div style="display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap"> | |
| <span class="badge badge-${a.severity==='high'?'danger':a.severity==='medium'?'warning':'info'}">${a.severity}</span> | |
| ${s.name ? `<span class="text-sm">Student: <strong>${s.name}</strong> (${s.roll_number||'—'})</span>` : ''} | |
| <span class="text-xs text-gray">${Utils.formatDateTime(a.detected_at||a.created_at)}</span> | |
| ${a.resolved ? '<span class="badge badge-success">Resolved</span>' : ''} | |
| </div> | |
| </div> | |
| ${!a.resolved ? `<button class="btn btn-sm btn-secondary" onclick="resolveAlert('${a.id}')">Resolve</button>` : ''} | |
| </div>`; | |
| }).join(''); | |
| } catch(e) {} | |
| } | |
| async function resolveAlert(id) { | |
| try { | |
| await API.patch('fraud_alerts', id, { resolved: true }); | |
| Toast.success('Alert resolved'); | |
| await loadAdminFraud(); | |
| } catch(e) { Toast.error('Error resolving alert'); } | |
| } | |
| /* ===== Audit Logs ===== */ | |
| async function loadAuditLogs() { | |
| try { | |
| const result = await API.get('system_logs', { limit: 200 }); | |
| let logs = (result.data||[]).sort((a,b) => (b.timestamp||b.created_at)-(a.timestamp||a.created_at)); | |
| const filter = document.getElementById('logFilter')?.value; | |
| if (filter) logs = logs.filter(l => l.action === filter); | |
| const tbody = document.getElementById('logsTableBody'); | |
| if (!logs.length) { | |
| tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--gray)">No logs found</td></tr>`; | |
| return; | |
| } | |
| const uRes = await API.get('users', { limit: 200 }); | |
| const userMap = {}; | |
| (uRes.data||[]).forEach(u => { userMap[u.id] = u; }); | |
| tbody.innerHTML = logs.map(l => { | |
| const user = userMap[l.user_id] || {}; | |
| return `<tr> | |
| <td class="text-sm">${Utils.formatDateTime(l.timestamp||l.created_at)}</td> | |
| <td class="text-sm">${user.name||l.user_id||'system'}</td> | |
| <td><code style="font-size:12px;background:#F1F5F9;padding:2px 6px;border-radius:4px">${l.action||'—'}</code></td> | |
| <td class="text-sm text-gray">${l.resource||'—'}</td> | |
| <td><span class="badge badge-${l.status==='success'?'success':'danger'}">${l.status||'—'}</span></td> | |
| </tr>`; | |
| }).join(''); | |
| } catch(e) {} | |
| } | |
| /* ===== Analytics ===== */ | |
| async function loadAnalytics() { | |
| try { | |
| const [uRes, aRes, fRes] = await Promise.all([ | |
| API.get('users', { limit: 500 }), | |
| API.get('attendance', { limit: 1000 }), | |
| API.get('fraud_alerts', { limit: 200 }) | |
| ]); | |
| const users = uRes.data || []; | |
| const att = aRes.data || []; | |
| const fraudAlerts = fRes.data || []; | |
| // Daily trend | |
| const days = []; | |
| for (let i = 6; i >= 0; i--) { | |
| const d = new Date(Date.now() - i * 86400000); | |
| const start = new Date(d.setHours(0,0,0,0)).getTime(); | |
| const dayAtt = att.filter(a => { const ts = a.marked_at||a.created_at; return ts >= start && ts < start+86400000; }); | |
| days.push({ label: d.toLocaleDateString('en-US',{weekday:'short'}), present: dayAtt.filter(a=>a.status==='present'||a.status==='late').length, total: dayAtt.length }); | |
| } | |
| const tCtx = document.getElementById('adminTrendChart').getContext('2d'); | |
| if (adminTrendChart) adminTrendChart.destroy(); | |
| adminTrendChart = new Chart(tCtx, { | |
| type: 'line', | |
| data: { labels: days.map(d=>d.label), datasets: [ | |
| { label: 'Present', data: days.map(d=>d.present), borderColor: '#10B981', backgroundColor: 'rgba(16,185,129,0.1)', fill: true, tension: 0.4 }, | |
| { label: 'Total', data: days.map(d=>d.total), borderColor: '#4F46E5', backgroundColor: 'rgba(79,70,229,0.1)', fill: true, tension: 0.4 } | |
| ]}, | |
| options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' } } } | |
| }); | |
| // User distribution | |
| const students = users.filter(u=>u.role==='student').length; | |
| const teachers = users.filter(u=>u.role==='teacher').length; | |
| const admins = users.filter(u=>u.role==='admin').length; | |
| const uCtx = document.getElementById('adminUserChart').getContext('2d'); | |
| if (adminUserChart) adminUserChart.destroy(); | |
| adminUserChart = new Chart(uCtx, { | |
| type: 'doughnut', | |
| data: { labels: ['Students','Teachers','Admins'], datasets: [{ data: [students,teachers,admins], backgroundColor: ['#4F46E5','#06B6D4','#F59E0B'], borderWidth: 0 }]}, | |
| options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' } } } | |
| }); | |
| // Fraud summary | |
| const fraudByType = {}; | |
| fraudAlerts.forEach(a => { fraudByType[a.alert_type||'Unknown'] = (fraudByType[a.alert_type||'Unknown']||0) + 1; }); | |
| const fCtx = document.getElementById('adminFraudChart').getContext('2d'); | |
| if (adminFraudChart) adminFraudChart.destroy(); | |
| adminFraudChart = new Chart(fCtx, { | |
| type: 'bar', | |
| data: { labels: Object.keys(fraudByType), datasets: [{ label: 'Alerts', data: Object.values(fraudByType), backgroundColor: 'rgba(239,68,68,0.7)', borderRadius: 6 }]}, | |
| options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false } }, y: { grid: { color: '#f1f5f9' }, ticks: { stepSize: 1 } } } } | |
| }); | |
| } catch(e) { console.error('Analytics error:', e); } | |
| } | |
| /* ===== Config ===== */ | |
| async function saveConfig() { | |
| Toast.success('Configuration saved successfully'); | |
| await Logger.log('CONFIG_UPDATED', 'system', { admin: currentAdmin.id }); | |
| } | |
| /* ===== Recent Logs ===== */ | |
| async function loadRecentLogs() { | |
| try { | |
| const result = await API.get('system_logs', { limit: 10 }); | |
| const logs = (result.data||[]).sort((a,b) => (b.timestamp||b.created_at)-(a.timestamp||a.created_at)).slice(0,5); | |
| const el = document.getElementById('recentLogs'); | |
| if (!logs.length) { | |
| el.innerHTML = `<div class="empty-state" style="padding:20px"><p>No recent activity</p></div>`; | |
| return; | |
| } | |
| const uRes = await API.get('users', { limit: 200 }); | |
| const userMap = {}; | |
| (uRes.data||[]).forEach(u => { userMap[u.id] = u; }); | |
| el.innerHTML = logs.map(l => { | |
| const u = userMap[l.user_id] || {}; | |
| return `<div class="activity-item"><div class="activity-content"><code style="font-size:12px;background:#F1F5F9;padding:1px 6px;border-radius:4px">${l.action}</code> by <strong>${u.name||l.user_id||'system'}</strong></div><div class="activity-time">${Utils.timeAgo(l.timestamp||l.created_at)}</div></div>`; | |
| }).join(''); | |
| } catch(e) {} | |
| } | |
| /* ===== Export ===== */ | |
| async function exportUsers() { | |
| const headers = ['Name','Email','Role','Roll Number','Department','Institution','Status']; | |
| const rows = allUsers.map(u => [u.name,u.email,u.role,u.roll_number||'',u.department||'',u.institution||'',u.is_active!==false?'Active':'Inactive'].map(v=>`"${v||''}"`).join(',')); | |
| const csv = [headers.join(','),...rows].join('\n'); | |
| const blob = new Blob([csv], { type: 'text/csv' }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = `users_export_${Date.now()}.csv`; | |
| a.click(); | |
| Toast.success('Users exported'); | |
| } | |
| async function exportAllAttendance() { | |
| Toast.info('Preparing export...'); | |
| const [aRes, uRes, sRes] = await Promise.all([API.get('attendance',{limit:2000}), API.get('users',{limit:500}), API.get('sessions',{limit:200})]); | |
| const userMap = {}; | |
| (uRes.data||[]).forEach(u => { userMap[u.id] = u; }); | |
| const sessMap = {}; | |
| (sRes.data||[]).forEach(s => { sessMap[s.id] = s; }); | |
| const headers = ['Roll No','Name','Email','Status','Session','Time','GPS','Face Score','Fraud Flags']; | |
| const rows = (aRes.data||[]).map(r => { | |
| const u = userMap[r.student_id]||{}; | |
| const s = sessMap[r.session_id]||{}; | |
| return [r.roll_number||'',u.name||'',u.email||'',r.status,s.subject||'',Utils.formatDateTime(r.marked_at||r.created_at),r.gps_lat?`${r.gps_lat},${r.gps_lng}`:'',r.face_match_score||'',(r.fraud_flags||[]).join(';')].map(v=>`"${v||''}"`).join(','); | |
| }); | |
| const csv = [headers.join(','),...rows].join('\n'); | |
| const blob = new Blob([csv], { type: 'text/csv' }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = `all_attendance_${Date.now()}.csv`; | |
| a.click(); | |
| Toast.success('Attendance exported'); | |
| } | |
| /* ===== Modal Helpers ===== */ | |
| function openModal(id) { document.getElementById(id).classList.add('open'); } | |
| function closeModal(id) { document.getElementById(id).classList.remove('open'); document.getElementById('addUserAlert').style.display='none'; } | |
| function showModalAlert(msg, type, id) { | |
| const el = document.getElementById(id); | |
| el.style.display = 'block'; | |
| el.innerHTML = `<div class="alert alert-${type}"><span>${type==='danger'?'❌':'⚠️'}</span><div>${msg}</div></div>`; | |
| } | |
| async function confirmAction(action, msg) { | |
| if (!confirm(msg)) return; | |
| if (action === 'clear_fraud') { | |
| const result = await API.get('fraud_alerts', { limit: 500 }); | |
| await Promise.all((result.data||[]).filter(a=>!a.resolved).map(a => API.patch('fraud_alerts', a.id, { resolved: true }))); | |
| Toast.success('All fraud alerts resolved'); | |
| await loadAdminFraud(); | |
| } else if (action === 'end_sessions') { | |
| await forceEndAllSessions(); | |
| } | |
| } | |
| async function refreshAll() { | |
| Toast.info('Refreshing...'); | |
| await Promise.all([loadDashboard(), loadAllUsers(), loadAdminSessions(), loadAdminFraud(), loadAuditLogs()]); | |
| Toast.success('Refreshed'); | |
| } | |
| /* ===== Init ===== */ | |
| window.addEventListener('load', () => { | |
| checkAdminAuth(); | |
| if (APP_CONFIG.googleClientId !== 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com' && typeof google !== 'undefined') { | |
| google.accounts.id.initialize({ client_id: APP_CONFIG.googleClientId, callback: handleGoogleCredential, auto_select: false }); | |
| // Render official Google button | |
| google.accounts.id.renderButton( | |
| document.getElementById("adminGoogleBtnContainer"), | |
| { theme: "outline", size: "large", width: "100%", text: "signin_with", shape: "rectangular" } | |
| ); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |