commit b61f6579ee182273cad56875cf932cfea7cd0b70 Author: mwpn Date: Thu Mar 5 14:19:22 2026 +0700 feat: mobile face attendance integration diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b98738 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# SMAN1 Mobile Device Client (Web/PWA) + +Aplikasi web mobile-first untuk **perangkat absensi** yang terhubung ke backend CodeIgniter 4. + +> Fokusnya: mempermudah testing dan penggunaan awal di Android (Chrome) tanpa perlu build APK native dulu. Nantinya bisa dibungkus jadi aplikasi Android (WebView / TWA) jika dibutuhkan. + +## Struktur + +- `index.html` — halaman utama device absensi +- `styles.css` — styling mobile-first (tombol besar, UI simpel untuk layar sentuh) +- `app.js` — logic pemanggilan API backend `/api/attendance/checkin` + +## Fitur + +- Simpan **Backend URL**, `device_code`, dan `api_key` di localStorage. +- Ambil lokasi GPS lewat `navigator.geolocation` dan kirim ke backend. +- Form sederhana untuk input `student_id` (sementara, menunggu integrasi Face Recognition / QR). +- Tampilkan hasil status: + - `PRESENT`, `LATE`, `OUTSIDE_ZONE`, `NO_SCHEDULE`, `INVALID_DEVICE`, dll. +- Desain UI: + - Satu kolom, tombol besar, teks jelas, nyaman dipakai di HP Android (Chrome). + +## Cara Menjalankan (dev) + +1. Pastikan backend jalan di misalnya: + - `http://localhost/sman1/backend/public` + - atau IP di jaringan lokal, misal `http://192.168.1.10/sman1/backend/public` + +2. Buka folder `mobile` di VSCode / editor. + +3. Jalankan static server sederhana (opsi): + + - Dengan PHP: + + ```bash + cd mobile + php -S localhost:8001 + ``` + + Lalu buka `http://localhost:8001` di browser Android. + + - Atau cukup buka `index.html` langsung di browser (double click), tapi lebih baik lewat HTTP. + +4. Di HP Android (Chrome): + + - Akses: `http://IP_LAPTOP:8001` (misal `http://192.168.1.10:8001`). + - Masukkan: + - **Backend URL**: `http://IP_LAPTOP/sman1/backend/public` + - **Device Code** dan **API Key** dari halaman **Device Absen** di dashboard. + - Isi `ID Siswa`, pastikan ada jadwal aktif dan zona device sudah diatur. + - Tekan **Check-in Sekarang**. + +## Integrasi Lanjutan + +Ke depan, layar ini bisa dikembangkan menjadi: + +- Integrasi kamera + Face Recognition (panggil external engine, hanya kirim `student_id` ke backend). +- Scan QR code untuk `student_id`. +- Pembatasan UI (mode kiosk) untuk dipasang di tablet di gerbang sekolah. + diff --git a/app.js b/app.js new file mode 100644 index 0000000..9478cf5 --- /dev/null +++ b/app.js @@ -0,0 +1,1334 @@ +(function () { + // Screens + var screenWelcome = document.getElementById('screen-welcome'); + var screenLogin = document.getElementById('screen-login'); + var screenRegisterNisn = document.getElementById('screen-register-nisn'); + var screenRegisterPin = document.getElementById('screen-register-pin'); + var screenForgotPin = document.getElementById('screen-forgot-pin'); + var screenHome = document.getElementById('screen-home'); + + // Header & settings + var btnOpenSettings = document.getElementById('btn-open-settings'); + var settingsModal = document.getElementById('settings-modal'); + var btnCloseSettings = document.getElementById('btn-close-settings'); + var backendUrlInput = document.getElementById('backend-url'); + var btnSaveConfig = document.getElementById('btn-save-config'); + var configStatus = document.getElementById('config-status'); + + // Registration step 1 + var regNisnInput = document.getElementById('reg-nisn'); + var btnCheckNisn = document.getElementById('btn-check-nisn'); + var regNisnStatus = document.getElementById('reg-nisn-status'); + + // Registration step 2 + var regStudentSummary = document.getElementById('reg-student-summary'); + var regClassSelect = document.getElementById('reg-class'); + var regPinInput = document.getElementById('reg-pin'); + var regPin2Input = document.getElementById('reg-pin2'); + var btnCompleteRegister = document.getElementById('btn-complete-register'); + var regPinStatus = document.getElementById('reg-pin-status'); + + // Login + var loginNisnInput = document.getElementById('login-nisn'); + var loginPinInput = document.getElementById('login-pin'); + var btnLogin = document.getElementById('btn-login'); + var loginStatus = document.getElementById('login-status'); + + var forgotNisnInput = document.getElementById('forgot-nisn'); + var forgotNewPinInput = document.getElementById('forgot-new-pin'); + var forgotNewPin2Input = document.getElementById('forgot-new-pin2'); + var forgotPinStatus = document.getElementById('forgot-pin-status'); + var btnResetPin = document.getElementById('btn-reset-pin'); + + // Home screen + var homeStudentSummary = document.getElementById('home-student-summary'); + var btnAbsenMasuk = document.getElementById('btn-absen-masuk'); + var btnAbsenPulang = document.getElementById('btn-absen-pulang'); + var btnRefreshLocation = document.getElementById('btn-refresh-location'); + var locationStatus = document.getElementById('location-status'); + var resultBox = document.getElementById('result-box'); + var resultStatus = document.getElementById('result-status'); + var resultMessage = document.getElementById('result-message'); + var resultIcon = document.getElementById('result-icon'); + var resultCloseBtn = document.getElementById('result-close-btn'); + var resultMeta = document.getElementById('result-meta'); + var masukStatusEl = document.getElementById('masuk-status'); + var pulangStatusEl = document.getElementById('pulang-status'); + var homeDateLabel = document.getElementById('home-date-label'); + var currentCheckinType = null; + var pendingMasukPulangType = null; + + var toastContainer = document.getElementById('toast-container'); + + var currentLat = null; + var currentLng = null; + var currentStudentProfile = null; + + var cameraModal = document.getElementById('camera-modal'); + var cameraVideo = document.getElementById('camera-video'); + var cameraError = document.getElementById('camera-error'); + var cameraFaceStatus = document.getElementById('camera-face-status'); + var cameraBtnCancel = document.getElementById('camera-modal-close'); + var cameraBtnCancel2 = document.getElementById('camera-btn-cancel'); + var cameraStream = null; + var faceDetectIntervalId = null; + var faceDetectConsecutiveCount = 0; + var faceDetectSubmitting = false; + + var scanQrModal = document.getElementById('scan-qr-modal'); + var scanQrClose = document.getElementById('scan-qr-close'); + var scanQrStatus = document.getElementById('scan-qr-status'); + var qrReader = document.getElementById('qr-reader'); + var qrPinModal = document.getElementById('qr-pin-modal'); + var qrPinClose = document.getElementById('qr-pin-close'); + var qrPinBackdrop = document.getElementById('qr-pin-backdrop'); + var qrPinInput = document.getElementById('qr-pin-input'); + var qrPinCancel = document.getElementById('qr-pin-cancel'); + var qrPinSubmit = document.getElementById('qr-pin-submit'); + var pendingQrToken = null; + var html5QrCodeInstance = null; + + // Device credentials — bisa diisi di Pengaturan, default untuk development + var DEFAULT_DEVICE_CODE = 'MOBILE_APP'; + var DEFAULT_API_KEY = 'MOBILE_APP_SECRET'; + + function showToast(message, type) { + if (!toastContainer) return; + type = type || 'info'; + var el = document.createElement('div'); + el.className = 'toast ' + (type === 'error' ? 'toast-error' : type === 'success' ? 'toast-success' : 'toast-info'); + el.textContent = message; + toastContainer.appendChild(el); + setTimeout(function () { + el.remove(); + }, 3000); + } + + function loadConfig() { + try { + var raw = localStorage.getItem('sman1_device_config'); + if (!raw) return; + var cfg = JSON.parse(raw); + if (cfg.backendUrl) backendUrlInput.value = cfg.backendUrl; + } catch (e) { + // ignore + } + } + + function saveConfig() { + var backendUrl = backendUrlInput.value.trim(); + + if (!backendUrl) { + configStatus.textContent = 'Backend URL wajib diisi.'; + configStatus.style.color = '#f97316'; + showToast('Backend URL belum diisi', 'error'); + return; + } + + var cfg = { + backendUrl: backendUrl + }; + try { + localStorage.setItem('sman1_device_config', JSON.stringify(cfg)); + configStatus.textContent = 'Konfigurasi disimpan.'; + configStatus.style.color = '#22c55e'; + showToast('Konfigurasi disimpan', 'success'); + testConnection(); + } catch (e) { + configStatus.textContent = 'Gagal menyimpan konfigurasi di browser.'; + configStatus.style.color = '#f97316'; + } + } + + function testConnection() { + var url = backendBase() + '/api/mobile/ping'; + configStatus.textContent = 'Mengecek koneksi…'; + configStatus.style.color = '#64748b'; + fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } }) + .then(function (res) { return res.json(); }) + .then(function (data) { + configStatus.textContent = 'Server OK — ' + (data.message || 'Terhubung'); + configStatus.style.color = '#22c55e'; + showToast('Server terhubung', 'success'); + }) + .catch(function (err) { + var msg = err && err.message ? err.message : 'Tidak bisa terhubung'; + configStatus.textContent = 'Gagal: ' + msg; + configStatus.style.color = '#dc2626'; + showToast('Gagal: ' + msg, 'error'); + }); + } + + function openSettingsModal() { + settingsModal.classList.remove('hidden'); + } + + function closeSettingsModal() { + settingsModal.classList.add('hidden'); + } + + function updateLocationStatus(text, isError) { + locationStatus.textContent = 'Lokasi: ' + text; + locationStatus.style.borderColor = isError ? 'rgba(220,38,38,0.5)' : 'rgba(148,163,184,0.4)'; + locationStatus.style.color = isError ? '#fecaca' : '#9ca3af'; + } + + function getLocation() { + if (!navigator.geolocation) { + updateLocationStatus('Geolocation tidak didukung di perangkat ini', true); + showToast('Geolocation tidak didukung', 'error'); + return; + } + updateLocationStatus('Mengambil lokasi…', false); + navigator.geolocation.getCurrentPosition( + function (pos) { + currentLat = pos.coords.latitude; + currentLng = pos.coords.longitude; + updateLocationStatus(currentLat.toFixed(6) + ', ' + currentLng.toFixed(6), false); + }, + function (err) { + currentLat = null; + currentLng = null; + updateLocationStatus('Gagal mengambil lokasi: ' + err.message, true); + showToast('Gagal mengambil lokasi', 'error'); + }, + { + enableHighAccuracy: true, + timeout: 8000, + maximumAge: 0 + } + ); + } + + function setResult(statusCode, message, metaText) { + resultBox.classList.remove('hidden'); + resultBox.classList.remove('success', 'warning', 'danger'); + var type = 'danger'; + var upper = (statusCode || '').toUpperCase(); + if (upper === 'PRESENT') type = 'success'; + else if (upper === 'LATE') type = 'warning'; + resultBox.classList.add(type); + resultStatus.className = 'result-popup-status ' + type; + resultStatus.textContent = upper || '-'; + resultMessage.textContent = message || ''; + if (resultIcon) { + resultIcon.className = 'result-popup-icon'; + resultIcon.textContent = type === 'success' ? '✓' : type === 'warning' ? '⚠' : '✕'; + } + if (resultMeta) resultMeta.textContent = metaText || ''; + } + + function backendBase() { + var backendUrl = backendUrlInput.value.trim(); + return backendUrl.replace(/\/+$/, ''); + } + + function ensureConfig() { + var backendUrl = backendUrlInput.value.trim(); + if (!backendUrl) { + showToast('Isi URL server SMAN 1 Garut terlebih dahulu.', 'error'); + openSettingsModal(); + return false; + } + return true; + } + + function renderStudentSummary(container, profile) { + if (!profile) { + container.textContent = ''; + return; + } + container.innerHTML = + '
' + (profile.name || '-') + '
' + + '
NISN: ' + (profile.nisn || '-') + '
' + + '
Kelas: ' + (profile.class_label || '-') + '
'; + if (container === homeStudentSummary) { + var greetingEl = document.getElementById('home-greeting-text'); + if (greetingEl && profile.name) { + var firstName = profile.name.split(/\s+/)[0] || profile.name; + greetingEl.textContent = 'Halo, ' + firstName; + } + } + } + + function goToScreen(name) { + screenWelcome.classList.add('hidden'); + screenLogin.classList.add('hidden'); + screenRegisterNisn.classList.add('hidden'); + screenRegisterPin.classList.add('hidden'); + if (screenForgotPin) screenForgotPin.classList.add('hidden'); + screenHome.classList.add('hidden'); + if (name === 'welcome') { + screenWelcome.classList.remove('hidden'); + } else if (name === 'login') { + screenLogin.classList.remove('hidden'); + } else if (name === 'register_nisn') { + screenRegisterNisn.classList.remove('hidden'); + } else if (name === 'register_pin') { + screenRegisterPin.classList.remove('hidden'); + } else if (name === 'forgot_pin') { + if (screenForgotPin) { + screenForgotPin.classList.remove('hidden'); + if (forgotNisnInput && loginNisnInput) forgotNisnInput.value = loginNisnInput.value.trim(); + } + } else if (name === 'home') { + screenHome.classList.remove('hidden'); + setHomeDateLabel(); + fetchTodayStatus(); + } + } + + function saveStudentProfile(profile) { + currentStudentProfile = profile; + try { + localStorage.setItem('sman1_student_profile', JSON.stringify(profile)); + } catch (e) { + // ignore + } + } + + function loadStudentProfile() { + try { + var raw = localStorage.getItem('sman1_student_profile'); + if (!raw) return null; + return JSON.parse(raw); + } catch (e) { + return null; + } + } + + function handleCheckNisn() { + if (!ensureConfig()) return; + + var nisn = regNisnInput.value.trim(); + if (nisn === '') { + regNisnStatus.textContent = 'NISN wajib diisi.'; + regNisnStatus.style.color = '#f97316'; + return; + } + + var url = backendBase() + '/api/mobile/register/nisn'; + btnCheckNisn.disabled = true; + btnCheckNisn.textContent = 'Memeriksa…'; + regNisnStatus.textContent = ''; + + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ nisn: nisn }) + }).then(function (res) { + return res.json().then(function (data) { + return { ok: res.ok, status: res.status, data: data }; + }); + }).then(function (r) { + btnCheckNisn.disabled = false; + btnCheckNisn.textContent = 'Lanjutkan'; + + if (!r.ok) { + var msg = (r.data && r.data.message) ? r.data.message : 'NISN tidak valid'; + regNisnStatus.textContent = msg; + regNisnStatus.style.color = '#f97316'; + showToast(msg, 'error'); + if (r.status === 409 || (msg && msg.indexOf('sudah terdaftar') !== -1)) { + if (loginNisnInput) loginNisnInput.value = nisn; + goToScreen('login'); + } + return; + } + + var d = r.data && r.data.data ? r.data.data : r.data; + if (!d || !d.student_id) { + regNisnStatus.textContent = 'Response tidak valid dari server.'; + regNisnStatus.style.color = '#f97316'; + return; + } + + // Isi summary + dropdown kelas + var summaryProfile = { + student_id: d.student_id, + name: d.name, + nisn: d.nisn, + class_id: d.current_class_id, + class_label: d.current_class_label + }; + renderStudentSummary(regStudentSummary, summaryProfile); + + // Isi dropdown kelas + var list = Array.isArray(d.available_classes) ? d.available_classes : []; + var first = regClassSelect.options[0]; + regClassSelect.innerHTML = ''; + if (first) regClassSelect.appendChild(first); + list.forEach(function (c) { + var opt = document.createElement('option'); + opt.value = c.id; + opt.textContent = c.label; + if (d.current_class_id && c.id === d.current_class_id) { + opt.selected = true; + summaryProfile.class_label = c.label; + } + regClassSelect.appendChild(opt); + }); + + currentStudentProfile = summaryProfile; + regPinInput.value = ''; + regPin2Input.value = ''; + regPinStatus.textContent = ''; + + goToScreen('register_pin'); + showToast('NISN ditemukan. Lengkapi data.', 'success'); + }).catch(function (err) { + btnCheckNisn.disabled = false; + btnCheckNisn.textContent = 'Lanjutkan'; + regNisnStatus.textContent = 'Gagal menghubungi server.'; + regNisnStatus.style.color = '#f97316'; + var msg = (err && err.message) ? err.message : 'Gagal menghubungi server'; + showToast(msg, 'error'); + }); + } + + function handleLogin() { + if (!ensureConfig()) return; + + var nisn = loginNisnInput.value.trim(); + var pin = loginPinInput.value.trim(); + + if (nisn === '' || pin === '') { + loginStatus.textContent = 'NISN dan PIN wajib diisi.'; + loginStatus.style.color = '#f97316'; + return; + } + + var url = backendBase() + '/api/mobile/login'; + btnLogin.disabled = true; + btnLogin.textContent = 'Memproses…'; + loginStatus.textContent = ''; + + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ nisn: nisn, pin: pin }) + }).then(function (res) { + return res.json().then(function (data) { + return { ok: res.ok, status: res.status, data: data }; + }); + }).then(function (r) { + btnLogin.disabled = false; + btnLogin.textContent = 'Masuk'; + + if (!r.ok) { + var msg = (r.data && r.data.message) ? r.data.message : 'Login gagal'; + loginStatus.textContent = msg; + loginStatus.style.color = '#f97316'; + showToast(msg, 'error'); + return; + } + + var d = r.data && r.data.data ? r.data.data : r.data; + if (!d || !d.student_id) { + loginStatus.textContent = 'Response tidak valid dari server.'; + loginStatus.style.color = '#f97316'; + return; + } + + var profile = { + student_id: d.student_id, + name: d.name, + nisn: d.nisn, + class_id: d.class_id, + class_label: d.class_label + }; + saveStudentProfile(profile); + renderStudentSummary(homeStudentSummary, profile); + + showToast('Login berhasil', 'success'); + goToScreen('home'); + }).catch(function (err) { + btnLogin.disabled = false; + btnLogin.textContent = 'Masuk'; + loginStatus.textContent = 'Gagal menghubungi server.'; + loginStatus.style.color = '#f97316'; + var msg = (err && err.message) ? err.message : 'Gagal menghubungi server'; + showToast(msg, 'error'); + }); + } + + function handleCompleteRegister() { + if (!ensureConfig()) return; + if (!currentStudentProfile || !currentStudentProfile.student_id) { + showToast('Mulai dari input NISN dulu.', 'error'); + goToScreen('register_nisn'); + return; + } + + var classId = parseInt(regClassSelect.value, 10); + var pin = regPinInput.value.trim(); + var pin2 = regPin2Input.value.trim(); + + if (!classId || classId <= 0) { + regPinStatus.textContent = 'Pilih kelas terlebih dahulu.'; + regPinStatus.style.color = '#f97316'; + return; + } + if (pin.length < 4) { + regPinStatus.textContent = 'PIN minimal 4 digit.'; + regPinStatus.style.color = '#f97316'; + return; + } + if (pin !== pin2) { + regPinStatus.textContent = 'PIN dan ulangi PIN tidak sama.'; + regPinStatus.style.color = '#f97316'; + return; + } + + var url = backendBase() + '/api/mobile/register/complete'; + btnCompleteRegister.disabled = true; + btnCompleteRegister.textContent = 'Menyimpan…'; + regPinStatus.textContent = ''; + + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + student_id: currentStudentProfile.student_id, + class_id: classId, + pin: pin + }) + }).then(function (res) { + return res.json().then(function (data) { + return { ok: res.ok, status: res.status, data: data }; + }); + }).then(function (r) { + btnCompleteRegister.disabled = false; + btnCompleteRegister.textContent = 'Simpan & Mulai'; + + if (!r.ok) { + var msg = (r.data && r.data.message) ? r.data.message : 'Gagal menyimpan registrasi'; + regPinStatus.textContent = msg; + regPinStatus.style.color = '#f97316'; + showToast(msg, 'error'); + return; + } + + var d = r.data && r.data.data ? r.data.data : r.data; + if (!d || !d.student_id) { + regPinStatus.textContent = 'Response tidak valid dari server.'; + regPinStatus.style.color = '#f97316'; + return; + } + + var profile = { + student_id: d.student_id, + name: d.name, + nisn: d.nisn, + class_id: d.class_id, + class_label: d.class_label + }; + saveStudentProfile(profile); + renderStudentSummary(homeStudentSummary, profile); + + goToScreen('home'); + showToast('Registrasi berhasil. Siap presensi.', 'success'); + }).catch(function (err) { + btnCompleteRegister.disabled = false; + btnCompleteRegister.textContent = 'Simpan & Mulai'; + regPinStatus.textContent = 'Gagal menghubungi server.'; + regPinStatus.style.color = '#f97316'; + var msg = (err && err.message) ? err.message : 'Gagal menghubungi server'; + showToast(msg, 'error'); + }); + } + + function handleResetPin() { + if (!ensureConfig()) return; + + var nisn = forgotNisnInput ? forgotNisnInput.value.trim() : ''; + var newPin = forgotNewPinInput ? forgotNewPinInput.value.trim() : ''; + var newPin2 = forgotNewPin2Input ? forgotNewPin2Input.value.trim() : ''; + + if (nisn === '') { + if (forgotPinStatus) { + forgotPinStatus.textContent = 'NISN wajib diisi.'; + forgotPinStatus.style.color = '#f97316'; + } + showToast('NISN wajib diisi', 'error'); + return; + } + if (newPin.length < 4) { + if (forgotPinStatus) { + forgotPinStatus.textContent = 'PIN baru minimal 4 digit.'; + forgotPinStatus.style.color = '#f97316'; + } + showToast('PIN baru minimal 4 digit', 'error'); + return; + } + if (newPin !== newPin2) { + if (forgotPinStatus) { + forgotPinStatus.textContent = 'PIN baru dan ulangi tidak sama.'; + forgotPinStatus.style.color = '#f97316'; + } + showToast('PIN baru dan ulangi tidak sama', 'error'); + return; + } + + if (forgotPinStatus) forgotPinStatus.textContent = ''; + var url = backendBase() + '/api/mobile/forgot-pin'; + if (btnResetPin) { + btnResetPin.disabled = true; + btnResetPin.textContent = 'Memproses…'; + } + + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ + nisn: nisn, + new_pin: newPin, + new_pin_confirm: newPin2 + }) + }).then(function (res) { + return res.json().then(function (data) { + return { ok: res.ok, status: res.status, data: data }; + }); + }).then(function (r) { + if (btnResetPin) { + btnResetPin.disabled = false; + btnResetPin.textContent = 'Reset PIN'; + } + if (!r.ok) { + var msg = (r.data && r.data.message) ? r.data.message : 'Gagal reset PIN'; + if (forgotPinStatus) { + forgotPinStatus.textContent = msg; + forgotPinStatus.style.color = '#f97316'; + } + showToast(msg, 'error'); + return; + } + showToast('PIN berhasil direset. Silakan masuk dengan PIN baru.', 'success'); + if (loginNisnInput) loginNisnInput.value = nisn; + if (loginPinInput) loginPinInput.value = ''; + goToScreen('login'); + }).catch(function (err) { + if (btnResetPin) { + btnResetPin.disabled = false; + btnResetPin.textContent = 'Reset PIN'; + } + var msg = (err && err.message) ? err.message : 'Gagal menghubungi server'; + if (forgotPinStatus) { + forgotPinStatus.textContent = msg; + forgotPinStatus.style.color = '#f97316'; + } + showToast(msg, 'error'); + }); + } + + function doCheckin() { + if (!ensureConfig()) return; + if (!currentStudentProfile || !currentStudentProfile.student_id) { + showToast('Lengkapi registrasi siswa dulu.', 'error'); + goToScreen('register_nisn'); + return; + } + if (currentLat == null || currentLng == null) { + showToast('Lokasi belum diambil. Tekan tombol refresh lokasi dulu.', 'error'); + return; + } + openCameraModal(); + } + + function getActiveCheckinButton() { + if (currentCheckinType === 'masuk' && btnAbsenMasuk) return btnAbsenMasuk; + if (currentCheckinType === 'pulang' && btnAbsenPulang) return btnAbsenPulang; + return null; + } + + function fetchTodayStatus() { + if (!currentStudentProfile || !currentStudentProfile.student_id) return; + var url = backendBase() + '/api/mobile/attendance/today?student_id=' + encodeURIComponent(currentStudentProfile.student_id); + fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' } }) + .then(function (res) { return res.json(); }) + .then(function (res) { + if (res && res.success && res.data) { + updatePresenceUI(res.data); + } + }) + .catch(function () {}); + } + + function updatePresenceUI(data) { + var hasMasuk = data.has_masuk === true; + var hasPulang = data.has_pulang === true; + if (masukStatusEl && btnAbsenMasuk) { + if (hasMasuk) { + masukStatusEl.classList.remove('hidden'); + btnAbsenMasuk.classList.add('hidden'); + } else { + masukStatusEl.classList.add('hidden'); + btnAbsenMasuk.classList.remove('hidden'); + } + } + if (pulangStatusEl && btnAbsenPulang) { + if (hasPulang) { + pulangStatusEl.classList.remove('hidden'); + btnAbsenPulang.classList.add('hidden'); + } else { + pulangStatusEl.classList.add('hidden'); + btnAbsenPulang.classList.remove('hidden'); + } + } + } + + function setHomeDateLabel() { + if (!homeDateLabel) return; + var d = new Date(); + var days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu']; + var dayName = days[d.getDay()]; + var dateStr = d.getDate() + ' ' + ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agu','Sep','Okt','Nov','Des'][d.getMonth()] + ' ' + d.getFullYear(); + homeDateLabel.textContent = dayName + ', ' + dateStr; + } + + function stopFaceDetection() { + if (faceDetectIntervalId) { + clearInterval(faceDetectIntervalId); + faceDetectIntervalId = null; + } + faceDetectConsecutiveCount = 0; + if (cameraFaceStatus) { + cameraFaceStatus.classList.add('hidden'); + cameraFaceStatus.textContent = ''; + } + } + + function openCameraModal() { + if (!cameraModal || !cameraVideo) return; + faceDetectSubmitting = false; + stopFaceDetection(); + cameraModal.classList.remove('hidden'); + if (cameraError) cameraError.classList.add('hidden'); + if (cameraFaceStatus) cameraFaceStatus.classList.add('hidden'); + cameraVideo.style.display = 'block'; + var constraints = { video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, audio: false }; + navigator.mediaDevices.getUserMedia(constraints).then(function (stream) { + cameraStream = stream; + cameraVideo.srcObject = stream; + cameraVideo.onloadedmetadata = function () { + cameraVideo.play().then(function () { + startFaceDetection(); + }).catch(function () {}); + }; + }).catch(function (err) { + if (cameraError) { + cameraError.textContent = 'Kamera tidak tersedia atau izin ditolak.'; + cameraError.classList.remove('hidden'); + } + cameraVideo.style.display = 'none'; + showToast('Aktifkan izin kamera untuk verifikasi wajah.', 'error'); + }); + } + + function captureCurrentFrameAsBase64() { + if (!cameraVideo || cameraVideo.readyState < 2) return null; + var w = cameraVideo.videoWidth || 640; + var h = cameraVideo.videoHeight || 480; + if (w === 0 || h === 0) return null; + var canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + var ctx = canvas.getContext('2d'); + ctx.drawImage(cameraVideo, 0, 0, w, h); + return canvas.toDataURL('image/jpeg', 0.9); + } + + function verifyFaceOnServer(imageBase64) { + if (!ensureConfig()) return Promise.reject(new Error('Config backend belum diisi')); + if (!currentStudentProfile || !currentStudentProfile.student_id) { + return Promise.reject(new Error('Profil siswa belum tersedia')); + } + var url = backendBase() + '/api/attendance/verify-face'; + return fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + student_id: currentStudentProfile.student_id, + image: imageBase64 + }) + }).then(function (res) { + return res.json().then(function (data) { + return { ok: res.ok, status: res.status, data: data }; + }); + }).then(function (r) { + if (!r.ok || !r.data) { + var msg = (r.data && r.data.message) ? r.data.message : 'Verifikasi wajah gagal'; + throw new Error(msg); + } + var d = r.data.data || r.data; + if (!d) throw new Error('Response verifikasi tidak valid'); + return d; // { student_id, similarity, threshold, matched_source, status } + }); + } + + function startFaceDetection() { + if (faceDetectSubmitting || !cameraVideo || !cameraStream) return; + if (typeof faceapi === 'undefined') return; + + var isMasukPulang = currentCheckinType === 'masuk' || currentCheckinType === 'pulang'; + var weightsUrl = 'https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/weights/'; + + function runDetection() { + if (faceDetectIntervalId || !cameraStream) return; + if (cameraFaceStatus) { + cameraFaceStatus.classList.remove('hidden'); + cameraFaceStatus.classList.add('detecting'); + cameraFaceStatus.textContent = 'Mendeteksi wajah…'; + } + var requiredConsecutive = 5; + faceDetectIntervalId = setInterval(function () { + if (faceDetectSubmitting || !cameraStream || !cameraVideo.srcObject) return; + if (cameraVideo.readyState < 2) return; + var detect = faceapi.detectSingleFace(cameraVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.5 })); + detect.then(function (detection) { + if (faceDetectSubmitting) return; + if (detection) { + faceDetectConsecutiveCount++; + if (cameraFaceStatus) { + cameraFaceStatus.textContent = 'Wajah terdeteksi… ' + (faceDetectConsecutiveCount >= requiredConsecutive ? 'Memverifikasi…' : ''); + } + if (faceDetectConsecutiveCount >= requiredConsecutive) { + faceDetectSubmitting = true; + stopFaceDetection(); + if (cameraFaceStatus) { + cameraFaceStatus.classList.remove('detecting'); + cameraFaceStatus.classList.add('sending'); + cameraFaceStatus.textContent = 'Memverifikasi wajah…'; + } + if (isMasukPulang) { + var frameBase64 = captureCurrentFrameAsBase64(); + if (!frameBase64) { + faceDetectSubmitting = false; + faceDetectConsecutiveCount = 0; + if (cameraFaceStatus) { + cameraFaceStatus.textContent = 'Gagal menangkap frame. Coba lagi.'; + } + return; + } + verifyFaceOnServer(frameBase64).then(function (result) { + if (result.status !== 'match') { + faceDetectSubmitting = false; + faceDetectConsecutiveCount = 0; + if (cameraFaceStatus) { + cameraFaceStatus.textContent = 'Wajah tidak dikenali server. Coba lagi.'; + } + showToast('Verifikasi wajah gagal. Coba lagi.', 'error'); + return; + } + pendingMasukPulangType = currentCheckinType; + closeCameraModal(); + var titleEl = qrPinModal && qrPinModal.querySelector('.modal-panel-title'); + if (titleEl) titleEl.textContent = currentCheckinType === 'masuk' ? 'Verifikasi wajah berhasil - Masukkan PIN' : 'Verifikasi wajah berhasil - Masukkan PIN'; + var hintEl = qrPinModal && qrPinModal.querySelector('.modal-panel-hint'); + if (hintEl) hintEl.textContent = 'Wajah terverifikasi server. Masukkan PIN untuk menyelesaikan absen ' + (currentCheckinType === 'masuk' ? 'masuk' : 'pulang') + '.'; + if (qrPinInput) { qrPinInput.value = ''; qrPinInput.focus(); } + if (qrPinModal) qrPinModal.classList.remove('hidden'); + }).catch(function (err) { + faceDetectSubmitting = false; + faceDetectConsecutiveCount = 0; + if (cameraFaceStatus) { + cameraFaceStatus.textContent = 'Gagal verifikasi wajah di server.'; + } + var msg = (err && err.message) ? err.message : 'Gagal verifikasi wajah di server'; + showToast(msg, 'error'); + }); + } else { + doCheckinSubmit(); + } + } + } else { + faceDetectConsecutiveCount = 0; + if (cameraFaceStatus && !faceDetectSubmitting) { + cameraFaceStatus.textContent = 'Arahkan wajah ke kamera…'; + } + } + }) + .catch(function () { + faceDetectConsecutiveCount = 0; + }); + }, 300); + } + + faceapi.nets.tinyFaceDetector.loadFromUri(weightsUrl).then(runDetection).catch(function () { + if (cameraFaceStatus) cameraFaceStatus.classList.add('hidden'); + }); + } + + function closeCameraModal() { + stopFaceDetection(); + stopCamera(); + if (cameraModal) cameraModal.classList.add('hidden'); + } + + function stopCamera() { + if (cameraStream) { + cameraStream.getTracks().forEach(function (t) { t.stop(); }); + cameraStream = null; + } + if (cameraVideo) cameraVideo.srcObject = null; + } + + function doCheckinSubmit() { + var url = backendBase() + '/api/attendance/checkin'; + var deviceCode = DEFAULT_DEVICE_CODE; + var apiKey = DEFAULT_API_KEY; + var payload = { + device_code: deviceCode, + api_key: apiKey, + student_id: currentStudentProfile.student_id, + datetime: new Date().toISOString().slice(0, 19).replace('T', ' '), + lat: currentLat, + lng: currentLng, + confidence: 1.0 + }; + + var activeBtn = getActiveCheckinButton(); + if (activeBtn) { + activeBtn.disabled = true; + activeBtn.textContent = 'Memproses…'; + } + + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(payload) + }) + .then(function (res) { + return res.json().then(function (data) { + return { ok: res.ok, status: res.status, data: data }; + }); + }) + .then(function (r) { + if (activeBtn) { + activeBtn.disabled = false; + activeBtn.textContent = currentCheckinType === 'masuk' ? 'Absen Masuk' : 'Absen Pulang'; + } + + if (!r.ok) { + faceDetectSubmitting = false; + var msg = (r.data && r.data.message) ? r.data.message : 'Gagal check-in'; + showToast(msg, 'error'); + setResult('ERROR', msg, ''); + return; + } + + var body = r.data || {}; + var status = body.data && body.data.status ? body.data.status : 'UNKNOWN'; + var meta = ''; + if (body.data && body.data.checkin_at) { + meta = 'Waktu server: ' + body.data.checkin_at; + } + + if (status === 'OUTSIDE_ZONE') { + updateLocationStatus('Anda di luar jangkauan yang diizinkan', true); + } + + var friendly; + switch (status) { + case 'PRESENT': + friendly = 'Absensi tercatat sebagai HADIR.'; + break; + case 'LATE': + friendly = 'Absensi tercatat, namun TERLAMBAT.'; + break; + case 'OUTSIDE_ZONE': + friendly = 'Anda di luar jangkauan yang diizinkan.'; + break; + case 'NO_SCHEDULE': + friendly = 'Tidak ada jadwal aktif untuk siswa ini.'; + break; + case 'INVALID_DEVICE': + friendly = 'Device code / API key tidak valid.'; + break; + case 'ALREADY_CHECKED_IN': + friendly = 'Siswa ini sudah check-in untuk jadwal hari ini.'; + break; + case 'ABSENCE_WINDOW_CLOSED': + friendly = 'Check-in di luar window waktu absensi.'; + break; + case 'SESSION_CLOSED': + friendly = 'Sesi pelajaran sudah ditutup.'; + break; + default: + friendly = body.message || 'Status: ' + status; + break; + } + + setResult(status, friendly, meta); + showToast('Status: ' + status, (status === 'PRESENT' || status === 'LATE') ? 'success' : 'info'); + currentCheckinType = null; + fetchTodayStatus(); + closeCameraModal(); + }) + .catch(function (err) { + if (activeBtn) { + activeBtn.disabled = false; + activeBtn.textContent = currentCheckinType === 'masuk' ? 'Absen Masuk' : 'Absen Pulang'; + } + faceDetectSubmitting = false; + currentCheckinType = null; + var msg = (err && err.message) ? err.message : 'Tidak dapat terhubung ke backend.'; + showToast(msg, 'error'); + setResult('ERROR', msg, ''); + }); + } + + function openScanQrModal() { + if (!ensureConfig()) return; + if (!currentStudentProfile || !currentStudentProfile.student_id) { + showToast('Anda harus masuk dulu.', 'error'); + return; + } + pendingQrToken = null; + if (scanQrStatus) scanQrStatus.textContent = ''; + if (qrReader) qrReader.innerHTML = ''; + if (scanQrModal) scanQrModal.classList.remove('hidden'); + + function startScanner() { + if (typeof Html5Qrcode !== 'undefined') { + if (html5QrCodeInstance) { + try { html5QrCodeInstance.stop(); } catch (e) {} + html5QrCodeInstance = null; + } + html5QrCodeInstance = new Html5Qrcode('qr-reader'); + var opts = { fps: 8, qrbox: { width: 220, height: 220 } }; + html5QrCodeInstance.start( + { facingMode: 'environment' }, + opts, + function (decodedText) { + var token = (decodedText || '').trim(); + if (!token) return; + try { html5QrCodeInstance.stop(); } catch (e) {} + html5QrCodeInstance = null; + pendingQrToken = token; + if (scanQrModal) scanQrModal.classList.add('hidden'); + var titleEl = qrPinModal && qrPinModal.querySelector('.modal-panel-title'); + if (titleEl) titleEl.textContent = 'Konfirmasi PIN'; + var hintEl = qrPinModal && qrPinModal.querySelector('.modal-panel-hint'); + if (hintEl) hintEl.textContent = 'Masukkan PIN untuk mengirim absen mapel.'; + if (qrPinInput) { qrPinInput.value = ''; qrPinInput.focus(); } + if (qrPinModal) qrPinModal.classList.remove('hidden'); + }, + function () {} + ).catch(function (err) { + if (scanQrStatus) scanQrStatus.textContent = 'Kamera tidak tersedia atau izin ditolak.'; + showToast('Aktifkan izin kamera untuk scan QR.', 'error'); + }); + } else { + if (scanQrStatus) scanQrStatus.textContent = 'Library scan QR tidak terbaca. Refresh halaman.'; + } + } + + if (typeof Html5Qrcode !== 'undefined') { + startScanner(); + } else { + if (scanQrStatus) scanQrStatus.textContent = 'Memuat pemindai QR…'; + var s = document.createElement('script'); + s.src = 'https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js'; + s.onload = startScanner; + s.onerror = function () { + if (scanQrStatus) scanQrStatus.textContent = 'Gagal memuat library. Cek koneksi.'; + }; + document.head.appendChild(s); + } + } + + function closeScanQrModal() { + if (html5QrCodeInstance) { + try { html5QrCodeInstance.stop(); } catch (e) {} + html5QrCodeInstance = null; + } + if (scanQrModal) scanQrModal.classList.add('hidden'); + } + + function submitCheckinMasukPulang(pin) { + if (!currentStudentProfile || !currentStudentProfile.nisn || !pendingMasukPulangType) return; + var url = backendBase() + '/api/mobile/checkin-masuk-pulang'; + var payload = { + nisn: currentStudentProfile.nisn, + pin: pin, + type: pendingMasukPulangType, + lat: currentLat, + lng: currentLng + }; + if (qrPinSubmit) { qrPinSubmit.disabled = true; qrPinSubmit.textContent = 'Mengirim…'; } + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(payload) + }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); }) + .then(function (r) { + if (qrPinSubmit) { qrPinSubmit.disabled = false; qrPinSubmit.textContent = 'Kirim Absen'; } + closeQrPinModal(); + pendingMasukPulangType = null; + currentCheckinType = null; + if (!r.ok) { + var msg = (r.data && r.data.message) ? r.data.message : 'Gagal mengirim absen'; + showToast(msg, 'error'); + setResult('ERROR', msg, ''); + return; + } + var body = r.data || {}; + var status = (body.data && body.data.status) ? body.data.status : 'UNKNOWN'; + var message = body.message || status; + var meta = (body.data && body.data.checkin_at) ? ('Waktu: ' + body.data.checkin_at) : ''; + var friendly = message; + if (status === 'PRESENT') friendly = pendingMasukPulangType === 'masuk' ? 'Absen masuk berhasil.' : 'Absen pulang berhasil.'; + else if (status === 'OUTSIDE_ZONE') { + friendly = 'Anda di luar jangkauan sekolah.'; + updateLocationStatus('Anda di luar jangkauan sekolah', true); + } + else if (status === 'INVALID_DEVICE') friendly = 'Device aplikasi belum dikonfigurasi. Hubungi admin.'; + else if (status === 'ALREADY_CHECKED_IN') friendly = pendingMasukPulangType === 'masuk' ? 'Sudah absen masuk hari ini.' : 'Sudah absen pulang hari ini.'; + else if (status === 'ABSENCE_WINDOW_CLOSED') friendly = 'Di luar jam absen. Cek jam masuk/pulang di pengaturan sekolah.'; + setResult(status, friendly, meta); + showToast(friendly, (status === 'PRESENT') ? 'success' : 'info'); + fetchTodayStatus(); + }) + .catch(function () { + if (qrPinSubmit) { qrPinSubmit.disabled = false; qrPinSubmit.textContent = 'Kirim Absen'; } + pendingMasukPulangType = null; + currentCheckinType = null; + showToast('Tidak dapat terhubung ke server.', 'error'); + setResult('ERROR', 'Tidak dapat terhubung ke server.', ''); + }); + } + + function closeQrPinModal() { + pendingQrToken = null; + pendingMasukPulangType = null; + if (qrPinModal) qrPinModal.classList.add('hidden'); + if (qrPinInput) qrPinInput.value = ''; + var titleEl = qrPinModal && qrPinModal.querySelector('.modal-panel-title'); + if (titleEl) titleEl.textContent = 'Konfirmasi PIN'; + var hintEl = qrPinModal && qrPinModal.querySelector('.modal-panel-hint'); + if (hintEl) hintEl.textContent = 'Masukkan PIN untuk mengirim absen mapel.'; + } + + function submitCheckinQr() { + var pin = (qrPinInput && qrPinInput.value) ? qrPinInput.value.trim() : ''; + if (!pin) { + showToast('Masukkan PIN.', 'error'); + return; + } + if (pendingMasukPulangType === 'masuk' || pendingMasukPulangType === 'pulang') { + submitCheckinMasukPulang(pin); + return; + } + if (!pendingQrToken || !currentStudentProfile || !currentStudentProfile.nisn) { + showToast('Data tidak lengkap.', 'error'); + return; + } + var url = backendBase() + '/api/mobile/checkin-qr'; + var payload = { + nisn: currentStudentProfile.nisn, + pin: pin, + qr_token: pendingQrToken + }; + if (currentLat != null && currentLng != null) { + payload.lat = currentLat; + payload.lng = currentLng; + } + if (qrPinSubmit) { qrPinSubmit.disabled = true; qrPinSubmit.textContent = 'Mengirim…'; } + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(payload) + }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); }) + .then(function (r) { + if (qrPinSubmit) { qrPinSubmit.disabled = false; qrPinSubmit.textContent = 'Kirim Absen'; } + closeQrPinModal(); + if (!r.ok) { + var msg = (r.data && r.data.message) ? r.data.message : 'Gagal mengirim absen'; + showToast(msg, 'error'); + setResult('ERROR', msg, ''); + return; + } + var body = r.data || {}; + var status = (body.data && body.data.status) ? body.data.status : 'UNKNOWN'; + var message = body.message || status; + var meta = (body.data && body.data.checkin_at) ? ('Waktu: ' + body.data.checkin_at) : ''; + var friendly = message; + if (status === 'PRESENT') friendly = 'Absensi berhasil (Hadir).'; + else if (status === 'LATE') friendly = 'Absensi berhasil (Terlambat).'; + else if (status === 'OUTSIDE_ZONE') { + friendly = 'Anda di luar jangkauan yang diizinkan.'; + updateLocationStatus('Anda di luar jangkauan yang diizinkan', true); + } + else if (status === 'INVALID_QR_TOKEN') friendly = 'QR tidak valid atau sudah kadaluarsa. Minta guru menampilkan QR lagi.'; + else if (status === 'STUDENT_NOT_IN_CLASS') friendly = 'Anda tidak termasuk kelas untuk mapel ini.'; + else if (status === 'ALREADY_CHECKED_IN') friendly = 'Sudah absen untuk mapel ini hari ini.'; + setResult(status, friendly, meta); + showToast(friendly, (status === 'PRESENT' || status === 'LATE') ? 'success' : 'info'); + }) + .catch(function () { + if (qrPinSubmit) { qrPinSubmit.disabled = false; qrPinSubmit.textContent = 'Kirim Absen'; } + showToast('Tidak dapat terhubung ke server.', 'error'); + setResult('ERROR', 'Tidak dapat terhubung ke server.', ''); + }); + } + + // Event binding + btnOpenSettings.addEventListener('click', openSettingsModal); + btnCloseSettings.addEventListener('click', closeSettingsModal); + btnSaveConfig.addEventListener('click', function () { + saveConfig(); + }); + var btnTestConnection = document.getElementById('btn-test-connection'); + if (btnTestConnection) { + btnTestConnection.addEventListener('click', function () { + if (!backendUrlInput.value.trim()) { + configStatus.textContent = 'Isi URL backend dulu.'; + configStatus.style.color = '#f97316'; + return; + } + testConnection(); + }); + } + btnRefreshLocation.addEventListener('click', getLocation); + if (btnAbsenMasuk) { + btnAbsenMasuk.addEventListener('click', function () { + currentCheckinType = 'masuk'; + doCheckin(); + }); + } + if (btnAbsenPulang) { + btnAbsenPulang.addEventListener('click', function () { + currentCheckinType = 'pulang'; + doCheckin(); + }); + } + var btnScanQr = document.getElementById('btn-scan-qr'); + if (btnScanQr) btnScanQr.addEventListener('click', openScanQrModal); + btnCheckNisn.addEventListener('click', handleCheckNisn); + btnCompleteRegister.addEventListener('click', handleCompleteRegister); + + if (cameraBtnCancel) { + cameraBtnCancel.addEventListener('click', closeCameraModal); + } + if (cameraBtnCancel2) { + cameraBtnCancel2.addEventListener('click', closeCameraModal); + } + var cameraBackdrop = document.getElementById('camera-backdrop'); + if (cameraBackdrop) { + cameraBackdrop.addEventListener('click', closeCameraModal); + } + if (resultCloseBtn) { + resultCloseBtn.addEventListener('click', function () { + resultBox.classList.add('hidden'); + }); + } + + if (scanQrClose) scanQrClose.addEventListener('click', closeScanQrModal); + var scanQrBackdrop = document.getElementById('scan-qr-backdrop'); + if (scanQrBackdrop) scanQrBackdrop.addEventListener('click', closeScanQrModal); + if (qrPinClose) qrPinClose.addEventListener('click', closeQrPinModal); + if (qrPinBackdrop) qrPinBackdrop.addEventListener('click', closeQrPinModal); + if (qrPinCancel) qrPinCancel.addEventListener('click', closeQrPinModal); + if (qrPinSubmit) qrPinSubmit.addEventListener('click', submitCheckinQr); + + var btnGoLogin = document.getElementById('btn-go-login'); + var btnGoRegister = document.getElementById('btn-go-register'); + var linkGoRegister = document.getElementById('link-go-register'); + + if (btnGoLogin) { + btnGoLogin.addEventListener('click', function () { + goToScreen('login'); + }); + } + if (btnGoRegister) { + btnGoRegister.addEventListener('click', function () { + goToScreen('register_nisn'); + }); + } + if (linkGoRegister) { + linkGoRegister.addEventListener('click', function () { + goToScreen('register_nisn'); + }); + } + + var linkBackLogin1 = document.getElementById('link-back-login-1'); + if (linkBackLogin1) { + linkBackLogin1.addEventListener('click', function (e) { + e.preventDefault(); + goToScreen('login'); + }); + } + + var btnRegisterPinBatal = document.getElementById('btn-register-pin-batal'); + if (btnRegisterPinBatal) { + btnRegisterPinBatal.addEventListener('click', function (e) { + e.preventDefault(); + goToScreen('register_nisn'); + }); + } + + var btnLogout = document.getElementById('btn-logout'); + if (btnLogout) { + btnLogout.addEventListener('click', function (e) { + e.preventDefault(); + currentStudentProfile = null; + try { + localStorage.removeItem('sman1_student_profile'); + } catch (err) {} + showToast('Anda sudah keluar', 'info'); + goToScreen('welcome'); + }); + } + + if (btnLogin) { + btnLogin.addEventListener('click', handleLogin); + } + + var linkForgotPin = document.getElementById('link-forgot-pin'); + if (linkForgotPin) { + linkForgotPin.addEventListener('click', function (e) { + e.preventDefault(); + goToScreen('forgot_pin'); + }); + } + var linkBackToLogin = document.getElementById('link-back-to-login'); + if (linkBackToLogin) { + linkBackToLogin.addEventListener('click', function (e) { + e.preventDefault(); + goToScreen('login'); + }); + } + if (btnResetPin) { + btnResetPin.addEventListener('click', handleResetPin); + } + + // Init + loadConfig(); + currentStudentProfile = loadStudentProfile(); + if (currentStudentProfile && currentStudentProfile.student_id) { + renderStudentSummary(homeStudentSummary, currentStudentProfile); + goToScreen('home'); + } else { + goToScreen('welcome'); + } + getLocation(); +})(); + diff --git a/assets/img/logo_sman1garut.png b/assets/img/logo_sman1garut.png new file mode 100644 index 0000000..5ea8368 Binary files /dev/null and b/assets/img/logo_sman1garut.png differ diff --git a/assets/webfonts/Outfit-Black.woff2 b/assets/webfonts/Outfit-Black.woff2 new file mode 100644 index 0000000..c1897f7 Binary files /dev/null and b/assets/webfonts/Outfit-Black.woff2 differ diff --git a/assets/webfonts/Outfit-Bold.woff2 b/assets/webfonts/Outfit-Bold.woff2 new file mode 100644 index 0000000..6976280 Binary files /dev/null and b/assets/webfonts/Outfit-Bold.woff2 differ diff --git a/assets/webfonts/Outfit-ExtraBold.woff2 b/assets/webfonts/Outfit-ExtraBold.woff2 new file mode 100644 index 0000000..3f18fbe Binary files /dev/null and b/assets/webfonts/Outfit-ExtraBold.woff2 differ diff --git a/assets/webfonts/Outfit-ExtraLight.woff2 b/assets/webfonts/Outfit-ExtraLight.woff2 new file mode 100644 index 0000000..06173bc Binary files /dev/null and b/assets/webfonts/Outfit-ExtraLight.woff2 differ diff --git a/assets/webfonts/Outfit-Light.woff2 b/assets/webfonts/Outfit-Light.woff2 new file mode 100644 index 0000000..97cee48 Binary files /dev/null and b/assets/webfonts/Outfit-Light.woff2 differ diff --git a/assets/webfonts/Outfit-Medium.woff2 b/assets/webfonts/Outfit-Medium.woff2 new file mode 100644 index 0000000..fa5b9f3 Binary files /dev/null and b/assets/webfonts/Outfit-Medium.woff2 differ diff --git a/assets/webfonts/Outfit-Regular.woff2 b/assets/webfonts/Outfit-Regular.woff2 new file mode 100644 index 0000000..fc991db Binary files /dev/null and b/assets/webfonts/Outfit-Regular.woff2 differ diff --git a/assets/webfonts/Outfit-SemiBold.woff2 b/assets/webfonts/Outfit-SemiBold.woff2 new file mode 100644 index 0000000..512663f Binary files /dev/null and b/assets/webfonts/Outfit-SemiBold.woff2 differ diff --git a/assets/webfonts/Outfit-Thin.woff2 b/assets/webfonts/Outfit-Thin.woff2 new file mode 100644 index 0000000..6431a4e Binary files /dev/null and b/assets/webfonts/Outfit-Thin.woff2 differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..a6c67b9 --- /dev/null +++ b/index.html @@ -0,0 +1,329 @@ + + + + + + SMAN 1 Garut - Presensi Siswa + + + + + + + + +
+ + + + +
+ +
+ + Presensi Online +
+ + +
+
+ +

Presensi Online

+

SMAN 1 Garut

+
+ + +
+ + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..f45f597 --- /dev/null +++ b/run.bat @@ -0,0 +1,7 @@ +@echo off +echo Menjalankan server mobile di http://localhost:8001 +echo. +echo Buka di browser: http://localhost:8001 +echo Tekan Ctrl+C untuk stop. +echo. +php -S localhost:8001 diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..3a636ee --- /dev/null +++ b/styles.css @@ -0,0 +1,1225 @@ +@font-face { + font-family: 'Outfit'; + src: url('./assets/webfonts/Outfit-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Outfit'; + src: url('./assets/webfonts/Outfit-Medium.woff2') format('woff2'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Outfit'; + src: url('./assets/webfonts/Outfit-SemiBold.woff2') format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Outfit'; + src: url('./assets/webfonts/Outfit-Bold.woff2') format('woff2'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; +} + +body { + background: #f5f5f5; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Status bar ala Android */ +.status-bar { + background: #f5f5f5; + padding: 12px 24px 8px; + display: flex; + justify-content: space-between; + font-size: 15px; + font-weight: 500; + color: #1e1e1e; +} + +.status-bar .time { + font-weight: 500; +} + +.status-bar .icons { + display: flex; + gap: 6px; +} + +/* Container utama - full screen tanpa card */ +.container { + flex: 1; + padding: 24px 24px 32px; + display: flex; + flex-direction: column; + position: relative; + z-index: 1; +} + +/* Header dengan tombol back khas Android */ +.app-header { + margin-bottom: 40px; + display: flex; + align-items: center; + gap: 4px; +} + +.back-button { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + color: #1e1e1e; + cursor: pointer; +} + +.back-button:active { + background: rgba(0, 0, 0, 0.05); +} + +.back-button svg { + width: 24px; + height: 24px; + fill: #1e1e1e; +} + +/* Icon pengaturan lebih kecil */ +.btn-settings { + width: 36px; + height: 36px; +} + +.btn-settings svg { + width: 20px; + height: 20px; +} + +.app-title { + font-size: 22px; + font-weight: 500; + color: #1e1e1e; + margin-left: 4px; +} + +/* Ilustrasi/logo khas Android */ +.android-illustration { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 48px; +} + +.android-logo { + margin-bottom: 20px; +} + +.android-logo img { + width: 96px; + height: auto; + object-fit: cover; +} + +.welcome-text { + font-size: 28px; + font-weight: 500; + color: #1e1e1e; + margin-bottom: 8px; +} + +.sub-text { + font-size: 16px; + color: #5e5e5e; +} + +/* Form input ala Android native */ +.form-group { + margin-bottom: 24px; +} + +.input-label { + font-size: 12px; + font-weight: 500; + color: #3c3c3c; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + margin-left: 16px; +} + +.input-android { + background: #eeeeee; + border-radius: 28px; + padding: 4px 20px; + border: 2px solid transparent; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 12px; +} + +.input-android:focus-within { + background: #ffffff; + border-color: #2563eb; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.2); +} + +.input-android svg { + width: 20px; + height: 20px; + fill: #5e5e5e; +} + +.input-android:focus-within svg { + fill: #2563eb; +} + +.input-android input { + flex: 1; + background: transparent; + border: none; + padding: 16px 0; + font-size: 16px; + color: #1e1e1e; + outline: none; +} + +.input-android input::placeholder { + color: #9e9e9e; +} + +/* Lupa password */ +.forgot-section { + display: flex; + justify-content: flex-end; + margin: 8px 16px 32px; +} + +.forgot-link { + color: #2563eb; + font-size: 14px; + font-weight: 600; + text-decoration: none; + padding: 8px 4px; +} + +.forgot-link:active { + opacity: 0.7; +} + +/* Tombol login ala Android */ +.login-button { + width: 100%; + min-height: 48px; + background: #2563eb; + border: none; + border-radius: 30px; + padding: 18px; + font-size: 16px; + font-weight: 600; + color: #fff; + text-transform: uppercase; + letter-spacing: 1px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-bottom: 24px; + box-shadow: 0 4px 12px rgba(61, 220, 132, 0.3); + cursor: pointer; + transition: all 0.1s; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +.login-button:active { + transform: scale(0.98); + background: #1d4ed8; + box-shadow: 0 2px 6px rgba(37, 99, 235, 0.4); +} + +.login-button svg { + width: 20px; + height: 20px; + fill: #fff; +} + +/* Divider */ +.divider { + display: flex; + align-items: center; + margin: 24px 0; + color: #9e9e9e; + font-size: 14px; +} + +.divider-line { + flex: 1; + height: 1px; + background: #e0e0e0; +} + +.divider-text { + margin: 0 16px; +} + +/* Opsi login lain */ +.other-login { + display: flex; + justify-content: center; + gap: 24px; + margin-bottom: 32px; +} + +.other-btn { + width: 56px; + height: 56px; + border-radius: 28px; + background: #ffffff; + border: 1px solid #e0e0e0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02); +} + +.other-btn:active { + background: #f5f5f5; + transform: scale(0.95); +} + +.other-btn svg { + width: 28px; + height: 28px; +} + +/* Biometric section */ +.biometric-section { + display: flex; + flex-direction: column; + align-items: center; + margin-top: auto; + padding-top: 20px; +} + +.biometric-hint { + font-size: 14px; + color: #5e5e5e; + margin-bottom: 12px; +} + +.fingerprint-icon { + width: 64px; + height: 64px; + border-radius: 50%; + background: #f0f0f0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: 2px solid transparent; +} + +.fingerprint-icon:active { + background: #e4e4e4; + border-color: #3ddc84; +} + +.fingerprint-icon svg { + width: 32px; + height: 32px; + fill: #5e5e5e; +} + +/* Link daftar */ +.signup-link { + text-align: center; + margin-top: 24px; + padding: 16px; + font-size: 15px; + color: #5e5e5e; +} + +.signup-link a { + color: #3ddc84; + text-decoration: none; + font-weight: 600; + margin-left: 4px; +} + +.signup-link a:active { + opacity: 0.7; +} + +/* Navigation bar ala Android */ +.nav-bar { + background: #ffffff; + padding: 8px 24px 12px; + display: flex; + justify-content: space-between; + border-top: 1px solid #eaeaea; +} + +.nav-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + color: #9e9e9e; + font-size: 12px; + cursor: pointer; +} + +.nav-item.active { + color: #3ddc84; +} + +.nav-item.active svg { + fill: #3ddc84; +} + +.nav-item svg { + width: 24px; + height: 24px; + fill: #9e9e9e; +} + +/* Animasi */ +@keyframes ripple { + 0% { transform: scale(1); } + 50% { transform: scale(0.98); } + 100% { transform: scale(1); } +} + +.ripple:active { + animation: ripple 0.2s ease; +} + +/* ---------- Screen flow & hidden ---------- */ +.hidden { + display: none !important; + pointer-events: none !important; + visibility: hidden !important; +} + +.screen { + display: flex; + flex-direction: column; + flex: 1; +} + +.android-illustration-small { + margin-bottom: 24px; +} + +.android-illustration-small .welcome-text { + font-size: 22px; +} + +/* ---------- Modal Settings ---------- */ +.modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); +} + +.modal-content { + position: relative; + background: #fff; + border-radius: 16px; + padding: 24px; + width: 100%; + max-width: 400px; + max-height: 90vh; + overflow: auto; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.modal-header h2 { + font-size: 18px; + font-weight: 600; + color: #1e1e1e; +} + +.modal-close { + width: 36px; + height: 36px; + border: none; + background: none; + font-size: 24px; + color: #5e5e5e; + cursor: pointer; + line-height: 1; +} + +.modal-body .input-full { + width: 100%; + padding: 12px 16px; + border-radius: 12px; + border: 2px solid #e0e0e0; + font-size: 14px; +} + +.config-status { + font-size: 13px; + margin-bottom: 12px; + min-height: 18px; +} + +.input-hint { + font-size: 12px; + color: #94a3b8; + margin-top: 6px; + margin-bottom: 0; +} + +.modal-actions { + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-actions .secondary-button { + margin-bottom: 0; +} + +/* ---------- Modal overlay & panel (check-in, QR, PIN) ---------- */ +.modal-overlay { + padding: 16px; +} +.modal-overlay.hidden { + pointer-events: none; +} + +.modal-panel { + position: relative; + z-index: 1; + background: #fff; + border-radius: 16px; + width: 100%; + max-width: 400px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 40px rgba(0,0,0,0.15); + overflow: hidden; +} + +.modal-panel-header { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #e2e8f0; +} + +.modal-panel-title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #1e1e1e; +} + +.modal-panel-close { + width: 40px; + height: 40px; + border: none; + background: none; + font-size: 24px; + color: #64748b; + cursor: pointer; + line-height: 1; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} +.modal-panel-close:hover, +.modal-panel-close:active { + background: #f1f5f9; + color: #1e1e1e; +} + +.modal-panel-body { + flex: 1; + padding: 20px; + overflow-y: auto; + min-height: 0; +} + +.modal-panel-hint { + font-size: 14px; + color: #475569; + margin-bottom: 16px; + line-height: 1.5; +} + +.modal-panel-actions { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.modal-panel-actions-row { + flex-direction: row; + justify-content: flex-end; + gap: 10px; +} + +.modal-panel-actions .btn-outline, +.modal-panel-actions .btn-primary { + margin-bottom: 0; +} + +.modal-panel-actions-row .btn-outline { + flex: 1; + min-width: 0; +} +.modal-panel-actions-row .btn-primary { + flex: 1; + min-width: 0; +} + +.btn-outline { + width: 100%; + padding: 12px 20px; + font-size: 14px; + font-weight: 600; + color: #475569; + background: #fff; + border: 2px solid #e2e8f0; + border-radius: 12px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.btn-outline:active { + background: #f1f5f9; + border-color: #cbd5e1; +} + +.btn-primary { + width: 100%; + padding: 12px 20px; + font-size: 14px; + font-weight: 600; + color: #fff; + background: #2563eb; + border: 2px solid #2563eb; + border-radius: 12px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.btn-primary:active { + background: #1d4ed8; + border-color: #1d4ed8; +} + +.btn-sm { + padding: 8px 16px; + font-size: 13px; + max-width: 120px; + margin: 0 auto; + display: block; +} + +/* Modal kamera fullscreen */ +.modal-panel-camera { + max-width: 100%; + max-height: 100%; + height: 100%; + border-radius: 0; +} + +.modal-panel-camera .modal-panel-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.camera-frame { + position: relative; + width: 100%; + flex: 1; + min-height: 240px; + background: #000; + border-radius: 12px; + overflow: hidden; + margin-bottom: 16px; +} + +.camera-video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.camera-face-status { + font-size: 14px; + font-weight: 600; + color: #16a34a; + margin-bottom: 12px; + min-height: 22px; +} +.camera-face-status.detecting { color: #2563eb; } +.camera-face-status.sending { color: #ca8a04; } +.camera-face-status.success { color: #16a34a; } + +.camera-frame-error { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + color: #fef2f2; + background: #1e1e1e; + font-size: 14px; + text-align: center; +} + +.qr-reader-wrap { + margin-bottom: 12px; + border-radius: 12px; + overflow: hidden; + background: #000; + min-height: 200px; +} +.qr-reader-wrap #qr-reader { + width: 100%; +} +.qr-reader-wrap video { + border-radius: 12px; +} + +.modal-panel-status { + font-size: 13px; + color: #64748b; + margin: 0; + min-height: 20px; +} + +/* ---------- Result popup (hasil absen) ---------- */ +.result-popup { + margin-top: 20px; + padding: 20px; + border-radius: 16px; + background: #fff; + border: 2px solid #e2e8f0; + box-shadow: 0 4px 20px rgba(0,0,0,0.08); +} + +.result-popup.success { border-color: #22c55e; background: #f0fdf4; } +.result-popup.warning { border-color: #eab308; background: #fefce8; } +.result-popup.danger { border-color: #ef4444; background: #fef2f2; } + +.result-popup-inner { + text-align: center; +} + +.result-popup-icon { + font-size: 32px; + margin-bottom: 8px; + line-height: 1; +} +.result-popup.success .result-popup-icon { color: #16a34a; } +.result-popup.warning .result-popup-icon { color: #ca8a04; } +.result-popup.danger .result-popup-icon { color: #dc2626; } + +.result-popup-status { + font-size: 18px; + font-weight: 700; + margin-bottom: 6px; +} +.result-popup.success .result-popup-status { color: #16a34a; } +.result-popup.warning .result-popup-status { color: #ca8a04; } +.result-popup.danger .result-popup-status { color: #dc2626; } + +.result-popup-message { + font-size: 14px; + color: #475569; + margin-bottom: 16px; + line-height: 1.5; +} + +.result-popup .btn-outline.btn-sm { + margin-bottom: 0; +} + +.camera-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); +} + +.camera-modal { + padding: 0; + align-items: stretch; + justify-content: stretch; +} + +/* ---------- Toast ---------- */ +.toast-container { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 1100; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.toast { + padding: 12px 20px; + border-radius: 12px; + font-size: 14px; + font-weight: 500; + color: #fff; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + white-space: nowrap; + max-width: 90vw; + overflow: hidden; + text-overflow: ellipsis; +} + +.toast-success { background: #16a34a; } +.toast-error { background: #dc2626; } +.toast-info { background: #2563eb; } + +/* ---------- Peringatan file:// ---------- */ +.file-protocol-warning { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 2000; + background: #fef3c7; + border-bottom: 2px solid #f59e0b; + padding: 16px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); +} + +.file-protocol-warning-inner { + max-width: 480px; + margin: 0 auto; + font-size: 14px; + color: #92400e; +} + +.file-protocol-warning-inner strong { display: block; margin-bottom: 8px; } +.file-protocol-warning-inner p { margin-bottom: 8px; } +.file-protocol-warning-inner ol { margin-left: 20px; margin-bottom: 8px; } +.file-protocol-warning-inner li { margin-bottom: 4px; } +.file-protocol-warning-inner code { + background: rgba(0,0,0,0.08); + padding: 2px 6px; + border-radius: 4px; + font-size: 13px; +} +.file-protocol-warning-inner a { color: #2563eb; font-weight: 600; } + +.modal-hint { + font-size: 13px; + color: #475569; + background: #f1f5f9; + padding: 12px; + border-radius: 8px; + margin-bottom: 16px; +} + +.modal-hint code { font-size: 12px; background: #e2e8f0; padding: 2px 4px; border-radius: 4px; } + +/* ---------- Form status & Student summary ---------- */ +.form-status { + font-size: 13px; + margin: -8px 0 12px 16px; + min-height: 18px; +} + +.student-summary { + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 12px; + padding: 12px 16px; + margin-bottom: 20px; + font-size: 14px; + color: #0c4a6e; +} + +.student-summary strong { + display: block; + margin-bottom: 4px; +} + +/* ---------- Home: greeting, card, logout ---------- */ +.home-greeting { + margin-bottom: 16px; +} + +.home-greeting-title { + font-size: 20px; + font-weight: 600; + color: #1e1e1e; + margin-bottom: 4px; +} + +.home-greeting-sub { + font-size: 14px; + color: #64748b; +} + +.home-info-box { + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 12px; + padding: 14px 16px; + margin-bottom: 16px; + font-size: 13px; + color: #0c4a6e; +} + +.home-info-box strong { + display: block; + margin-bottom: 8px; +} + +.home-info-box ul { + margin: 0; + padding-left: 18px; +} + +.home-info-box li { + margin-bottom: 6px; + line-height: 1.4; +} + +.home-info-box li:last-child { + margin-bottom: 0; +} + +.home-card { + background: #fff; + border-radius: 16px; + padding: 20px; + margin-bottom: 20px; + border: 1px solid #e2e8f0; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} + +.home-card .home-card-label { + font-size: 12px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; +} + +.home-card .location-status { + margin-bottom: 12px; +} + +.home-card .secondary-button { + margin-bottom: 12px; +} + +.home-card .login-button { + margin-bottom: 0; +} + +/* ---------- Home: enterprise presence grid ---------- */ +.home-presence-grid { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.presence-card { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px; + background: #fff; + border-radius: 12px; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} + +.presence-card-featured { + border-color: #2563eb; + background: linear-gradient(135deg, #eff6ff 0%, #fff 100%); + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.12); +} + +.presence-card-icon { + flex-shrink: 0; + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; +} + +.presence-card-icon svg { + width: 24px; + height: 24px; +} + +.presence-card-icon-masuk { + background: #dcfce7; + color: #16a34a; +} + +.presence-card-icon-mapel { + background: #dbeafe; + color: #2563eb; +} + +.presence-card-icon-pulang { + background: #fef3c7; + color: #d97706; +} + +.presence-card-body { + flex: 1; + min-width: 0; +} + +.presence-card-label { + font-size: 13px; + font-weight: 600; + color: #1e293b; + margin-bottom: 2px; +} + +.presence-card-hint { + font-size: 12px; + color: #64748b; + margin: 0 0 10px 0; +} + +.presence-card-status { + font-size: 13px; + font-weight: 500; + color: #16a34a; + margin-bottom: 6px; +} + +.presence-card-btn { + display: block; + width: 100%; + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + border: none; + border-radius: 10px; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +.presence-card-btn-primary { + background: #16a34a; + color: #fff; +} + +.presence-card-btn-primary:active { + background: #15803d; +} + +.presence-card-btn-secondary { + background: #d97706; + color: #fff; +} + +.presence-card-btn-secondary:active { + background: #b45309; +} + +.presence-card-btn-featured { + background: #2563eb; + color: #fff; +} + +.presence-card-btn-featured:active { + background: #1d4ed8; +} + +.student-summary-compact { + margin-bottom: 16px; + padding: 12px 16px; + font-size: 13px; +} + +.home-location-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + background: #f8fafc; + border-radius: 10px; + font-size: 12px; + color: #64748b; + margin-bottom: 12px; +} + +.location-status-inline { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.link-button { + background: none; + border: none; + font-size: 18px; + color: #64748b; + cursor: pointer; + padding: 4px; +} + +.link-button:active { + color: #2563eb; +} + +.result-box-compact { + margin-top: 12px; + padding: 12px 16px; +} + +.result-box-compact .result-message { + margin-bottom: 0; +} + +.home-footer { + margin-top: auto; + padding-top: 24px; + text-align: center; +} + +.logout-link { + font-size: 14px; + color: #64748b; + text-decoration: none; +} + +.logout-link:hover, +.logout-link:active { + color: #dc2626; +} + +/* ---------- Home: location & result ---------- */ +.location-status { + padding: 12px 16px; + border-radius: 12px; + border: 2px solid rgba(148, 163, 184, 0.4); + font-size: 13px; + color: #64748b; + margin-bottom: 12px; +} + +.secondary-button { + width: 100%; + padding: 12px; + font-size: 14px; + font-weight: 600; + color: #2563eb; + background: #eff6ff; + border: 2px solid #2563eb; + border-radius: 12px; + cursor: pointer; + margin-bottom: 16px; +} + +.secondary-button:active { + background: #dbeafe; +} + +.result-box { + margin-top: 20px; + padding: 16px; + border-radius: 12px; + background: #f8fafc; + border: 2px solid #e2e8f0; +} + +.result-box.success { border-color: #22c55e; background: #f0fdf4; } +.result-box.warning { border-color: #eab308; background: #fefce8; } +.result-box.danger { border-color: #ef4444; background: #fef2f2; } + +.result-status { + font-size: 18px; + font-weight: 700; + margin-bottom: 8px; +} + +.result-status.success { color: #16a34a; } +.result-status.warning { color: #ca8a04; } +.result-status.danger { color: #dc2626; } + +.result-message { + font-size: 14px; + color: #475569; + margin-bottom: 4px; +} + +.result-meta { + font-size: 12px; + color: #94a3b8; +} + +/* Select di form */ +select.input-full { + width: 100%; + padding: 14px 20px; + border-radius: 28px; + border: 2px solid #eeeeee; + background: #eeeeee; + font-size: 16px; + color: #1e1e1e; +} \ No newline at end of file diff --git a/test.html b/test.html new file mode 100644 index 0000000..b6961d5 --- /dev/null +++ b/test.html @@ -0,0 +1,504 @@ + + + + + + Login Native Android + + + + + +
+ +
+
+ + +
+ +

Selamat datang

+

Masuk ke akun Android kamu

+
+ + +
+
NISN
+
+ + + + +
+
+ +
+
Kata sandi
+
+ + + + +
+
+ + + + + + + + +
+
+ atau +
+
+ + + + + +
+
Atau gunakan sidik jari
+
+ + + +
+
+ + + +
+ + + + + + + \ No newline at end of file