Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"> | |
| <title>Student Dashboard — 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/student.css"> | |
| <!-- QR Scanner Library --> | |
| <script src="https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js"></script> | |
| <!-- Face API --> | |
| <script src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js"></script> | |
| <!-- Chart.js --> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> | |
| </head> | |
| <body> | |
| <!-- Main App --> | |
| <div class="student-app" id="studentApp" style="display:none"> | |
| <!-- Mobile Header --> | |
| <header class="student-header"> | |
| <div class="header-brand"> | |
| <div class="header-logo">AI</div> | |
| <span>AttendAI Pro</span> | |
| </div> | |
| <div class="header-user" id="headerUser"> | |
| <img id="userAvatar" src="" alt="" class="avatar avatar-sm"> | |
| <button class="btn btn-sm btn-secondary" onclick="logout()">Sign Out</button> | |
| </div> | |
| </header> | |
| <!-- Welcome Card --> | |
| <div class="student-welcome" id="welcomeSection"> | |
| <div class="welcome-avatar-wrap"> | |
| <img id="welcomeAvatar" src="" alt="Profile" class="welcome-avatar"> | |
| <div class="online-indicator"></div> | |
| </div> | |
| <div class="welcome-text"> | |
| <h2 id="welcomeName">Loading...</h2> | |
| <p id="welcomeDetails">Fetching your profile...</p> | |
| </div> | |
| <div class="attendance-overview" id="attendanceOverview"> | |
| <!-- Loaded dynamically --> | |
| </div> | |
| </div> | |
| <!-- Navigation Tabs --> | |
| <div class="student-nav-tabs"> | |
| <button class="s-tab active" data-tab="home" onclick="switchTab('home', this)"> | |
| <span class="tab-icon">🏠</span> | |
| <span>Home</span> | |
| </button> | |
| <button class="s-tab" data-tab="scan" onclick="switchTab('scan', this)"> | |
| <span class="tab-icon">📷</span> | |
| <span>Scan QR</span> | |
| </button> | |
| <button class="s-tab" data-tab="records" onclick="switchTab('records', this)"> | |
| <span class="tab-icon">📋</span> | |
| <span>My Attendance</span> | |
| </button> | |
| <button class="s-tab" data-tab="alerts" onclick="switchTab('alerts', this)"> | |
| <span class="tab-icon">🔔</span> | |
| <span>Alerts</span> | |
| </button> | |
| </div> | |
| <!-- Tab Content --> | |
| <div class="tab-content"> | |
| <!-- Home Tab --> | |
| <div class="tab-pane active" id="tab-home"> | |
| <div class="home-actions"> | |
| <div class="action-card scan-action" onclick="switchTab('scan', document.querySelector('[data-tab=scan]'))"> | |
| <div class="action-icon">📷</div> | |
| <h3>Scan QR Code</h3> | |
| <p>Mark attendance for current class</p> | |
| <div class="action-arrow">→</div> | |
| </div> | |
| <div class="action-card records-action" onclick="switchTab('records', document.querySelector('[data-tab=records]'))"> | |
| <div class="action-icon">📊</div> | |
| <h3>My Records</h3> | |
| <p>View all attendance history</p> | |
| <div class="action-arrow">→</div> | |
| </div> | |
| </div> | |
| <!-- Quick Stats --> | |
| <div class="quick-stats" id="quickStats"> | |
| <div class="qstat-card"> | |
| <div class="qstat-label">This Month</div> | |
| <div class="qstat-value" id="statMonth">—</div> | |
| <div class="qstat-sub">Classes Attended</div> | |
| </div> | |
| <div class="qstat-card"> | |
| <div class="qstat-label">Attendance %</div> | |
| <div class="qstat-value" id="statPercent">—</div> | |
| <div class="qstat-sub">Overall Rate</div> | |
| </div> | |
| <div class="qstat-card"> | |
| <div class="qstat-label">Total Absent</div> | |
| <div class="qstat-value" id="statAbsent">—</div> | |
| <div class="qstat-sub">Classes Missed</div> | |
| </div> | |
| </div> | |
| <!-- Attendance Trend Chart --> | |
| <div class="card mt-16"> | |
| <div class="section-header"> | |
| <div class="section-title">Attendance Trend</div> | |
| <span class="badge badge-info" id="trendPeriod">Last 30 Days</span> | |
| </div> | |
| <div style="height:200px;position:relative"> | |
| <canvas id="trendChart"></canvas> | |
| </div> | |
| <div id="trendEmpty" class="empty-state" style="padding:30px;display:none"> | |
| <div class="empty-state-icon" style="font-size:2.5rem">📊</div> | |
| <h3>No attendance data yet</h3> | |
| <p>Start marking attendance to see your trend</p> | |
| </div> | |
| </div> | |
| <!-- Low Attendance Warning --> | |
| <div id="lowAttendanceWarn" class="hidden" style="margin-top:16px"> | |
| <div class="alert alert-danger"> | |
| <span class="alert-icon">⚠️</span> | |
| <div> | |
| <strong>Attendance Alert!</strong><br> | |
| Your attendance is below 75%. You are at risk of losing eligibility for exams. <a href="#" onclick="switchTab('records', document.querySelector('[data-tab=records]'))">View Details →</a> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Scan QR Tab --> | |
| <div class="tab-pane hidden" id="tab-scan"> | |
| <!-- Step 1: Enter Session Code / Scan QR --> | |
| <div id="scanStep1" class="scan-step"> | |
| <h2 class="scan-title">📷 Scan Attendance QR</h2> | |
| <p class="scan-desc">Point your camera at the QR code displayed by your teacher</p> | |
| <!-- QR Scanner --> | |
| <div class="qr-scan-box" id="qrScanBox"> | |
| <div id="qr-reader" style="width:100%;border-radius:16px;overflow:hidden"></div> | |
| <div id="qrScanPlaceholder" class="qr-placeholder"> | |
| <div class="qr-placeholder-icon">📷</div> | |
| <p>Camera will open here</p> | |
| <button class="btn btn-primary" onclick="startQRScanner()">Start Camera</button> | |
| </div> | |
| </div> | |
| <div class="scan-separator">— OR —</div> | |
| <div class="form-group"> | |
| <label class="form-label">Enter Session Code Manually</label> | |
| <div style="display:flex;gap:10px"> | |
| <input type="text" class="form-control" id="manualCode" placeholder="Enter 8-character code..." maxlength="32" style="text-transform:uppercase;letter-spacing:2px"> | |
| <button class="btn btn-primary" onclick="verifyManualCode()" style="white-space:nowrap">Verify</button> | |
| </div> | |
| </div> | |
| <div id="scan1Alert" style="margin-top:12px;display:none"></div> | |
| </div> | |
| <!-- Step 2: Roll Number Verification --> | |
| <div id="scanStep2" class="scan-step hidden"> | |
| <div class="step-indicator"> | |
| <div class="step-badge">Step 2 of 4</div> | |
| <h2 class="scan-title">🎓 Verify Your Identity</h2> | |
| </div> | |
| <div class="session-info-card" id="sessionInfoCard"> | |
| <!-- Loaded dynamically --> | |
| </div> | |
| <div class="form-group"> | |
| <label class="form-label">Your Roll Number</label> | |
| <input type="text" class="form-control form-control-lg" id="rollInput" placeholder="e.g. CS2024001" style="text-align:center;letter-spacing:2px;font-size:18px;font-weight:700"> | |
| <div class="form-hint">Enter exactly as registered in the database</div> | |
| </div> | |
| <div id="scan2Alert" style="margin-top:12px;display:none"></div> | |
| <button class="btn btn-primary btn-full btn-lg" onclick="verifyRollNumber()">Verify Roll Number →</button> | |
| <button class="btn btn-secondary btn-full" style="margin-top:10px" onclick="resetScan()">← Cancel</button> | |
| </div> | |
| <!-- Step 3: Face Capture + GPS --> | |
| <div id="scanStep3" class="scan-step hidden"> | |
| <div class="step-badge">Step 3 of 4</div> | |
| <h2 class="scan-title">🤖 Face + Location Check</h2> | |
| <p class="scan-desc">Look directly at the camera. Your location will be verified automatically.</p> | |
| <div class="camera-container" id="cameraContainer"> | |
| <video id="selfieVideo" class="camera-video" autoplay playsinline muted></video> | |
| <div class="camera-overlay"> | |
| <div class="face-guide" id="faceGuide"></div> | |
| <div class="camera-status" id="cameraStatus">Position your face in the circle</div> | |
| </div> | |
| </div> | |
| <div class="verification-checklist" id="verificationChecklist"> | |
| <div class="check-item" id="checkFace"> | |
| <span class="check-spinner">⏳</span> | |
| <span>Face Detection</span> | |
| </div> | |
| <div class="check-item" id="checkGPS"> | |
| <span class="check-spinner">⏳</span> | |
| <span>GPS Location</span> | |
| </div> | |
| <div class="check-item" id="checkGeofence"> | |
| <span class="check-spinner">⏳</span> | |
| <span>Geofence Check</span> | |
| </div> | |
| </div> | |
| <div id="scan3Alert" style="margin-top:12px;display:none"></div> | |
| <button class="btn btn-primary btn-full btn-lg" id="captureBtn" onclick="captureAndVerify()" disabled> | |
| <span class="spinner" id="captureSpinner" style="display:none"></span> | |
| Capture & Mark Attendance | |
| </button> | |
| <button class="btn btn-secondary btn-full" style="margin-top:10px" onclick="resetScan()">Cancel</button> | |
| </div> | |
| <!-- Step 4: Result --> | |
| <div id="scanStep4" class="scan-step hidden"> | |
| <div class="attendance-result" id="attendanceResult"> | |
| <!-- Success or failure state loaded dynamically --> | |
| </div> | |
| <button class="btn btn-primary btn-full btn-lg" style="margin-top:20px" onclick="resetScan()">Done</button> | |
| </div> | |
| </div> | |
| <!-- Attendance Records Tab --> | |
| <div class="tab-pane hidden" id="tab-records"> | |
| <div class="records-header"> | |
| <h2>My Attendance Records</h2> | |
| <div class="records-filter"> | |
| <select class="form-control form-select" id="recordsFilter" onchange="loadAttendanceRecords()"> | |
| <option value="all">All Time</option> | |
| <option value="month">This Month</option> | |
| <option value="week">This Week</option> | |
| </select> | |
| </div> | |
| </div> | |
| <!-- Summary --> | |
| <div class="records-summary" id="recordsSummary" style="display:none"> | |
| <!-- Dynamically loaded --> | |
| </div> | |
| <!-- Records List --> | |
| <div id="recordsList"> | |
| <div class="empty-state"> | |
| <div class="empty-state-icon">📋</div> | |
| <h3>Loading records...</h3> | |
| <p>Fetching your attendance history</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Alerts Tab --> | |
| <div class="tab-pane hidden" id="tab-alerts"> | |
| <h2 style="margin-bottom:16px">Notifications & Alerts</h2> | |
| <div id="alertsList"> | |
| <div class="empty-state"> | |
| <div class="empty-state-icon">🔔</div> | |
| <h3>No notifications</h3> | |
| <p>Alerts will appear here when your attendance is flagged or you have warnings.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Auth Loading Screen --> | |
| <div class="auth-check-screen" id="authCheckScreen"> | |
| <div class="spinner spinner-dark" style="width:32px;height:32px;border-width:3px"></div> | |
| <p>Verifying session...</p> | |
| </div> | |
| <script src="js/config.js"></script> | |
| <script> | |
| /* ============================================= | |
| Student Dashboard — Core Logic | |
| ============================================= */ | |
| let currentUser = null; | |
| let currentSessionToken = null; | |
| let currentSession = null; | |
| let selfieStream = null; | |
| let qrScanner = null; | |
| let gpsData = null; | |
| let faceDetected = false; | |
| let trendChart = null; | |
| // ---- Auth Guard ---- | |
| async function init() { | |
| currentUser = requireAuth('student'); | |
| if (!currentUser) return; | |
| document.getElementById('authCheckScreen').style.display = 'none'; | |
| document.getElementById('studentApp').style.display = 'block'; | |
| // Populate UI | |
| document.getElementById('userAvatar').src = currentUser.picture || 'https://ui-avatars.com/api/?name=' + encodeURIComponent(currentUser.name) + '&background=4F46E5&color=fff'; | |
| document.getElementById('welcomeAvatar').src = currentUser.picture || 'https://ui-avatars.com/api/?name=' + encodeURIComponent(currentUser.name) + '&background=4F46E5&color=fff'; | |
| document.getElementById('welcomeName').textContent = `Hey, ${currentUser.name.split(' ')[0]}! 👋`; | |
| document.getElementById('welcomeDetails').textContent = `${currentUser.roll_number || 'Roll N/A'} • ${currentUser.department || 'Department N/A'}`; | |
| await loadStats(); | |
| await loadAttendanceRecords(); | |
| await loadAlerts(); | |
| } | |
| // ---- Tab Navigation ---- | |
| function switchTab(tabName, el) { | |
| document.querySelectorAll('.s-tab').forEach(t => t.classList.remove('active')); | |
| document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden')); | |
| if (el) el.classList.add('active'); | |
| const pane = document.getElementById('tab-' + tabName); | |
| if (pane) { pane.classList.remove('hidden'); pane.classList.add('fade-in'); } | |
| if (tabName === 'scan') { stopQRScanner(); resetScan(); } | |
| if (tabName === 'records') loadAttendanceRecords(); | |
| } | |
| // ---- Load Stats ---- | |
| async function loadStats() { | |
| try { | |
| const result = await API.get('attendance', { limit: 500 }); | |
| const records = (result.data || []).filter(r => r.student_id === currentUser.id); | |
| const present = records.filter(r => r.status === 'present' || r.status === 'late'); | |
| const total = records.length; | |
| const absent = records.filter(r => r.status === 'absent').length; | |
| const pct = total > 0 ? Math.round((present.length / total) * 100) : 0; | |
| // Month filter | |
| const monthAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; | |
| const monthRecords = records.filter(r => r.marked_at > monthAgo || r.created_at > monthAgo); | |
| document.getElementById('statMonth').textContent = monthRecords.filter(r => r.status !== 'absent').length; | |
| document.getElementById('statPercent').textContent = `${pct}%`; | |
| document.getElementById('statPercent').className = 'qstat-value text-' + Utils.percentColor(pct); | |
| document.getElementById('statAbsent').textContent = absent; | |
| if (pct < 75 && total > 0) { | |
| document.getElementById('lowAttendanceWarn').classList.remove('hidden'); | |
| } | |
| // Overview | |
| const overviewEl = document.getElementById('attendanceOverview'); | |
| overviewEl.innerHTML = ` | |
| <div class="overview-stat"> | |
| <div class="ov-value text-success">${present.length}</div> | |
| <div class="ov-label">Present</div> | |
| </div> | |
| <div class="ov-divider"></div> | |
| <div class="overview-stat"> | |
| <div class="ov-value text-danger">${absent}</div> | |
| <div class="ov-label">Absent</div> | |
| </div> | |
| <div class="ov-divider"></div> | |
| <div class="overview-stat"> | |
| <div class="ov-value" style="color:${pct>=75?'var(--success)':pct>=60?'var(--warning)':'var(--danger)'}">${pct}%</div> | |
| <div class="ov-label">Overall</div> | |
| </div> | |
| `; | |
| // Trend Chart | |
| buildTrendChart(records); | |
| } catch (e) { | |
| console.error('Stats load error:', e); | |
| } | |
| } | |
| function buildTrendChart(records) { | |
| const last7 = []; | |
| for (let i = 6; i >= 0; i--) { | |
| const date = new Date(Date.now() - i * 86400000); | |
| const dayStr = date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); | |
| const dayStart = new Date(date.setHours(0,0,0,0)).getTime(); | |
| const dayEnd = dayStart + 86400000; | |
| const dayRecords = records.filter(r => { | |
| const ts = r.marked_at || r.created_at; | |
| return ts >= dayStart && ts < dayEnd; | |
| }); | |
| const present = dayRecords.filter(r => r.status === 'present' || r.status === 'late').length; | |
| last7.push({ label: date.toLocaleDateString('en-US',{weekday:'short'}), present, total: dayRecords.length }); | |
| } | |
| if (last7.every(d => d.total === 0)) { | |
| document.getElementById('trendEmpty').style.display = 'block'; | |
| return; | |
| } | |
| const ctx = document.getElementById('trendChart').getContext('2d'); | |
| if (trendChart) trendChart.destroy(); | |
| trendChart = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: last7.map(d => d.label), | |
| datasets: [{ | |
| label: 'Present', | |
| data: last7.map(d => d.present), | |
| backgroundColor: 'rgba(16,185,129,0.8)', | |
| borderRadius: 6, | |
| }, { | |
| label: 'Absent', | |
| data: last7.map(d => d.total - d.present), | |
| backgroundColor: 'rgba(239,68,68,0.5)', | |
| borderRadius: 6, | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { legend: { display: false } }, | |
| scales: { | |
| x: { grid: { display: false }, border: { display: false } }, | |
| y: { grid: { color: '#f1f5f9' }, border: { display: false }, ticks: { stepSize: 1, font: { size: 11 } } } | |
| } | |
| } | |
| }); | |
| } | |
| // ---- QR Scanner ---- | |
| function startQRScanner() { | |
| document.getElementById('qrScanPlaceholder').style.display = 'none'; | |
| if (qrScanner) { qrScanner.clear(); } | |
| qrScanner = new Html5Qrcode("qr-reader"); | |
| qrScanner.start( | |
| { facingMode: "environment" }, | |
| { fps: 10, qrbox: { width: 220, height: 220 } }, | |
| (decodedText) => { | |
| qrScanner.stop(); | |
| processQRCode(decodedText); | |
| }, | |
| (error) => {} | |
| ).catch(err => { | |
| showScanAlert('Camera access denied or unavailable. Please use manual code entry.', 'danger', 'scan1Alert'); | |
| }); | |
| } | |
| function stopQRScanner() { | |
| if (qrScanner) { | |
| try { qrScanner.stop(); } catch(e){} | |
| qrScanner = null; | |
| } | |
| } | |
| async function processQRCode(token) { | |
| stopQRScanner(); | |
| await verifyQRToken(token.trim().toUpperCase()); | |
| } | |
| async function verifyManualCode() { | |
| const code = document.getElementById('manualCode').value.trim().toUpperCase(); | |
| if (!code || code.length < 6) { | |
| showScanAlert('Please enter a valid session code.', 'danger', 'scan1Alert'); | |
| return; | |
| } | |
| await verifyQRToken(code); | |
| } | |
| async function verifyQRToken(token) { | |
| try { | |
| showScanAlert('Verifying QR code...', 'info', 'scan1Alert'); | |
| // Find matching active session | |
| const result = await API.get('sessions', { limit: 100 }); | |
| const sessions = result.data || []; | |
| const session = sessions.find(s => | |
| s.qr_code === token && | |
| s.is_active === true && | |
| s.qr_expires_at > Date.now() | |
| ); | |
| if (!session) { | |
| showScanAlert('QR code is invalid or has expired. Ask your teacher to refresh the code.', 'danger', 'scan1Alert'); | |
| return; | |
| } | |
| // Check if already marked attendance for this session | |
| const attResult = await API.get('attendance', { limit: 500 }); | |
| const existing = (attResult.data || []).find(a => | |
| a.session_id === session.id && a.student_id === currentUser.id | |
| ); | |
| if (existing) { | |
| showScanAlert(`Attendance already marked for this session (${existing.status}). Duplicate attempts are not allowed.`, 'warning', 'scan1Alert'); | |
| return; | |
| } | |
| currentSessionToken = token; | |
| currentSession = session; | |
| // Move to Step 2 | |
| showScanAlert('', '', 'scan1Alert'); | |
| document.getElementById('scanStep1').classList.add('hidden'); | |
| document.getElementById('scanStep2').classList.remove('hidden'); | |
| document.getElementById('sessionInfoCard').innerHTML = ` | |
| <div class="sinfo-item"><span>📚</span> <strong>Subject:</strong> ${session.subject || 'N/A'}</div> | |
| <div class="sinfo-item"><span>👤</span> <strong>Class:</strong> ${session.class_name || 'N/A'}</div> | |
| <div class="sinfo-item"><span>⏰</span> <strong>Session Started:</strong> ${Utils.formatDateTime(session.started_at || session.created_at)}</div> | |
| <div class="sinfo-item"><span>⏱️</span> <strong>QR Expires In:</strong> <span id="qrExpireCountdown" class="text-warning">calculating...</span></div> | |
| `; | |
| // Countdown for step 2 | |
| updateExpireCountdown(session.qr_expires_at); | |
| } catch (e) { | |
| console.error('QR verification error:', e); | |
| showScanAlert('Error verifying QR code. Please try again.', 'danger', 'scan1Alert'); | |
| } | |
| } | |
| function updateExpireCountdown(expireTime) { | |
| const el = document.getElementById('qrExpireCountdown'); | |
| if (!el) return; | |
| const remaining = Math.max(0, Math.floor((expireTime - Date.now()) / 1000)); | |
| if (remaining === 0) { | |
| if (el) el.textContent = 'EXPIRED — Go back and scan again'; | |
| el.className = 'text-danger font-bold'; | |
| return; | |
| } | |
| el.textContent = `${remaining}s remaining`; | |
| setTimeout(() => updateExpireCountdown(expireTime), 1000); | |
| } | |
| async function verifyRollNumber() { | |
| const roll = document.getElementById('rollInput').value.trim().toUpperCase(); | |
| if (!roll) { | |
| showScanAlert('Please enter your roll number.', 'danger', 'scan2Alert'); | |
| return; | |
| } | |
| if (currentUser.roll_number && roll !== currentUser.roll_number.toUpperCase()) { | |
| showScanAlert(`Roll number doesn't match your registered profile. Expected: ${currentUser.roll_number}`, 'danger', 'scan2Alert'); | |
| await Logger.log('FRAUD_ATTEMPT', 'attendance', { | |
| student_id: currentUser.id, expected_roll: currentUser.roll_number, entered_roll: roll, session_id: currentSession.id | |
| }, 'failure'); | |
| return; | |
| } | |
| if (currentSession.qr_expires_at < Date.now()) { | |
| showScanAlert('Session QR has expired. Please go back and scan the new code.', 'danger', 'scan2Alert'); | |
| return; | |
| } | |
| // Move to Step 3 | |
| document.getElementById('scanStep2').classList.add('hidden'); | |
| document.getElementById('scanStep3').classList.remove('hidden'); | |
| startSelfieCamera(); | |
| getGPSLocation(); | |
| } | |
| // ---- Camera + GPS ---- | |
| async function startSelfieCamera() { | |
| try { | |
| selfieStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: 640, height: 480 } }); | |
| document.getElementById('selfieVideo').srcObject = selfieStream; | |
| document.getElementById('cameraStatus').textContent = 'Camera ready — look at the screen'; | |
| document.getElementById('captureBtn').disabled = false; | |
| faceDetected = true; // Simplified: enable after camera ready | |
| document.getElementById('faceGuide').classList.add('detected'); | |
| updateCheckItem('checkFace', 'done', 'Face Detected ✓'); | |
| } catch (e) { | |
| document.getElementById('cameraStatus').textContent = 'Camera unavailable'; | |
| showScanAlert('Camera access denied. Please allow camera access and try again.', 'danger', 'scan3Alert'); | |
| updateCheckItem('checkFace', 'failed', 'Camera Access Denied'); | |
| } | |
| } | |
| async function getGPSLocation() { | |
| updateCheckItem('checkGPS', 'loading', 'Getting GPS...'); | |
| try { | |
| gpsData = await Utils.getGeoLocation(); | |
| updateCheckItem('checkGPS', 'done', `GPS: ±${Math.round(gpsData.accuracy)}m`); | |
| if (currentSession.geofence_lat && currentSession.geofence_lng) { | |
| const dist = Utils.getDistance(gpsData.lat, gpsData.lng, currentSession.geofence_lat, currentSession.geofence_lng); | |
| const radius = currentSession.geofence_radius || APP_CONFIG.geofenceRadius; | |
| if (dist <= radius) { | |
| updateCheckItem('checkGeofence', 'done', `Within range (${Math.round(dist)}m) ✓`); | |
| } else { | |
| updateCheckItem('checkGeofence', 'failed', `Out of range (${Math.round(dist)}m from classroom)`); | |
| showScanAlert(`You are ${Math.round(dist)} meters from the classroom. Maximum allowed: ${radius}m. You must be physically present.`, 'danger', 'scan3Alert'); | |
| } | |
| } else { | |
| updateCheckItem('checkGeofence', 'done', 'Geofence check passed ✓'); | |
| } | |
| } catch (e) { | |
| gpsData = null; | |
| updateCheckItem('checkGPS', 'failed', 'GPS unavailable'); | |
| updateCheckItem('checkGeofence', 'failed', 'Cannot verify location'); | |
| showScanAlert('GPS location is required for attendance. Please enable location services.', 'danger', 'scan3Alert'); | |
| } | |
| } | |
| function updateCheckItem(id, state, text) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| const icons = { loading: '⏳', done: '✅', failed: '❌' }; | |
| el.innerHTML = `<span>${icons[state] || '⏳'}</span><span>${text}</span>`; | |
| el.className = 'check-item ' + state; | |
| } | |
| async function captureAndVerify() { | |
| const btn = document.getElementById('captureBtn'); | |
| btn.disabled = true; | |
| document.getElementById('captureSpinner').style.display = 'inline-block'; | |
| btn.lastChild.textContent = ' Processing...'; | |
| try { | |
| if (!gpsData && currentSession.geofence_lat) { | |
| showScanAlert('GPS location is required. Please allow location access.', 'danger', 'scan3Alert'); | |
| btn.disabled = false; | |
| document.getElementById('captureSpinner').style.display = 'none'; | |
| return; | |
| } | |
| if (currentSession.qr_expires_at < Date.now()) { | |
| showScanAlert('QR session expired during verification. Please restart.', 'danger', 'scan3Alert'); | |
| return; | |
| } | |
| // Capture selfie frame | |
| const video = document.getElementById('selfieVideo'); | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = video.videoWidth || 640; | |
| canvas.height = video.videoHeight || 480; | |
| canvas.getContext('2d').drawImage(video, 0, 0); | |
| const selfieData = canvas.toDataURL('image/jpeg', 0.7); | |
| // Stop camera | |
| if (selfieStream) { selfieStream.getTracks().forEach(t => t.stop()); } | |
| // Device fingerprint for fraud detection | |
| const deviceFP = Utils.getDeviceFingerprint(); | |
| // Check if same device already submitted for this session | |
| const attResult = await API.get('attendance', { limit: 500 }); | |
| const existing = (attResult.data || []).find(a => a.session_id === currentSession.id && a.student_id === currentUser.id); | |
| if (existing) { | |
| showResult('error', 'Duplicate Attempt', 'You have already marked attendance for this session.'); | |
| return; | |
| } | |
| // Fraud checks | |
| const fraudFlags = []; | |
| if (!gpsData) fraudFlags.push('no_gps'); | |
| if (selfieData.length < 1000) fraudFlags.push('invalid_selfie'); | |
| // Mark attendance | |
| const isLate = false; // Could compute based on session start time | |
| const attendanceRecord = { | |
| id: Utils.generateUUID(), | |
| session_id: currentSession.id, | |
| student_id: currentUser.id, | |
| roll_number: currentUser.roll_number || document.getElementById('rollInput').value.trim().toUpperCase(), | |
| status: fraudFlags.length > 0 ? 'fraudulent' : (isLate ? 'late' : 'present'), | |
| marked_at: Date.now(), | |
| gps_lat: gpsData ? gpsData.lat : null, | |
| gps_lng: gpsData ? gpsData.lng : null, | |
| gps_accuracy: gpsData ? gpsData.accuracy : null, | |
| face_match_score: 0.92, // Placeholder; real value from face-api.js | |
| device_fingerprint: deviceFP, | |
| ip_address: 'client', | |
| fraud_flags: fraudFlags, | |
| verified: fraudFlags.length === 0 | |
| }; | |
| await API.post('attendance', attendanceRecord); | |
| await Logger.log('ATTENDANCE_MARKED', 'attendance', { session_id: currentSession.id, student_id: currentUser.id, status: attendanceRecord.status }, 'success'); | |
| if (fraudFlags.length > 0) { | |
| await API.post('fraud_alerts', { | |
| id: Utils.generateUUID(), | |
| session_id: currentSession.id, | |
| student_id: currentUser.id, | |
| alert_type: fraudFlags.join(','), | |
| severity: 'medium', | |
| description: `Flags detected: ${fraudFlags.join(', ')}`, | |
| resolved: false, | |
| detected_at: Date.now() | |
| }); | |
| } | |
| showResult('success', 'Attendance Marked!', ` | |
| <strong>${currentUser.name}</strong><br> | |
| Roll: ${attendanceRecord.roll_number}<br> | |
| Time: ${Utils.formatTime(attendanceRecord.marked_at)}<br> | |
| Status: <strong style="color:var(--success)">${attendanceRecord.status.toUpperCase()}</strong> | |
| `); | |
| await loadStats(); | |
| } catch (e) { | |
| console.error('Capture error:', e); | |
| showResult('error', 'Verification Failed', 'An error occurred during verification. Please try again.'); | |
| } | |
| } | |
| function showResult(type, title, message) { | |
| document.getElementById('scanStep3').classList.add('hidden'); | |
| document.getElementById('scanStep4').classList.remove('hidden'); | |
| const icon = type === 'success' ? '✅' : '❌'; | |
| const color = type === 'success' ? 'var(--success)' : 'var(--danger)'; | |
| document.getElementById('attendanceResult').innerHTML = ` | |
| <div class="result-icon" style="font-size:4rem;text-align:center;margin-bottom:16px">${icon}</div> | |
| <h2 style="text-align:center;color:${color};margin-bottom:12px">${title}</h2> | |
| <div style="background:#F8FAFC;border-radius:12px;padding:20px;text-align:center;font-size:15px;line-height:2;color:#475569">${message}</div> | |
| `; | |
| } | |
| function resetScan() { | |
| stopQRScanner(); | |
| if (selfieStream) { selfieStream.getTracks().forEach(t => t.stop()); selfieStream = null; } | |
| currentSessionToken = null; | |
| currentSession = null; | |
| gpsData = null; | |
| faceDetected = false; | |
| document.getElementById('scanStep1').classList.remove('hidden'); | |
| document.getElementById('scanStep2').classList.add('hidden'); | |
| document.getElementById('scanStep3').classList.add('hidden'); | |
| document.getElementById('scanStep4').classList.add('hidden'); | |
| document.getElementById('manualCode').value = ''; | |
| document.getElementById('rollInput').value = ''; | |
| document.getElementById('qrScanPlaceholder').style.display = 'flex'; | |
| ['scan1Alert','scan2Alert','scan3Alert'].forEach(id => { const el = document.getElementById(id); if(el) el.style.display='none'; }); | |
| ['checkFace','checkGPS','checkGeofence'].forEach(id => updateCheckItem(id, 'loading', id.replace('check','') + ' Pending...')); | |
| } | |
| function showScanAlert(msg, type, id) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| if (!msg) { el.style.display='none'; return; } | |
| el.style.display='block'; | |
| const icons = {danger:'❌',warning:'⚠️',info:'ℹ️',success:'✅'}; | |
| el.innerHTML = `<div class="alert alert-${type}"><span class="alert-icon">${icons[type]||'ℹ️'}</span><div>${msg}</div></div>`; | |
| } | |
| // ---- Attendance Records ---- | |
| async function loadAttendanceRecords() { | |
| const listEl = document.getElementById('recordsList'); | |
| try { | |
| const result = await API.get('attendance', { limit: 500 }); | |
| let records = (result.data || []).filter(r => r.student_id === currentUser.id); | |
| const filter = document.getElementById('recordsFilter')?.value; | |
| if (filter === 'month') { | |
| const cutoff = Date.now() - 30 * 86400000; | |
| records = records.filter(r => (r.marked_at || r.created_at) > cutoff); | |
| } else if (filter === 'week') { | |
| const cutoff = Date.now() - 7 * 86400000; | |
| records = records.filter(r => (r.marked_at || r.created_at) > cutoff); | |
| } | |
| records.sort((a,b) => (b.marked_at||b.created_at) - (a.marked_at||a.created_at)); | |
| if (!records.length) { | |
| listEl.innerHTML = `<div class="empty-state"><div class="empty-state-icon">📋</div><h3>No attendance records</h3><p>Your attendance records will appear here once you start scanning QR codes.</p></div>`; | |
| document.getElementById('recordsSummary').style.display = 'none'; | |
| return; | |
| } | |
| const present = records.filter(r => r.status === 'present' || r.status === 'late').length; | |
| const total = records.length; | |
| const pct = Math.round((present/total)*100); | |
| const summaryEl = document.getElementById('recordsSummary'); | |
| summaryEl.style.display = 'grid'; | |
| summaryEl.innerHTML = ` | |
| <div class="rsummary-card green"><div class="rs-value">${present}</div><div class="rs-label">Present</div></div> | |
| <div class="rsummary-card red"><div class="rs-value">${total-present}</div><div class="rs-label">Absent</div></div> | |
| <div class="rsummary-card ${pct>=75?'green':'red'}"><div class="rs-value">${pct}%</div><div class="rs-label">Rate</div></div> | |
| `; | |
| // Fetch session info for labels | |
| const sessResult = await API.get('sessions', {limit:100}); | |
| const sessions = {}; | |
| (sessResult.data||[]).forEach(s => { sessions[s.id] = s; }); | |
| listEl.innerHTML = records.map(r => { | |
| const sess = sessions[r.session_id] || {}; | |
| const statusColors = { present:'success', absent:'danger', late:'warning', fraudulent:'primary' }; | |
| const statusIcons = { present:'✅', absent:'❌', late:'🕒', fraudulent:'🚨' }; | |
| return ` | |
| <div class="record-card"> | |
| <div class="record-status ${statusColors[r.status]||'gray'}"> | |
| ${statusIcons[r.status]||'—'} | |
| </div> | |
| <div class="record-info"> | |
| <div class="record-subject">${sess.subject || 'Unknown Subject'}</div> | |
| <div class="record-meta">${sess.class_name||''} • ${Utils.formatDateTime(r.marked_at||r.created_at)}</div> | |
| </div> | |
| <div class="badge badge-${statusColors[r.status]||'gray'}">${r.status}</div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } catch (e) { | |
| listEl.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠️</div><h3>Error loading records</h3><p>Please refresh the page</p></div>`; | |
| } | |
| } | |
| // ---- Alerts ---- | |
| async function loadAlerts() { | |
| try { | |
| const result = await API.get('fraud_alerts', { limit:100 }); | |
| const alerts = (result.data||[]).filter(a => a.student_id === currentUser.id && !a.resolved); | |
| const listEl = document.getElementById('alertsList'); | |
| if (!alerts.length) { | |
| listEl.innerHTML = `<div class="empty-state"><div class="empty-state-icon">🔔</div><h3>No active alerts</h3><p>All clear! No issues detected on your account.</p></div>`; | |
| return; | |
| } | |
| listEl.innerHTML = alerts.map(a => ` | |
| <div class="fraud-alert-card"> | |
| <div class="fraud-alert-icon">🚨</div> | |
| <div class="fraud-alert-content"> | |
| <h4>${a.alert_type || 'Fraud Detected'}</h4> | |
| <p>${a.description}</p> | |
| <span class="badge badge-${a.severity==='high'?'danger':a.severity==='medium'?'warning':'info'}">${a.severity}</span> | |
| <span class="text-xs text-gray" style="margin-left:8px">${Utils.formatDateTime(a.detected_at||a.created_at)}</span> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } catch(e) {} | |
| } | |
| // ---- Init ---- | |
| document.addEventListener('DOMContentLoaded', init); | |
| </script> | |
| </body> | |
| </html> | |