From 37deaedf0d0e7e096bdafb141ebc414a1e4e7721 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 14 May 2026 01:28:36 -0400 Subject: [PATCH] feat(fusion_clock): NFC kiosk Enroll Mode UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Task 18 stub renderEnroll with the full four-phase implementation (password numpad → employee picker → tap-to-enroll → result), adds _onEnrollTap wired to the NFC reading event, and exposes it via window.__nfcKiosk. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../static/src/js/fusion_clock_nfc_kiosk.js | 168 +++++++++++++++++- 1 file changed, 165 insertions(+), 3 deletions(-) diff --git a/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js b/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js index 8a82aad0..4ec5503d 100644 --- a/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js +++ b/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js @@ -82,9 +82,171 @@ } } + // ────────────────────────────────────────────────────────────── + // Enroll Mode + // ────────────────────────────────────────────────────────────── + let enrollPassword = ""; + let enrollSelectedEmployee = null; + let enrollIdleTimer = null; + + function resetEnrollIdleTimer() { + if (enrollIdleTimer) clearTimeout(enrollIdleTimer); + enrollIdleTimer = setTimeout(() => { + // 60s of inactivity in Enroll Mode → exit + exitEnrollMode(); + }, 60000); + } + + function exitEnrollMode() { + if (enrollIdleTimer) clearTimeout(enrollIdleTimer); + enrollIdleTimer = null; + enrollPassword = ""; + enrollSelectedEmployee = null; + setState(STATE.IDLE); + } + function renderEnroll(payload) { - // Full implementation lands in Task 18; this stub keeps the state machine valid. - stateContainer.innerHTML = `
Enroll mode (filled in by Task 18)
`; + const phase = (payload && payload.phase) || "password"; + resetEnrollIdleTimer(); + + if (phase === "password") { + const masked = "•".repeat(enrollPassword.length); + stateContainer.innerHTML = ` +
+
+

Enter Enroll Mode Password

+
${masked}
+
+ ${[1,2,3,4,5,6,7,8,9].map(n => ``).join("")} + + + +
+
+ +
+
+
+ `; + stateContainer.querySelectorAll(".numpad button").forEach(btn => { + btn.addEventListener("click", async () => { + resetEnrollIdleTimer(); + const n = btn.dataset.n; + if (n === "back") enrollPassword = enrollPassword.slice(0, -1); + else if (n === "ok") { + if (enrollPassword.length === 0) return; + renderEnroll({ phase: "search" }); + return; + } + else enrollPassword += n; + renderEnroll({ phase: "password" }); + }); + }); + document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode); + return; + } + + if (phase === "search") { + stateContainer.innerHTML = ` +
+
+

Pick the employee to enroll

+ +
+
+ +
+
+
+ `; + const searchEl = document.getElementById("enroll_search"); + const listEl = document.getElementById("enroll_list"); + let debounceTimer = null; + searchEl.addEventListener("input", () => { + resetEnrollIdleTimer(); + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(async () => { + const result = await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value }); + listEl.innerHTML = (result.employees || []).map(e => + `
${escapeHtml(e.name)} · ${escapeHtml(e.department || "")}
` + ).join(""); + listEl.querySelectorAll(".employee-row").forEach(row => { + row.addEventListener("click", () => { + enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name }; + renderEnroll({ phase: "tap" }); + }); + }); + }, 200); + }); + searchEl.focus(); + document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode); + return; + } + + if (phase === "tap") { + stateContainer.innerHTML = ` +
+
+

Now tap ${escapeHtml(enrollSelectedEmployee.name)}'s card

+
⌐■
+

Hold the card to the back of the tablet

+
+ +
+
+
+ `; + document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode); + return; + } + + if (phase === "result") { + const ok = !payload.error; + const msg = ok + ? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}` + : (payload.error === "invalid_password" + ? "Wrong password. Try again." + : payload.error === "card_already_assigned" + ? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.` + : `Enroll failed: ${escapeHtml(payload.error)}`); + stateContainer.innerHTML = ` +
+
+

${msg}

+
+ + +
+
+
+ `; + document.getElementById("enroll_another").addEventListener("click", () => { + enrollSelectedEmployee = null; + renderEnroll({ phase: ok ? "search" : "password" }); + }); + document.getElementById("enroll_done").addEventListener("click", exitEnrollMode); + } + } + + async function _onEnrollTap(uid) { + if (!enrollSelectedEmployee) return; + const result = await postJson("/fusion_clock/kiosk/nfc/enroll", { + employee_id: enrollSelectedEmployee.id, + card_uid: uid, + enroll_password: enrollPassword, + }); + renderEnroll({ phase: "result", ...result }); + } + + // ⚙ button → enter Enroll Mode + const settingsBtn = document.getElementById("nfc_settings_btn"); + if (settingsBtn) { + settingsBtn.addEventListener("click", () => { + if (currentState !== STATE.IDLE) return; + enrollPassword = ""; + enrollSelectedEmployee = null; + setState(STATE.ENROLL, { phase: "password" }); + }); } function escapeHtml(s) { @@ -234,6 +396,6 @@ window.__nfcKiosk = { setState, STATE, photoRequired, debugEnabled, locationConfigured, - handleTap, // exposed for mock-tap debug (Task 19) + handleTap, _onEnrollTap, // handleTap for mock-tap debug (Task 19) }; })();