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:
@@ -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 = `<div class="nfc-kiosk__processing">Enroll mode (filled in by Task 18)</div>`;
|
||||
const phase = (payload && payload.phase) || "password";
|
||||
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) {
|
||||
@@ -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)
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user