feat(fusion_clock): NFC kiosk Enroll Mode UI

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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-14 01:28:36 -04:00
parent 30f7f18472
commit 37deaedf0d

View File

@@ -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) { function renderEnroll(payload) {
// Full implementation lands in Task 18; this stub keeps the state machine valid. const phase = (payload && payload.phase) || "password";
stateContainer.innerHTML = `<div class="nfc-kiosk__processing">Enroll mode (filled in by Task 18)</div>`; resetEnrollIdleTimer();
if (phase === "password") {
const masked = "•".repeat(enrollPassword.length);
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Enter Enroll Mode Password</h2>
<div class="pin-display">${masked}</div>
<div class="numpad">
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
<button data-n="back">⌫</button>
<button data-n="0">0</button>
<button data-n="ok">OK</button>
</div>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
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 = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Pick the employee to enroll</h2>
<input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
<div class="employee-list" id="enroll_list"></div>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
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 =>
`<div class="employee-row" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${escapeHtml(e.name)}<small style="opacity:.6"> · ${escapeHtml(e.department || "")}</small></div>`
).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 = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2>Now tap ${escapeHtml(enrollSelectedEmployee.name)}'s card</h2>
<div class="nfc-kiosk__icon" style="font-size:5rem">⌐■</div>
<p style="color:#9ba3ad">Hold the card to the back of the tablet</p>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
</div>
`;
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 = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2 style="color:${ok ? "#18a957" : "#d9374e"}">${msg}</h2>
<div class="actions" style="justify-content:center">
<button class="confirm" id="enroll_another">Enroll another</button>
<button class="cancel" id="enroll_done">Done</button>
</div>
</div>
</div>
`;
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) { function escapeHtml(s) {
@@ -234,6 +396,6 @@
window.__nfcKiosk = { window.__nfcKiosk = {
setState, STATE, photoRequired, debugEnabled, locationConfigured, setState, STATE, photoRequired, debugEnabled, locationConfigured,
handleTap, // exposed for mock-tap debug (Task 19) handleTap, _onEnrollTap, // handleTap for mock-tap debug (Task 19)
}; };
})(); })();