1513 lines
65 KiB
JavaScript
1513 lines
65 KiB
JavaScript
(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;
|
||
|
||
// Enroll wajah saat daftar
|
||
var enrollFaceModal = document.getElementById('enroll-face-modal');
|
||
var enrollFaceVideo = document.getElementById('enroll-face-video');
|
||
var enrollFaceStatus = document.getElementById('enroll-face-status');
|
||
var enrollFaceError = document.getElementById('enroll-face-error');
|
||
var enrollFaceStart = document.getElementById('enroll-face-start');
|
||
var enrollFaceBackdrop = document.getElementById('enroll-face-backdrop');
|
||
var enrollStream = null;
|
||
var enrollIntervalId = null;
|
||
var enrollCollectedImages = [];
|
||
var enrollTargetCount = 5;
|
||
var enrollMinCount = 3;
|
||
var enrollSubmitting = false;
|
||
|
||
// 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 =
|
||
'<div><strong>' + (profile.name || '-') + '</strong></div>' +
|
||
'<div>NISN: ' + (profile.nisn || '-') + '</div>' +
|
||
'<div>Kelas: ' + (profile.class_label || '-') + '</div>';
|
||
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);
|
||
|
||
openEnrollFaceModal();
|
||
showToast('Registrasi berhasil. Rekam wajah untuk verifikasi absen.', '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 captureEnrollFrameAsBase64() {
|
||
if (!enrollFaceVideo || enrollFaceVideo.readyState < 2) return null;
|
||
var w = enrollFaceVideo.videoWidth || 640;
|
||
var h = enrollFaceVideo.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(enrollFaceVideo, 0, 0, w, h);
|
||
return canvas.toDataURL('image/jpeg', 0.9);
|
||
}
|
||
|
||
function closeEnrollFaceModal() {
|
||
if (enrollIntervalId) {
|
||
clearInterval(enrollIntervalId);
|
||
enrollIntervalId = null;
|
||
}
|
||
enrollCollectedImages = [];
|
||
if (enrollStream) {
|
||
enrollStream.getTracks().forEach(function (t) { t.stop(); });
|
||
enrollStream = null;
|
||
}
|
||
if (enrollFaceVideo) enrollFaceVideo.srcObject = null;
|
||
if (enrollFaceModal) enrollFaceModal.classList.add('hidden');
|
||
if (enrollFaceStatus) {
|
||
enrollFaceStatus.classList.add('hidden');
|
||
enrollFaceStatus.textContent = '';
|
||
}
|
||
}
|
||
|
||
function openEnrollFaceModal() {
|
||
if (!ensureConfig()) {
|
||
goToScreen('home');
|
||
return;
|
||
}
|
||
if (!enrollFaceModal || !enrollFaceVideo || !currentStudentProfile || !currentStudentProfile.student_id) {
|
||
goToScreen('home');
|
||
return;
|
||
}
|
||
enrollCollectedImages = [];
|
||
enrollSubmitting = false;
|
||
if (enrollFaceError) enrollFaceError.classList.add('hidden');
|
||
if (enrollFaceStatus) {
|
||
enrollFaceStatus.textContent = 'Menyiapkan kamera…';
|
||
enrollFaceStatus.classList.remove('hidden');
|
||
}
|
||
enrollFaceModal.classList.remove('hidden');
|
||
enrollFaceVideo.style.display = 'block';
|
||
var constraints = { video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, audio: false };
|
||
navigator.mediaDevices.getUserMedia(constraints).then(function (stream) {
|
||
enrollStream = stream;
|
||
enrollFaceVideo.srcObject = stream;
|
||
enrollFaceVideo.onloadedmetadata = function () {
|
||
enrollFaceVideo.play().then(function () {
|
||
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Arahkan wajah ke kamera lalu tekan Rekam.';
|
||
}).catch(function () {});
|
||
};
|
||
}).catch(function () {
|
||
if (enrollFaceError) {
|
||
enrollFaceError.textContent = 'Kamera tidak tersedia atau izin ditolak. Bisa dilewati.';
|
||
enrollFaceError.classList.remove('hidden');
|
||
}
|
||
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Kamera gagal. Tekan Lewati untuk lanjut.';
|
||
});
|
||
}
|
||
|
||
function startEnrollCapture() {
|
||
if (enrollSubmitting || !enrollStream || !enrollFaceVideo || typeof faceapi === 'undefined') return;
|
||
if (!currentStudentProfile || !currentStudentProfile.student_id) return;
|
||
enrollSubmitting = true;
|
||
enrollCollectedImages = [];
|
||
if (enrollFaceStart) { enrollFaceStart.disabled = true; enrollFaceStart.textContent = 'Merekam…'; }
|
||
var weightsUrl = 'https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/weights/';
|
||
faceapi.nets.tinyFaceDetector.loadFromUri(weightsUrl).then(function () {
|
||
var tick = 0;
|
||
var lastFaceCount = 0;
|
||
enrollIntervalId = setInterval(function () {
|
||
if (!enrollStream || !enrollFaceVideo.srcObject || enrollFaceVideo.readyState < 2) return;
|
||
var detect = faceapi.detectSingleFace(enrollFaceVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.5 }));
|
||
detect.then(function (detection) {
|
||
if (detection && enrollCollectedImages.length < enrollTargetCount) {
|
||
var b64 = captureEnrollFrameAsBase64();
|
||
if (b64) {
|
||
enrollCollectedImages.push(b64);
|
||
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Rekam ' + enrollCollectedImages.length + '/' + enrollTargetCount + '…';
|
||
}
|
||
}
|
||
if (enrollCollectedImages.length >= enrollMinCount) {
|
||
clearInterval(enrollIntervalId);
|
||
enrollIntervalId = null;
|
||
var imgs = enrollCollectedImages.slice(0, enrollTargetCount);
|
||
var url = backendBase() + '/api/face/enroll-live';
|
||
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Mengirim ke server…';
|
||
fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||
body: JSON.stringify({ student_id: currentStudentProfile.student_id, images: imgs })
|
||
}).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
|
||
.then(function (r) {
|
||
enrollSubmitting = false;
|
||
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; }
|
||
if (r.ok && r.data && (r.data.saved || 0) > 0) {
|
||
showToast('Wajah berhasil direkam. Siap untuk verifikasi absen.', 'success');
|
||
} else {
|
||
showToast('Rekam wajah gagal. Bisa dicoba nanti dari pengaturan.', 'info');
|
||
}
|
||
closeEnrollFaceModal();
|
||
goToScreen('home');
|
||
}).catch(function () {
|
||
enrollSubmitting = false;
|
||
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; }
|
||
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Gagal mengirim. Coba lagi atau Lewati.';
|
||
showToast('Gagal mengirim rekaman wajah.', 'error');
|
||
});
|
||
}
|
||
}).catch(function () {});
|
||
tick++;
|
||
if (tick > 80) {
|
||
clearInterval(enrollIntervalId);
|
||
enrollIntervalId = null;
|
||
enrollSubmitting = false;
|
||
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; }
|
||
if (enrollCollectedImages.length >= 1) {
|
||
var url = backendBase() + '/api/face/enroll-live';
|
||
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Mengirim ' + enrollCollectedImages.length + ' foto…';
|
||
fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||
body: JSON.stringify({ student_id: currentStudentProfile.student_id, images: enrollCollectedImages })
|
||
}).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
|
||
.then(function (r) {
|
||
if (r.ok && (r.data && r.data.saved > 0)) showToast('Wajah berhasil direkam.', 'success');
|
||
else showToast('Rekam wajah gagal. Bisa dicoba nanti.', 'info');
|
||
closeEnrollFaceModal();
|
||
goToScreen('home');
|
||
}).catch(function () {
|
||
enrollSubmitting = false;
|
||
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; }
|
||
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Gagal mengirim. Coba lagi atau Lewati.';
|
||
showToast('Gagal mengirim rekaman wajah.', 'error');
|
||
});
|
||
} else {
|
||
enrollSubmitting = false;
|
||
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; }
|
||
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Wajah tidak terdeteksi. Arahkan wajah ke kamera lalu Rekam lagi.';
|
||
}
|
||
}
|
||
}, 500);
|
||
}).catch(function () {
|
||
enrollSubmitting = false;
|
||
if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; }
|
||
if (enrollFaceStatus) enrollFaceStatus.textContent = 'Gagal memuat deteksi wajah. Lewati atau coba lagi.';
|
||
});
|
||
}
|
||
|
||
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 (enrollFaceBackdrop) {
|
||
enrollFaceBackdrop.addEventListener('click', function () {
|
||
showToast('Rekam wajah wajib untuk melanjutkan.', 'info');
|
||
});
|
||
}
|
||
if (enrollFaceStart) {
|
||
enrollFaceStart.addEventListener('click', startEnrollCapture);
|
||
}
|
||
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();
|
||
})();
|
||
|