<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sistem Absensi Face Recognition</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- FontAwesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Face API (Versi optimal untuk browser) -->
<script src="https://cdn.jsdelivr.net/npm/@vladmandic/face-api/dist/face-api.js"></script>
<!-- PDF Generation Libraries -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.28/jspdf.plugin.autotable.min.js"></script>
<style>
/* CSS Khusus Blogger Override & Tampilan Aplikasi */
html, body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
height: 100% !important;
overflow: hidden !important; /* Mencegah scroll bawaan blogger */
font-family: 'Plus Jakarta Sans', sans-serif;
}
/* Sembunyikan elemen navbar default Blogger */
#navbar-iframe, .Navbar, .navbar { display: none !important; }
#app-container {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%) !important;
z-index: 2147483647 !important;
overflow-y: auto !important;
}
/* Elemen UI Modern (Glassmorphism) */
.glass-card { background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.6); box-shadow: 0 10px 30px rgba(0,0,0,0.08); }
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
.view-section { display: none; min-height: 100vh; flex-direction: column; }
.view-section.active { display: flex; }
/* Sidebar Styles & Transitions */
.sidebar-container { transition: width 0.3s ease; }
.sidebar-collapsed { width: 5.5rem !important; }
.sidebar-collapsed .nav-text { display: none; }
.sidebar-collapsed .admin-tab { justify-content: center; padding-left: 0; padding-right: 0; }
.sidebar-collapsed .admin-tab i { margin: 0; font-size: 1.25rem; }
/* Bottom Nav untuk HP & Tablet */
@media (max-width: 767px) {
.mobile-bottom-nav { position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; flex-direction: row; justify-content: space-around; padding: 0.5rem; background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); box-shadow: 0 -4px 15px rgba(0, 0, 0, 0.05); border-top: 1px solid #e5e7eb; overflow-x: auto;}
.mobile-bottom-nav button { flex-direction: column; padding: 0.5rem; font-size: 0.7rem; gap: 0.25rem; justify-content: center; min-width: 20%; flex-shrink: 0; }
.mobile-bottom-nav button i { margin: 0; font-size: 1.25rem; }
.mobile-bottom-nav button span { display: block; }
.content-area-mobile-fix { padding-bottom: 5.5rem !important; }
#btn-collapse-sidebar { display: none; }
}
@media (min-width: 768px) {
.mobile-bottom-nav button span { display: inline-block; }
}
/* Loading Overlay */
#loading-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(255,255,255,0.9); z-index: 99999999;
display: flex; flex-direction: column; justify-content: center; align-items: center;
}
.loader {
border: 5px solid #f3f3f3; border-top: 5px solid #3b82f6;
border-radius: 50%; width: 50px; height: 50px; animation: spin 1s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
/* Notifikasi Toast */
#toast-container { position: fixed; top: 20px; right: 20px; z-index: 999999999; }
.toast { background: #10b981; color: white; padding: 15px 25px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); opacity: 0; transform: translateX(100%); transition: all 0.3s ease; }
.toast.show { opacity: 1; transform: translateX(0); }
.toast.error { background: #ef4444; }
.toast.warning { background: #f59e0b; }
.toast.info { background: #3b82f6; }
</style>
</head>
<body>
<!-- THUMBNAIL BLOGGER (TERSEMBUNYI) -->
<img src="https://images.unsplash.com/photo-1554224155-8d04cb21cd6c?auto=format&fit=crop&w=800&q=80" alt="Thumbnail" style="display:none;">
<!-- SCRIPT OVERRIDE BLOGGER -->
<script>
window.addEventListener('DOMContentLoaded', (event) => {
const appContainer = document.getElementById('app-container');
if (appContainer) {
document.body.appendChild(appContainer);
Array.from(document.body.children).forEach(child => {
if (child !== appContainer && child.tagName !== 'SCRIPT' && child.tagName !== 'STYLE' && child.tagName !== 'LINK') {
child.style.display = 'none';
child.style.visibility = 'hidden';
}
});
}
});
</script>
<!-- APP CONTAINER -->
<div id="app-container">
<!-- Loading Screen -->
<div id="loading-overlay">
<div class="loader mb-4"></div>
<h2 class="text-xl font-bold text-gray-700">Memuat Sistem...</h2>
<p class="text-gray-500 mt-2" id="loading-text">Menyiapkan model AI dan lingkungan kerja</p>
</div>
<!-- Toast Notifications -->
<div id="toast-container"></div>
<!-- VIEW: LANDING PAGE -->
<div id="view-landing" class="view-section active justify-center items-center p-4 md:p-8">
<div class="glass-card rounded-[2rem] w-full max-w-5xl overflow-hidden shadow-2xl flex flex-col md:flex-row border border-white/40">
<!-- Kiri: Profil Lembaga -->
<div class="w-full md:w-3/5 p-8 md:p-12 flex flex-col justify-center">
<div class="inline-block px-4 py-1.5 bg-blue-100 text-blue-700 rounded-full text-xs font-bold tracking-wider uppercase mb-6 w-max border border-blue-200">
Sistem Informasi Kiosk
</div>
<h1 id="ui-nama-lembaga" class="text-4xl md:text-5xl font-extrabold text-gray-900 mb-4 leading-tight tracking-tight">Smart Absensi AI</h1>
<p id="ui-profil-lembaga" class="text-lg text-gray-600 mb-8 font-medium leading-relaxed">
Selamat datang di portal absensi masa depan. Sistem Kiosk Berbasis Pengenalan Wajah kami memastikan pencatatan kehadiran yang akurat, cepat, dan terintegrasi penuh.
</p>
<div class="space-y-4 mb-8">
<div class="flex items-center gap-4 bg-white/50 p-4 rounded-xl border border-white">
<div class="w-12 h-12 bg-blue-500 text-white rounded-lg flex items-center justify-center text-xl shadow-md"><i class="fa-solid fa-bolt"></i></div>
<div>
<h3 class="font-bold text-gray-800">Cepat & Akurat</h3>
<p class="text-sm text-gray-500">Pemindaian instan dengan validasi biometrik 128-dimensi.</p>
</div>
</div>
<div class="flex items-center gap-4 bg-white/50 p-4 rounded-xl border border-white">
<div class="w-12 h-12 bg-green-500 text-white rounded-lg flex items-center justify-center text-xl shadow-md"><i class="fa-solid fa-chart-pie"></i></div>
<div>
<h3 class="font-bold text-gray-800">Rekapitulasi Otomatis</h3>
<p class="text-sm text-gray-500">Laporan harian & bulanan, siap cetak ke format PDF.</p>
</div>
</div>
</div>
</div>
<!-- Kanan: Login Panel -->
<div class="w-full md:w-2/5 bg-gray-900 text-white p-8 md:p-12 flex flex-col justify-center items-center relative overflow-hidden">
<div class="absolute -top-24 -right-24 w-64 h-64 bg-blue-600 rounded-full mix-blend-multiply filter blur-3xl opacity-50"></div>
<div class="absolute -bottom-24 -left-24 w-64 h-64 bg-purple-600 rounded-full mix-blend-multiply filter blur-3xl opacity-50"></div>
<div class="relative z-10 w-full max-w-sm text-center">
<div class="w-24 h-24 bg-white/10 backdrop-blur-md rounded-2xl flex items-center justify-center mx-auto mb-8 border border-white/20 shadow-xl overflow-hidden p-1">
<i id="icon-landing" class="fa-solid fa-face-viewfinder text-5xl text-white"></i>
<img id="img-landing" src="" class="hidden w-full h-full object-cover rounded-xl bg-white">
</div>
<h2 class="text-2xl font-bold mb-2">Akses Sistem</h2>
<p class="text-gray-400 mb-8 text-sm">Masuk sebagai administrator untuk mengaktifkan mesin pemindai dan mengelola laporan.</p>
<button onclick="navigate('login')" class="w-full py-4 px-4 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition flex items-center justify-center gap-3 shadow-lg hover:shadow-blue-500/50 transform hover:-translate-y-1">
<i class="fa-solid fa-lock text-lg"></i> Login Administrator
</button>
</div>
</div>
</div>
</div>
<!-- VIEW: LOGIN ADMIN -->
<div id="view-login" class="view-section justify-center items-center p-4">
<div class="glass-card p-8 rounded-[2rem] max-w-sm w-full relative shadow-2xl">
<button onclick="navigate('landing')" class="absolute top-5 left-5 text-gray-400 hover:text-gray-700 transition">
<i class="fa-solid fa-arrow-left text-xl"></i>
</button>
<div class="text-center mb-8 mt-2 flex flex-col items-center">
<div class="h-20 w-20 flex items-center justify-center mb-4 rounded-2xl overflow-hidden bg-blue-50/50 p-2 border border-blue-100">
<i id="icon-login" class="fa-solid fa-shield-halved text-5xl text-blue-600 drop-shadow-md"></i>
<img id="img-login" src="" class="hidden h-full w-full object-contain drop-shadow-md">
</div>
<h2 class="text-2xl font-extrabold text-gray-800">Admin Panel</h2>
</div>
<form id="form-login" onsubmit="handleLogin(event)">
<div class="mb-5">
<label class="block text-sm font-bold text-gray-700 mb-2">Username</label>
<div class="relative">
<i class="fa-solid fa-user absolute left-4 top-4 text-gray-400"></i>
<input type="text" id="login-user" required class="w-full pl-12 p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium">
</div>
</div>
<div class="mb-8">
<label class="block text-sm font-bold text-gray-700 mb-2">Password</label>
<div class="relative">
<i class="fa-solid fa-lock absolute left-4 top-4 text-gray-400"></i>
<input type="password" id="login-pass" required class="w-full pl-12 p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium">
</div>
</div>
<button type="submit" id="btn-login" class="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold transition shadow-lg hover:shadow-xl transform hover:-translate-y-1">
Masuk Sistem
</button>
</form>
<div id="preview-hint" class="hidden">
<p class="text-xs text-center text-blue-800 mt-6 font-semibold bg-blue-50 py-2 rounded-lg border border-blue-100">Preview Akun: admin / admin2026</p>
</div>
</div>
</div>
<!-- VIEW: ADMIN DASHBOARD -->
<div id="view-admin" class="view-section bg-transparent">
<!-- Navbar Admin -->
<nav class="glass-card px-6 py-4 flex justify-between items-center sticky top-0 z-50 md:rounded-none rounded-b-2xl mx-2 md:mx-0 border-t-0 shadow-sm">
<div class="font-extrabold text-xl text-gray-800 flex items-center gap-3 tracking-tight">
<div class="flex items-center justify-center h-8 w-8 rounded overflow-hidden">
<i id="icon-nav" class="fa-solid fa-desktop text-blue-600"></i>
<img id="img-nav" src="" class="hidden h-full w-full object-cover">
</div>
<span class="hidden sm:inline" id="ui-nav-title">Kiosk Admin</span>
<button id="btn-collapse-sidebar" onclick="toggleSidebar()" class="ml-2 text-gray-400 hover:text-gray-700 focus:outline-none hidden md:block">
<i class="fa-solid fa-bars"></i>
</button>
</div>
<button onclick="logout()" class="text-red-500 hover:text-red-700 bg-red-50 hover:bg-red-100 px-4 py-2 rounded-xl font-bold text-sm flex items-center gap-2 transition border border-red-100">
<i class="fa-solid fa-right-from-bracket"></i> Keluar
</button>
</nav>
<div class="flex flex-col md:flex-row flex-1 overflow-hidden">
<!-- Sidebar Menu -->
<div id="admin-sidebar" class="mobile-bottom-nav md:w-64 sidebar-container md:bg-white/50 md:backdrop-blur-md md:border-r flex md:flex-col md:p-4 gap-2 shrink-0 md:shadow-inner custom-scrollbar">
<button onclick="switchAdminTab('scanner')" id="tab-scanner" class="admin-tab active w-full text-center md:text-left px-2 py-2 md:px-5 md:py-4 rounded-2xl font-bold text-blue-700 md:bg-blue-100 flex items-center gap-1 md:gap-3 transition">
<i class="fa-solid fa-expand text-xl md:text-lg md:w-6 text-center"></i> <span class="nav-text">Kiosk</span>
</button>
<button onclick="switchAdminTab('manual')" id="tab-manual" class="admin-tab w-full text-center md:text-left px-2 py-2 md:px-5 md:py-4 rounded-2xl font-semibold text-gray-500 hover:text-blue-600 md:hover:bg-blue-50 flex items-center gap-1 md:gap-3 transition">
<i class="fa-solid fa-pen-to-square text-xl md:text-lg md:w-6 text-center"></i> <span class="nav-text">Absen Manual</span>
</button>
<button onclick="switchAdminTab('register')" id="tab-register" class="admin-tab w-full text-center md:text-left px-2 py-2 md:px-5 md:py-4 rounded-2xl font-semibold text-gray-500 hover:text-blue-600 md:hover:bg-blue-50 flex items-center gap-1 md:gap-3 transition">
<i class="fa-solid fa-user-plus text-xl md:text-lg md:w-6 text-center"></i> <span class="nav-text">Daftar Wajah</span>
</button>
<button onclick="switchAdminTab('pegawai')" id="tab-pegawai" class="admin-tab w-full text-center md:text-left px-2 py-2 md:px-5 md:py-4 rounded-2xl font-semibold text-gray-500 hover:text-blue-600 md:hover:bg-blue-50 flex items-center gap-1 md:gap-3 transition">
<i class="fa-solid fa-users text-xl md:text-lg md:w-6 text-center"></i> <span class="nav-text">Database</span>
</button>
<button onclick="switchAdminTab('absensi')" id="tab-absensi" class="admin-tab w-full text-center md:text-left px-2 py-2 md:px-5 md:py-4 rounded-2xl font-semibold text-gray-500 hover:text-blue-600 md:hover:bg-blue-50 flex items-center gap-1 md:gap-3 transition">
<i class="fa-solid fa-clipboard-list text-xl md:text-lg md:w-6 text-center"></i> <span class="nav-text">Rekapitulasi</span>
</button>
<div class="hidden md:block my-2 border-t border-gray-200"></div>
<button onclick="switchAdminTab('settings')" id="tab-settings" class="admin-tab w-full text-center md:text-left px-2 py-2 md:px-5 md:py-4 rounded-2xl font-semibold text-gray-500 hover:text-blue-600 md:hover:bg-blue-50 flex items-center gap-1 md:gap-3 transition">
<i class="fa-solid fa-gear text-xl md:text-lg md:w-6 text-center"></i> <span class="nav-text">Pengaturan</span>
</button>
</div>
<!-- Content Area -->
<div class="flex-1 p-4 md:p-6 overflow-y-auto content-area-mobile-fix">
<!-- TAB: SCANNER KIOSK -->
<div id="admin-content-scanner" class="admin-content block">
<div class="glass-card rounded-[2rem] p-5 md:p-6 flex flex-col h-full md:min-h-[500px]">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4 shrink-0">
<div>
<h2 class="text-2xl font-extrabold text-gray-800 tracking-tight">Kamera Kiosk Absensi</h2>
<p class="text-gray-500 text-sm font-medium mt-1">Biarkan halaman ini terbuka agar mesin absen mendeteksi wajah otomatis.</p>
</div>
<div id="kiosk-status" class="px-4 py-2 bg-red-100 text-red-700 rounded-full text-sm font-bold border border-red-200 shadow-sm flex items-center gap-2">
<i class="fa-solid fa-circle-dot animate-pulse"></i> Kamera Mati
</div>
</div>
<div class="flex flex-col md:flex-row gap-6 items-stretch justify-center w-full max-w-5xl mx-auto">
<div class="flex-1 flex flex-col items-center w-full">
<div class="relative w-full shadow-2xl rounded-2xl overflow-hidden bg-black border-[4px] border-gray-900 mx-auto" style="max-width: 550px;">
<video id="video-kiosk" class="w-full h-auto block" autoplay muted playsinline></video>
<canvas id="canvas-kiosk" class="absolute top-0 left-0 w-full h-full"></canvas>
</div>
<div class="mt-4 flex gap-4 w-full" style="max-width: 550px;">
<button onclick="startKioskScanner()" class="flex-1 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg transition transform hover:-translate-y-1">Mulai Scanner</button>
<button onclick="stopCamera('kiosk')" class="flex-1 py-3 bg-red-100 hover:bg-red-200 text-red-700 rounded-xl font-bold shadow transition">Hentikan</button>
</div>
</div>
<div class="w-full md:w-[320px] lg:w-[360px] bg-white/60 backdrop-blur-sm rounded-2xl border border-gray-200 flex flex-col shadow-inner shrink-0 h-[350px] md:h-auto" style="max-height: 450px;">
<h3 class="font-bold text-gray-800 p-4 border-b border-gray-200 flex justify-between items-center shrink-0">
<span>Log Realtime</span>
<span class="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-md font-bold">Hari Ini</span>
</h3>
<div id="kiosk-log" class="flex-1 overflow-y-auto p-4 custom-scrollbar">
<div class="text-sm text-gray-400 text-center mt-10 font-medium">Belum ada absen terdeteksi</div>
</div>
</div>
</div>
</div>
</div>
<!-- TAB: ABSEN MANUAL -->
<div id="admin-content-manual" class="admin-content hidden">
<div class="glass-card rounded-[2rem] p-5 md:p-8 max-w-2xl mx-auto">
<h2 class="text-2xl font-extrabold text-gray-800 border-b border-gray-200 pb-4 mb-6 tracking-tight">
<i class="fa-solid fa-pen-to-square text-blue-600 mr-3"></i> Form Absensi Manual
</h2>
<div class="bg-blue-50 border border-blue-200 p-4 rounded-xl flex gap-3 text-sm text-blue-800 mb-6 shadow-sm">
<i class="fa-solid fa-circle-info mt-0.5 text-blue-600 text-lg"></i>
<p>Gunakan menu ini untuk mencatat kehadiran bagi mereka yang tidak bisa memindai wajah (Sakit, Izin, Dinas Luar, dll). Data akan langsung tersinkronisasi ke laporan Hari Ini.</p>
</div>
<div class="space-y-5">
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">1. Pilih Kategori Pegawai/Siswa</label>
<select id="manual-kategori" onchange="onManualKategoriChange()" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium cursor-pointer">
<option value="">-- Memuat Kategori --</option>
</select>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">2. Pilih Nama</label>
<select id="manual-nama" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium cursor-pointer">
<option value="">-- Pilih Kategori Terlebih Dahulu --</option>
</select>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">3. Status Kehadiran</label>
<select id="manual-status" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium cursor-pointer">
<option value="Hadir">Hadir</option>
<option value="Sakit">Sakit</option>
<option value="Izin">Izin</option>
<option value="Cuti">Cuti</option>
<option value="DL">Dinas Luar (DL)</option>
<option value="Lainnya">Lainnya</option>
</select>
</div>
<div class="pt-4">
<button id="btn-submit-manual" onclick="submitManualAbsen()" class="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold transition shadow-lg hover:shadow-xl transform hover:-translate-y-1 flex justify-center items-center gap-3">
<i class="fa-solid fa-save text-lg"></i> Simpan Ke Database
</button>
</div>
</div>
</div>
</div>
<!-- TAB: REGISTRASI WAJAH -->
<div id="admin-content-register" class="admin-content hidden">
<div class="glass-card rounded-[2rem] p-5 md:p-8">
<h2 class="text-2xl font-extrabold text-gray-800 border-b border-gray-200 pb-4 mb-6 tracking-tight"><i class="fa-solid fa-camera-retro text-blue-600 mr-3"></i> Pendaftaran Wajah Baru</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<div class="mb-5">
<label class="block text-sm font-bold text-gray-700 mb-2">Nama Lengkap</label>
<input type="text" id="reg-nama" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium" placeholder="Contoh: Budi Santoso">
</div>
<div class="mb-5">
<label class="block text-sm font-bold text-gray-700 mb-2">Nomor Identitas (NIP/NIS/KTP)</label>
<input type="text" id="reg-identitas" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium" placeholder="Contoh: 199012345678">
</div>
<div class="mb-6">
<label class="block text-sm font-bold text-gray-700 mb-2">Kategori (Pilih atau Ketik Baru)</label>
<input type="text" id="reg-kategori" list="kategori-list" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium" placeholder="Contoh: Pegawai, Siswa, Guru...">
<datalist id="kategori-list">
<option value="Pegawai / Guru"></option>
<option value="Siswa"></option>
</datalist>
</div>
<div class="bg-blue-50 text-blue-800 p-5 rounded-2xl mb-6 text-sm border border-blue-100 shadow-sm">
<ul class="list-disc pl-5 space-y-1.5 font-medium">
<li>Pastikan cahaya ruangan terang.</li>
<li>Posisikan wajah tepat di tengah kamera.</li>
<li>Jangan gunakan masker atau kacamata gelap.</li>
</ul>
</div>
<button id="btn-rekam" onclick="captureAndRegister()" class="w-full py-4 bg-green-600 hover:bg-green-700 text-white rounded-xl font-bold transition shadow-lg flex justify-center items-center gap-3">
<i class="fa-solid fa-expand text-lg"></i> Pindai & Simpan Wajah
</button>
</div>
<div class="flex flex-col items-center justify-center">
<div class="relative w-full max-w-[500px] shadow-inner border-[4px] border-dashed border-gray-300 rounded-3xl bg-gray-50/50 overflow-hidden mx-auto">
<video id="video-reg" class="w-full h-auto block" autoplay muted playsinline></video>
<canvas id="canvas-reg" class="absolute top-0 left-0 w-full h-full"></canvas>
</div>
<p class="text-sm text-blue-600 mt-4 font-bold bg-blue-50 px-4 py-2 rounded-lg border border-blue-100" id="reg-status"><i class="fa-solid fa-info-circle"></i> Kamera akan aktif saat tab ini dibuka.</p>
</div>
</div>
</div>
</div>
<!-- TAB: DATA PEGAWAI (WAJAH) -->
<div id="admin-content-pegawai" class="admin-content hidden">
<div class="glass-card rounded-[2rem] p-5 md:p-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-extrabold text-gray-800 tracking-tight">Data Wajah Terdaftar</h2>
<button onclick="fetchPegawaiData()" class="px-4 py-2.5 bg-white hover:bg-gray-50 shadow-sm border border-gray-200 rounded-xl text-sm font-bold text-gray-700 transition flex items-center gap-2"><i class="fa-solid fa-rotate-right"></i> Refresh</button>
</div>
<div class="overflow-x-auto rounded-xl border border-gray-100">
<table class="w-full text-left border-collapse bg-white/60">
<thead>
<tr class="bg-gray-100 border-b border-gray-200 text-gray-700 text-sm font-bold uppercase tracking-wider">
<th class="p-4">Identitas</th>
<th class="p-4">Nama Lengkap</th>
<th class="p-4">Kategori</th>
<th class="p-4 text-right">Aksi</th>
</tr>
</thead>
<tbody id="table-pegawai" class="divide-y divide-gray-100">
<tr><td colspan="4" class="p-6 text-center text-gray-500 font-medium">Memuat data...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- TAB: REKAP ABSENSI HARIAN & BULANAN -->
<div id="admin-content-absensi" class="admin-content hidden">
<div class="glass-card rounded-[2rem] p-5 md:p-8">
<!-- Header & Mode Switch -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4 border-b border-gray-200 pb-4">
<div>
<h2 class="text-2xl font-extrabold text-gray-800 tracking-tight">Laporan Kehadiran</h2>
<p class="text-sm text-gray-500 font-medium">Pilih mode tampilan dan filter periode.</p>
</div>
<div class="flex p-1 bg-gray-100 rounded-xl border border-gray-200 w-full md:w-auto">
<button id="btn-mode-harian" onclick="setRecapMode('harian')" class="flex-1 md:w-32 py-2 px-4 bg-white text-blue-600 shadow-sm rounded-lg font-bold text-sm transition">Harian</button>
<button id="btn-mode-bulanan" onclick="setRecapMode('bulanan')" class="flex-1 md:w-32 py-2 px-4 text-gray-500 hover:text-gray-700 rounded-lg font-bold text-sm transition">Bulanan</button>
</div>
</div>
<!-- Filter Controls -->
<div class="flex flex-wrap items-center gap-3 mb-6 bg-blue-50/50 p-4 rounded-2xl border border-blue-100">
<div id="filter-date-container" class="flex-1 min-w-[150px]">
<label class="block text-xs font-bold text-blue-800 mb-1">Tanggal Absensi</label>
<input type="date" id="filter-date" onchange="renderRecap()" class="w-full px-4 py-2.5 bg-white border border-gray-200 rounded-xl text-sm font-bold text-gray-700 outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div id="filter-month-container" class="flex-1 min-w-[150px] hidden">
<label class="block text-xs font-bold text-blue-800 mb-1">Bulan Absensi</label>
<input type="month" id="filter-month" onchange="renderRecap()" class="w-full px-4 py-2.5 bg-white border border-gray-200 rounded-xl text-sm font-bold text-gray-700 outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="flex-1 min-w-[150px]">
<label class="block text-xs font-bold text-blue-800 mb-1">Kategori</label>
<select id="filter-kategori" onchange="renderRecap()" class="w-full px-4 py-2.5 bg-white border border-gray-200 rounded-xl text-sm font-bold text-gray-700 outline-none focus:ring-2 focus:ring-blue-500">
<option value="Semua">Semua Kategori</option>
</select>
</div>
<div class="flex gap-2 items-end mt-4 sm:mt-0 w-full sm:w-auto">
<button onclick="loadAndRenderRecap()" class="flex-1 sm:flex-none px-4 py-2.5 bg-white hover:bg-gray-50 shadow-sm border border-gray-200 rounded-xl text-sm font-bold text-gray-700 transition" title="Refresh Data"><i class="fa-solid fa-rotate-right"></i></button>
<button onclick="downloadPDF()" class="flex-1 sm:flex-none px-4 py-2.5 bg-red-600 hover:bg-red-700 shadow-sm text-white rounded-xl text-sm font-bold transition flex items-center justify-center gap-2">
<i class="fa-solid fa-file-pdf"></i> Unduh PDF
</button>
</div>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white/60 border border-gray-200 p-4 rounded-xl shadow-sm text-center">
<p class="text-xs font-bold text-gray-500 uppercase tracking-wide">Total Terdaftar</p>
<p id="stat-total" class="text-2xl font-extrabold text-gray-800">0</p>
</div>
<div class="bg-blue-50 border border-blue-100 p-4 rounded-xl shadow-sm text-center" id="stat-card-2">
<p class="text-xs font-bold text-blue-600 uppercase tracking-wide" id="stat-label-2">Sudah Tercatat</p>
<p id="stat-val-2" class="text-2xl font-extrabold text-blue-700">0</p>
</div>
<div class="bg-red-50 border border-red-100 p-4 rounded-xl shadow-sm text-center" id="stat-card-3">
<p class="text-xs font-bold text-red-600 uppercase tracking-wide" id="stat-label-3">Belum/Alpa</p>
<p id="stat-val-3" class="text-2xl font-extrabold text-red-700">0</p>
</div>
<div class="bg-green-50 border border-green-100 p-4 rounded-xl shadow-sm text-center">
<p class="text-xs font-bold text-green-600 uppercase tracking-wide" id="stat-label-4">Rata-rata Hadir</p>
<p id="stat-val-4" class="text-2xl font-extrabold text-green-700">0%</p>
</div>
</div>
<!-- Data Table -->
<div class="overflow-x-auto rounded-xl border border-gray-100">
<table class="w-full text-left border-collapse bg-white/60" id="recap-table">
<thead>
<tr class="bg-gray-100 border-b border-gray-200 text-gray-700 text-sm font-bold uppercase tracking-wider" id="recap-table-head">
<!-- Headers injected via JS -->
</tr>
</thead>
<tbody id="table-recap-harian" class="divide-y divide-gray-100">
<tr><td colspan="5" class="p-6 text-center text-gray-500 font-medium">Tekan tombol refresh untuk memuat data.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- TAB: PENGATURAN -->
<div id="admin-content-settings" class="admin-content hidden">
<div class="glass-card rounded-[2rem] p-5 md:p-8 max-w-4xl mx-auto">
<h2 class="text-2xl font-extrabold text-gray-800 border-b border-gray-200 pb-4 mb-6 tracking-tight"><i class="fa-solid fa-gear text-blue-600 mr-3"></i> Pengaturan Sistem & Laporan</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Bagian 1: Profil Lembaga -->
<div class="space-y-5">
<h3 class="font-bold text-lg text-gray-800 border-l-4 border-blue-500 pl-3">Profil Lembaga</h3>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">Nama Lembaga / Instansi</label>
<input type="text" id="set-nama" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium" placeholder="Contoh: SMA Negeri 1 Bangsa">
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">Slogan / Deskripsi Singkat</label>
<textarea id="set-profil" rows="2" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium custom-scrollbar" placeholder="Sistem presensi wajah pintar..."></textarea>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">Alamat Lengkap</label>
<textarea id="set-alamat" rows="2" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium custom-scrollbar" placeholder="Jl. Raya Kemerdekaan No. 1, Jakarta"></textarea>
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">URL Logo Lembaga (Opsional)</label>
<input type="text" id="set-logo" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium" placeholder="https://i.imgur.com/logo.png">
</div>
</div>
<!-- Bagian 2: Tanda Tangan Laporan -->
<div class="space-y-5">
<h3 class="font-bold text-lg text-gray-800 border-l-4 border-green-500 pl-3">Data Pengesahan (Untuk Cetak PDF)</h3>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">Tempat Tanda Tangan</label>
<input type="text" id="set-tempat-ttd" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium" placeholder="Contoh: Jakarta / Surabaya">
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">Jenis Jabatan Atasan</label>
<input type="text" id="set-jenis-atasan" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium" placeholder="Contoh: Kepala Sekolah / Direktur">
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">Nama Lengkap Atasan (Beserta Gelar)</label>
<input type="text" id="set-nama-atasan" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium" placeholder="Dr. H. Budi Santoso, M.Pd.">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">Jenis ID</label>
<input type="text" id="set-jenis-id-atasan" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium" placeholder="Contoh: NIP / NIK">
</div>
<div>
<label class="block text-sm font-bold text-gray-700 mb-2">Nomor ID Atasan</label>
<input type="text" id="set-nomor-id-atasan" class="w-full p-3.5 bg-white/60 border border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition font-medium" placeholder="198001012005011001">
</div>
</div>
</div>
</div>
<div class="mt-8 pt-6 border-t border-gray-200 flex flex-col sm:flex-row items-center justify-between gap-4">
<p class="text-xs text-gray-500"><i class="fa-solid fa-info-circle text-blue-500 mr-1"></i> Data disinkronkan dan disimpan dengan aman ke Google Spreadsheet.</p>
<button id="btn-save-settings" onclick="saveSettings()" class="w-full sm:w-auto py-3.5 px-8 bg-gray-900 hover:bg-black text-white rounded-xl font-bold transition shadow-lg transform hover:-translate-y-1 flex justify-center items-center gap-2">
<i class="fa-solid fa-floppy-disk"></i> Simpan Pengaturan
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- SCRIPT APLIKASI UTAMA -->
<script>
/**
* ==========================================
* KONFIGURASI APLIKASI
* ==========================================
*/
const IS_PREVIEW = true; // Ubah ke false setelah integrasi GAS
const GAS_URL = "https://script.google.com/macros/s/AKfycbxdc4f_ym7sZvAAT1x1xrBBIiAH-K1cd7W7B3LVym2u8CgrvR-UO6ocisYauaYjNH-QIg/exec";
/**
* ==========================================
* STATE MANAGEMENT
* ==========================================
*/
let currentStream = null;
let faceMatcher = null;
let scannerInterval = null;
let labeledDescriptors = [];
let isSidebarCollapsed = false;
let cachedPegawai = [];
let cachedAbsensi = [];
const cooldownMap = {};
let todayAttended = new Set();
let recapMode = 'harian'; // 'harian' atau 'bulanan'
let currentPdfHead = [];
let currentPdfBody = [];
// Data dummy untuk preview
let dummyAdmin = { user: "admin", pass: "admin2026" };
let dummyPegawai = [
{ id: 'AI-101', identitas: '199001', nama: 'Budi Santoso', kategori: 'Pegawai', descriptor: '[]' },
{ id: 'AI-102', identitas: '199002', nama: 'Siti Aminah', kategori: 'Pegawai', descriptor: '[]' },
{ id: 'AI-103', identitas: '202301', nama: 'Andi Kusuma', kategori: 'Siswa', descriptor: '[]' }
];
// Simulasi absen hari ini dan kemarin
let dummyAbsensi = [
{ timestamp: new Date().toLocaleString('id-ID'), nama: 'Budi Santoso', ket: 'Hadir' },
{ timestamp: new Date().toLocaleString('id-ID'), nama: 'Siti Aminah', ket: 'Sakit' }
];
/**
* ==========================================
* INISIALISASI
* ==========================================
*/
window.onload = async () => {
if (IS_PREVIEW) document.getElementById('preview-hint').classList.remove('hidden');
loadSettings();
// Set default date to today for filters
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
document.getElementById('filter-date').value = `${yyyy}-${mm}-${dd}`;
document.getElementById('filter-month').value = `${yyyy}-${mm}`;
try {
const MODEL_URL = 'https://cdn.jsdelivr.net/npm/@vladmandic/face-api/model/';
await faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL);
await faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL);
await faceapi.nets.faceRecognitionNet.loadFromUri(MODEL_URL);
if (localStorage.getItem('app_is_logged_in') === 'true') {
navigate('admin');
} else {
navigate('landing');
}
document.getElementById('loading-overlay').style.display = 'none';
} catch (error) {
console.error(error);
document.getElementById('loading-text').innerText = "Gagal memuat sistem AI. Cek koneksi internet.";
document.getElementById('loading-text').style.color = "red";
}
};
/**
* ==========================================
* PENGATURAN UI & LOGO
* ==========================================
*/
async function loadSettings() {
// Render dari cache lokal dulu agar UI tidak kosong (Instant Load)
applySettingsToUI();
if (!IS_PREVIEW) {
try {
const res = await fetch(GAS_URL, { method: 'POST', body: JSON.stringify({ action: 'getSettings' }) });
const result = await res.json();
if(result.status === 'success') {
// Simpan data dari database ke cache lokal
for (const [key, value] of Object.entries(result.data)) {
localStorage.setItem(key, value);
}
// Render ulang UI dengan data terbaru dari database
applySettingsToUI();
}
} catch(err) {
console.error("Gagal memuat pengaturan dari server", err);
}
}
}
function applySettingsToUI() {
const getVal = (key, def) => localStorage.getItem(key) || def;
const nama = getVal('app_nama_lembaga', 'Smart Absensi AI');
const profil = getVal('app_profil_singkat', 'Sistem Kiosk Berbasis Pengenalan Wajah memastikan pencatatan kehadiran yang akurat, cepat, dan terintegrasi penuh.');
const logo = getVal('app_logo_url', '');
const alamat = getVal('app_alamat', 'Jl. Jend. Sudirman No 1');
const tempatTtd = getVal('app_tempat_ttd', 'Jakarta');
const jAtasan = getVal('app_jenis_atasan', 'Kepala Instansi');
const nAtasan = getVal('app_nama_atasan', 'Dr. Budi M.Pd');
const idTAtasan = getVal('app_jenis_id_atasan', 'NIP');
const idNAtasan = getVal('app_nomor_id_atasan', '198001012005011001');
// Update Teks Layar
document.getElementById('ui-nama-lembaga').innerText = nama;
document.getElementById('ui-nav-title').innerText = nama.substring(0, 15) + (nama.length > 15 ? '...' : '');
document.getElementById('ui-profil-lembaga').innerText = profil;
// Update Input
document.getElementById('set-nama').value = nama;
document.getElementById('set-profil').value = profil;
document.getElementById('set-logo').value = logo;
document.getElementById('set-alamat').value = alamat;
document.getElementById('set-tempat-ttd').value = tempatTtd;
document.getElementById('set-jenis-atasan').value = jAtasan;
document.getElementById('set-nama-atasan').value = nAtasan;
document.getElementById('set-jenis-id-atasan').value = idTAtasan;
document.getElementById('set-nomor-id-atasan').value = idNAtasan;
// Update Logo Display
const updateLogoDisplay = (imgId, iconId, url) => {
const img = document.getElementById(imgId);
const icon = document.getElementById(iconId);
if (url && url.trim() !== "") {
img.src = url;
img.classList.remove('hidden');
if(icon) icon.style.display = 'none';
} else {
img.classList.add('hidden');
if(icon) icon.style.display = '';
}
};
updateLogoDisplay('img-landing', 'icon-landing', logo);
updateLogoDisplay('img-login', 'icon-login', logo);
updateLogoDisplay('img-nav', 'icon-nav', logo);
}
async function saveSettings() {
const nama = document.getElementById('set-nama').value.trim();
if(!nama) return showToast("Nama lembaga tidak boleh kosong", "error");
const settingsData = {
app_nama_lembaga: nama,
app_profil_singkat: document.getElementById('set-profil').value.trim(),
app_logo_url: document.getElementById('set-logo').value.trim(),
app_alamat: document.getElementById('set-alamat').value.trim(),
app_tempat_ttd: document.getElementById('set-tempat-ttd').value.trim(),
app_jenis_atasan: document.getElementById('set-jenis-atasan').value.trim(),
app_nama_atasan: document.getElementById('set-nama-atasan').value.trim(),
app_jenis_id_atasan: document.getElementById('set-jenis-id-atasan').value.trim(),
app_nomor_id_atasan: document.getElementById('set-nomor-id-atasan').value.trim()
};
// Simpan ke cache lokal agar langsung berubah di UI
for (const [key, value] of Object.entries(settingsData)) {
localStorage.setItem(key, value);
}
applySettingsToUI();
if (IS_PREVIEW) {
showToast("Pengaturan berhasil disimpan (Mode Preview)", "success");
} else {
const btn = document.getElementById('btn-save-settings');
const originalHtml = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Menyimpan ke Database...';
btn.disabled = true;
try {
const res = await fetch(GAS_URL, {
method: 'POST',
body: JSON.stringify({ action: 'saveSettings', data: settingsData })
});
const result = await res.json();
if (result.status === 'success') {
showToast("Pengaturan tersimpan ke Database!", "success");
} else {
throw new Error(result.message);
}
} catch (err) {
showToast("Gagal menyimpan ke server: " + err.message, "error");
} finally {
btn.innerHTML = originalHtml;
btn.disabled = false;
}
}
}
/**
* ==========================================
* NAVIGASI & UI LOGIC
* ==========================================
*/
function navigate(viewId) {
stopCamera('reg'); stopCamera('kiosk');
document.querySelectorAll('.view-section').forEach(el => el.classList.remove('active'));
document.getElementById(`view-${viewId}`).classList.add('active');
if (viewId === 'admin') { switchAdminTab('scanner'); fetchPegawaiData(); }
}
function switchAdminTab(tabName) {
document.querySelectorAll('.admin-tab').forEach(el => {
el.classList.remove('text-blue-700', 'md:bg-blue-100');
el.classList.add('text-gray-500');
});
let activeTab = document.getElementById(`tab-${tabName}`);
activeTab.classList.remove('text-gray-500');
activeTab.classList.add('text-blue-700', 'md:bg-blue-100');
document.querySelectorAll('.admin-content').forEach(el => {
el.classList.remove('block'); el.classList.add('hidden');
});
document.getElementById(`admin-content-${tabName}`).classList.remove('hidden');
document.getElementById(`admin-content-${tabName}`).classList.add('block');
stopCamera('kiosk'); stopCamera('reg');
if (tabName === 'register') startCamera('reg');
if (tabName === 'absensi') loadAndRenderRecap();
if (tabName === 'manual') initManualForm();
}
function toggleSidebar() {
const sidebar = document.getElementById('admin-sidebar');
isSidebarCollapsed = !isSidebarCollapsed;
if (isSidebarCollapsed) sidebar.classList.add('sidebar-collapsed');
else sidebar.classList.remove('sidebar-collapsed');
}
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icon = type === 'error' ? 'fa-circle-exclamation' : (type === 'success' ? 'fa-circle-check' : 'fa-info-circle');
toast.innerHTML = `<i class="fa-solid ${icon} mr-2"></i> ${message}`;
container.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 300); }, 3000);
}
/**
* ==========================================
* KAMERA LOGIC
* ==========================================
*/
async function startCamera(mode) {
const video = document.getElementById(mode === 'reg' ? 'video-reg' : 'video-kiosk');
try {
currentStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" }, audio: false });
video.srcObject = currentStream;
if(mode === 'reg') {
document.getElementById('reg-status').innerHTML = "<i class='fa-solid fa-camera mr-2'></i>Kamera aktif.";
} else if (mode === 'kiosk') {
document.getElementById('kiosk-status').innerHTML = '<i class="fa-solid fa-video"></i> Scanner Aktif';
document.getElementById('kiosk-status').className = 'px-4 py-2 bg-green-100 text-green-700 rounded-full text-sm font-bold border border-green-200 shadow-sm flex items-center gap-2';
}
} catch (err) { showToast("Izin kamera ditolak atau kamera tidak ditemukan.", "error"); }
}
function stopCamera(mode) {
if (currentStream) { currentStream.getTracks().forEach(track => track.stop()); currentStream = null; }
if (scannerInterval) { clearInterval(scannerInterval); scannerInterval = null; }
if (mode === 'kiosk') {
document.getElementById('kiosk-status').innerHTML = '<i class="fa-solid fa-circle-dot"></i> Kamera Mati';
document.getElementById('kiosk-status').className = 'px-4 py-2 bg-red-100 text-red-700 rounded-full text-sm font-bold border border-red-200 shadow-sm flex items-center gap-2';
const canvas = document.getElementById('canvas-kiosk');
canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
}
}
/**
* ==========================================
* FITUR: ABSEN MANUAL
* ==========================================
*/
function initManualForm() {
if (cachedPegawai.length === 0) {
fetchPegawaiData().then(() => populateManualKategori());
} else {
populateManualKategori();
}
}
function populateManualKategori() {
const select = document.getElementById('manual-kategori');
const cats = [...new Set(cachedPegawai.map(p => p.kategori || 'Umum'))];
let html = '<option value="">-- Pilih Kategori --</option>';
cats.forEach(c => html += `<option value="${c}">${c}</option>`);
select.innerHTML = html;
document.getElementById('manual-nama').innerHTML = '<option value="">-- Pilih Kategori Dahulu --</option>';
}
function onManualKategoriChange() {
const kat = document.getElementById('manual-kategori').value;
const select = document.getElementById('manual-nama');
if(!kat) { select.innerHTML = '<option value="">-- Pilih Kategori Dahulu --</option>'; return; }
const filtered = cachedPegawai.filter(p => (p.kategori || 'Umum') === kat);
let html = '<option value="">-- Pilih Nama --</option>';
filtered.forEach(p => html += `<option value="${p.nama}">${p.identitas || '-'} - ${p.nama}</option>`);
select.innerHTML = html;
}
async function submitManualAbsen() {
const nama = document.getElementById('manual-nama').value;
const ket = document.getElementById('manual-status').value;
if (!nama || !ket) return showToast("Silakan pilih Nama dan Status Kehadiran!", "error");
const btn = document.getElementById('btn-submit-manual');
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin text-lg"></i> Menyimpan...';
const ts = new Date().toLocaleString('id-ID'); // Waktu saat ini
try {
if (IS_PREVIEW) {
dummyAbsensi.push({ timestamp: ts, nama: nama, ket: ket });
cachedAbsensi = dummyAbsensi;
todayAttended.add(nama);
showToast(`Absen Manual (${ket}) berhasil disimpan!`, "success");
document.getElementById('manual-nama').value = '';
} else {
const payload = { action: 'recordAbsen', nama: nama, ket: ket, timestamp: ts };
const res = await fetch(GAS_URL, { method: 'POST', body: JSON.stringify(payload) });
const result = await res.json();
if(result.status === 'success') {
todayAttended.add(nama);
showToast(`Absen Manual (${ket}) berhasil disimpan!`, "success");
document.getElementById('manual-nama').value = '';
} else throw new Error(result.message);
}
} catch (err) {
showToast(err.message || "Gagal menyimpan absen", "error");
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-save text-lg"></i> Simpan Ke Database';
}
}
/**
* ==========================================
* FITUR: REGISTRASI & LOGIN & PEGAWAI CRUD
* ==========================================
*/
async function captureAndRegister() {
const nama = document.getElementById('reg-nama').value.trim();
const identitas = document.getElementById('reg-identitas').value.trim();
const kategori = document.getElementById('reg-kategori').value.trim() || 'Umum';
if (!nama || !identitas) return showToast("Nama dan Identitas wajib diisi!", "error");
const video = document.getElementById('video-reg');
const btn = document.getElementById('btn-rekam');
btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Memindai...';
try {
const detection = await faceapi.detectSingleFace(video, new faceapi.TinyFaceDetectorOptions()).withFaceLandmarks().withFaceDescriptor();
if (!detection) throw new Error("Wajah tidak terdeteksi dengan jelas.");
const descriptorJson = JSON.stringify(Array.from(detection.descriptor));
if (IS_PREVIEW) {
dummyPegawai.push({ id: 'AI-'+Date.now(), identitas: identitas, nama: nama, kategori: kategori, descriptor: descriptorJson });
setTimeout(() => { showToast(`Wajah ${nama} terdaftar!`, "success"); resetRegForm(); }, 1000);
} else {
const res = await fetch(GAS_URL, { method: 'POST', body: JSON.stringify({ action: 'registerFace', nama, identitas, kategori, descriptor: descriptorJson }) });
const result = await res.json();
if(result.status === 'success') { showToast("Registrasi berhasil!", "success"); resetRegForm(); }
else throw new Error(result.message);
}
} catch (err) { showToast(err.message, "error"); }
finally { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-expand text-lg"></i> Pindai & Simpan Wajah'; }
}
function resetRegForm() {
document.getElementById('reg-nama').value = ""; document.getElementById('reg-identitas').value = ""; document.getElementById('reg-kategori').value = "";
fetchPegawaiData();
}
async function handleLogin(e) {
e.preventDefault();
const user = document.getElementById('login-user').value;
const pass = document.getElementById('login-pass').value;
const btn = document.getElementById('btn-login');
btn.disabled = true; btn.innerText = "Memverifikasi...";
try {
if (IS_PREVIEW) {
setTimeout(() => {
if (user === dummyAdmin.user && pass === dummyAdmin.pass) {
localStorage.setItem('app_is_logged_in', 'true');
showToast("Login Berhasil", "success");
document.getElementById('login-user').value = ''; document.getElementById('login-pass').value = '';
navigate('admin');
} else showToast("Username/Password salah!", "error");
btn.disabled = false; btn.innerText = "Masuk Sistem";
}, 800);
} else {
const res = await fetch(GAS_URL, { method: 'POST', body: JSON.stringify({ action: 'loginAdmin', user, pass }) });
const result = await res.json();
if(result.status === 'success') {
localStorage.setItem('app_is_logged_in', 'true');
showToast("Login Berhasil", "success");
document.getElementById('login-user').value = ''; document.getElementById('login-pass').value = '';
navigate('admin');
} else throw new Error(result.message);
}
} catch (err) { showToast(err.message, "error"); btn.disabled = false; btn.innerText = "Masuk Sistem"; }
}
function logout() { localStorage.removeItem('app_is_logged_in'); stopCamera('kiosk'); navigate('landing'); showToast("Anda telah keluar", "info"); }
async function fetchPegawaiData() {
const tbody = document.getElementById('table-pegawai');
if(tbody) tbody.innerHTML = '<tr><td colspan="4" class="p-6 text-center text-gray-500 font-medium">Memuat data...</td></tr>';
try {
if (IS_PREVIEW) { cachedPegawai = dummyPegawai; }
else {
const res = await fetch(GAS_URL, { method: 'POST', body: JSON.stringify({ action: 'getPegawai' }) });
const result = await res.json();
if(result.status === 'success') cachedPegawai = result.data;
}
labeledDescriptors = [];
cachedPegawai.forEach(p => {
try {
let arr = JSON.parse(p.descriptor);
if (arr && typeof arr === 'object' && !Array.isArray(arr)) arr = Object.values(arr);
if (Array.isArray(arr) && arr.length === 128) {
labeledDescriptors.push(new faceapi.LabeledFaceDescriptors(p.nama, [new Float32Array(arr.map(Number))]));
}
} catch(e) {}
});
if(tbody) {
if (cachedPegawai.length === 0) tbody.innerHTML = '<tr><td colspan="4" class="p-6 text-center text-gray-500 font-medium">Belum ada data.</td></tr>';
else tbody.innerHTML = cachedPegawai.map((p, i) => `<tr class="border-b hover:bg-gray-50"><td class="p-4 text-sm font-bold text-gray-600">${p.identitas||'-'}</td><td class="p-4 font-bold text-gray-800">${p.nama}</td><td class="p-4 text-xs font-bold text-gray-600">${p.kategori||'Umum'}</td><td class="p-4 text-right"><button onclick="hapusPegawai('${p.id}')" class="w-8 h-8 bg-red-50 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition"><i class="fa-solid fa-trash"></i></button></td></tr>`).join('');
}
} catch(err) { if(tbody) tbody.innerHTML = `<tr><td colspan="4" class="p-6 text-center text-red-500">Gagal memuat data.</td></tr>`; }
}
async function hapusPegawai(id) {
if(!confirm("Yakin ingin menghapus data wajah ini?")) return;
if (IS_PREVIEW) { dummyPegawai = dummyPegawai.filter(p => p.id !== id); showToast("Terhapus", "success"); fetchPegawaiData(); }
else {
const res = await fetch(GAS_URL, { method: 'POST', body: JSON.stringify({ action: 'deletePegawai', id }) });
const result = await res.json();
if(result.status === 'success') { showToast("Terhapus", "success"); fetchPegawaiData(); }
}
}
/**
* ==========================================
* FITUR: REKAP HARIAN & BULANAN & PDF
* ==========================================
*/
function setRecapMode(mode) {
recapMode = mode;
const btnHarian = document.getElementById('btn-mode-harian');
const btnBulanan = document.getElementById('btn-mode-bulanan');
if(mode === 'harian') {
btnHarian.className = 'flex-1 md:w-32 py-2 px-4 bg-white text-blue-600 shadow-sm rounded-lg font-bold text-sm transition';
btnBulanan.className = 'flex-1 md:w-32 py-2 px-4 text-gray-500 hover:text-gray-700 rounded-lg font-bold text-sm transition';
document.getElementById('filter-date-container').classList.remove('hidden');
document.getElementById('filter-month-container').classList.add('hidden');
} else {
btnBulanan.className = 'flex-1 md:w-32 py-2 px-4 bg-white text-blue-600 shadow-sm rounded-lg font-bold text-sm transition';
btnHarian.className = 'flex-1 md:w-32 py-2 px-4 text-gray-500 hover:text-gray-700 rounded-lg font-bold text-sm transition';
document.getElementById('filter-month-container').classList.remove('hidden');
document.getElementById('filter-date-container').classList.add('hidden');
}
renderRecap();
}
async function syncTodayAttendance() {
try {
if (IS_PREVIEW) { cachedAbsensi = [...dummyAbsensi]; }
else {
const res = await fetch(GAS_URL, { method: 'POST', body: JSON.stringify({ action: 'getAbsensi' }) });
const result = await res.json();
if(result.status === 'success') cachedAbsensi = result.data;
}
const todayPrefix = new Date().toLocaleDateString('id-ID');
todayAttended.clear();
cachedAbsensi.forEach(a => { if (a.timestamp.includes(todayPrefix)) todayAttended.add(a.nama); });
} catch (err) { console.error(err); }
}
async function loadAndRenderRecap() {
document.getElementById('table-recap-harian').innerHTML = '<tr><td colspan="5" class="p-6 text-center text-gray-500 font-medium"><i class="fa-solid fa-spinner fa-spin mr-2"></i> Mensinkronisasi...</td></tr>';
if(cachedPegawai.length === 0) await fetchPegawaiData();
try {
await syncTodayAttendance();
updateCategoryFilter();
renderRecap();
} catch(err) { document.getElementById('table-recap-harian').innerHTML = `<tr><td colspan="5" class="p-6 text-center text-red-500">Gagal memuat rekap.</td></tr>`; }
}
function updateCategoryFilter() {
const select = document.getElementById('filter-kategori');
const val = select.value;
const cats = [...new Set(cachedPegawai.map(p => p.kategori || 'Umum'))];
let html = '<option value="Semua">Semua Kategori</option>';
cats.forEach(c => html += `<option value="${c}">${c}</option>`);
select.innerHTML = html;
if(cats.includes(val)) select.value = val;
}
function parseIdDateString(tsStr) {
const match = tsStr.match(/(\d+)[-/](\d+)[-/](\d+)/);
if(match) {
let p1 = parseInt(match[1]), p2 = parseInt(match[2]), p3 = parseInt(match[3]);
if(p1 > 31) return new Date(p1, p2-1, p3);
return new Date(p3, p2-1, p1);
}
return new Date();
}
function renderRecap() {
const tbody = document.getElementById('table-recap-harian');
const thead = document.getElementById('recap-table-head');
const filterKat = document.getElementById('filter-kategori').value;
currentPdfHead = recapMode === 'harian'
? [['Identitas', 'Nama Lengkap', 'Kategori', 'Status', 'Waktu']]
: [['Identitas', 'Nama Lengkap', 'Kategori', 'Total Kehadiran', 'Persentase']];
thead.innerHTML = recapMode === 'harian'
? '<th class="p-4">Identitas</th><th class="p-4">Nama Lengkap</th><th class="p-4">Kategori</th><th class="p-4">Status</th><th class="p-4">Waktu</th>'
: '<th class="p-4">Identitas</th><th class="p-4">Nama Lengkap</th><th class="p-4">Kategori</th><th class="p-4">Total Kehadiran</th><th class="p-4">Persentase</th>';
let basePegawai = cachedPegawai;
if (filterKat !== 'Semua') basePegawai = basePegawai.filter(p => (p.kategori || 'Umum') === filterKat);
currentPdfBody = [];
if (recapMode === 'harian') {
// --- MODE HARIAN ---
const dateInput = document.getElementById('filter-date').value;
if(!dateInput) return;
const [y, m, d] = dateInput.split('-');
const dayAbsen = cachedAbsensi.filter(a => parseIdDateString(a.timestamp).toDateString() === new Date(y, m-1, d).toDateString());
const dictAbsen = {};
dayAbsen.forEach(a => { dictAbsen[a.nama] = { ts: a.timestamp, ket: a.ket }; });
let tercatatCount = 0;
let html = '';
basePegawai.forEach(p => {
const absenData = dictAbsen[p.nama];
const isAbsen = absenData !== undefined;
if(isAbsen) tercatatCount++;
const ketStatus = isAbsen ? absenData.ket : 'Belum Absen';
const waktu = isAbsen ? absenData.ts.split(/[, ]+/)[1] || '-' : '-';
let statBadge = '';
if(ketStatus === 'Hadir') statBadge = '<span class="px-3 py-1 bg-green-100 text-green-700 border border-green-200 rounded-lg text-xs font-bold">Hadir</span>';
else if(ketStatus === 'Sakit') statBadge = '<span class="px-3 py-1 bg-yellow-100 text-yellow-700 border border-yellow-200 rounded-lg text-xs font-bold">Sakit</span>';
else if(ketStatus === 'Izin' || ketStatus === 'Cuti') statBadge = '<span class="px-3 py-1 bg-blue-100 text-blue-700 border border-blue-200 rounded-lg text-xs font-bold">'+ketStatus+'</span>';
else if(ketStatus === 'DL') statBadge = '<span class="px-3 py-1 bg-purple-100 text-purple-700 border border-purple-200 rounded-lg text-xs font-bold">Dinas Luar</span>';
else if(ketStatus === 'Belum Absen') statBadge = '<span class="px-3 py-1 bg-red-50 text-red-600 border border-red-200 rounded-lg text-xs font-bold">Belum/Alpa</span>';
else statBadge = `<span class="px-3 py-1 bg-gray-100 text-gray-700 border border-gray-200 rounded-lg text-xs font-bold">${ketStatus}</span>`;
currentPdfBody.push([p.identitas||'-', p.nama, p.kategori||'Umum', ketStatus, waktu]);
html += `<tr class="hover:bg-blue-50/30">
<td class="p-4 text-sm font-bold text-gray-600">${p.identitas||'-'}</td>
<td class="p-4 font-bold text-gray-800">${p.nama}</td>
<td class="p-4 text-sm text-gray-600">${p.kategori||'Umum'}</td>
<td class="p-4">${statBadge}</td><td class="p-4 text-sm font-medium text-gray-700">${waktu}</td>
</tr>`;
});
document.getElementById('stat-label-2').innerText = "Sudah Tercatat";
document.getElementById('stat-label-3').innerText = "Belum/Alpa";
document.getElementById('stat-label-4').innerText = "Persentase Tercatat";
document.getElementById('stat-total').innerText = basePegawai.length;
document.getElementById('stat-val-2').innerText = tercatatCount;
document.getElementById('stat-val-3').innerText = basePegawai.length - tercatatCount;
document.getElementById('stat-val-4').innerText = basePegawai.length > 0 ? Math.round((tercatatCount/basePegawai.length)*100)+'%' : '0%';
tbody.innerHTML = html || '<tr><td colspan="5" class="p-6 text-center text-gray-500">Tidak ada data terdaftar.</td></tr>';
} else {
// --- MODE BULANAN ---
const monthInput = document.getElementById('filter-month').value;
if(!monthInput) return;
const [y, m] = monthInput.split('-');
const monthAbsen = cachedAbsensi.filter(a => {
const dt = parseIdDateString(a.timestamp);
return dt.getFullYear() == parseInt(y) && dt.getMonth() == parseInt(m)-1;
});
// Hari efektif = hari dimana minimal 1 orang absensi
const uniqueDays = new Set(monthAbsen.map(a => parseIdDateString(a.timestamp).getDate()));
const hariEfektif = uniqueDays.size || 1;
const hadirPerOrang = {};
monthAbsen.forEach(a => {
// Hitung Hadir dan Dinas Luar (DL) sebagai persentase kehadiran
if(a.ket === 'Hadir' || a.ket === 'DL') {
const dStr = parseIdDateString(a.timestamp).toDateString();
if(!hadirPerOrang[a.nama]) hadirPerOrang[a.nama] = new Set();
hadirPerOrang[a.nama].add(dStr);
}
});
let totalPersenAll = 0;
let html = '';
basePegawai.forEach(p => {
const totalHadir = hadirPerOrang[p.nama] ? hadirPerOrang[p.nama].size : 0;
const persen = Math.min(Math.round((totalHadir / hariEfektif) * 100), 100);
totalPersenAll += persen;
const color = persen >= 80 ? 'green' : (persen >= 50 ? 'orange' : 'red');
const badge = `<span class="px-3 py-1 bg-${color}-100 text-${color}-700 rounded-lg text-xs font-bold">${persen}%</span>`;
currentPdfBody.push([p.identitas||'-', p.nama, p.kategori||'Umum', `${totalHadir} Hari`, `${persen}%`]);
html += `<tr class="hover:bg-blue-50/30">
<td class="p-4 text-sm font-bold text-gray-600">${p.identitas||'-'}</td>
<td class="p-4 font-bold text-gray-800">${p.nama}</td>
<td class="p-4 text-sm text-gray-600">${p.kategori||'Umum'}</td>
<td class="p-4 font-bold text-gray-700">${totalHadir} Hari</td>
<td class="p-4">${badge}</td>
</tr>`;
});
const avgPersen = basePegawai.length > 0 ? Math.round(totalPersenAll / basePegawai.length) : 0;
document.getElementById('stat-label-2').innerText = "Hari Efektif";
document.getElementById('stat-label-3').innerText = "Total Data Log";
document.getElementById('stat-label-4').innerText = "Rata-rata Kehadiran";
document.getElementById('stat-total').innerText = basePegawai.length;
document.getElementById('stat-val-2').innerText = hariEfektif;
document.getElementById('stat-val-3').innerText = monthAbsen.length;
document.getElementById('stat-val-4').innerText = avgPersen + '%';
tbody.innerHTML = html || '<tr><td colspan="5" class="p-6 text-center text-gray-500">Tidak ada data terdaftar.</td></tr>';
}
}
// --- FUNGSI DOWNLOAD PDF ---
function getBase64ImageFromURL(url) {
return new Promise((resolve, reject) => {
var img = new Image();
img.setAttribute("crossOrigin", "anonymous");
img.onload = () => {
var canvas = document.createElement("canvas");
canvas.width = img.width; canvas.height = img.height;
var ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0);
resolve(canvas.toDataURL("image/png"));
};
img.onerror = error => reject(error);
img.src = url;
});
}
async function downloadPDF() {
if(currentPdfBody.length === 0) return showToast("Tabel kosong, tidak ada data untuk dicetak", "error");
showToast("Menyiapkan Dokumen PDF...", "info");
const { jsPDF } = window.jspdf;
const doc = new jsPDF('p', 'mm', 'a4');
const nLembaga = localStorage.getItem('app_nama_lembaga') || 'Sistem Absensi';
const alamat = localStorage.getItem('app_alamat') || '-';
const tempatTtd = localStorage.getItem('app_tempat_ttd') || 'Jakarta';
const jAtasan = localStorage.getItem('app_jenis_atasan') || 'Pimpinan';
const nAtasan = localStorage.getItem('app_nama_atasan') || 'Nama Pimpinan';
const idTAtasan = localStorage.getItem('app_jenis_id_atasan') || 'NIP';
const idNAtasan = localStorage.getItem('app_nomor_id_atasan') || '-';
const logoUrl = localStorage.getItem('app_logo_url') || '';
if (logoUrl) {
try {
const base64Img = await getBase64ImageFromURL(logoUrl);
doc.addImage(base64Img, 'PNG', 15, 10, 22, 22);
} catch(e) { console.warn("Logo gagal dimuat ke PDF karena aturan CORS server gambar."); }
}
doc.setFontSize(16); doc.setFont("helvetica", "bold");
doc.text(nLembaga.toUpperCase(), 105, 18, { align: "center" });
doc.setFontSize(10); doc.setFont("helvetica", "normal");
doc.text(alamat, 105, 24, { align: "center" });
doc.setLineWidth(0.5); doc.line(15, 32, 195, 32);
doc.setFontSize(12); doc.setFont("helvetica", "bold");
const title = recapMode === 'harian' ? 'LAPORAN REKAPITULASI KEHADIRAN HARIAN' : 'LAPORAN REKAPITULASI KEHADIRAN BULANAN';
doc.text(title, 105, 42, { align: "center" });
doc.setFontSize(10); doc.setFont("helvetica", "normal");
// Format Bulan Indonesia
const monthNames = ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"];
let periodStr = "";
if (recapMode === 'harian') {
const dt = document.getElementById('filter-date').value.split('-');
periodStr = `${dt[2]} ${monthNames[parseInt(dt[1])-1]} ${dt[0]}`;
} else {
const dt = document.getElementById('filter-month').value.split('-');
periodStr = `${monthNames[parseInt(dt[1])-1]} ${dt[0]}`;
}
doc.text(`Periode: ${periodStr} | Kategori: ${document.getElementById('filter-kategori').value}`, 105, 48, { align: "center" });
doc.autoTable({
startY: 55,
head: currentPdfHead,
body: currentPdfBody,
theme: 'grid',
headStyles: { fillColor: [37, 99, 235], textColor: [255, 255, 255], fontStyle: 'bold' },
styles: { fontSize: 9, cellPadding: 3 },
alternateRowStyles: { fillColor: [240, 249, 255] }
});
const finalY = doc.lastAutoTable.finalY + 15;
const datePrinted = new Date().toLocaleDateString('id-ID', { year: 'numeric', month: 'long', day: 'numeric' });
doc.setFontSize(10);
doc.text(`${tempatTtd}, ${datePrinted}`, 140, finalY);
doc.text(`Mengetahui,`, 140, finalY + 5);
doc.text(jAtasan, 140, finalY + 10);
doc.setFont("helvetica", "bold");
doc.text(nAtasan, 140, finalY + 30);
doc.setFont("helvetica", "normal");
doc.text(`${idTAtasan}. ${idNAtasan}`, 140, finalY + 35);
doc.save(`Laporan_Absensi_${recapMode}_${periodStr.replace(/\s+/g, '_')}.pdf`);
showToast("PDF Berhasil Diunduh!", "success");
}
/**
* ==========================================
* FITUR 4: KIOSK SCANNER (AUTO ABSEN)
* ==========================================
*/
async function startKioskScanner() {
stopCamera('kiosk');
showToast("Mensinkronisasi data...", "info");
if (labeledDescriptors.length === 0) await fetchPegawaiData();
await syncTodayAttendance();
const validDescriptors = labeledDescriptors.filter(ld => ld.descriptors.length > 0 && ld.descriptors[0].length === 128);
if (validDescriptors.length === 0) return showToast("Tidak ada data wajah AI yang valid.", "error");
faceMatcher = new faceapi.FaceMatcher(validDescriptors, 0.55);
await startCamera('kiosk');
const video = document.getElementById('video-kiosk');
const canvas = document.getElementById('canvas-kiosk');
video.onplay = () => {
const displaySize = { width: video.videoWidth, height: video.videoHeight };
faceapi.matchDimensions(canvas, displaySize);
if (scannerInterval) clearInterval(scannerInterval);
scannerInterval = setInterval(async () => {
try {
const detections = await faceapi.detectAllFaces(video, new faceapi.TinyFaceDetectorOptions()).withFaceLandmarks().withFaceDescriptors();
const resizedDetections = faceapi.resizeResults(detections, displaySize);
canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
resizedDetections.forEach(d => {
if (d && d.descriptor && d.descriptor.length === 128) {
const result = faceMatcher.findBestMatch(d.descriptor);
const box = d.detection.box;
let text = result.label, boxColor = 'green';
if (result.label === 'unknown') { text = 'Tidak Dikenali'; boxColor = 'red'; }
else {
if (todayAttended.has(result.label)) { text = `${result.label} (Sudah Absen)`; boxColor = 'orange'; }
else { text = `${result.label} (${Math.round(result.distance * 100)})`; boxColor = 'green'; triggerAbsen(result.label, 'Hadir'); }
}
const drawBox = new faceapi.draw.DrawBox(box, { label: text, boxColor: boxColor });
drawBox.draw(canvas);
}
});
} catch (err) {}
}, 500);
};
}
// Modified: Menerima status khusus (Hadir/Sakit/Izin dll)
async function triggerAbsen(nama, ketStatus) {
const now = Date.now();
if (todayAttended.has(nama)) return;
if (cooldownMap[nama] && (now - cooldownMap[nama] < 5000)) return;
cooldownMap[nama] = now; todayAttended.add(nama);
logToKiosk(nama, `Tercatat: ${ketStatus}`, "success");
try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const osc = ctx.createOscillator(); osc.type = "sine"; osc.frequency.setValueAtTime(800, ctx.currentTime); osc.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.1); } catch(e) {}
const ts = new Date().toLocaleString('id-ID');
if (IS_PREVIEW) { dummyAbsensi.push({ timestamp: ts, nama: nama, ket: ketStatus }); cachedAbsensi = dummyAbsensi; }
else fetch(GAS_URL, { method: 'POST', body: JSON.stringify({ action: 'recordAbsen', nama: nama, ket: ketStatus, timestamp: ts }) });
}
function logToKiosk(nama, status, type) {
const logDiv = document.getElementById('kiosk-log');
const timeStr = new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if(logDiv.innerHTML.includes('Belum ada absen')) logDiv.innerHTML = '';
const logItem = document.createElement('div');
logItem.className = 'p-3 bg-green-50 border border-green-200 rounded-xl text-sm flex items-center gap-4 shadow-sm animate-fade-in mb-3';
logItem.innerHTML = `<i class="fa-solid fa-check-circle text-green-500 text-3xl drop-shadow-sm"></i> <div class="flex-1"><p class="font-extrabold text-gray-800 text-base">${nama}</p><p class="text-xs text-green-700 font-bold">${timeStr} - ${status}</p></div>`;
logDiv.prepend(logItem);
}
</script>
</body>
</html>

0 Comments:
Post a Comment
Subscribe to Post Comments [Atom]
<< Home