(function () { var config = window.VUIAttendanceOfflineConfig || {}; if (!config.enabled) return; var root = document.querySelector('[data-offline-attendance="1"]'); if (!root) return; var STORAGE_KEY = 'vuimedia_attendance_offline_state_v1'; var DEVICE_KEY = 'vuimedia_attendance_offline_device_uuid_v1'; var STUDENT_DEVICE_KEY = 'vuimedia_attendance_device_id'; var state = loadState(); var syncTimer = null; var els = { status: root.querySelector('.js-offline-connection-status'), deviceId: root.querySelector('.js-offline-device-id'), lastRefresh: root.querySelector('.js-offline-last-refresh'), pendingCount: root.querySelector('.js-offline-pending-count'), lastSync: root.querySelector('.js-offline-last-sync'), refreshBtn: root.querySelector('.js-offline-refresh'), syncBtn: root.querySelector('.js-offline-sync'), sessionSelect: root.querySelector('.js-offline-session'), studentSearch: root.querySelector('.js-offline-student-search'), students: root.querySelector('.js-offline-students'), log: root.querySelector('.js-offline-log') }; if (!state.deviceUuid) { state.deviceUuid = readOrCreateDeviceUuid(); persistState(); } function loadState() { try { var raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) { return { deviceUuid: '', bootstrap: null, pendingEvents: [], history: [], lastBootstrapAt: '', lastSyncAt: '', lastSyncSummary: '' }; } var parsed = JSON.parse(raw); return { deviceUuid: String(parsed.deviceUuid || ''), bootstrap: parsed.bootstrap && typeof parsed.bootstrap === 'object' ? parsed.bootstrap : null, pendingEvents: Array.isArray(parsed.pendingEvents) ? parsed.pendingEvents : [], history: Array.isArray(parsed.history) ? parsed.history : [], lastBootstrapAt: String(parsed.lastBootstrapAt || ''), lastSyncAt: String(parsed.lastSyncAt || ''), lastSyncSummary: String(parsed.lastSyncSummary || '') }; } catch (error) { return { deviceUuid: '', bootstrap: null, pendingEvents: [], history: [], lastBootstrapAt: '', lastSyncAt: '', lastSyncSummary: '' }; } } function persistState() { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } function readOrCreateDeviceUuid() { try { var existing = String(window.localStorage.getItem(DEVICE_KEY) || '').trim(); if (existing) { return existing; } var next = createUuid(); window.localStorage.setItem(DEVICE_KEY, next); return next; } catch (error) { return createUuid(); } } function createUuid() { if (window.crypto && typeof window.crypto.randomUUID === 'function') { return window.crypto.randomUUID(); } return 'offline-' + Date.now() + '-' + Math.random().toString(16).slice(2, 12); } function nowIso() { return new Date().toISOString(); } function formatTime(value) { if (!value) return 'Not yet synced'; var date = new Date(value); if (isNaN(date.getTime())) return value; return date.toLocaleString(); } function log(message, isError) { if (!els.log) return; var item = document.createElement('div'); item.className = 'a-offline-logitem' + (isError ? ' a-offline-logitem--error' : ''); item.textContent = message; els.log.prepend(item); while (els.log.children.length > 8) { els.log.removeChild(els.log.lastChild); } } function setStatus(text, mode) { if (!els.status) return; els.status.textContent = text; els.status.className = 'a-offline-status js-offline-connection-status'; if (mode === 'warn') { els.status.classList.add('a-offline-status--warn'); } else if (mode === 'error') { els.status.classList.add('a-offline-status--error'); } } function deviceMeta() { return { device_name: config.userLabel + ' device', platform_name: navigator.platform || navigator.userAgent || 'browser', app_version: 'attendance-offline-v1' }; } function apiUrl(action) { return config.endpoint + '?action=' + encodeURIComponent(action); } function isStudentMode() { return String(config.role || '').toLowerCase() === 'student'; } function readCookieValue(key) { var cookies = String(document.cookie || '').split(';'); for (var i = 0; i < cookies.length; i += 1) { var part = cookies[i].trim(); if (part.indexOf(key + '=') === 0) { return decodeURIComponent(part.substring(key.length + 1)); } } return ''; } function writeCookieValue(key, value) { document.cookie = key + '=' + encodeURIComponent(value) + '; path=/; max-age=' + String(60 * 60 * 24 * 365 * 2) + '; SameSite=Lax'; } function createStudentDeviceIdentifier() { if (window.crypto && typeof window.crypto.randomUUID === 'function') { return 'dv-' + window.crypto.randomUUID().replace(/[^a-zA-Z0-9]/g, ''); } return 'dv-' + String(Date.now()) + String(Math.random()).replace(/[^0-9]/g, '').slice(0, 18); } function readStudentDeviceIdentifier() { try { var existing = String(window.localStorage.getItem(STUDENT_DEVICE_KEY) || '').trim(); if (existing) { writeCookieValue(STUDENT_DEVICE_KEY, existing); return existing; } existing = String(readCookieValue(STUDENT_DEVICE_KEY) || '').trim(); if (existing) { window.localStorage.setItem(STUDENT_DEVICE_KEY, existing); return existing; } var next = createStudentDeviceIdentifier(); window.localStorage.setItem(STUDENT_DEVICE_KEY, next); writeCookieValue(STUDENT_DEVICE_KEY, next); return next; } catch (error) { var fallback = String(readCookieValue(STUDENT_DEVICE_KEY) || '').trim(); if (fallback) return fallback; fallback = createStudentDeviceIdentifier(); writeCookieValue(STUDENT_DEVICE_KEY, fallback); return fallback; } } function readStudentGeo() { return new Promise(function (resolve, reject) { if (!navigator.geolocation || typeof navigator.geolocation.getCurrentPosition !== 'function') { reject(new Error('Location access is required for offline student attendance.')); return; } navigator.geolocation.getCurrentPosition(function (position) { var coords = position && position.coords ? position.coords : {}; if (typeof coords.latitude !== 'number' || typeof coords.longitude !== 'number') { reject(new Error('Location access is required for offline student attendance.')); return; } resolve({ lat: Number(coords.latitude), lng: Number(coords.longitude), accuracy: typeof coords.accuracy === 'number' ? Number(coords.accuracy) : null }); }, function (error) { var code = Number(error && error.code || 0); if (code === 1) { reject(new Error('Allow location access before queueing offline student attendance.')); return; } reject(new Error('Unable to verify your location for offline student attendance.')); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }); }); } function csrfToken() { return String(config.csrfToken || ''); } function requestBootstrap() { var url = apiUrl('bootstrap') + '&device_uuid=' + encodeURIComponent(state.deviceUuid) + '&device_name=' + encodeURIComponent(deviceMeta().device_name) + '&platform_name=' + encodeURIComponent(deviceMeta().platform_name) + '&app_version=' + encodeURIComponent(deviceMeta().app_version) + '&_ts=' + encodeURIComponent(String(Date.now())); return fetch(url, { cache: 'no-store', credentials: 'same-origin', headers: { 'Accept': 'application/json' } }).then(readJson).then(function (payload) { if (!payload.ok) { throw new Error(payload.message || payload.raw_text || 'Offline bootstrap failed.'); } state.bootstrap = payload.data || null; state.lastBootstrapAt = nowIso(); persistState(); window.VUIAttendanceOfflineLastBootstrap = payload.data || null; return payload; }); } function pushPendingEvents() { if (!state.pendingEvents.length) { render(); return Promise.resolve(); } if (!navigator.onLine) { setStatus('Offline. Pending marks will sync when internet returns.', 'warn'); return Promise.resolve(); } var events = state.pendingEvents.slice(); var payload = { device_uuid: state.deviceUuid, batch_uuid: createUuid(), events: events, device_name: deviceMeta().device_name, platform_name: deviceMeta().platform_name, app_version: deviceMeta().app_version, csrf_token: csrfToken() }; setStatus('Syncing pending offline marks...', 'warn'); return fetch(apiUrl('push'), { method: 'POST', cache: 'no-store', credentials: 'same-origin', headers: { 'Content-Type': 'application/json; charset=UTF-8', 'Accept': 'application/json', 'X-CSRF-Token': csrfToken() }, body: JSON.stringify(payload) }).then(readJson).then(function (response) { if (!response.ok) { throw new Error(response.message || 'Offline sync failed.'); } var data = response.data || {}; var results = Array.isArray(data.results) ? data.results : []; if (results.length) { var resultMap = {}; results.forEach(function (item) { resultMap[String(item.event_uuid || '')] = item; }); state.pendingEvents = state.pendingEvents.filter(function (event) { var eventId = String(event.event_uuid || ''); var result = resultMap[eventId]; if (!result) return true; state.history.unshift({ event_uuid: eventId, status: String(result.status || 'accepted'), message: String(result.message || ''), student_user_id: Number(event.student_user_id || 0), lecture_session_id: Number(event.lecture_session_id || 0), marked_at_device: String(event.marked_at_device || ''), synced_at: nowIso() }); return false; }); } else { state.pendingEvents = []; } state.history = state.history.slice(0, 80); state.lastSyncAt = nowIso(); state.lastSyncSummary = summarizeSync(data.summary || {}); persistState(); render(); if (navigator.onLine) { requestBootstrap().then(render).catch(function () { render(); }); } log(state.lastSyncSummary || 'Offline marks synced.', false); logProblemSyncResults(results, events); }).catch(function (error) { var details = explainRequestError(error); setStatus(error.message || 'Sync failed.', 'error'); log(details, true); render(); }); } function summarizeSync(summary) { var accepted = Number(summary.accepted_events || 0); var duplicate = Number(summary.duplicate_events || 0); var rejected = Number(summary.rejected_events || 0); var processed = Number(summary.processed_events || 0); return processed + ' processed, ' + accepted + ' accepted, ' + duplicate + ' duplicate, ' + rejected + ' rejected'; } function syncResultReason(syncResult) { if (!syncResult) return ''; return String(syncResult.message || syncResult.rejection_reason || '').trim(); } function syncResolutionHint(reason) { var text = String(reason || '').toLowerCase(); if (!text) return 'Retry after refreshing the offline copy.'; if (text.indexOf('not registered') !== -1) { return 'Register the student for the course in the active session/semester, then refresh the offline copy.'; } if (text.indexOf('window closed') !== -1) { return 'Start or reuse an active session and refresh the offline copy before marking.'; } if (text.indexOf('lecture location') !== -1 || text.indexOf('location') !== -1 || text.indexOf('distance') !== -1) { return 'Move within the lecture venue range or ask admin to confirm the venue coordinates/radius.'; } if (text.indexOf('device') !== -1 || text.indexOf('browser') !== -1) { return 'Use the registered attendance device/browser or ask admin to reset the device binding.'; } if (text.indexOf('already exists') !== -1 || text.indexOf('duplicate') !== -1) { return 'No action needed if this mark is already present in the attendance record.'; } if (text.indexOf('not available') !== -1 || text.indexOf('not found') !== -1) { return 'Refresh the offline copy while online, then try again with the current session.'; } return 'Resolve the reason above, refresh the offline copy, then retry.'; } function sessionLabelForEvent(event) { var sessionId = Number(event && event.lecture_session_id || 0); var session = getSessions().find(function (item) { return Number(item.id || 0) === sessionId; }); if (!session) return 'session #' + String(sessionId || '-'); var label = String(session.course_code || '') + ' - ' + String(session.course_title || ''); label = label.trim() || ('session #' + String(sessionId || '-')); return label + ' (' + String(session.session_date || '-') + ')'; } function studentLabelForEvent(event) { var sessionId = Number(event && event.lecture_session_id || 0); var studentId = Number(event && event.student_user_id || 0); var roster = getRosterForSession(sessionId); var row = roster.find(function (item) { return Number(item.student_user_id || 0) === studentId; }); if (!row) return isStudentMode() ? 'Your mark' : 'student #' + String(studentId || '-'); var code = String(row.student_id || row.registration_no || '').trim(); return String(row.full_name || (isStudentMode() ? 'Your mark' : 'Student')) + (code ? ' [' + code + ']' : ''); } function logProblemSyncResults(results, events) { if (!Array.isArray(results) || !results.length) return; var eventMap = {}; (Array.isArray(events) ? events : []).forEach(function (event) { eventMap[String(event.event_uuid || '')] = event; }); results.forEach(function (item) { var status = String(item.status || '').toLowerCase(); if (status !== 'rejected' && status !== 'duplicate') return; var event = eventMap[String(item.event_uuid || '')] || {}; var reason = syncResultReason(item) || (status === 'duplicate' ? 'Attendance already exists for this student and session.' : 'No reason supplied by server.'); var prefix = status === 'duplicate' ? 'Duplicate' : 'Rejected'; log(prefix + ': ' + studentLabelForEvent(event) + ' | ' + sessionLabelForEvent(event) + ' | Reason: ' + reason + ' | Fix: ' + syncResolutionHint(reason), true); }); } function readJson(response) { return response.text().then(function (text) { var data = {}; var trimmed = String(text || '').trim(); var contentType = String(response.headers.get('content-type') || '').toLowerCase(); if (text) { try { data = JSON.parse(text); } catch (error) { data = { ok: false, raw_text: trimmed.slice(0, 500), response_status: Number(response.status || 0), response_url: String(response.url || ''), response_content_type: contentType }; if (trimmed) { if (trimmed.charAt(0) === '<' || contentType.indexOf('text/html') !== -1) { var preview = trimmed .replace(//gi, ' ') .replace(//gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 220); var lowerPreview = preview.toLowerCase(); var reason = 'HTML page returned'; if (lowerPreview.indexOf('login') !== -1 || lowerPreview.indexOf('sign in') !== -1 || lowerPreview.indexOf('password') !== -1) { reason = 'login or account-step page returned'; } else if (lowerPreview.indexOf('fatal error') !== -1 || lowerPreview.indexOf('warning') !== -1 || lowerPreview.indexOf('parse error') !== -1) { reason = 'PHP error page returned'; } data.message = 'Offline endpoint returned HTML instead of JSON (' + reason + ').'; data.html_preview = preview; } else { data.message = trimmed.slice(0, 240); } } } } if (!response.ok && !data.message) { data.message = 'Request failed with status ' + response.status + '.'; } return data; }); } function explainRequestError(error) { if (!error) return 'Request failed.'; if (typeof error === 'string') return error; var parts = []; if (error.message) { parts.push(String(error.message)); } if (error.response_status) { parts.push('HTTP ' + String(error.response_status)); } if (error.response_content_type) { parts.push(String(error.response_content_type)); } if (error.html_preview) { parts.push('Preview: ' + String(error.html_preview)); } else if (error.raw_text) { parts.push('Preview: ' + String(error.raw_text).slice(0, 220)); } return parts.join(' | ') || 'Request failed.'; } function getSessions() { if (!state.bootstrap || !Array.isArray(state.bootstrap.sessions)) return []; return state.bootstrap.sessions.slice().sort(function (a, b) { return String(b.session_date || '').localeCompare(String(a.session_date || '')) || String(b.start_time || '').localeCompare(String(a.start_time || '')); }); } function getRosterForSelectedSession() { var sessionId = Number(els.sessionSelect && els.sessionSelect.value || 0); if (!sessionId) return []; return getRosterForSession(sessionId); } function getRosterForSession(sessionId) { sessionId = Number(sessionId || 0); if (!sessionId) return []; var session = getSessions().find(function (item) { return Number(item.id || 0) === sessionId; }); if (!session || !Array.isArray(state.bootstrap && state.bootstrap.rosters)) return []; var pendingByStudent = {}; state.pendingEvents.forEach(function (item) { if (Number(item.lecture_session_id || 0) === sessionId) { pendingByStudent[Number(item.student_user_id || 0)] = item; } }); var historyByStudent = {}; state.history.forEach(function (item) { if (Number(item.lecture_session_id || 0) === sessionId && !historyByStudent[Number(item.student_user_id || 0)]) { historyByStudent[Number(item.student_user_id || 0)] = item; } }); return state.bootstrap.rosters.filter(function (row) { return Number(row.course_id || 0) === Number(session.course_id || 0); }).map(function (row) { row = Object.assign({}, row); row.pendingEvent = pendingByStudent[Number(row.student_user_id || 0)] || null; row.syncResult = historyByStudent[Number(row.student_user_id || 0)] || null; return row; }); } function renderSessions() { if (!els.sessionSelect) return; var sessions = getSessions(); var previous = Number(els.sessionSelect.value || 0); els.sessionSelect.innerHTML = ''; sessions.forEach(function (session) { var option = document.createElement('option'); var label = String(session.course_code || '') + ' - ' + String(session.course_title || ''); label += ' | ' + String(session.session_date || '-'); label += ' ' + String(session.start_time || '-'); label += ' | ' + String(session.status || 'closed'); option.value = String(session.id || ''); option.textContent = label; els.sessionSelect.appendChild(option); }); if (previous && sessions.some(function (session) { return Number(session.id || 0) === previous; })) { els.sessionSelect.value = String(previous); } else { var openSession = sessions.find(function (session) { return String(session.status || '').toLowerCase() === 'open'; }); if (openSession) { els.sessionSelect.value = String(openSession.id || ''); } else if (sessions.length) { els.sessionSelect.value = String(sessions[0].id || ''); } } } function renderStudents() { if (!els.students) return; var roster = getRosterForSelectedSession(); var search = String(els.studentSearch && els.studentSearch.value || '').trim().toLowerCase(); if (search) { roster = roster.filter(function (row) { var haystack = [ row.full_name, row.student_id, row.registration_no, row.email ].join(' ').toLowerCase(); return haystack.indexOf(search) !== -1; }); } if (!roster.length) { els.students.innerHTML = '
' + buildEmptyStateMessage() + '
'; return; } els.students.innerHTML = ''; roster.forEach(function (row) { var card = document.createElement('div'); var statusText = isStudentMode() ? 'Ready to queue your offline attendance' : 'Ready to mark offline'; card.className = 'a-offline-student'; if (row.pendingEvent) { card.classList.add('is-pending'); statusText = 'Queued offline at ' + formatTime(row.pendingEvent.marked_at_device); } else if (row.syncResult) { if (String(row.syncResult.status || '') === 'rejected') { card.classList.add('is-rejected'); } statusText = String(row.syncResult.status || 'synced') + ' at ' + formatTime(row.syncResult.synced_at); var reasonText = syncResultReason(row.syncResult); if (reasonText) { statusText += ' | Reason: ' + reasonText + ' | Fix: ' + syncResolutionHint(reasonText); } } var meta = document.createElement('div'); meta.className = 'a-offline-student-meta'; var title = document.createElement('strong'); title.textContent = String(row.full_name || (isStudentMode() ? 'My attendance entry' : 'Student')); var sub = document.createElement('div'); sub.className = 'a-offline-student-sub'; sub.textContent = [ row.student_id || row.registration_no || '-', row.email || 'No email', statusText ].join(' | '); meta.appendChild(title); meta.appendChild(sub); var button = document.createElement('button'); button.type = 'button'; button.className = row.pendingEvent ? 'btn btn--ghost' : 'btn btn--primary'; button.textContent = row.pendingEvent ? 'Queued' : (isStudentMode() ? 'Queue Offline Mark' : 'Mark Offline'); button.disabled = !!row.pendingEvent; button.addEventListener('click', function () { var queued = queueStudent(row); if (queued && typeof queued.catch === 'function') { queued.catch(function (error) { log(error && error.message ? error.message : 'Unable to queue offline attendance.', true); }); } }); card.appendChild(meta); card.appendChild(button); els.students.appendChild(card); }); } function buildEmptyStateMessage() { if (!isStudentMode()) { return 'No synced students match this lecture session or search.'; } var search = String(els.studentSearch && els.studentSearch.value || '').trim(); var sessions = getSessions(); var courses = state.bootstrap && Array.isArray(state.bootstrap.courses) ? state.bootstrap.courses : []; var rosters = state.bootstrap && Array.isArray(state.bootstrap.rosters) ? state.bootstrap.rosters : []; var activeSemester = String(config.activeSemester || state.bootstrap && state.bootstrap.settings && state.bootstrap.settings.active_semester || '').trim(); if (search) { return 'No synced attendance entry matches this session or search.'; } if (!sessions.length && courses.length && rosters.length) { return 'Your registration matches the active semester' + (activeSemester ? ' (' + activeSemester + ')' : '') + ', but no lecture session from the last 14 days was synced for it yet. Switch the active semester or create/open a matching session, then refresh the offline copy.'; } if (!sessions.length && !courses.length) { return 'No registered course in the active session/semester is available for offline attendance on this device. Refresh the offline copy after your registration is confirmed.'; } return 'No synced attendance entry matches this session or search.'; } function queueStudent(row, sessionIdOverride, sourceMethodOverride) { var sessionId = Number(sessionIdOverride || els.sessionSelect && els.sessionSelect.value || 0); if (!sessionId) { log(isStudentMode() ? 'Choose a synced attendance session first.' : 'Choose a synced lecture session first.', true); throw new Error(isStudentMode() ? 'Choose a synced attendance session first.' : 'Choose a synced lecture session first.'); } var duplicate = state.pendingEvents.find(function (event) { return Number(event.lecture_session_id || 0) === sessionId && Number(event.student_user_id || 0) === Number(row.student_user_id || 0); }); if (duplicate) { log(isStudentMode() ? 'You already have a pending offline mark for the selected session.' : 'This student already has a pending offline mark for the selected session.', true); renderStudents(); throw new Error(isStudentMode() ? 'You already have a pending offline mark for the selected session.' : 'This student already has a pending offline mark for the selected session.'); } if (isStudentMode()) { return readStudentGeo().then(function (geo) { var event = { event_uuid: createUuid(), lecture_session_id: sessionId, student_user_id: Number(row.student_user_id || 0), marked_at_device: nowIso(), source_method: String(sourceMethodOverride || 'manual'), device_identifier: readStudentDeviceIdentifier(), device_info: String(navigator.userAgent || 'Offline attendance').slice(0, 180), geo_lat: geo.lat, geo_lng: geo.lng, geo_accuracy: geo.accuracy }; state.pendingEvents.unshift(event); persistState(); render(); log('Queued your offline attendance mark.', false); if (navigator.onLine) { pushPendingEvents(); } else { setStatus('Offline. Marks are being queued on this device.', 'warn'); } return event; }); } var event = { event_uuid: createUuid(), lecture_session_id: sessionId, student_user_id: Number(row.student_user_id || 0), marked_at_device: nowIso(), source_method: String(sourceMethodOverride || 'manual'), device_identifier: 'offline:' + state.deviceUuid, device_info: String(navigator.userAgent || 'Offline attendance').slice(0, 180) }; state.pendingEvents.unshift(event); persistState(); render(); log('Queued offline mark for ' + String(row.full_name || 'student') + '.', false); if (navigator.onLine) { pushPendingEvents(); } else { setStatus('Offline. Marks are being queued on this device.', 'warn'); } return event; } function normalizeLookupValue(value) { return String(value || '').trim().toLowerCase(); } function findRosterStudentByReference(sessionId, reference) { var lookup = normalizeLookupValue(reference); if (!lookup) return null; var roster = getRosterForSession(sessionId); for (var i = 0; i < roster.length; i += 1) { var row = roster[i]; var candidates = [ row.student_id, row.registration_no, row.email, row.full_name ]; for (var j = 0; j < candidates.length; j += 1) { if (normalizeLookupValue(candidates[j]) === lookup) { return row; } } } return null; } function queueByReference(sessionId, reference) { sessionId = Number(sessionId || 0); if (!sessionId) { throw new Error('Select a lecture session first.'); } if (!state.bootstrap) { throw new Error('Offline attendance copy is not ready on this device. Open Offline Attendance and refresh it while internet is available.'); } var session = getSessions().find(function (item) { return Number(item.id || 0) === sessionId; }); if (!session) { throw new Error('This lecture session is not available in the offline attendance copy. Refresh the offline copy while online and try again.'); } var row = findRosterStudentByReference(sessionId, reference); if (!row) { throw new Error('Student was not found in the synced offline roster for this session. Use the matric number or registration number after refreshing the offline copy.'); } if (els.sessionSelect) { els.sessionSelect.value = String(sessionId); } var queuedEvent = queueStudent(row, sessionId); return { queued: true, event: queuedEvent, studentName: String(row.full_name || 'student'), message: navigator.onLine ? 'Connection dropped. The mark was queued offline for ' + String(row.full_name || 'student') + ' and will sync automatically.' : 'You are offline. The mark was queued on this device for ' + String(row.full_name || 'student') + ' and will sync when internet returns.' }; } function queueFingerprint(sessionId, studentUserId) { sessionId = Number(sessionId || 0); studentUserId = Number(studentUserId || 0); if (!state.bootstrap) throw new Error('Offline attendance copy is not ready. Refresh it while online first.'); var row = getRosterForSession(sessionId).find(function (item) { return Number(item.student_user_id || 0) === studentUserId; }); if (!row) throw new Error('Matched student is not available in the synced offline roster.'); var event = queueStudent(row, sessionId, 'fingerprint'); return { queued: true, event: event, studentName: String(row.full_name || 'student'), message: 'Fingerprint attendance queued offline for ' + String(row.full_name || 'student') + '.' }; } function render() { if (els.deviceId) { els.deviceId.textContent = state.deviceUuid || '-'; } if (els.lastRefresh) { els.lastRefresh.textContent = formatTime(state.lastBootstrapAt); } if (els.pendingCount) { els.pendingCount.textContent = String(state.pendingEvents.length); } if (els.lastSync) { els.lastSync.textContent = state.lastSyncSummary ? state.lastSyncSummary + ' (' + formatTime(state.lastSyncAt) + ')' : 'No sync yet'; } if (navigator.onLine) { setStatus('Online. Offline attendance copy is ready on this device.', 'ok'); } else if (state.bootstrap) { setStatus('Offline. Using the last synced attendance copy on this device.', 'warn'); } else { setStatus('Offline and no attendance copy is stored yet.', 'error'); } renderSessions(); renderStudents(); } function installHandlers() { if (els.refreshBtn) { els.refreshBtn.addEventListener('click', function () { if (!navigator.onLine) { setStatus('Cannot refresh while offline. Existing cached copy remains available.', 'warn'); return; } setStatus('Refreshing offline attendance copy...', 'warn'); requestBootstrap().then(function (payload) { render(); var data = payload && payload.data && typeof payload.data === 'object' ? payload.data : {}; var sessionCount = Array.isArray(data.sessions) ? data.sessions.length : 0; var rosterCount = Array.isArray(data.rosters) ? data.rosters.length : 0; log('Offline attendance copy refreshed for this device. ' + sessionCount + ' session(s), ' + rosterCount + ' roster entr' + (rosterCount === 1 ? 'y' : 'ies') + '.', false); }).catch(function (error) { var details = explainRequestError(error); setStatus(error.message || 'Refresh failed.', 'error'); log(details, true); render(); }); }); } if (els.syncBtn) { els.syncBtn.addEventListener('click', function () { pushPendingEvents(); }); } if (els.sessionSelect) { els.sessionSelect.addEventListener('change', renderStudents); } if (els.studentSearch) { els.studentSearch.addEventListener('input', renderStudents); } window.addEventListener('online', function () { render(); if (state.pendingEvents.length) { pushPendingEvents(); } else { requestBootstrap().then(function () { render(); log('Connection restored and offline copy refreshed.', false); }).catch(function () { render(); }); } }); window.addEventListener('offline', render); } function startBackgroundSync() { if (syncTimer) { window.clearInterval(syncTimer); } syncTimer = window.setInterval(function () { if (!navigator.onLine) return; if (state.pendingEvents.length) { pushPendingEvents(); } }, 30000); } installHandlers(); render(); startBackgroundSync(); window.VUIAttendanceOffline = { queueByReference: queueByReference, queueFingerprint: queueFingerprint }; if (navigator.onLine) { requestBootstrap().then(function (payload) { render(); var data = payload && payload.data && typeof payload.data === 'object' ? payload.data : {}; var sessionCount = Array.isArray(data.sessions) ? data.sessions.length : 0; var rosterCount = Array.isArray(data.rosters) ? data.rosters.length : 0; log('Offline attendance copy loaded for this device. ' + sessionCount + ' session(s), ' + rosterCount + ' roster entr' + (rosterCount === 1 ? 'y' : 'ies') + '.', false); if (state.pendingEvents.length) { return pushPendingEvents(); } }).catch(function (error) { var details = explainRequestError(error); setStatus(error.message || 'Offline bootstrap failed.', 'error'); log(details, true); render(); }); } })(); Campus Flow | VUI ID Card Portal
VUI VUI ID Card, Campus & Health Platform

Campus Entry, Exit, Visitor and Exeat Flow

Student apply -> Student Affairs/Admin review -> Porter accept/reject -> Security gate exit/return logs.

Active Dialogue

Login with your ID card profile to continue.

Campus Agent Login

Login with the same ID card credentials (email/student ID/registration no/staff ID + password).