Spaces:
Runtime error
Runtime error
| /** | |
| * SecureAttend AI - Frontend Orchestration Engine | |
| * Coordinates tabs, API requests, multi-angle enrollment, dynamic SVG charts, and real-time SSE triggers | |
| */ | |
| class SecureAttendApp { | |
| constructor() { | |
| this.activeTab = 'dashboard'; | |
| this.currentEnrollId = null; | |
| this.currentEnrollName = null; | |
| this.capturedAngles = { | |
| center: false, | |
| left: false, | |
| right: false, | |
| up: false, | |
| down: false | |
| }; | |
| this.eventSource = null; | |
| this.sessionLogsCount = 0; | |
| this.cameraActive = true; | |
| this.listenersSetup = false; | |
| // Client-side camera state variables | |
| this.videoEl = null; | |
| this.localStream = null; | |
| this.scanIntervalId = null; | |
| this.latestScanResults = null; | |
| this.scanlineY = 15; | |
| this.scanlineDirection = 3; | |
| // Auth & Session state | |
| this.token = localStorage.getItem('secureattend_token') || null; | |
| this.role = localStorage.getItem('secureattend_role') || null; | |
| this.username = localStorage.getItem('secureattend_username') || null; | |
| // Bind methods to keep correct 'this' context | |
| this.init = this.init.bind(this); | |
| this.switchTab = this.switchTab.bind(this); | |
| this.loadStats = this.loadStats.bind(this); | |
| this.loadEmployees = this.loadEmployees.bind(this); | |
| this.loadLogs = this.loadLogs.bind(this); | |
| this.loadAnalytics = this.loadAnalytics.bind(this); | |
| this.setupSSE = this.setupSSE.bind(this); | |
| this.triggerToast = this.triggerToast.bind(this); | |
| this.checkAuth = this.checkAuth.bind(this); | |
| this.handleLogin = this.handleLogin.bind(this); | |
| this.handleLogout = this.handleLogout.bind(this); | |
| } | |
| getHeaders() { | |
| return { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${this.token}` | |
| }; | |
| } | |
| checkAuth() { | |
| const overlay = document.getElementById('login-overlay'); | |
| if (!this.token || !this.role) { | |
| overlay.style.display = 'flex'; | |
| document.body.classList.remove('kiosk-mode'); | |
| return false; | |
| } | |
| overlay.style.display = 'none'; | |
| // Apply Role UI customization | |
| const badge = document.getElementById('role-badge'); | |
| const badgeContainer = document.getElementById('role-badge-container'); | |
| const registerBtn = document.getElementById('btn-open-enrollment'); | |
| const saveCamBtn = document.getElementById('btn-save-camera'); | |
| if (this.role === 'admin') { | |
| document.body.classList.remove('kiosk-mode'); | |
| document.getElementById('btn-kiosk-escape').style.display = 'none'; | |
| if (badge) { | |
| badge.textContent = 'ADMIN TERMINAL NODE'; | |
| badge.style.borderColor = 'var(--neon-cyan)'; | |
| badge.style.color = 'var(--neon-cyan)'; | |
| badge.style.background = 'rgba(0, 255, 242, 0.1)'; | |
| } | |
| if (badgeContainer) badgeContainer.style.display = 'block'; | |
| if (registerBtn) registerBtn.style.display = 'inline-flex'; | |
| if (saveCamBtn) { | |
| saveCamBtn.disabled = false; | |
| saveCamBtn.style.opacity = '1'; | |
| saveCamBtn.textContent = 'Switch Active Camera Source'; | |
| } | |
| } else if (this.role === 'hr') { | |
| document.body.classList.remove('kiosk-mode'); | |
| document.getElementById('btn-kiosk-escape').style.display = 'none'; | |
| if (badge) { | |
| badge.textContent = 'HR OPERATOR SCOPE'; | |
| badge.style.borderColor = 'var(--neon-green)'; | |
| badge.style.color = 'var(--neon-green)'; | |
| badge.style.background = 'rgba(46, 204, 113, 0.1)'; | |
| } | |
| if (badgeContainer) badgeContainer.style.display = 'block'; | |
| if (registerBtn) registerBtn.style.display = 'none'; // Hide registration for HR | |
| if (saveCamBtn) { | |
| saveCamBtn.disabled = true; | |
| saveCamBtn.style.opacity = '0.5'; | |
| saveCamBtn.textContent = 'Hardware Locked for HR'; | |
| } | |
| } else if (this.role === 'kiosk') { | |
| document.body.classList.add('kiosk-mode'); | |
| document.getElementById('btn-kiosk-escape').style.display = 'inline-flex'; | |
| if (badgeContainer) badgeContainer.style.display = 'none'; | |
| // Force direct lock inside webcam scan screen | |
| this.switchTab('scanner'); | |
| } | |
| return true; | |
| } | |
| async handleLogin() { | |
| const userEl = document.getElementById('login-username'); | |
| const passEl = document.getElementById('login-password'); | |
| const errorEl = document.getElementById('login-error-msg'); | |
| const username = userEl.value.trim(); | |
| const password = passEl.value.trim(); | |
| if (!username || !password) { | |
| errorEl.textContent = "Credential fields cannot be blank."; | |
| errorEl.style.display = 'block'; | |
| return; | |
| } | |
| try { | |
| const response = await fetch('/api/auth/login', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ username, password }) | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) throw new Error(data.detail || "Access handshake rejected."); | |
| localStorage.setItem('secureattend_token', data.token); | |
| localStorage.setItem('secureattend_role', data.role); | |
| localStorage.setItem('secureattend_username', data.username); | |
| this.token = data.token; | |
| this.role = data.role; | |
| this.username = data.username; | |
| errorEl.style.display = 'none'; | |
| userEl.value = ''; | |
| passEl.value = ''; | |
| // Re-init application parameters | |
| this.init(); | |
| this.triggerToast("Session Authorized", `Welcome back, security role: ${this.role.toUpperCase()}`, "check_in"); | |
| } catch (err) { | |
| errorEl.textContent = err.message; | |
| errorEl.style.display = 'block'; | |
| } | |
| } | |
| handleLogout() { | |
| // If admin, send a debounced request to shut down camera hardware capture thread | |
| if (this.role === 'admin' && this.token) { | |
| fetch('/api/camera/toggle', { | |
| method: 'POST', | |
| headers: this.getHeaders(), | |
| body: JSON.stringify({ active: false }) | |
| }).catch(() => {}); | |
| } | |
| this.stopClientWebcam(); | |
| localStorage.removeItem('secureattend_token'); | |
| localStorage.removeItem('secureattend_role'); | |
| localStorage.removeItem('secureattend_username'); | |
| this.token = null; | |
| this.role = null; | |
| this.username = null; | |
| if (this.eventSource) { | |
| this.eventSource.close(); | |
| this.eventSource = null; | |
| } | |
| this.checkAuth(); | |
| } | |
| init() { | |
| this.videoEl = document.getElementById('client-webcam'); | |
| this.setupEventListeners(); | |
| this.startLiveClock(); | |
| if (!this.checkAuth()) { | |
| return; | |
| } | |
| this.loadStats(); | |
| this.loadLogs(); | |
| this.loadEmployees(); | |
| this.loadAnalytics(); | |
| this.setupSSE(); | |
| console.log("[+] SecureAttend Client initialized successfully!"); | |
| } | |
| startLiveClock() { | |
| const clockEl = document.getElementById('live-clock'); | |
| const updateClock = () => { | |
| const now = new Date(); | |
| let hours = now.getHours(); | |
| const minutes = String(now.getMinutes()).padStart(2, '0'); | |
| const seconds = String(now.getSeconds()).padStart(2, '0'); | |
| const ampm = hours >= 12 ? 'PM' : 'AM'; | |
| hours = hours % 12; | |
| hours = hours ? hours : 12; | |
| clockEl.textContent = `${hours}:${minutes}:${seconds} ${ampm}`; | |
| }; | |
| setInterval(updateClock, 1000); | |
| updateClock(); | |
| } | |
| setupEventListeners() { | |
| if (this.listenersSetup) return; | |
| // 1. Sidebar Tab Navigation | |
| document.querySelectorAll('.nav-item').forEach(item => { | |
| item.addEventListener('click', (e) => { | |
| if (this.role === 'kiosk') return; // navigation locked in Kiosk mode | |
| const tab = e.currentTarget.getAttribute('data-tab'); | |
| this.switchTab(tab); | |
| }); | |
| }); | |
| // 2. Open Register Modal | |
| const btnOpenEnroll = document.getElementById('btn-open-enrollment'); | |
| if (btnOpenEnroll) { | |
| btnOpenEnroll.addEventListener('click', () => this.openEnrollmentWizard()); | |
| } | |
| // 3. Wizard Navigation Events | |
| document.getElementById('btn-cancel-wizard').addEventListener('click', () => this.closeEnrollmentWizard()); | |
| document.getElementById('btn-step1-next').addEventListener('click', () => this.submitWizardStep1()); | |
| document.getElementById('btn-step2-back').addEventListener('click', () => this.backToWizardStep1()); | |
| document.getElementById('btn-step2-next').addEventListener('click', () => this.completeWizardEnrollment()); | |
| document.getElementById('btn-wizard-finish').addEventListener('click', () => this.closeEnrollmentWizard()); | |
| // 4. Directory Local Filter Search | |
| document.getElementById('directory-search').addEventListener('input', (e) => { | |
| this.filterDirectoryTable(e.target.value); | |
| }); | |
| // 5. Admin Settings - Save Camera Select | |
| document.getElementById('btn-save-camera').addEventListener('click', () => this.saveCameraSettings()); | |
| // 6. Camera On/Off Toggle Button | |
| const btnToggleCam = document.getElementById('btn-toggle-camera'); | |
| if (btnToggleCam) { | |
| btnToggleCam.addEventListener('click', () => this.toggleCameraFeed()); | |
| } | |
| // 7. Auth Event Listeners | |
| document.getElementById('btn-login-submit').addEventListener('click', this.handleLogin); | |
| document.getElementById('nav-logout').addEventListener('click', this.handleLogout); | |
| // Kiosk Mode Exit Handler | |
| document.getElementById('btn-kiosk-escape').addEventListener('click', () => { | |
| const pass = prompt("Enter ADMIN secure keypass to escape Kiosk Mode:"); | |
| if (pass === 'admin123') { | |
| this.handleLogout(); | |
| } else if (pass !== null) { | |
| alert("Unauthorized security keypass! Access denied."); | |
| } | |
| }); | |
| this.listenersSetup = true; | |
| } | |
| switchTab(tabId) { | |
| if (this.activeTab === tabId) return; | |
| // 1. Swap navigation classes | |
| document.querySelectorAll('.nav-item').forEach(item => { | |
| if (item.getAttribute('data-tab') === tabId) { | |
| item.classList.add('active'); | |
| } else { | |
| item.classList.remove('active'); | |
| } | |
| }); | |
| // 2. Swap active panel visibility | |
| document.querySelectorAll('.tab-panel').forEach(panel => { | |
| panel.classList.remove('active'); | |
| }); | |
| const targetPanel = document.getElementById(`tab-${tabId}`); | |
| if (targetPanel) { | |
| targetPanel.classList.add('active'); | |
| } | |
| // 3. Update Title Header | |
| const titles = { | |
| dashboard: { title: "Dashboard Overview", subtitle: "Real-time attendance metrics & engine status." }, | |
| scanner: { title: "Facial Live Scanner", subtitle: "Stand in front of camera to auto log check-ins." }, | |
| directory: { title: "Employee Directory", subtitle: "Management portal for employee credentials." }, | |
| settings: { title: "Admin Portal Settings", subtitle: "Configure camera nodes, thresholds, and parameters." } | |
| }; | |
| if (titles[tabId]) { | |
| document.getElementById('view-title-text').textContent = titles[tabId].title; | |
| document.getElementById('view-subtitle-text').textContent = titles[tabId].subtitle; | |
| } | |
| this.activeTab = tabId; | |
| // Reload data if opening specific tabs | |
| if (tabId === 'dashboard') { | |
| this.loadStats(); | |
| this.loadLogs(); | |
| this.loadAnalytics(); | |
| } else if (tabId === 'directory') { | |
| this.loadEmployees(); | |
| } | |
| // Manage client-side webcam stream based on active tab | |
| if (tabId === 'scanner' && this.cameraActive && this.token) { | |
| this.startClientWebcam().then(success => { | |
| if (success) { | |
| this.startScanningLoop(); | |
| requestAnimationFrame(() => this.renderCanvasLoop()); | |
| } | |
| }); | |
| } else { | |
| // If we are not in enrollment wizard, stop the camera | |
| if (document.getElementById('wizard-modal').style.display !== 'flex') { | |
| this.stopClientWebcam(); | |
| } | |
| } | |
| } | |
| // REST API: Load Metrics | |
| async loadStats() { | |
| try { | |
| const response = await fetch('/api/attendance/stats', { | |
| headers: this.getHeaders() | |
| }); | |
| if (!response.ok) throw new Error("Failed to fetch dashboard statistics."); | |
| const stats = await response.json(); | |
| document.getElementById('stat-total').textContent = stats.total_employees; | |
| document.getElementById('stat-present').textContent = stats.present_count; | |
| document.getElementById('stat-out').textContent = stats.checked_out_count; | |
| document.getElementById('stat-absent').textContent = stats.absent_count; | |
| } catch (err) { | |
| console.error("[-] Error loading stats:", err); | |
| } | |
| } | |
| // REST API: Load Logs | |
| async loadLogs() { | |
| try { | |
| const response = await fetch('/api/attendance/logs?limit=25', { | |
| headers: this.getHeaders() | |
| }); | |
| if (!response.ok) throw new Error("Failed to fetch logs."); | |
| const logs = await response.json(); | |
| const listEl = document.getElementById('dashboard-activity-list'); | |
| listEl.innerHTML = ''; | |
| if (logs.length === 0) { | |
| listEl.innerHTML = ` | |
| <div class="activity-item" style="justify-content: center; color: var(--text-muted); border: none; background: transparent;"> | |
| <p>No scans recorded yet today.</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| logs.forEach(log => { | |
| const item = document.createElement('div'); | |
| item.className = `activity-item ${log.event_type}`; | |
| const logTime = new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); | |
| const initial = log.name.charAt(0).toUpperCase(); | |
| item.innerHTML = ` | |
| <div class="activity-avatar">${initial}</div> | |
| <div class="activity-details"> | |
| <h5>${log.name}</h5> | |
| <p>${log.role} • Score: ${parseInt(log.similarity_score * 100)}%</p> | |
| </div> | |
| <div style="text-align: right;"> | |
| <span class="activity-badge">${log.event_type.replace('_', ' ')}</span> | |
| <div style="font-size: 11px; color: var(--text-secondary); margin-top: 4px;">${logTime}</div> | |
| </div> | |
| `; | |
| listEl.appendChild(item); | |
| }); | |
| } catch (err) { | |
| console.error("[-] Error loading logs:", err); | |
| } | |
| } | |
| // REST API: Load Analytics statistics & draw SVG Charts | |
| async loadAnalytics() { | |
| if (this.role === 'kiosk') return; | |
| try { | |
| const response = await fetch('/api/attendance/analytics', { | |
| headers: this.getHeaders() | |
| }); | |
| if (!response.ok) throw new Error("Failed to fetch analytics statistics."); | |
| const data = await response.json(); | |
| this.renderWeeklyChart(data.weekly); | |
| this.renderHourlyChart(data.hourly); | |
| } catch (err) { | |
| console.error("[-] Error loading analytics:", err); | |
| } | |
| } | |
| // Dynamic SVG Area Spline render | |
| renderWeeklyChart(data) { | |
| const svg = document.getElementById('weekly-trends-svg'); | |
| if (!svg) return; | |
| // Clear previous dynamic elements | |
| svg.querySelectorAll('.dynamic-chart-element').forEach(el => el.remove()); | |
| const svgWidth = 500; | |
| const svgHeight = 200; | |
| const paddingLeft = 40; | |
| const paddingRight = 15; | |
| const paddingTop = 15; | |
| const paddingBottom = 30; | |
| const chartW = svgWidth - paddingLeft - paddingRight; | |
| const chartH = svgHeight - paddingTop - paddingBottom; | |
| const maxCount = Math.max(...data.map(d => d.count), 0); | |
| const yMax = maxCount > 0 ? Math.ceil(maxCount / 2) * 2 : 4; | |
| // 1. Gridlines and Labels | |
| const gridDivs = 4; | |
| for (let i = 0; i <= gridDivs; i++) { | |
| const yVal = yMax * (i / gridDivs); | |
| const yPos = svgHeight - paddingBottom - (i / gridDivs) * chartH; | |
| const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); | |
| line.setAttribute('x1', paddingLeft); | |
| line.setAttribute('y1', yPos); | |
| line.setAttribute('x2', svgWidth - paddingRight); | |
| line.setAttribute('y2', yPos); | |
| line.setAttribute('class', 'chart-grid-line dynamic-chart-element'); | |
| svg.appendChild(line); | |
| const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); | |
| text.setAttribute('x', paddingLeft - 10); | |
| text.setAttribute('y', yPos + 4); | |
| text.setAttribute('text-anchor', 'end'); | |
| text.setAttribute('class', 'chart-axis-text dynamic-chart-element'); | |
| text.textContent = Math.round(yVal); | |
| svg.appendChild(text); | |
| } | |
| if (data.length === 0) return; | |
| // 2. Map point positions | |
| const points = data.map((d, i) => { | |
| const x = paddingLeft + i * (chartW / (data.length - 1)); | |
| const y = svgHeight - paddingBottom - (d.count / yMax) * chartH; | |
| return { x, y, label: d.label, date: d.date, count: d.count }; | |
| }); | |
| // 3. Create Spline Path (Cubic Bezier curve logic) | |
| let pathD = `M ${points[0].x} ${points[0].y}`; | |
| for (let i = 0; i < points.length - 1; i++) { | |
| const p0 = points[i]; | |
| const p1 = points[i+1]; | |
| const cpX = (p0.x + p1.x) / 2; | |
| pathD += ` C ${cpX} ${p0.y}, ${cpX} ${p1.y}, ${p1.x} ${p1.y}`; | |
| } | |
| // 4. Fill Spline Gradient Area underneath path | |
| const areaD = pathD + ` L ${points[points.length-1].x} ${svgHeight - paddingBottom} L ${points[0].x} ${svgHeight - paddingBottom} Z`; | |
| const areaPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
| areaPath.setAttribute('d', areaD); | |
| areaPath.setAttribute('fill', 'url(#cyan-gradient)'); | |
| areaPath.setAttribute('class', 'dynamic-chart-element'); | |
| svg.appendChild(areaPath); | |
| // 5. Draw Spline neon line | |
| const strokePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
| strokePath.setAttribute('d', pathD); | |
| strokePath.setAttribute('class', 'chart-path-line dynamic-chart-element'); | |
| svg.appendChild(strokePath); | |
| // 6. Horizontal base line | |
| const baseLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); | |
| baseLine.setAttribute('x1', paddingLeft); | |
| baseLine.setAttribute('y1', svgHeight - paddingBottom); | |
| baseLine.setAttribute('x2', svgWidth - paddingRight); | |
| baseLine.setAttribute('y2', svgHeight - paddingBottom); | |
| baseLine.setAttribute('class', 'chart-axis-line dynamic-chart-element'); | |
| svg.appendChild(baseLine); | |
| // 7. Render Circles & Labels & Tooltips | |
| // Check if tooltip element already exists inside the card | |
| let tooltip = svg.parentNode.querySelector('.chart-tooltip'); | |
| if (!tooltip) { | |
| tooltip = document.createElement('div'); | |
| tooltip.className = 'chart-tooltip'; | |
| svg.parentNode.appendChild(tooltip); | |
| } | |
| points.forEach((pt) => { | |
| // Date Label text | |
| const dateText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); | |
| dateText.setAttribute('x', pt.x); | |
| dateText.setAttribute('y', svgHeight - paddingBottom + 18); | |
| dateText.setAttribute('text-anchor', 'middle'); | |
| dateText.setAttribute('class', 'chart-axis-text dynamic-chart-element'); | |
| dateText.textContent = pt.label; | |
| svg.appendChild(dateText); | |
| // Coordinate node circular point | |
| const node = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); | |
| node.setAttribute('cx', pt.x); | |
| node.setAttribute('cy', pt.y); | |
| node.setAttribute('r', '4'); | |
| node.setAttribute('class', 'chart-data-node dynamic-chart-element'); | |
| // Interactive mouse tooltip mappings | |
| node.addEventListener('mousemove', (e) => { | |
| tooltip.innerHTML = `<div style="font-weight:700;color:var(--neon-cyan);">${pt.count} Check-Ins</div><div style="font-size:9px;color:var(--text-muted);margin-top:2px;">${pt.date}</div>`; | |
| tooltip.style.opacity = '1'; | |
| const box = svg.parentNode.getBoundingClientRect(); | |
| tooltip.style.left = `${e.clientX - box.left + 15}px`; | |
| tooltip.style.top = `${e.clientY - box.top - 20}px`; | |
| }); | |
| node.addEventListener('mouseleave', () => { | |
| tooltip.style.opacity = '0'; | |
| }); | |
| svg.appendChild(node); | |
| }); | |
| } | |
| // Dynamic SVG Column Chart render | |
| renderHourlyChart(data) { | |
| const svg = document.getElementById('hourly-clusters-svg'); | |
| if (!svg) return; | |
| svg.querySelectorAll('.dynamic-chart-element').forEach(el => el.remove()); | |
| const svgWidth = 500; | |
| const svgHeight = 200; | |
| const paddingLeft = 35; | |
| const paddingRight = 15; | |
| const paddingTop = 15; | |
| const paddingBottom = 30; | |
| const chartW = svgWidth - paddingLeft - paddingRight; | |
| const chartH = svgHeight - paddingTop - paddingBottom; | |
| const maxCount = Math.max(...data.map(d => d.count), 0); | |
| const yMax = maxCount > 0 ? Math.ceil(maxCount / 2) * 2 : 4; | |
| // 1. Gridlines and Labels | |
| const gridDivs = 4; | |
| for (let i = 0; i <= gridDivs; i++) { | |
| const yVal = yMax * (i / gridDivs); | |
| const yPos = svgHeight - paddingBottom - (i / gridDivs) * chartH; | |
| const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); | |
| line.setAttribute('x1', paddingLeft); | |
| line.setAttribute('y1', yPos); | |
| line.setAttribute('x2', svgWidth - paddingRight); | |
| line.setAttribute('y2', yPos); | |
| line.setAttribute('class', 'chart-grid-line dynamic-chart-element'); | |
| svg.appendChild(line); | |
| const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); | |
| text.setAttribute('x', paddingLeft - 8); | |
| text.setAttribute('y', yPos + 4); | |
| text.setAttribute('text-anchor', 'end'); | |
| text.setAttribute('class', 'chart-axis-text dynamic-chart-element'); | |
| text.textContent = Math.round(yVal); | |
| svg.appendChild(text); | |
| } | |
| if (data.length === 0) return; | |
| // 2. Render Rounded Bars | |
| const barWidth = 22; | |
| const spacing = chartW / data.length; | |
| let tooltip = svg.parentNode.querySelector('.chart-tooltip'); | |
| if (!tooltip) { | |
| tooltip = document.createElement('div'); | |
| tooltip.className = 'chart-tooltip'; | |
| svg.parentNode.appendChild(tooltip); | |
| } | |
| data.forEach((d, i) => { | |
| const x = paddingLeft + i * spacing + (spacing - barWidth) / 2; | |
| const valHeight = (d.count / yMax) * chartH; | |
| const y = svgHeight - paddingBottom - valHeight; | |
| // SVG column node | |
| const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); | |
| rect.setAttribute('x', x); | |
| rect.setAttribute('y', y); | |
| rect.setAttribute('width', barWidth); | |
| rect.setAttribute('height', Math.max(valHeight, 1.5)); | |
| rect.setAttribute('rx', '4'); | |
| rect.setAttribute('class', 'chart-bar-column dynamic-chart-element'); | |
| // Interactive mouse tooltip mappings | |
| rect.addEventListener('mousemove', (e) => { | |
| tooltip.innerHTML = `<div style="font-weight:700;color:var(--neon-green);">${d.count} Scans</div><div style="font-size:9px;color:var(--text-muted);margin-top:2px;">Peak: ${d.label}</div>`; | |
| tooltip.style.opacity = '1'; | |
| const box = svg.parentNode.getBoundingClientRect(); | |
| tooltip.style.left = `${e.clientX - box.left + 15}px`; | |
| tooltip.style.top = `${e.clientY - box.top - 20}px`; | |
| }); | |
| rect.addEventListener('mouseleave', () => { | |
| tooltip.style.opacity = '0'; | |
| }); | |
| svg.appendChild(rect); | |
| // Axis label text | |
| const axisText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); | |
| axisText.setAttribute('x', x + barWidth / 2); | |
| axisText.setAttribute('y', svgHeight - paddingBottom + 18); | |
| axisText.setAttribute('text-anchor', 'middle'); | |
| axisText.setAttribute('class', 'chart-axis-text dynamic-chart-element'); | |
| axisText.textContent = d.label.replace(' ', ''); | |
| svg.appendChild(axisText); | |
| }); | |
| // 3. Horizontal base line | |
| const baseLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); | |
| baseLine.setAttribute('x1', paddingLeft); | |
| baseLine.setAttribute('y1', svgHeight - paddingBottom); | |
| baseLine.setAttribute('x2', svgWidth - paddingRight); | |
| baseLine.setAttribute('y2', svgHeight - paddingBottom); | |
| baseLine.setAttribute('class', 'chart-axis-line dynamic-chart-element'); | |
| svg.appendChild(baseLine); | |
| } | |
| // REST API: Load Employees Directory | |
| async loadEmployees() { | |
| try { | |
| const response = await fetch('/api/employees', { | |
| headers: this.getHeaders() | |
| }); | |
| if (!response.ok) throw new Error("Failed to fetch employees."); | |
| const employees = await response.json(); | |
| const tbody = document.getElementById('directory-table-body'); | |
| tbody.innerHTML = ''; | |
| if (employees.length === 0) { | |
| tbody.innerHTML = ` | |
| <tr> | |
| <td colspan="6" style="text-align: center; color: var(--text-muted); padding: 40px;"> | |
| No employees registered. Click "Register Employee" to begin biometric enrollment! | |
| </td> | |
| </tr> | |
| `; | |
| return; | |
| } | |
| employees.forEach(emp => { | |
| const tr = document.createElement('tr'); | |
| tr.setAttribute('data-id', emp.id.toLowerCase()); | |
| tr.setAttribute('data-name', emp.name.toLowerCase()); | |
| tr.setAttribute('data-role', emp.role.toLowerCase()); | |
| const joinDate = new Date(emp.created_at).toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }); | |
| // HR can only view directory, delete locks inside table for non-admins | |
| const actionHtml = this.role === 'admin' ? ` | |
| <button class="btn btn-danger" style="padding: 6px 12px; font-size: 12px; border-radius: 8px;" onclick="app.deleteEmployee('${emp.id}')"> | |
| <i class="material-icons" style="font-size: 16px;">delete</i> | |
| Delete | |
| </button> | |
| ` : `<span style="font-size: 12px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px;">Read-Only</span>`; | |
| tr.innerHTML = ` | |
| <td style="font-weight: 700; color: var(--neon-cyan);">${emp.id}</td> | |
| <td style="font-weight: 600;">${emp.name}</td> | |
| <td style="color: var(--text-secondary);">${emp.email}</td> | |
| <td>${emp.role}</td> | |
| <td style="color: var(--text-muted);">${joinDate}</td> | |
| <td style="text-align: right;">${actionHtml}</td> | |
| `; | |
| tbody.appendChild(tr); | |
| }); | |
| } catch (err) { | |
| console.error("[-] Error loading employees:", err); | |
| } | |
| } | |
| // Local Filter Directory search | |
| filterDirectoryTable(query) { | |
| const cleanQuery = query.toLowerCase().trim(); | |
| const rows = document.querySelectorAll('#directory-table-body tr'); | |
| rows.forEach(row => { | |
| if (row.cells.length === 1) return; | |
| const id = row.getAttribute('data-id') || ''; | |
| const name = row.getAttribute('data-name') || ''; | |
| const role = row.getAttribute('data-role') || ''; | |
| if (id.includes(cleanQuery) || name.includes(cleanQuery) || role.includes(cleanQuery)) { | |
| row.style.display = ''; | |
| } else { | |
| row.style.display = 'none'; | |
| } | |
| }); | |
| } | |
| // REST API: Delete Employee | |
| async deleteEmployee(empId) { | |
| if (this.role !== 'admin') { | |
| alert("Access Denied: Requires Admin rights."); | |
| return; | |
| } | |
| if (!confirm(`Are you absolutely sure you want to delete employee ${empId}? This will remove all their biometric face templates and history logs!`)) { | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`/api/employees/${empId}`, { | |
| method: 'DELETE', | |
| headers: this.getHeaders() | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) throw new Error(data.detail || "Failed to delete employee profile."); | |
| this.triggerToast("Profile Deleted", `Employee ${empId} has been successfully purged.`, "check_out"); | |
| this.loadEmployees(); | |
| } catch (err) { | |
| alert(`Error: ${err.message}`); | |
| } | |
| } | |
| // REST API: Change active camera index | |
| async saveCameraSettings() { | |
| if (this.role !== 'admin') { | |
| alert("Access Denied: Requires Admin privileges."); | |
| return; | |
| } | |
| const select = document.getElementById('camera-select'); | |
| const index = parseInt(select.value); | |
| try { | |
| const response = await fetch('/api/camera/select', { | |
| method: 'POST', | |
| headers: this.getHeaders(), | |
| body: JSON.stringify({ index: index }) | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) throw new Error(data.detail || "Failed to switch camera."); | |
| this.triggerToast("Camera Updated", `Successfully connected to camera device index ${index}!`, "check_in"); | |
| const stream = document.getElementById('live-stream-feed'); | |
| if (stream) { | |
| stream.src = `/api/stream/live?token=${this.token}&t=${new Date().getTime()}`; | |
| } | |
| } catch (err) { | |
| alert(`Hardware Error: ${err.message}`); | |
| } | |
| } | |
| // REST API: Turn Camera On/Off dynamically | |
| async toggleCameraFeed() { | |
| if (this.role !== 'admin') { | |
| alert("Access Denied: Requires Admin privileges."); | |
| return; | |
| } | |
| const nextState = !this.cameraActive; | |
| try { | |
| const response = await fetch('/api/camera/toggle', { | |
| method: 'POST', | |
| headers: this.getHeaders(), | |
| body: JSON.stringify({ active: nextState }) | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) throw new Error(data.detail || "Failed to toggle camera state."); | |
| this.cameraActive = nextState; | |
| // Update UI button state | |
| const btn = document.getElementById('btn-toggle-camera'); | |
| const badge = document.getElementById('cam-badge'); | |
| if (this.cameraActive) { | |
| btn.innerHTML = `<i class="material-icons" style="font-size: 18px;">videocam_off</i><span>Disable Camera</span>`; | |
| btn.style.borderColor = ''; | |
| btn.style.boxShadow = ''; | |
| btn.style.background = ''; | |
| badge.textContent = 'ACTIVE'; | |
| badge.style.background = 'rgba(46, 204, 113, 0.15)'; | |
| badge.style.borderColor = 'var(--neon-green)'; | |
| badge.style.color = 'var(--neon-green)'; | |
| this.startClientWebcam().then(success => { | |
| if (success) { | |
| this.startScanningLoop(); | |
| requestAnimationFrame(() => this.renderCanvasLoop()); | |
| } | |
| }); | |
| this.triggerToast("Camera Enabled", "Webcam hardware has been activated.", "check_in"); | |
| } else { | |
| btn.innerHTML = `<i class="material-icons" style="font-size: 18px;">videocam</i><span>Enable Camera</span>`; | |
| btn.style.borderColor = 'var(--neon-amber)'; | |
| btn.style.boxShadow = '0 0 10px rgba(241, 196, 15, 0.2)'; | |
| btn.style.background = 'rgba(241, 196, 15, 0.05)'; | |
| badge.textContent = 'OFFLINE'; | |
| badge.style.background = 'rgba(231, 76, 60, 0.15)'; | |
| badge.style.borderColor = 'var(--neon-red)'; | |
| badge.style.color = 'var(--neon-red)'; | |
| this.stopClientWebcam(); | |
| this.triggerToast("Camera Disabled", "Webcam hardware has been released.", "check_out"); | |
| } | |
| } catch (err) { | |
| alert(`Toggle Error: ${err.message}`); | |
| } | |
| } | |
| // SSE: Real-Time Attendance Logs alerts listener | |
| setupSSE() { | |
| if (this.eventSource) { | |
| this.eventSource.close(); | |
| } | |
| console.log("[*] Establishing real-time event stream connection..."); | |
| this.eventSource = new EventSource(`/api/stream/alerts?token=${this.token}`); | |
| this.eventSource.onmessage = (event) => { | |
| try { | |
| const alert = JSON.parse(event.data); | |
| console.log("[+] SSE ALERT RECEIVED:", alert); | |
| // 1. Intercept security threat warning spoofs | |
| if (alert.event_type === 'spoof_attempt') { | |
| this.triggerToast("Security Alert", "SPOOF SCAN BLOCKED: Motionless printed photo presented!", "spoof_attempt"); | |
| return; | |
| } | |
| // 2. Draw glowing toast popup on screen for standard check-ins/outs | |
| const eventTitle = alert.event_type === 'check_in' ? 'Check In Success' : 'Check Out Success'; | |
| this.triggerToast(eventTitle, `${alert.name} checked in successfully at ${alert.timestamp}!`, alert.event_type); | |
| // 3. Increment session log counter | |
| this.sessionLogsCount++; | |
| const counterEl = document.getElementById('session-log-count'); | |
| if (counterEl) { | |
| counterEl.textContent = `${this.sessionLogsCount} LOGS`; | |
| } | |
| // 4. Append to Session Scanner logs tab | |
| const scanConsole = document.getElementById('scanner-session-logs'); | |
| if (scanConsole) { | |
| if (scanConsole.children.length === 1 && scanConsole.children[0].style.border === 'none') { | |
| scanConsole.innerHTML = ''; | |
| } | |
| const scanItem = document.createElement('div'); | |
| scanItem.className = `activity-item ${alert.event_type}`; | |
| const initial = alert.name.charAt(0).toUpperCase(); | |
| scanItem.innerHTML = ` | |
| <div class="activity-avatar">${initial}</div> | |
| <div class="activity-details"> | |
| <h5>${alert.name}</h5> | |
| <p>Score: ${alert.score}% • Biometrics Verified</p> | |
| </div> | |
| <div style="text-align: right;"> | |
| <span class="activity-badge">${alert.event_type.replace('_', ' ')}</span> | |
| <div style="font-size: 11px; color: var(--text-secondary); margin-top: 4px;">${alert.timestamp}</div> | |
| </div> | |
| `; | |
| scanConsole.insertBefore(scanItem, scanConsole.firstChild); | |
| if (scanConsole.children.length > 10) { | |
| scanConsole.removeChild(scanConsole.lastChild); | |
| } | |
| } | |
| // 5. Force reload stats and dashboard logs in background | |
| this.loadStats(); | |
| this.loadLogs(); | |
| this.loadAnalytics(); | |
| } catch (err) { | |
| console.error("[-] Error parsing SSE event data:", err); | |
| } | |
| }; | |
| this.eventSource.onerror = (err) => { | |
| console.warn("[-] EventSource connection interrupted, trying to reconnect..."); | |
| setTimeout(() => { | |
| if (this.token) this.setupSSE(); | |
| }, 5000); | |
| }; | |
| } | |
| // UI Toast alert trigger | |
| triggerToast(title, message, eventType = 'check_in') { | |
| const container = document.getElementById('alerts-container'); | |
| if (!container) return; | |
| const toast = document.createElement('div'); | |
| // Choose CSS styling classes and icons based on alert context | |
| let toastClass = eventType; | |
| let icon = 'check'; | |
| let customStyles = ''; | |
| if (eventType === 'check_out') { | |
| icon = 'exit_to_app'; | |
| } else if (eventType === 'spoof_attempt') { | |
| toastClass = 'check_out'; // Use warning crimson accents | |
| icon = 'security'; | |
| // Inject crimson alarm custom borders/shadows directly | |
| customStyles = 'border-left-color: var(--neon-red) !important; box-shadow: 0 10px 30px rgba(231, 76, 60, 0.35) !important; border-right: 1px solid rgba(231, 76, 60, 0.25);'; | |
| } | |
| toast.className = `alert-toast ${toastClass}`; | |
| if (customStyles) { | |
| toast.style.cssText = customStyles; | |
| } | |
| let iconStyle = ''; | |
| if (eventType === 'spoof_attempt') { | |
| iconStyle = 'style="background: rgba(231, 76, 60, 0.1) !important; color: var(--neon-red) !important; box-shadow: 0 0 10px rgba(231, 76, 60, 0.3);"'; | |
| } | |
| toast.innerHTML = ` | |
| <div class="alert-toast-icon" ${iconStyle}><i class="material-icons">${icon}</i></div> | |
| <div class="alert-toast-content"> | |
| <h4 style="${eventType === 'spoof_attempt' ? 'color: var(--neon-red);' : ''}">${title}</h4> | |
| <p>${message}</p> | |
| </div> | |
| `; | |
| container.appendChild(toast); | |
| setTimeout(() => { | |
| toast.style.opacity = '0'; | |
| toast.style.transform = 'translateX(100px)'; | |
| setTimeout(() => { | |
| if (toast.parentNode === container) { | |
| container.removeChild(toast); | |
| } | |
| }, 300); | |
| }, 4500); | |
| } | |
| // Client Webcam helpers | |
| async startClientWebcam() { | |
| if (this.localStream) return true; | |
| try { | |
| this.localStream = await navigator.mediaDevices.getUserMedia({ | |
| video: { width: 640, height: 480, facingMode: "user" } | |
| }); | |
| this.videoEl.srcObject = this.localStream; | |
| this.videoEl.play(); | |
| console.log("[+] Client webcam stream initialized."); | |
| return true; | |
| } catch (err) { | |
| console.error("[-] Failed to open client webcam:", err); | |
| alert("Error accessing camera: Please grant camera permissions to use the Face Recognition features."); | |
| return false; | |
| } | |
| } | |
| stopClientWebcam() { | |
| if (this.scanIntervalId) { | |
| clearInterval(this.scanIntervalId); | |
| this.scanIntervalId = null; | |
| } | |
| if (this.localStream) { | |
| this.localStream.getTracks().forEach(track => track.stop()); | |
| this.localStream = null; | |
| this.videoEl.srcObject = null; | |
| console.log("[+] Client webcam stream stopped."); | |
| } | |
| this.latestScanResults = null; | |
| } | |
| startScanningLoop() { | |
| if (this.scanIntervalId) return; | |
| const offlineCanvas = document.createElement('canvas'); | |
| offlineCanvas.width = 640; | |
| offlineCanvas.height = 480; | |
| const offlineCtx = offlineCanvas.getContext('2d'); | |
| this.scanIntervalId = setInterval(async () => { | |
| if (this.activeTab !== 'scanner' || !this.localStream) return; | |
| // Draw mirrored to offline canvas for backend processing | |
| offlineCtx.save(); | |
| offlineCtx.translate(offlineCanvas.width, 0); | |
| offlineCtx.scale(-1, 1); | |
| offlineCtx.drawImage(this.videoEl, 0, 0, offlineCanvas.width, offlineCanvas.height); | |
| offlineCtx.restore(); | |
| // Export to JPEG blob | |
| offlineCanvas.toBlob(async (blob) => { | |
| if (!blob) return; | |
| const formData = new FormData(); | |
| formData.append('file', blob, 'frame.jpg'); | |
| try { | |
| const response = await fetch('/api/scan/frame', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${this.token}` | |
| }, | |
| body: formData | |
| }); | |
| if (response.status === 401) { | |
| this.handleLogout(); | |
| return; | |
| } | |
| const data = await response.json(); | |
| this.latestScanResults = data.faces || []; | |
| } catch (err) { | |
| console.error("[-] Frame scan failed:", err); | |
| } | |
| }, 'image/jpeg', 0.85); | |
| }, 250); // Scan 4 frames per second | |
| } | |
| renderCanvasLoop() { | |
| if (!this.localStream) return; | |
| const canvas = this.activeTab === 'scanner' | |
| ? document.getElementById('live-scanner-canvas') | |
| : document.getElementById('enrollment-canvas'); | |
| if (!canvas) { | |
| requestAnimationFrame(() => this.renderCanvasLoop()); | |
| return; | |
| } | |
| const ctx = canvas.getContext('2d'); | |
| // Draw video frame (mirrored) | |
| ctx.save(); | |
| ctx.translate(canvas.width, 0); | |
| ctx.scale(-1, 1); | |
| ctx.drawImage(this.videoEl, 0, 0, canvas.width, canvas.height); | |
| ctx.restore(); | |
| // Draw the scanner HUD overlay lines | |
| this.drawHUD(canvas, ctx); | |
| // Draw latest faces/bounding boxes/labels returned by the API | |
| if (this.activeTab === 'scanner' && this.latestScanResults) { | |
| this.drawFaces(ctx, this.latestScanResults); | |
| } | |
| requestAnimationFrame(() => this.renderCanvasLoop()); | |
| } | |
| drawHUD(canvas, ctx) { | |
| const w = canvas.width; | |
| const h = canvas.height; | |
| ctx.strokeStyle = '#00fff2'; | |
| ctx.lineWidth = 1; | |
| // Outer border rectangle | |
| ctx.strokeRect(10, 10, w - 20, h - 20); | |
| // Draw corners (tick lines) | |
| const len = 15; | |
| ctx.lineWidth = 2; | |
| const drawCorners = (x1, y1, x2, y2) => { | |
| // Top-left | |
| ctx.beginPath(); ctx.moveTo(x1, y1 + len); ctx.lineTo(x1, y1); ctx.lineTo(x1 + len, y1); ctx.stroke(); | |
| // Top-right | |
| ctx.beginPath(); ctx.moveTo(x2, y1 + len); ctx.lineTo(x2, y1); ctx.lineTo(x2 - len, y1); ctx.stroke(); | |
| // Bottom-left | |
| ctx.beginPath(); ctx.moveTo(x1, y2 - len); ctx.lineTo(x1, y2); ctx.lineTo(x1 + len, y2); ctx.stroke(); | |
| // Bottom-right | |
| ctx.beginPath(); ctx.moveTo(x2, y2 - len); ctx.lineTo(x2, y2); ctx.lineTo(x2 - len, y2); ctx.stroke(); | |
| }; | |
| drawCorners(10, 10, w - 10, h - 10); | |
| // If no faces detected, draw laser line animation | |
| if (!this.latestScanResults || this.latestScanResults.length === 0) { | |
| ctx.strokeStyle = '#00fff2'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(10, this.scanlineY); | |
| ctx.lineTo(w - 10, this.scanlineY); | |
| ctx.stroke(); | |
| this.scanlineY += this.scanlineDirection; | |
| if (this.scanlineY >= h - 15 || this.scanlineY <= 15) { | |
| this.scanlineDirection *= -1; | |
| } | |
| } | |
| } | |
| drawFaces(ctx, faces) { | |
| faces.forEach(face => { | |
| const [x, y, fw, fh] = face.bbox; | |
| let color = '#e74c3c'; // Unknown (red) | |
| let label = `UNKNOWN ID`; | |
| if (face.employee_id) { | |
| color = '#2ecc71'; // Recognized (emerald) | |
| label = `${face.name} (${Math.round(face.match_score * 100)}%)`; | |
| if (face.event_status) { | |
| label += ` | ${face.event_status.replace('_', ' ').toUpperCase()}`; | |
| } | |
| } | |
| // Draw bounding box | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 2; | |
| ctx.strokeRect(x, y, fw, fh); | |
| // Draw corners on bounding box | |
| ctx.strokeStyle = '#00fff2'; | |
| ctx.lineWidth = 2; | |
| const len = 8; | |
| // Top-left | |
| ctx.beginPath(); ctx.moveTo(x, y + len); ctx.lineTo(x, y); ctx.lineTo(x + len, y); ctx.stroke(); | |
| // Top-right | |
| ctx.beginPath(); ctx.moveTo(x + fw, y + len); ctx.lineTo(x + fw, y); ctx.lineTo(x + fw - len, y); ctx.stroke(); | |
| // Bottom-left | |
| ctx.beginPath(); ctx.moveTo(x, y + fh - len); ctx.lineTo(x, y + fh); ctx.lineTo(x + len, y + fh); ctx.stroke(); | |
| // Bottom-right | |
| ctx.beginPath(); ctx.moveTo(x + fw, y + fh - len); ctx.lineTo(x + fw, y + fh); ctx.lineTo(x + fw - len, y + fh); ctx.stroke(); | |
| // Draw landmarks (5 points) | |
| ctx.fillStyle = '#00fff2'; | |
| face.landmarks.forEach(lm => { | |
| ctx.beginPath(); | |
| ctx.arc(lm[0], lm[1], 3, 0, 2 * Math.PI); | |
| ctx.fill(); | |
| ctx.strokeStyle = '#00fff2'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.arc(lm[0], lm[1], 6, 0, 2 * Math.PI); | |
| ctx.stroke(); | |
| }); | |
| // Draw text label banner | |
| ctx.font = '12px Courier New'; | |
| const textWidth = ctx.measureText(label).width; | |
| ctx.fillStyle = color; | |
| ctx.fillRect(x, Math.max(0, y - 20), textWidth + 14, 20); | |
| ctx.fillStyle = '#ffffff'; | |
| ctx.fillText(label, x + 7, Math.max(14, y - 5)); | |
| }); | |
| } | |
| // WIZARD STATE MACHINE METHODS | |
| openEnrollmentWizard() { | |
| this.currentEnrollId = null; | |
| this.currentEnrollName = null; | |
| this.capturedAngles = { | |
| center: false, | |
| left: false, | |
| right: false, | |
| up: false, | |
| down: false | |
| }; | |
| document.getElementById('emp-id-input').value = ''; | |
| document.getElementById('emp-name-input').value = ''; | |
| document.getElementById('emp-email-input').value = ''; | |
| document.getElementById('emp-role-input').value = ''; | |
| this.updateWizardStepUI(1); | |
| document.getElementById('wizard-modal').style.display = 'flex'; | |
| } | |
| closeEnrollmentWizard() { | |
| this.stopClientWebcam(); | |
| document.getElementById('wizard-modal').style.display = 'none'; | |
| this.loadEmployees(); | |
| // Re-enable dashboard/scanner camera if scanner tab is active | |
| if (this.activeTab === 'scanner') { | |
| this.startClientWebcam().then(success => { | |
| if (success) { | |
| this.startScanningLoop(); | |
| requestAnimationFrame(() => this.renderCanvasLoop()); | |
| } | |
| }); | |
| } | |
| } | |
| updateWizardStepUI(stepNum) { | |
| for (let i = 1; i <= 3; i++) { | |
| const panel = document.getElementById(`wizard-panel-${i}`); | |
| const ind = document.getElementById(`step-ind-${i}`); | |
| if (i === stepNum) { | |
| panel.classList.add('active'); | |
| ind.classList.add('active'); | |
| ind.classList.remove('completed'); | |
| } else if (i < stepNum) { | |
| panel.classList.remove('active'); | |
| ind.classList.remove('active'); | |
| ind.classList.add('completed'); | |
| } else { | |
| panel.classList.remove('active'); | |
| ind.classList.remove('active'); | |
| ind.classList.remove('completed'); | |
| } | |
| } | |
| } | |
| // Wizard Step 1 -> Step 2 | |
| async submitWizardStep1() { | |
| const id = document.getElementById('emp-id-input').value.trim(); | |
| const name = document.getElementById('emp-name-input').value.trim(); | |
| const email = document.getElementById('emp-email-input').value.trim(); | |
| const role = document.getElementById('emp-role-input').value.trim(); | |
| if (!id || !name || !email || !role) { | |
| alert("Please fill in all employee fields before proceeding."); | |
| return; | |
| } | |
| try { | |
| const response = await fetch('/api/employees', { | |
| method: 'POST', | |
| headers: this.getHeaders(), | |
| body: JSON.stringify({ id, name, email, role }) | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) throw new Error(data.detail || "Failed to register profile details."); | |
| this.currentEnrollId = id; | |
| this.currentEnrollName = name; | |
| document.querySelectorAll('.angle-card').forEach(card => { | |
| card.className = 'angle-card'; | |
| const statusIcon = card.querySelector('.angle-status-icon'); | |
| const angle = card.getAttribute('data-angle'); | |
| if (angle === 'center') { | |
| card.classList.add('active'); | |
| statusIcon.textContent = 'face'; | |
| } else { | |
| statusIcon.textContent = this.getAngleIcon(angle); | |
| } | |
| }); | |
| document.getElementById('scan-progress-label').innerHTML = ` | |
| Scan progress: <span style="color: var(--neon-cyan); font-weight: 700;">0 / 5 angles</span> captured | |
| `; | |
| document.getElementById('btn-step2-next').disabled = true; | |
| document.getElementById('btn-step2-next').style.opacity = '0.5'; | |
| const loadingOverlay = document.getElementById('enroll-cam-loading-text'); | |
| // Stop scanner loop first if it was running | |
| if (this.scanIntervalId) { | |
| clearInterval(this.scanIntervalId); | |
| this.scanIntervalId = null; | |
| } | |
| this.startClientWebcam().then(success => { | |
| if (success) { | |
| loadingOverlay.style.display = 'none'; | |
| requestAnimationFrame(() => this.renderCanvasLoop()); | |
| } | |
| }); | |
| this.updateWizardStepUI(2); | |
| } catch (err) { | |
| alert(`Error: ${err.message}`); | |
| } | |
| } | |
| getAngleIcon(angle) { | |
| const icons = { | |
| center: 'face', | |
| left: 'chevron_left', | |
| right: 'chevron_right', | |
| up: 'expand_less', | |
| down: 'expand_more' | |
| }; | |
| return icons[angle] || 'face'; | |
| } | |
| backToWizardStep1() { | |
| if (this.currentEnrollId) { | |
| fetch(`/api/employees/${this.currentEnrollId}`, { | |
| method: 'DELETE', | |
| headers: this.getHeaders() | |
| }); | |
| } | |
| this.stopClientWebcam(); | |
| this.updateWizardStepUI(1); | |
| } | |
| // REST API: Trigger a single angle scan capture | |
| async captureAngle(angle) { | |
| if (!this.currentEnrollId) return; | |
| document.querySelectorAll('.angle-card').forEach(card => { | |
| if (card.getAttribute('data-angle') === angle) { | |
| card.classList.add('active'); | |
| } else { | |
| card.classList.remove('active'); | |
| } | |
| }); | |
| const offlineCanvas = document.createElement('canvas'); | |
| offlineCanvas.width = 640; | |
| offlineCanvas.height = 480; | |
| const offlineCtx = offlineCanvas.getContext('2d'); | |
| // Draw mirrored | |
| offlineCtx.save(); | |
| offlineCtx.translate(offlineCanvas.width, 0); | |
| offlineCtx.scale(-1, 1); | |
| offlineCtx.drawImage(this.videoEl, 0, 0, offlineCanvas.width, offlineCanvas.height); | |
| offlineCtx.restore(); | |
| offlineCanvas.toBlob(async (blob) => { | |
| if (!blob) { | |
| alert("Failed to capture webcam frame."); | |
| return; | |
| } | |
| const formData = new FormData(); | |
| formData.append('employee_id', this.currentEnrollId); | |
| formData.append('angle', angle); | |
| formData.append('file', blob, 'enroll.jpg'); | |
| try { | |
| const response = await fetch('/api/enroll/upload-frame', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${this.token}` | |
| }, | |
| body: formData | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) throw new Error(data.detail || "Capture failed."); | |
| this.capturedAngles[angle] = true; | |
| const activeCard = document.querySelector(`.angle-card[data-angle="${angle}"]`); | |
| activeCard.className = 'angle-card completed'; | |
| activeCard.querySelector('.angle-status-icon').textContent = 'check_circle'; | |
| this.triggerToast("Angle Saved", `Captured reference embedding for angle: ${angle.toUpperCase()}`, "check_in"); | |
| const count = Object.values(this.capturedAngles).filter(val => val === true).length; | |
| document.getElementById('scan-progress-label').innerHTML = ` | |
| Scan progress: <span style="color: var(--neon-green); font-weight: 700;">${count} / 5 angles</span> captured | |
| `; | |
| const anglesOrder = ['center', 'left', 'right', 'up', 'down']; | |
| const nextAngle = anglesOrder.find(a => !this.capturedAngles[a]); | |
| if (nextAngle) { | |
| const nextCard = document.querySelector(`.angle-card[data-angle="${nextAngle}"]`); | |
| nextCard.classList.add('active'); | |
| } | |
| if (count === 5) { | |
| const btnStep2Next = document.getElementById('btn-step2-next'); | |
| btnStep2Next.disabled = false; | |
| btnStep2Next.style.opacity = '1'; | |
| btnStep2Next.style.borderColor = 'var(--neon-green)'; | |
| btnStep2Next.style.boxShadow = 'var(--shadow-neon-success)'; | |
| } | |
| } catch (err) { | |
| alert(`Scan Failure: ${err.message}\nMake sure your face is clearly visible, centered, and looking in the requested direction.`); | |
| } | |
| }, 'image/jpeg', 0.9); | |
| } | |
| completeWizardEnrollment() { | |
| document.getElementById('enrolled-emp-name').textContent = this.currentEnrollName; | |
| this.stopClientWebcam(); | |
| this.updateWizardStepUI(3); | |
| } | |
| } | |
| // Global instance launcher | |
| let app; | |
| window.addEventListener('DOMContentLoaded', () => { | |
| app = new SecureAttendApp(); | |
| app.init(); | |
| window.app = app; | |
| }); | |