feat(fusion_clock): NFC kiosk — enrollment, manager page, sounds, lock, profile photos
Kiosk work across this session (19.0.3.6.0 -> 19.0.3.10.0): - Program-from-unknown-tap: amber prompt -> Manager PIN -> pick/create employee -> binds the captured UID (no re-tap). Reassign moves a card between employees. - Manager page (gear, when unlocked): search employees + tag status; assign/re-tag, clear tag, archive employee, + new employee. Server-gated by the enroll password. - Screen lock: kiosk starts locked (tap-only); Unlock -> Manager PIN, Lock button; PIN remembered for the session so the gear never re-prompts. - Sounds: pleasant + loud sine chimes (rising in / descending out) + a low "denied" tone for wrong/unknown taps. Gated by fusion_clock.enable_sounds. - Guided profile-photo capture for employees with no picture (clock-in or enroll): live camera + oval face guide -> capture -> preview -> save to hr.employee. - PIN no longer re-renders per digit; centered result card; 12h time; clock-out shows "Worked Xh Ym this shift"; modern clock idle icon; faster animations/result timers; session keep-alive so the kiosk login never expires. - New endpoints: create_employee, clear_tag, delete_employee (archive), verify_pin, save_profile_photo; enroll gains force-reassign. - Docs: fusion_clock is now developed in Claude Code (dropped Cursor references). Spec/plan under fusion_clock/docs/superpowers/. Deployed live on entech (odoo-entech / LXC 111 on pve-worker5), v19.0.3.10.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,20 @@
|
||||
const photoRequired = root.dataset.photoRequired === "1";
|
||||
const debugEnabled = root.dataset.debugEnabled === "1";
|
||||
const locationConfigured = root.dataset.locationConfigured === "1";
|
||||
const soundsEnabled = root.dataset.soundsEnabled === "1";
|
||||
|
||||
// On a known device (set up before) the browser already remembers camera/NFC
|
||||
// permission, so slim the prompt to a simple resume tap.
|
||||
try {
|
||||
if (localStorage.getItem("nfc_setup_done") === "1") {
|
||||
const _h2 = document.querySelector(".nfc-kiosk__setup h2");
|
||||
const _p = document.querySelector(".nfc-kiosk__setup p");
|
||||
const _btn = document.getElementById("nfc_setup_start");
|
||||
if (_h2) _h2.textContent = "Fusion Clock Kiosk";
|
||||
if (_p) _p.textContent = "Tap to resume.";
|
||||
if (_btn) _btn.textContent = "Tap to resume";
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Debug overlay (visible only when fusion_clock.nfc_kiosk_debug = True)
|
||||
@@ -140,8 +154,10 @@
|
||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<circle class="nfc-wave nfc-wave-1" cx="100" cy="100" r="58"
|
||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<rect class="nfc-chip" x="68" y="68" width="64" height="64"
|
||||
rx="11" fill="currentColor"/>
|
||||
<g class="nfc-chip" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="100" cy="100" r="34"/>
|
||||
<polyline points="100,80 100,100 116,108"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
|
||||
</div>
|
||||
@@ -157,11 +173,67 @@
|
||||
`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Clock sounds (Web Audio — synthesized, loud + distinct in/out).
|
||||
// AudioContext is created/resumed on the setup tap (a user gesture),
|
||||
// after which it can play on each clock event.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let _audioCtx = null;
|
||||
function unlockAudio() {
|
||||
try {
|
||||
if (!_audioCtx) {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (AC) _audioCtx = new AC();
|
||||
}
|
||||
if (_audioCtx && _audioCtx.state === "suspended") _audioCtx.resume();
|
||||
} catch (e) { debugLog("audio: unlock failed " + e.message); }
|
||||
}
|
||||
function _note(freq, startAt, dur, peak, type) {
|
||||
const osc = _audioCtx.createOscillator();
|
||||
const g = _audioCtx.createGain();
|
||||
osc.type = type || "sine";
|
||||
osc.frequency.setValueAtTime(freq, startAt);
|
||||
g.gain.setValueAtTime(0.0001, startAt);
|
||||
g.gain.exponentialRampToValueAtTime(peak, startAt + 0.015); // soft attack (no click)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, startAt + dur); // smooth decay
|
||||
osc.connect(g); g.connect(_audioCtx.destination);
|
||||
osc.start(startAt); osc.stop(startAt + dur + 0.04);
|
||||
}
|
||||
function playClockSound(action) {
|
||||
if (!soundsEnabled || !_audioCtx) return;
|
||||
try {
|
||||
if (_audioCtx.state === "suspended") _audioCtx.resume();
|
||||
const t = _audioCtx.currentTime;
|
||||
if (action === "clock_out") {
|
||||
// warm descending major triad (G–E–C) — a pleasant "goodbye"
|
||||
_note(783.99, t, 0.20, 0.6, "sine"); // G5
|
||||
_note(659.25, t + 0.13, 0.20, 0.6, "sine"); // E5
|
||||
_note(523.25, t + 0.26, 0.42, 0.7, "sine"); // C5
|
||||
} else {
|
||||
// bright ascending major triad (C–E–G) — a cheerful "welcome"
|
||||
_note(523.25, t, 0.18, 0.6, "sine"); // C5
|
||||
_note(659.25, t + 0.13, 0.18, 0.6, "sine"); // E5
|
||||
_note(783.99, t + 0.26, 0.42, 0.72, "sine"); // G5
|
||||
}
|
||||
} catch (e) { debugLog("audio: play failed " + e.message); }
|
||||
}
|
||||
// Distinct low "denied" tone for wrong / unknown taps — clearly not a success chime.
|
||||
function playErrorSound() {
|
||||
if (!soundsEnabled || !_audioCtx) return;
|
||||
try {
|
||||
if (_audioCtx.state === "suspended") _audioCtx.resume();
|
||||
const t = _audioCtx.currentTime;
|
||||
_note(311.13, t, 0.20, 0.55, "triangle"); // Eb4
|
||||
_note(207.65, t + 0.18, 0.36, 0.6, "triangle"); // Ab3 (low → "wrong")
|
||||
} catch (e) { debugLog("audio: play failed " + e.message); }
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
const isError = payload && payload.error;
|
||||
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
|
||||
|
||||
if (isError) {
|
||||
playErrorSound();
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__result ${cls}">
|
||||
<div class="nfc-kiosk__result-text">
|
||||
@@ -169,25 +241,36 @@
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => setState(STATE.IDLE), 4000);
|
||||
setTimeout(() => setState(STATE.IDLE), 3000);
|
||||
} else {
|
||||
playClockSound(payload.action);
|
||||
const avatar = payload.employee_avatar_url || "";
|
||||
const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
|
||||
const hours = payload.action === "clock_out" && payload.net_hours_today
|
||||
? `${payload.net_hours_today.toFixed(1)}h today`
|
||||
: "";
|
||||
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
let hoursLine = "";
|
||||
if (payload.action === "clock_out") {
|
||||
const mins = Math.round((payload.net_hours_today || 0) * 60);
|
||||
const h = Math.floor(mins / 60);
|
||||
const m = mins % 60;
|
||||
hoursLine = `<div class="hours">Worked ${h}h ${m}m this shift</div>`;
|
||||
}
|
||||
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__result ${cls}">
|
||||
<div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
|
||||
<div class="nfc-kiosk__result-text">
|
||||
<div class="name">${escapeHtml(payload.employee_name)}</div>
|
||||
<div class="action">${action} at ${time}</div>
|
||||
${hours ? `<div class="hours">${hours}</div>` : ""}
|
||||
${hoursLine}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => setState(STATE.IDLE), 3000);
|
||||
setTimeout(() => {
|
||||
if (payload.action === "clock_in" && payload.needs_photo && payload.employee_id) {
|
||||
openPhotoCapture(payload.employee_id, payload.employee_name, () => setState(STATE.IDLE));
|
||||
} else {
|
||||
setState(STATE.IDLE);
|
||||
}
|
||||
}, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +279,7 @@
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let enrollPassword = "";
|
||||
let enrollSelectedEmployee = null;
|
||||
let pendingEnrollUid = null; // set when programming a just-tapped unknown card
|
||||
let enrollIdleTimer = null;
|
||||
|
||||
function resetEnrollIdleTimer() {
|
||||
@@ -209,60 +293,175 @@
|
||||
function exitEnrollMode() {
|
||||
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
|
||||
enrollIdleTimer = null;
|
||||
enrollPassword = "";
|
||||
if (kioskLocked) enrollPassword = ""; // keep the PIN while unlocked (no re-prompt)
|
||||
enrollSelectedEmployee = null;
|
||||
pendingEnrollUid = null;
|
||||
setState(STATE.IDLE);
|
||||
}
|
||||
|
||||
// Fixed PIN pad: renders the panel ONCE, then mutates only the masked
|
||||
// display on each press — no innerHTML rebuild, no replayed entrance
|
||||
// animation. (Fixes the per-digit "screen refresh" bug.)
|
||||
function mountPinPad(opts) {
|
||||
let pin = "";
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel">
|
||||
<h2>${escapeHtml(opts.title || "Enter PIN")}</h2>
|
||||
<div class="pin-display" id="nfc_pin_display"></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="nfc_pin_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const displayEl = stateContainer.querySelector("#nfc_pin_display");
|
||||
const paint = () => { displayEl.textContent = "•".repeat(pin.length); };
|
||||
paint();
|
||||
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
resetEnrollIdleTimer();
|
||||
const n = btn.dataset.n;
|
||||
if (n === "back") { pin = pin.slice(0, -1); paint(); }
|
||||
else if (n === "ok") { if (pin.length) opts.onOk(pin); }
|
||||
else { pin += n; paint(); }
|
||||
});
|
||||
});
|
||||
stateContainer.querySelector("#nfc_pin_cancel").addEventListener("click", opts.onCancel);
|
||||
}
|
||||
|
||||
// Reactive flow: an unknown card was tapped — offer to program it now.
|
||||
function renderUnknownCard(uid) {
|
||||
playErrorSound();
|
||||
currentState = STATE.RESULT; // block taps while this prompt is up
|
||||
if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; }
|
||||
const autoCancel = setTimeout(() => {
|
||||
if (currentState === STATE.RESULT) setState(STATE.IDLE);
|
||||
}, 8000);
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel nfc-kiosk__unknown" style="text-align:center">
|
||||
<div class="unknown-icon">⚠</div>
|
||||
<h2>This card isn't programmed yet</h2>
|
||||
<p style="color:var(--nfc-text-muted)">Program it now, or ask a manager.</p>
|
||||
<div class="actions" style="justify-content:center">
|
||||
<button class="confirm" id="uc_program">Program this card</button>
|
||||
<button class="cancel" id="uc_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
stateContainer.querySelector("#uc_program").addEventListener("click", () => {
|
||||
clearTimeout(autoCancel);
|
||||
pendingEnrollUid = uid;
|
||||
enrollSelectedEmployee = null;
|
||||
// If a manager already unlocked, skip the PIN; otherwise ask for it.
|
||||
setState(STATE.ENROLL, { phase: enrollPassword ? "employee" : "password" });
|
||||
});
|
||||
stateContainer.querySelector("#uc_cancel").addEventListener("click", () => {
|
||||
clearTimeout(autoCancel);
|
||||
setState(STATE.IDLE);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEnroll(payload) {
|
||||
const phase = (payload && payload.phase) || "password";
|
||||
resetEnrollIdleTimer();
|
||||
|
||||
if (phase === "password") {
|
||||
const masked = "•".repeat(enrollPassword.length);
|
||||
mountPinPad({
|
||||
title: "Manager PIN",
|
||||
onOk: (pin) => { enrollPassword = pin; renderEnroll({ phase: "employee" }); },
|
||||
onCancel: exitEnrollMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "manager") {
|
||||
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>
|
||||
<h2>Manage employees</h2>
|
||||
<input class="employee-search" id="mgr_search" placeholder="Search by name…" autocomplete="off"/>
|
||||
<div class="employee-list" id="mgr_list"></div>
|
||||
<div class="actions" style="justify-content:space-between">
|
||||
<button class="confirm" id="mgr_new">+ New employee</button>
|
||||
<button class="cancel" id="mgr_close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const searchEl = document.getElementById("mgr_search");
|
||||
const listEl = document.getElementById("mgr_list");
|
||||
let confirmDeleteId = null;
|
||||
let debounceTimer = null;
|
||||
async function refresh() {
|
||||
resetEnrollIdleTimer();
|
||||
let emps = [];
|
||||
try { emps = (await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value })).employees || []; }
|
||||
catch (e) { listEl.innerHTML = `<div style="opacity:.6;padding:1rem">Connection error.</div>`; return; }
|
||||
if (!emps.length) { listEl.innerHTML = `<div style="opacity:.6;padding:1rem">No employees found.</div>`; return; }
|
||||
listEl.innerHTML = emps.map(e => {
|
||||
const tag = e.card_uid
|
||||
? `<span class="m-tag m-tag--on">● ${escapeHtml(e.card_uid)}</span>`
|
||||
: `<span class="m-tag">○ no tag</span>`;
|
||||
const actions = (confirmDeleteId === e.id)
|
||||
? `<button class="m-btn m-danger" data-act="delok" data-id="${e.id}">Confirm delete</button>
|
||||
<button class="m-btn" data-act="delno" data-id="${e.id}">Cancel</button>`
|
||||
: `<button class="m-btn" data-act="assign" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${e.card_uid ? "Re-tag" : "Assign"}</button>
|
||||
${e.card_uid ? `<button class="m-btn" data-act="clear" data-id="${e.id}">Clear tag</button>` : ""}
|
||||
<button class="m-btn m-danger" data-act="del" data-id="${e.id}">Delete</button>`;
|
||||
return `<div class="manager-row">
|
||||
<div class="m-info"><span class="m-name">${escapeHtml(e.name)}</span><small class="m-dept">${escapeHtml(e.department || "")}</small> ${tag}</div>
|
||||
<div class="m-actions">${actions}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
listEl.querySelectorAll(".m-btn").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;
|
||||
const id = parseInt(btn.dataset.id, 10);
|
||||
const act = btn.dataset.act;
|
||||
if (act === "assign") {
|
||||
enrollSelectedEmployee = { id, name: btn.dataset.name };
|
||||
pendingEnrollUid = null;
|
||||
renderEnroll({ phase: "tap" });
|
||||
} else if (act === "clear") {
|
||||
try { await postJson("/fusion_clock/kiosk/nfc/clear_tag", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {}
|
||||
refresh();
|
||||
} else if (act === "del") {
|
||||
confirmDeleteId = id; refresh();
|
||||
} else if (act === "delno") {
|
||||
confirmDeleteId = null; refresh();
|
||||
} else if (act === "delok") {
|
||||
try { await postJson("/fusion_clock/kiosk/nfc/delete_employee", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {}
|
||||
confirmDeleteId = null; refresh();
|
||||
}
|
||||
else enrollPassword += n;
|
||||
renderEnroll({ phase: "password" });
|
||||
});
|
||||
}));
|
||||
}
|
||||
searchEl.addEventListener("input", () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(refresh, 200);
|
||||
});
|
||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
||||
document.getElementById("mgr_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
|
||||
document.getElementById("mgr_close").addEventListener("click", exitEnrollMode);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "search") {
|
||||
if (phase === "employee") {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel">
|
||||
<h2>Pick the employee to enroll</h2>
|
||||
<h2>Who is this card for?</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">
|
||||
<div class="actions" style="justify-content:space-between">
|
||||
<button class="confirm" id="enroll_new">+ New employee</button>
|
||||
<button class="cancel" id="enroll_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,17 +480,61 @@
|
||||
).join("");
|
||||
listEl.querySelectorAll(".employee-row").forEach(row => {
|
||||
row.addEventListener("click", () => {
|
||||
enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name };
|
||||
renderEnroll({ phase: "tap" });
|
||||
chooseEmployee({ id: parseInt(row.dataset.id, 10), name: row.dataset.name });
|
||||
});
|
||||
});
|
||||
}, 200);
|
||||
});
|
||||
searchEl.focus();
|
||||
document.getElementById("enroll_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
|
||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "new_employee") {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel">
|
||||
<h2>New employee</h2>
|
||||
<input class="employee-search" id="new_emp_name" placeholder="Full name…" autocomplete="off"/>
|
||||
<div class="enroll-msg" id="new_emp_msg"></div>
|
||||
<div class="actions" style="justify-content:space-between">
|
||||
<button class="cancel" id="new_emp_back">Back</button>
|
||||
<button class="confirm" id="new_emp_create">Create & assign</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const nameEl = document.getElementById("new_emp_name");
|
||||
const msgEl = document.getElementById("new_emp_msg");
|
||||
nameEl.addEventListener("input", resetEnrollIdleTimer);
|
||||
nameEl.focus();
|
||||
const doCreate = async () => {
|
||||
resetEnrollIdleTimer();
|
||||
const nm = nameEl.value.trim();
|
||||
if (nm.length < 2) { msgEl.textContent = "Enter the employee's full name."; return; }
|
||||
msgEl.textContent = "Creating…";
|
||||
let res;
|
||||
try {
|
||||
res = await postJson("/fusion_clock/kiosk/nfc/create_employee", { name: nm, enroll_password: enrollPassword });
|
||||
} catch (e) {
|
||||
msgEl.textContent = "No connection. Try again.";
|
||||
return;
|
||||
}
|
||||
if (res.error) {
|
||||
msgEl.textContent = res.error === "invalid_password" ? "Wrong Manager PIN."
|
||||
: res.error === "invalid_name" ? "Enter a valid name."
|
||||
: "Could not create employee.";
|
||||
return;
|
||||
}
|
||||
chooseEmployee({ id: res.employee_id, name: res.employee_name });
|
||||
};
|
||||
document.getElementById("new_emp_create").addEventListener("click", doCreate);
|
||||
nameEl.addEventListener("keydown", (e) => { if (e.key === "Enter") doCreate(); });
|
||||
document.getElementById("new_emp_back").addEventListener("click", () => renderEnroll({ phase: "employee" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "tap") {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
@@ -312,52 +555,163 @@
|
||||
if (phase === "result") {
|
||||
const ok = !payload.error;
|
||||
const msg = ok
|
||||
? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}`
|
||||
? `✓ Card enrolled to ${escapeHtml(payload.employee_name)}`
|
||||
: (payload.error === "invalid_password"
|
||||
? "Wrong password. Try again."
|
||||
? "Wrong Manager PIN. 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>
|
||||
<h2 style="color:${ok ? "var(--nfc-success)" : "var(--nfc-error)"}">${msg}</h2>
|
||||
<div class="actions" style="justify-content:center">
|
||||
${ok && payload.needs_photo && payload.employee_id ? `<button class="confirm" id="enroll_photo">📷 Take photo</button>` : ""}
|
||||
<button class="confirm" id="enroll_another">Enroll another</button>
|
||||
<button class="cancel" id="enroll_done">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
if (ok && payload.needs_photo && payload.employee_id) {
|
||||
document.getElementById("enroll_photo").addEventListener("click", () => {
|
||||
openPhotoCapture(payload.employee_id, payload.employee_name, () => {
|
||||
if (enrollPassword) renderEnroll({ phase: "manager" }); else exitEnrollMode();
|
||||
});
|
||||
});
|
||||
}
|
||||
document.getElementById("enroll_another").addEventListener("click", () => {
|
||||
enrollSelectedEmployee = null;
|
||||
renderEnroll({ phase: ok ? "search" : "password" });
|
||||
pendingEnrollUid = null;
|
||||
renderEnroll({ phase: ok ? "employee" : "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 });
|
||||
// Existing employee picked → if we already hold a tapped UID, bind it now
|
||||
// (no re-tap); otherwise fall back to the proactive ⚙ "tap the card" step.
|
||||
function chooseEmployee(emp) {
|
||||
if (pendingEnrollUid) {
|
||||
doEnroll(emp.id, emp.name, pendingEnrollUid, false);
|
||||
} else {
|
||||
enrollSelectedEmployee = emp;
|
||||
renderEnroll({ phase: "tap" });
|
||||
}
|
||||
}
|
||||
|
||||
// ⚙ button → enter Enroll Mode
|
||||
// Single enroll path (program flow, ⚙ tap flow, manager re-tag). A card already
|
||||
// held by someone else triggers a reassign confirm rather than a hard error.
|
||||
async function doEnroll(empId, empName, uid, force) {
|
||||
resetEnrollIdleTimer();
|
||||
let result;
|
||||
try {
|
||||
result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
|
||||
employee_id: empId, card_uid: uid, enroll_password: enrollPassword, force: !!force,
|
||||
});
|
||||
} catch (e) {
|
||||
renderEnroll({ phase: "result", employee_name: empName, error: "network" });
|
||||
return;
|
||||
}
|
||||
if (result.error === "card_already_assigned" && !force) {
|
||||
renderReassignConfirm(empId, empName, uid, result.existing_employee);
|
||||
return;
|
||||
}
|
||||
renderEnroll({ phase: "result", employee_name: empName, ...result });
|
||||
}
|
||||
|
||||
function renderReassignConfirm(empId, empName, uid, existingName) {
|
||||
resetEnrollIdleTimer();
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
|
||||
<h2>Reassign card?</h2>
|
||||
<p style="color:var(--nfc-text-muted)">This card belongs to <b>${escapeHtml(existingName || "another employee")}</b>. Move it to <b>${escapeHtml(empName)}</b>?</p>
|
||||
<div class="actions" style="justify-content:center">
|
||||
<button class="cancel" id="ra_cancel">Cancel</button>
|
||||
<button class="confirm" id="ra_move">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
stateContainer.querySelector("#ra_move").addEventListener("click", () => doEnroll(empId, empName, uid, true));
|
||||
stateContainer.querySelector("#ra_cancel").addEventListener("click", () => renderEnroll({ phase: "manager" }));
|
||||
}
|
||||
|
||||
async function _onEnrollTap(uid) {
|
||||
if (!enrollSelectedEmployee) return;
|
||||
doEnroll(enrollSelectedEmployee.id, enrollSelectedEmployee.name, uid, false);
|
||||
}
|
||||
|
||||
// ⚙ button → enter Enroll Mode (only when unlocked)
|
||||
const settingsBtn = document.getElementById("nfc_settings_btn");
|
||||
if (settingsBtn) {
|
||||
settingsBtn.addEventListener("click", () => {
|
||||
if (currentState !== STATE.IDLE) return;
|
||||
enrollPassword = "";
|
||||
if (kioskLocked || currentState !== STATE.IDLE) return;
|
||||
enrollSelectedEmployee = null;
|
||||
setState(STATE.ENROLL, { phase: "password" });
|
||||
pendingEnrollUid = null;
|
||||
// Already unlocked → reuse that PIN, open the manager page (no re-prompt).
|
||||
setState(STATE.ENROLL, { phase: "manager" });
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Screen lock — the kiosk starts LOCKED: only card taps work, ⚙ hidden.
|
||||
// A manager long-presses the bottom-right corner and enters the Manager
|
||||
// PIN to unlock (revealing ⚙ + 🔒). Re-locks via 🔒, on reload, or after
|
||||
// inactivity. Unlock is in-memory only, so every reload starts locked.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let kioskLocked = true;
|
||||
let relockTimer = null;
|
||||
const lockBtn = document.getElementById("nfc_lock_btn");
|
||||
const unlockBtn = document.getElementById("nfc_unlock_btn");
|
||||
|
||||
function applyLockState() {
|
||||
if (settingsBtn) settingsBtn.style.display = kioskLocked ? "none" : "flex";
|
||||
if (lockBtn) lockBtn.style.display = kioskLocked ? "none" : "flex";
|
||||
if (unlockBtn) unlockBtn.style.display = kioskLocked ? "flex" : "none";
|
||||
}
|
||||
function armRelock() {
|
||||
if (relockTimer) clearTimeout(relockTimer);
|
||||
relockTimer = setTimeout(() => {
|
||||
relockTimer = null;
|
||||
if (currentState === STATE.IDLE) lockKiosk();
|
||||
else armRelock(); // don't re-lock mid-enroll
|
||||
}, 120000);
|
||||
}
|
||||
function lockKiosk() {
|
||||
kioskLocked = true;
|
||||
enrollPassword = ""; // forget the PIN → unlocking requires it again
|
||||
if (relockTimer) { clearTimeout(relockTimer); relockTimer = null; }
|
||||
applyLockState();
|
||||
}
|
||||
function unlockKiosk() {
|
||||
kioskLocked = false;
|
||||
applyLockState();
|
||||
armRelock();
|
||||
}
|
||||
if (lockBtn) lockBtn.addEventListener("click", lockKiosk);
|
||||
|
||||
function openUnlockPin() {
|
||||
currentState = STATE.RESULT; // block card taps during PIN entry
|
||||
mountPinPad({
|
||||
title: "Manager PIN — unlock",
|
||||
onOk: async (pin) => {
|
||||
let ok = false;
|
||||
try { ok = (await postJson("/fusion_clock/kiosk/nfc/verify_pin", { pin })).ok; } catch (e) {}
|
||||
if (ok) { enrollPassword = pin; unlockKiosk(); setState(STATE.IDLE); }
|
||||
else { const d = document.getElementById("nfc_pin_display"); if (d) d.textContent = "✕ wrong"; }
|
||||
},
|
||||
onCancel: () => setState(STATE.IDLE),
|
||||
});
|
||||
}
|
||||
|
||||
if (unlockBtn) unlockBtn.addEventListener("click", () => {
|
||||
if (kioskLocked && currentState === STATE.IDLE) openUnlockPin();
|
||||
});
|
||||
|
||||
applyLockState(); // start locked
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || "").replace(/[&<>"']/g, c => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
@@ -387,6 +741,9 @@
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
// Keep-alive: refresh the session every 4 min so the kiosk login never expires.
|
||||
setInterval(() => { postJson("/fusion_clock/get_settings", {}).catch(() => {}); }, 240000);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Setup wizard
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -577,6 +934,10 @@
|
||||
setState(STATE.IDLE);
|
||||
return;
|
||||
}
|
||||
if (result.error === "card_unknown") {
|
||||
renderUnknownCard(uid);
|
||||
return;
|
||||
}
|
||||
setState(STATE.RESULT, result);
|
||||
} catch (e) {
|
||||
debugLog("handleTap: POST failed: " + e.message);
|
||||
@@ -624,6 +985,82 @@
|
||||
return canvasEl.toDataURL("image/jpeg", 0.7);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Guided profile-photo capture (for employees with no picture).
|
||||
// Live camera + oval face guide → Capture → preview → Use/Retake →
|
||||
// saves a centered 512² square to the employee's profile image.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function _captureProfileSquare(video) {
|
||||
const vw = video.videoWidth, vh = video.videoHeight;
|
||||
if (!vw || !vh) return "";
|
||||
const side = Math.min(vw, vh);
|
||||
const sx = (vw - side) / 2, sy = (vh - side) / 2;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 512; c.height = 512;
|
||||
c.getContext("2d").drawImage(video, sx, sy, side, side, 0, 0, 512, 512);
|
||||
return c.toDataURL("image/jpeg", 0.85);
|
||||
}
|
||||
|
||||
function openPhotoCapture(employeeId, employeeName, onDone) {
|
||||
currentState = STATE.RESULT; // block card taps during capture
|
||||
if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; }
|
||||
const finish = () => { if (onDone) onDone(); };
|
||||
if (!cameraStream) { finish(); return; } // no camera → skip silently
|
||||
let captured = "";
|
||||
const renderPreview = () => {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__photo-panel">
|
||||
<h2>Use this photo?</h2>
|
||||
<div class="nfc-photo-stage">
|
||||
<img class="nfc-photo-preview" src="${captured}"/>
|
||||
<div class="nfc-photo-guide"></div>
|
||||
</div>
|
||||
<div class="actions" style="justify-content:space-between">
|
||||
<button class="cancel" id="photo_retake">Retake</button>
|
||||
<button class="confirm" id="photo_use">Use photo</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("photo_retake").addEventListener("click", renderLive);
|
||||
document.getElementById("photo_use").addEventListener("click", async () => {
|
||||
try { await postJson("/fusion_clock/kiosk/nfc/save_profile_photo", { employee_id: employeeId, photo_b64: captured }); } catch (e) {}
|
||||
finish();
|
||||
});
|
||||
};
|
||||
function renderLive() {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__photo-panel">
|
||||
<h2>Take ${escapeHtml(employeeName)}'s photo</h2>
|
||||
<div class="nfc-photo-stage" id="photo_stage">
|
||||
<div class="nfc-photo-guide"></div>
|
||||
<div class="nfc-photo-hint">Center the face in the oval</div>
|
||||
</div>
|
||||
<div class="actions" style="justify-content:space-between">
|
||||
<button class="cancel" id="photo_skip">Skip</button>
|
||||
<button class="confirm" id="photo_capture">Capture</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const stage = document.getElementById("photo_stage");
|
||||
const v = document.createElement("video");
|
||||
v.autoplay = true; v.muted = true; v.playsInline = true;
|
||||
v.className = "nfc-photo-video";
|
||||
v.srcObject = cameraStream;
|
||||
stage.insertBefore(v, stage.firstChild);
|
||||
v.play().catch(() => {});
|
||||
document.getElementById("photo_capture").addEventListener("click", () => {
|
||||
captured = _captureProfileSquare(v);
|
||||
if (captured) renderPreview(); else finish();
|
||||
});
|
||||
document.getElementById("photo_skip").addEventListener("click", finish);
|
||||
}
|
||||
renderLive();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Wake Lock — keeps the screen on while the kiosk page is active.
|
||||
// Released automatically on tab close/navigation; re-acquired on
|
||||
@@ -668,6 +1105,7 @@
|
||||
if (setupBtn) {
|
||||
setupBtn.addEventListener("click", async () => {
|
||||
debugLog("setup button clicked");
|
||||
unlockAudio(); // user gesture → unlock Web Audio for clock sounds
|
||||
// Try Web NFC, but don't fail if absent — USB HID reader is a
|
||||
// first-class alternative (works on desktops/iOS too).
|
||||
let webNfcOk = false;
|
||||
@@ -704,6 +1142,7 @@
|
||||
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
|
||||
}
|
||||
await acquireWakeLock();
|
||||
try { localStorage.setItem("nfc_setup_done", "1"); } catch (e) {}
|
||||
setState(STATE.IDLE);
|
||||
debugLog("setup: IDLE — Web NFC: " + (webNfcOk ? "✓" : "✗") + " · USB HID: ✓");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user