Nimish-ctrl/AttendX
1
1<!DOCTYPE html>2<html lang="en">3<head>4<link rel="icon" type="image/png" href="/favicon.png">5<meta charset="UTF-8">6<meta name="viewport" content="width=device-width, initial-scale=1.0">7<title>AttendX โ Admin</title>8<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Playfair+Display:wght@400;500&display=swap" rel="stylesheet">9<style>10 *, *::before, *::after { box-sizing:border-box; margin:0; padding:0; }11 :root {12 --bg:#f8f7f4; --surface:#fff; --surface2:#f3f2ee;13 --text:#1a1a1a; --muted:#8a8a8a; --border:#e8e6e1;14 --green:#22a06b; --red:#d94f4f; --blue:#2563eb;15 }16 html,body { min-height:100vh; background:var(--bg); color:var(--text); font-family:'Inter',sans-serif; }17 18 body::before {19 content:''; position:fixed; inset:0; z-index:0;20 background-image:radial-gradient(circle at 1px 1px,rgba(0,0,0,0.04) 1px,transparent 0);21 background-size:28px 28px; pointer-events:none;22 }23 24 /* gate */25 #gate {26 position:fixed; inset:0; z-index:200;27 display:flex; align-items:center; justify-content:center;28 background:var(--bg);29 }30 .gate-box {31 background:var(--surface); border:1px solid var(--border); border-radius:20px;32 padding:2.5rem; width:340px; text-align:center;33 }34 .gate-box .g-eye { font-size:2rem; margin-bottom:1.25rem; }35 .gate-box h2 { font-family:'Playfair Display',serif; font-size:1.5rem; font-weight:400; margin-bottom:0.35rem; }36 .gate-box p { font-size:0.8rem; color:var(--muted); margin-bottom:1.5rem; line-height:1.6; }37 .gate-box input {38 width:100%; padding:0.75rem 1rem; background:var(--surface2); border:1px solid var(--border);39 border-radius:10px; color:var(--text); font-family:'Inter',sans-serif; font-size:0.88rem;40 outline:none; text-align:center; letter-spacing:0.08em; margin-bottom:0.75rem;41 transition:border-color 0.2s;42 }43 .gate-box input:focus { border-color:var(--text); }44 .gate-err { font-size:0.78rem; color:var(--red); margin-bottom:0.75rem; display:none; }45 46 nav {47 position:fixed; top:0; left:0; right:0; z-index:100;48 display:flex; align-items:center; justify-content:space-between;49 padding:1.1rem 2.5rem;50 background:rgba(248,247,244,0.92); backdrop-filter:blur(16px);51 border-bottom:1px solid var(--border);52 }53 .logo { font-family:'Playfair Display',serif; font-size:1.15rem; font-weight:400; }54 .logo em { font-style:italic; color:var(--muted); }55 .nav-right { display:flex; align-items:center; gap:1.25rem; }56 .admin-chip { font-size:0.68rem; font-weight:500; letter-spacing:0.08em; text-transform:uppercase; padding:3px 10px; border-radius:20px; border:1px solid var(--border); color:var(--muted); }57 .nav-back { font-size:0.8rem; color:var(--muted); text-decoration:none; transition:color 0.2s; }58 .nav-back:hover { color:var(--text); }59 60 .page { position:relative; z-index:1; padding:6rem 2rem 4rem; max-width:1000px; margin:0 auto; }61 62 /* tabs */63 .tabs { display:flex; gap:0; margin-bottom:2.5rem; border-bottom:1px solid var(--border); }64 .tab {65 padding:0.65rem 1.25rem; border:none; background:transparent;66 font-family:'Inter',sans-serif; font-size:0.85rem; font-weight:500;67 color:var(--muted); cursor:pointer; position:relative; transition:color 0.2s;68 }69 .tab.active { color:var(--text); }70 .tab.active::after { content:''; position:absolute; bottom:-1px; left:0; right:0; height:2px; background:var(--text); }71 72 .tab-panel { display:none; animation:fadeIn 0.25s ease; }73 .tab-panel.active { display:block; }74 @keyframes fadeIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }75 76 .section-title { font-family:'Playfair Display',serif; font-size:1.8rem; font-weight:400; margin-bottom:0.4rem; letter-spacing:-0.01em; }77 .section-sub { font-size:0.82rem; color:var(--muted); line-height:1.7; margin-bottom:1.75rem; }78 79 /* controls */80 .controls {81 display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap;82 padding:1rem 1.25rem; background:var(--surface); border:1px solid var(--border);83 border-radius:12px; margin-bottom:1.75rem;84 }85 .controls label { font-size:0.72rem; font-weight:500; color:var(--muted); text-transform:uppercase; letter-spacing:0.08em; white-space:nowrap; }86 input[type=date] {87 padding:0.6rem 0.85rem; background:var(--surface2); border:1px solid var(--border);88 border-radius:8px; color:var(--text); font-family:'Inter',sans-serif; font-size:0.82rem;89 outline:none; cursor:pointer; color-scheme:light; transition:border-color 0.2s;90 }91 input[type=date]:focus { border-color:var(--text); }92 93 /* buttons */94 .btn {95 display:inline-flex; align-items:center; gap:0.4rem;96 padding:0.6rem 1.25rem; border-radius:8px; border:1px solid transparent;97 font-family:'Inter',sans-serif; font-size:0.8rem; font-weight:500;98 cursor:pointer; transition:all 0.2s; text-decoration:none;99 }100 .btn-dark { background:var(--text); color:#fff; border-color:var(--text); }101 .btn-dark:hover { background:#333; }102 .btn-ghost { background:transparent; color:var(--text); border-color:var(--border); }103 .btn-ghost:hover { border-color:var(--text); }104 .btn-danger { background:transparent; color:var(--red); border-color:#f0b8b8; }105 .btn-danger:hover { background:#fdf2f2; }106 .btn:disabled { opacity:0.35; cursor:not-allowed; }107 108 /* stats */109 .stats { display:grid; grid-template-columns:repeat(4,1fr); gap:0.75rem; margin-bottom:1.75rem; }110 .stat { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:1.1rem 1.25rem; }111 .stat .n { font-family:'Playfair Display',serif; font-size:1.9rem; font-weight:400; }112 .stat .l { font-size:0.7rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.08em; margin-top:2px; }113 .stat.g .n { color:var(--green); }114 .stat.r .n { color:var(--red); }115 .stat.b .n { color:var(--blue); }116 117 /* table */118 .tbl-wrap { background:var(--surface); border:1px solid var(--border); border-radius:14px; overflow:hidden; }119 .tbl-head {120 display:grid; grid-template-columns:1.8fr 2.5fr 1fr 1.8fr 1.2fr 0.6fr;121 padding:0.65rem 1.25rem;122 background:var(--surface2); border-bottom:1px solid var(--border);123 font-size:0.68rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.08em; font-weight:500;124 }125 .tbl-row {126 display:grid; grid-template-columns:1.8fr 2.5fr 1fr 1.8fr 1.2fr 0.6fr;127 padding:0.8rem 1.25rem; border-bottom:1px solid var(--border);128 align-items:center; transition:background 0.15s; animation:fadeIn 0.2s ease;129 }130 .tbl-row:last-child { border-bottom:none; }131 .tbl-row:hover { background:var(--surface2); }132 .tbl-row .roll { font-size:0.75rem; color:var(--muted); font-family:monospace; }133 .tbl-row .name { font-size:0.88rem; font-weight:500; }134 .tbl-row .time { font-size:0.75rem; color:var(--muted); }135 .tbl-row .conf { font-size:0.75rem; color:var(--muted); }136 137 .chip { font-size:0.68rem; font-weight:500; padding:3px 9px; border-radius:20px; display:inline-flex; align-items:center; gap:4px; }138 .chip.p { background:#f0faf5; color:var(--green); border:1px solid #a7e0c4; }139 .chip.a { background:#fdf2f2; color:var(--red); border:1px solid #f0b8b8; }140 141 /* enrolled grid */142 .enrolled-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:0.75rem; }143 .e-card {144 background:var(--surface); border:1px solid var(--border); border-radius:12px;145 padding:1rem 1.1rem; display:flex; align-items:center; gap:0.85rem;146 animation:fadeIn 0.25s ease;147 }148 .avatar {149 width:36px; height:36px; border-radius:50%; background:var(--surface2);150 border:1px solid var(--border); display:flex; align-items:center; justify-content:center;151 font-size:0.78rem; font-weight:600; color:var(--muted); flex-shrink:0;152 }153 .e-card .info { flex:1; min-width:0; }154 .e-card .ename { font-size:0.88rem; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }155 .e-card .eroll { font-size:0.72rem; color:var(--muted); font-family:monospace; }156 .e-card .edate { font-size:0.7rem; color:var(--muted); margin-top:1px; }157 158 .del-btn {159 background:transparent; border:none; cursor:pointer; color:var(--muted);160 padding:4px; border-radius:6px; line-height:1; font-size:0.9rem;161 transition:color 0.2s, background 0.2s; flex-shrink:0;162 }163 .del-btn:hover { color:var(--red); background:#fdf2f2; }164 165 /* confirm modal */166 #modal-bg {167 position:fixed; inset:0; z-index:300; background:rgba(0,0,0,0.25); backdrop-filter:blur(4px);168 display:flex; align-items:center; justify-content:center; display:none;169 }170 .modal {171 background:var(--surface); border:1px solid var(--border); border-radius:16px;172 padding:2rem; width:320px; text-align:center;173 }174 .modal h3 { font-family:'Playfair Display',serif; font-size:1.2rem; font-weight:400; margin-bottom:0.5rem; }175 .modal p { font-size:0.82rem; color:var(--muted); line-height:1.6; margin-bottom:1.5rem; }176 .modal-btns { display:flex; gap:0.6rem; justify-content:center; }177 178 /* empty / loading */179 .empty { text-align:center; padding:3.5rem 1rem; color:var(--muted); font-size:0.85rem; }180 181 .spin { width:13px;height:13px;border:2px solid rgba(255,255,255,0.3);border-top-color:#fff;border-radius:50%;animation:sp 0.7s linear infinite;display:none; }182 @keyframes sp { to{transform:rotate(360deg)} }183</style>184</head>185<body>186 187<!-- gate -->188<div id="gate">189 <div class="gate-box">190 <div class="g-eye">๐</div>191 <h2>Admin access</h2>192 <p>Enter the admin key to continue.</p>193 <p class="gate-err" id="gate-err">Incorrect key. Try again.</p>194 <input type="password" id="gate-input" placeholder="Admin key" />195 <button class="btn btn-dark" style="width:100%" onclick="checkKey()">Enter โ</button>196 </div>197</div>198 199<!-- confirm delete modal -->200<div id="modal-bg">201 <div class="modal">202 <h3>Remove student?</h3>203 <p id="modal-msg">This will delete the student and their face data. Attendance logs are kept.</p>204 <div class="modal-btns">205 <button class="btn btn-ghost" onclick="closeModal()">Cancel</button>206 <button class="btn btn-danger" id="modal-confirm">Delete</button>207 </div>208 </div>209</div>210 211<nav>212 <div class="logo">Attend<em>X</em></div>213 <div class="nav-right">214 <span class="admin-chip">Admin</span>215 <a href="index.html" class="nav-back">โ Home</a>216 </div>217</nav>218 219<div class="page">220 <div class="tabs">221 <button class="tab active" onclick="sw('report')">Daily Report</button>222 <button class="tab" onclick="sw('enrolled'); loadEnrolled()">Students</button>223 </div>224 225 <!-- REPORT TAB -->226 <div class="tab-panel active" id="tab-report">227 <h2 class="section-title">Attendance report</h2>228 <p class="section-sub">Select a date to see who attended and who didn't.</p>229 230 <div class="controls">231 <label>Date</label>232 <input type="date" id="date-pick" />233 <button class="btn btn-dark" id="load-btn" onclick="loadReport()">Load <div class="spin" id="load-spin"></div></button>234 <button class="btn btn-ghost" onclick="setToday()">Today</button>235 </div>236 237 <div id="report-stats" style="display:none">238 <div class="stats">239 <div class="stat"> <div class="n" id="s-tot">0</div><div class="l">Enrolled</div></div>240 <div class="stat g"><div class="n" id="s-pre">0</div><div class="l">Present</div></div>241 <div class="stat r"><div class="n" id="s-abs">0</div><div class="l">Absent</div></div>242 <div class="stat b"><div class="n" id="s-pct">โ</div><div class="l">Attendance</div></div>243 </div>244 <div class="tbl-wrap">245 <div class="tbl-head">246 <span>Roll No.</span><span>Name</span><span>Status</span>247 <span>Marked at</span><span>Confidence</span><span></span>248 </div>249 <div id="tbl-body"></div>250 </div>251 </div>252 253 <div class="empty" id="report-empty">Select a date and load the report.</div>254 </div>255 256 <!-- STUDENTS TAB -->257 <div class="tab-panel" id="tab-enrolled">258 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;flex-wrap:wrap;gap:0.75rem;">259 <div>260 <h2 class="section-title">Students</h2>261 <p class="section-sub" style="margin-bottom:0">All enrolled students. Delete removes face data.</p>262 </div>263 <button class="btn btn-ghost" onclick="loadEnrolled()">โป Refresh</button>264 </div>265 <div class="enrolled-grid" id="enrolled-grid">266 <div class="empty">Loading...</div>267 </div>268 </div>269</div>270 271<script>272const API = window.location.origin;273let KEY = '';274let pendingDelete = null;275 276// gate277document.getElementById('gate-input').addEventListener('keydown', e => { if(e.key==='Enter') checkKey(); });278 279async function checkKey() {280 const k = document.getElementById('gate-input').value.trim();281 if (!k) return;282 const r = await fetch(`${API}/students?key=${encodeURIComponent(k)}`);283 if (r.ok) {284 KEY = k;285 document.getElementById('gate').style.display = 'none';286 setToday(); loadReport();287 } else {288 document.getElementById('gate-err').style.display = 'block';289 }290}291 292function sw(t) {293 document.querySelectorAll('.tab').forEach((el,i) => el.classList.toggle('active', ['report','enrolled'][i]===t));294 document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));295 document.getElementById('tab-'+t).classList.add('active');296}297 298function setToday() {299 document.getElementById('date-pick').value = new Date().toISOString().split('T')[0];300}301 302async function loadReport() {303 const date = document.getElementById('date-pick').value;304 if (!date) return;305 const btn = document.getElementById('load-btn');306 const spin = document.getElementById('load-spin');307 btn.disabled = true; spin.style.display = 'block';308 309 try {310 const r = await fetch(`${API}/attendance?date=${date}&key=${encodeURIComponent(KEY)}`);311 const d = await r.json();312 if (!r.ok) { alert(d.detail); return; }313 314 document.getElementById('s-tot').textContent = d.total;315 document.getElementById('s-pre').textContent = d.attended;316 document.getElementById('s-abs').textContent = d.absent;317 const pct = d.total > 0 ? Math.round((d.attended/d.total)*100) : 0;318 document.getElementById('s-pct').textContent = pct + '%';319 320 document.getElementById('tbl-body').innerHTML = d.report.map(row => {321 const time = row.present322 ? new Date(row.marked_at).toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit'})323 : 'โ';324 const conf = row.present ? `${(row.similarity*100).toFixed(1)}%` : 'โ';325 return `<div class="tbl-row">326 <span class="roll">${row.roll_no}</span>327 <span class="name">${row.name}</span>328 <span><span class="chip ${row.present?'p':'a'}">${row.present?'Present':'Absent'}</span></span>329 <span class="time">${time}</span>330 <span class="conf">${conf}</span>331 <span></span>332 </div>`;333 }).join('');334 335 document.getElementById('report-empty').style.display = 'none';336 document.getElementById('report-stats').style.display = 'block';337 } catch(e) { alert('Cannot reach server.'); }338 339 btn.disabled = false; spin.style.display = 'none';340}341 342async function loadEnrolled() {343 const grid = document.getElementById('enrolled-grid');344 grid.innerHTML = '<div class="empty">Loading...</div>';345 try {346 const r = await fetch(`${API}/students?key=${encodeURIComponent(KEY)}`);347 const d = await r.json();348 if (!d.students.length) { grid.innerHTML='<div class="empty">No students enrolled yet.</div>'; return; }349 grid.innerHTML = d.students.map(s => {350 const initials = s.name.split(' ').map(n=>n[0]).join('').slice(0,2).toUpperCase();351 const date = new Date(s.enrolled_at).toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'});352 return `<div class="e-card" id="card-${s.roll_no}">353 <div class="avatar">${initials}</div>354 <div class="info">355 <div class="ename">${s.name}</div>356 <div class="eroll">${s.roll_no}</div>357 <div class="edate">Enrolled ${date}</div>358 </div>359 <button class="del-btn" onclick="askDelete('${s.roll_no}','${s.name}')" title="Remove student">โ</button>360 </div>`;361 }).join('');362 } catch(e) { grid.innerHTML='<div class="empty">Could not load students.</div>'; }363}364 365// delete flow366function askDelete(roll, name) {367 pendingDelete = roll;368 document.getElementById('modal-msg').textContent =369 `Remove "${name}" (${roll})? Their face data will be deleted. Attendance logs are kept.`;370 document.getElementById('modal-bg').style.display = 'flex';371 document.getElementById('modal-confirm').onclick = confirmDelete;372}373 374function closeModal() {375 document.getElementById('modal-bg').style.display = 'none';376 pendingDelete = null;377}378 379async function confirmDelete() {380 if (!pendingDelete) return;381 const roll = pendingDelete;382 closeModal();383 try {384 const r = await fetch(`${API}/students/${encodeURIComponent(roll)}?key=${encodeURIComponent(KEY)}`, {method:'DELETE'});385 if (r.ok) {386 const card = document.getElementById(`card-${roll}`);387 if (card) { card.style.opacity='0'; card.style.transform='scale(0.95)'; card.style.transition='all 0.25s'; setTimeout(()=>card.remove(), 250); }388 } else {389 const d = await r.json(); alert(d.detail || 'Delete failed.');390 }391 } catch(e) { alert('Cannot reach server.'); }392}393 394// close modal on bg click395document.getElementById('modal-bg').addEventListener('click', e => { if(e.target===e.currentTarget) closeModal(); });396</script>397</body>398</html>399 