NFC kiosk: - Add "📷 Photo" action to every Manage-page employee row and to the post-enroll result card, so a manager can set/replace a profile photo at any time (previously only surfaced when the employee had no image). - Slim the Manager PIN pad: dedicated --pin panel variant (max-width 360px, reduced padding) with a tighter numpad, removing the oversized whitespace. Deployed live to entech (LXC 111) as 19.0.3.11.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1180 lines
60 KiB
JavaScript
1180 lines
60 KiB
JavaScript
/* @odoo-module */
|
||
|
||
// NFC Clock Kiosk — Web NFC + camera + state machine.
|
||
// Loaded as a frontend asset on /fusion_clock/kiosk/nfc only (the
|
||
// element #nfc_kiosk_root only exists on that page, so the module is
|
||
// inert elsewhere).
|
||
|
||
(function() {
|
||
"use strict";
|
||
|
||
const root = document.getElementById("nfc_kiosk_root");
|
||
if (!root) return; // not on the kiosk page
|
||
|
||
const stateContainer = document.getElementById("nfc_state_container");
|
||
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)
|
||
// ──────────────────────────────────────────────────────────────
|
||
let _debugOverlayEl = null;
|
||
function debugLog(msg) {
|
||
try { console.log("[nfc-kiosk-debug]", msg); } catch (e) {}
|
||
if (!debugEnabled) return;
|
||
if (!_debugOverlayEl) {
|
||
_debugOverlayEl = document.createElement("div");
|
||
_debugOverlayEl.style.cssText = "position:fixed;top:0;left:0;right:0;background:rgba(0,0,0,0.9);color:#0f0;font-family:monospace;font-size:11px;padding:0.5rem;max-height:35vh;overflow-y:auto;z-index:9999;line-height:1.3;border-bottom:1px solid #0f0;";
|
||
document.body.appendChild(_debugOverlayEl);
|
||
}
|
||
const line = document.createElement("div");
|
||
const ts = new Date().toLocaleTimeString();
|
||
line.textContent = "[" + ts + "] " + msg;
|
||
_debugOverlayEl.appendChild(line);
|
||
while (_debugOverlayEl.childNodes.length > 40) {
|
||
_debugOverlayEl.removeChild(_debugOverlayEl.firstChild);
|
||
}
|
||
_debugOverlayEl.scrollTop = _debugOverlayEl.scrollHeight;
|
||
}
|
||
debugLog("page loaded; debugEnabled=" + debugEnabled + " photoRequired=" + photoRequired + " NDEFReader=" + ("NDEFReader" in window));
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Dominant-hue extraction from company logo
|
||
// Sets the CSS variable --nfc-h on <html> so SCSS can interpolate
|
||
// the entire palette from the brand color. Falls back to default
|
||
// (220 = aurora-blue) if no logo or extraction fails.
|
||
// ──────────────────────────────────────────────────────────────
|
||
function rgbToHue(r, g, b) {
|
||
const rN = r / 255, gN = g / 255, bN = b / 255;
|
||
const max = Math.max(rN, gN, bN), min = Math.min(rN, gN, bN);
|
||
const d = max - min;
|
||
if (d === 0) return null; // grayscale, no hue info
|
||
let h;
|
||
if (max === rN) h = ((gN - bN) / d) % 6;
|
||
else if (max === gN) h = (bN - rN) / d + 2;
|
||
else h = (rN - gN) / d + 4;
|
||
h = Math.round(h * 60);
|
||
if (h < 0) h += 360;
|
||
return h;
|
||
}
|
||
|
||
function extractDominantHue(img) {
|
||
try {
|
||
const c = document.createElement("canvas");
|
||
const w = c.width = Math.min(img.naturalWidth, 200);
|
||
const h = c.height = Math.min(img.naturalHeight, 200);
|
||
const ctx = c.getContext("2d", { willReadFrequently: true });
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
const data = ctx.getImageData(0, 0, w, h).data;
|
||
let r = 0, g = 0, b = 0, count = 0;
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
const a = data[i + 3];
|
||
if (a < 128) continue; // skip transparent
|
||
const red = data[i], green = data[i + 1], blue = data[i + 2];
|
||
const lum = (red + green + blue) / 3;
|
||
if (lum > 235 || lum < 25) continue; // skip near-white/near-black
|
||
const range = Math.max(red, green, blue) - Math.min(red, green, blue);
|
||
if (range < 25) continue; // skip near-grays
|
||
r += red; g += green; b += blue; count++;
|
||
}
|
||
if (count < 50) {
|
||
debugLog("hue extraction: too few colored pixels (" + count + "), using default");
|
||
return null;
|
||
}
|
||
const avgR = Math.round(r / count), avgG = Math.round(g / count), avgB = Math.round(b / count);
|
||
const hue = rgbToHue(avgR, avgG, avgB);
|
||
debugLog("hue extracted: rgb(" + avgR + "," + avgG + "," + avgB + ") → h=" + hue);
|
||
return hue;
|
||
} catch (e) {
|
||
debugLog("hue extraction failed: " + e.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function applyBrandHue(hue) {
|
||
if (hue == null) return;
|
||
document.documentElement.style.setProperty("--nfc-h", String(hue));
|
||
}
|
||
|
||
const logoImg = document.getElementById("nfc_company_logo");
|
||
if (logoImg) {
|
||
const tryExtract = () => {
|
||
const hue = extractDominantHue(logoImg);
|
||
applyBrandHue(hue);
|
||
};
|
||
if (logoImg.complete && logoImg.naturalWidth) {
|
||
tryExtract();
|
||
} else {
|
||
logoImg.addEventListener("load", tryExtract);
|
||
logoImg.addEventListener("error", () => debugLog("logo failed to load"));
|
||
}
|
||
} else {
|
||
debugLog("no company logo on page; using default hue");
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// State machine
|
||
// ──────────────────────────────────────────────────────────────
|
||
const STATE = { SETUP: "setup", IDLE: "idle", PROCESSING: "processing", RESULT: "result", ENROLL: "enroll" };
|
||
let currentState = STATE.SETUP;
|
||
|
||
function setState(next, payload) {
|
||
currentState = next;
|
||
if (next === STATE.IDLE) renderIdle();
|
||
else if (next === STATE.PROCESSING) renderProcessing();
|
||
else if (next === STATE.RESULT) renderResult(payload);
|
||
else if (next === STATE.ENROLL) renderEnroll(payload);
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Rendering helpers
|
||
// ──────────────────────────────────────────────────────────────
|
||
function renderIdle() {
|
||
stateContainer.innerHTML = `
|
||
<div class="nfc-kiosk__idle">
|
||
<svg class="nfc-kiosk__icon-svg" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||
<circle class="nfc-wave nfc-wave-3" cx="100" cy="100" r="98"
|
||
stroke="currentColor" stroke-width="4" fill="none"/>
|
||
<circle class="nfc-wave nfc-wave-2" cx="100" cy="100" r="78"
|
||
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"/>
|
||
<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>
|
||
`;
|
||
}
|
||
|
||
function renderProcessing() {
|
||
stateContainer.innerHTML = `
|
||
<div class="nfc-kiosk__processing">
|
||
<span>Reading card</span>
|
||
<span class="dots"><span></span><span></span><span></span></span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// 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">
|
||
<div class="name">${escapeHtml(payload.message || "Error")}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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";
|
||
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>
|
||
${hoursLine}
|
||
</div>
|
||
</div>
|
||
`;
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Enroll Mode
|
||
// ──────────────────────────────────────────────────────────────
|
||
let enrollPassword = "";
|
||
let enrollSelectedEmployee = null;
|
||
let pendingEnrollUid = null; // set when programming a just-tapped unknown card
|
||
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;
|
||
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 nfc-kiosk__enroll-panel--pin">
|
||
<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") {
|
||
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>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>
|
||
`;
|
||
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>
|
||
<button class="m-btn" data-act="photo" data-id="${e.id}" data-name="${escapeHtml(e.name)}">📷 Photo</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 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 === "photo") {
|
||
openPhotoCapture(id, btn.dataset.name, () => renderEnroll({ phase: "manager" }));
|
||
} 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();
|
||
}
|
||
}));
|
||
}
|
||
searchEl.addEventListener("input", () => {
|
||
if (debounceTimer) clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(refresh, 200);
|
||
});
|
||
document.getElementById("mgr_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
|
||
document.getElementById("mgr_close").addEventListener("click", exitEnrollMode);
|
||
refresh();
|
||
return;
|
||
}
|
||
|
||
if (phase === "employee") {
|
||
stateContainer.innerHTML = `
|
||
<div class="nfc-kiosk__enroll-overlay">
|
||
<div class="nfc-kiosk__enroll-panel">
|
||
<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" style="justify-content:space-between">
|
||
<button class="confirm" id="enroll_new">+ New employee</button>
|
||
<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", () => {
|
||
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">
|
||
<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 enrolled to ${escapeHtml(payload.employee_name)}`
|
||
: (payload.error === "invalid_password"
|
||
? "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 ? "var(--nfc-success)" : "var(--nfc-error)"}">${msg}</h2>
|
||
<div class="actions" style="justify-content:center">
|
||
${ok && 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.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;
|
||
pendingEnrollUid = null;
|
||
renderEnroll({ phase: ok ? "employee" : "password" });
|
||
});
|
||
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
|
||
}
|
||
}
|
||
|
||
// 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" });
|
||
}
|
||
}
|
||
|
||
// 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 (kioskLocked || currentState !== STATE.IDLE) return;
|
||
enrollSelectedEmployee = null;
|
||
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 => ({
|
||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||
}[c]));
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Clock display (centered top: time with AM/PM + date)
|
||
// ──────────────────────────────────────────────────────────────
|
||
function updateClock() {
|
||
const now = new Date();
|
||
let hours = now.getHours();
|
||
const ampm = hours >= 12 ? "PM" : "AM";
|
||
hours = hours % 12;
|
||
if (hours === 0) hours = 12; // 0 → 12 in 12-hour clock
|
||
const hh = String(hours).padStart(2, "0");
|
||
const mm = String(now.getMinutes()).padStart(2, "0");
|
||
const dateStr = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
||
const timeEl = document.getElementById("nfc_clock_time");
|
||
const dateEl = document.getElementById("nfc_clock_date");
|
||
if (timeEl) {
|
||
// Render hh:mm + AM/PM as separate spans so SCSS can style them differently
|
||
timeEl.innerHTML = `${hh}:${mm}<span class="ampm">${ampm}</span>`;
|
||
}
|
||
if (dateEl) dateEl.textContent = dateStr;
|
||
}
|
||
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
|
||
// ──────────────────────────────────────────────────────────────
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Web NFC reader
|
||
// ──────────────────────────────────────────────────────────────
|
||
let ndefReader = null;
|
||
let nfcReady = false;
|
||
|
||
async function startNfcReader() {
|
||
debugLog("startNfcReader: NDEFReader in window = " + ("NDEFReader" in window));
|
||
if (!("NDEFReader" in window)) {
|
||
throw new Error("Web NFC not supported on this browser/device. Use Chrome on Android.");
|
||
}
|
||
ndefReader = new NDEFReader();
|
||
debugLog("startNfcReader: ndefReader created, calling scan()...");
|
||
await ndefReader.scan();
|
||
debugLog("startNfcReader: scan() resolved ✓");
|
||
ndefReader.addEventListener("reading", onNfcReading);
|
||
ndefReader.addEventListener("readingerror", (ev) => {
|
||
debugLog("readingerror event fired");
|
||
console.warn("[nfc-kiosk] reading error; reader still active");
|
||
});
|
||
nfcReady = true;
|
||
debugLog("startNfcReader: listeners attached, nfcReady=true");
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// USB HID keyboard-wedge listener (works alongside Web NFC).
|
||
// Most USB NFC readers in HID mode type the UID as keystrokes and
|
||
// end with Enter. We buffer chars until Enter arrives (or 500ms
|
||
// pause), then route the UID through the same flow Web NFC uses.
|
||
//
|
||
// Critical: this listener fires the same handleTap()/_onEnrollTap()
|
||
// codepath as Web NFC, so penalty + photo + activity log all work
|
||
// identically regardless of which reader produced the UID.
|
||
// ──────────────────────────────────────────────────────────────
|
||
let _hidBuffer = "";
|
||
let _hidLastKeyAt = 0;
|
||
let _hidFlushTimer = null;
|
||
const HID_RESET_MS = 500; // pause longer than this resets the buffer
|
||
const HID_FLUSH_MS = 600; // if no Enter arrives, flush this long after last char
|
||
const HID_MIN_LEN = 4; // shortest plausible UID
|
||
const HID_CHAR_RE = /^[0-9A-Fa-f:\-]$/; // hex digits + common separators
|
||
|
||
function _flushHidBuffer() {
|
||
const uid = _hidBuffer.trim().toUpperCase();
|
||
_hidBuffer = "";
|
||
if (_hidFlushTimer) { clearTimeout(_hidFlushTimer); _hidFlushTimer = null; }
|
||
if (uid.length < HID_MIN_LEN) {
|
||
debugLog("HID flush: too short, ignored (" + JSON.stringify(uid) + ")");
|
||
return;
|
||
}
|
||
debugLog("HID flush: uid=" + uid + " state=" + currentState);
|
||
if (currentState === STATE.ENROLL) {
|
||
window.__nfcKiosk && window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
|
||
} else if (currentState === STATE.IDLE) {
|
||
handleTap(uid);
|
||
} else {
|
||
debugLog(" → IGNORED: state=" + currentState);
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Local wedge daemon SSE listener.
|
||
//
|
||
// If a `wedge.py` daemon is running on this machine (used for
|
||
// ACR122U / PC/SC readers that can't emit keystrokes themselves),
|
||
// it exposes a Server-Sent Events stream at
|
||
// http://localhost:8765/events that pushes each detected UID.
|
||
//
|
||
// Chrome treats http://localhost as a secure origin, so an HTTPS
|
||
// kiosk page can connect to it without mixed-content blocking.
|
||
// No keystroke injection, no Accessibility permission needed,
|
||
// no focused-window dependency.
|
||
//
|
||
// Routes the UID through the same handleTap()/_onEnrollTap() flow
|
||
// as Web NFC and USB HID — so photo, penalty, activity log all
|
||
// fire identically.
|
||
// ──────────────────────────────────────────────────────────────
|
||
const WEDGE_SSE_URL = "http://localhost:8765/events";
|
||
let _wedgeEs = null;
|
||
|
||
function startWedgeSseListener() {
|
||
try {
|
||
_wedgeEs = new EventSource(WEDGE_SSE_URL);
|
||
_wedgeEs.addEventListener("message", (ev) => {
|
||
const uid = (ev.data || "").trim().toUpperCase();
|
||
if (!uid) return;
|
||
debugLog("wedge SSE: " + uid + " state=" + currentState);
|
||
if (currentState === STATE.ENROLL) {
|
||
window.__nfcKiosk && window.__nfcKiosk._onEnrollTap &&
|
||
window.__nfcKiosk._onEnrollTap(uid);
|
||
} else if (currentState === STATE.IDLE) {
|
||
handleTap(uid);
|
||
} else {
|
||
debugLog(" → IGNORED: state=" + currentState);
|
||
}
|
||
});
|
||
_wedgeEs.addEventListener("open", () => {
|
||
debugLog("wedge SSE: connected to " + WEDGE_SSE_URL);
|
||
});
|
||
_wedgeEs.addEventListener("error", () => {
|
||
// EventSource auto-reconnects; this fires on every
|
||
// dropped connection. Log first occurrence only.
|
||
if (!_wedgeEs._loggedError) {
|
||
debugLog("wedge SSE: connection error (daemon may not be running) — will auto-retry");
|
||
_wedgeEs._loggedError = true;
|
||
}
|
||
});
|
||
debugLog("startWedgeSseListener: subscribed to " + WEDGE_SSE_URL);
|
||
} catch (e) {
|
||
debugLog("startWedgeSseListener: failed to start — " + e.message);
|
||
}
|
||
}
|
||
|
||
function startUsbHidListener() {
|
||
document.addEventListener("keydown", (e) => {
|
||
// Don't capture keystrokes inside form inputs — preserves
|
||
// typing in enroll-mode search box, etc.
|
||
const t = e.target;
|
||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) {
|
||
return;
|
||
}
|
||
// Don't fight the existing Ctrl+Shift+T mock-tap shortcut.
|
||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||
|
||
const now = Date.now();
|
||
if (now - _hidLastKeyAt > HID_RESET_MS) {
|
||
_hidBuffer = "";
|
||
}
|
||
_hidLastKeyAt = now;
|
||
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
_flushHidBuffer();
|
||
return;
|
||
}
|
||
if (HID_CHAR_RE.test(e.key)) {
|
||
_hidBuffer += e.key;
|
||
// Fallback flush if the reader doesn't emit Enter
|
||
if (_hidFlushTimer) clearTimeout(_hidFlushTimer);
|
||
_hidFlushTimer = setTimeout(_flushHidBuffer, HID_FLUSH_MS);
|
||
}
|
||
});
|
||
debugLog("startUsbHidListener: listening for HID keystrokes ✓");
|
||
}
|
||
|
||
function onNfcReading(event) {
|
||
// event.serialNumber is the card UID — works for raw MIFARE access cards
|
||
const rawSerial = event.serialNumber || "";
|
||
const uid = rawSerial.toUpperCase();
|
||
const recCount = (event.message && event.message.records) ? event.message.records.length : 0;
|
||
debugLog("reading event: serialNumber=" + JSON.stringify(rawSerial) + " (len=" + rawSerial.length + ") records=" + recCount + " state=" + currentState);
|
||
if (!uid) {
|
||
debugLog(" → IGNORED: empty serialNumber");
|
||
return;
|
||
}
|
||
if (currentState === STATE.ENROLL) {
|
||
debugLog(" → routing to _onEnrollTap");
|
||
window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
|
||
return;
|
||
}
|
||
if (currentState !== STATE.IDLE) {
|
||
debugLog(" → IGNORED: not in IDLE (state=" + currentState + ")");
|
||
return;
|
||
}
|
||
debugLog(" → calling handleTap(" + uid + ")");
|
||
handleTap(uid);
|
||
}
|
||
|
||
async function handleTap(uid) {
|
||
debugLog("handleTap: uid=" + uid);
|
||
setState(STATE.PROCESSING);
|
||
let photoB64 = "";
|
||
try {
|
||
photoB64 = await capturePhoto();
|
||
debugLog("handleTap: photo captured, size=" + photoB64.length);
|
||
} catch (e) {
|
||
debugLog("handleTap: photo capture failed: " + e.message);
|
||
console.warn("[nfc-kiosk] camera capture failed", e);
|
||
}
|
||
try {
|
||
debugLog("handleTap: POST /fusion_clock/kiosk/nfc/tap...");
|
||
const result = await postJson("/fusion_clock/kiosk/nfc/tap", { card_uid: uid, photo_b64: photoB64 });
|
||
debugLog("handleTap: response = " + JSON.stringify(result).slice(0, 200));
|
||
if (result.error === "debounce") {
|
||
setState(STATE.IDLE);
|
||
return;
|
||
}
|
||
if (result.error === "card_unknown") {
|
||
renderUnknownCard(uid);
|
||
return;
|
||
}
|
||
setState(STATE.RESULT, result);
|
||
} catch (e) {
|
||
debugLog("handleTap: POST failed: " + e.message);
|
||
setState(STATE.RESULT, { error: "network", message: "No connection. Please try again." });
|
||
}
|
||
}
|
||
|
||
async function postJson(url, params) {
|
||
const res = await fetch(url, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params }),
|
||
});
|
||
const json = await res.json();
|
||
return json.result || {};
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Camera
|
||
// ──────────────────────────────────────────────────────────────
|
||
let cameraStream = null;
|
||
const videoEl = document.getElementById("nfc_camera_feed");
|
||
const canvasEl = document.getElementById("nfc_camera_canvas");
|
||
|
||
async function startCamera() {
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||
throw new Error("Camera not supported on this browser/device.");
|
||
}
|
||
cameraStream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 480 } },
|
||
audio: false,
|
||
});
|
||
videoEl.srcObject = cameraStream;
|
||
await videoEl.play();
|
||
}
|
||
|
||
async function capturePhoto() {
|
||
if (!videoEl || !canvasEl || !videoEl.videoWidth) return "";
|
||
const w = videoEl.videoWidth;
|
||
const h = videoEl.videoHeight;
|
||
canvasEl.width = w;
|
||
canvasEl.height = h;
|
||
const ctx = canvasEl.getContext("2d");
|
||
ctx.drawImage(videoEl, 0, 0, w, h);
|
||
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
|
||
// visibilitychange when the page comes back to the foreground.
|
||
// ──────────────────────────────────────────────────────────────
|
||
let wakeLock = null;
|
||
|
||
async function acquireWakeLock() {
|
||
if (!("wakeLock" in navigator)) {
|
||
debugLog("wakeLock: API not supported on this browser");
|
||
return;
|
||
}
|
||
if (wakeLock) {
|
||
debugLog("wakeLock: already held, skipping");
|
||
return;
|
||
}
|
||
try {
|
||
wakeLock = await navigator.wakeLock.request("screen");
|
||
debugLog("wakeLock: acquired ✓ (screen will stay on)");
|
||
wakeLock.addEventListener("release", () => {
|
||
debugLog("wakeLock: released by browser/OS");
|
||
wakeLock = null;
|
||
});
|
||
} catch (e) {
|
||
debugLog("wakeLock: request failed: " + (e && e.message));
|
||
}
|
||
}
|
||
|
||
document.addEventListener("visibilitychange", async () => {
|
||
if (document.visibilityState === "visible") {
|
||
debugLog("visibility: visible — re-acquiring wakeLock");
|
||
await acquireWakeLock();
|
||
} else {
|
||
debugLog("visibility: " + document.visibilityState);
|
||
}
|
||
});
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Setup wizard activation
|
||
// ──────────────────────────────────────────────────────────────
|
||
const setupBtn = document.getElementById("nfc_setup_start");
|
||
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;
|
||
try {
|
||
await startNfcReader();
|
||
webNfcOk = true;
|
||
debugLog("setup: Web NFC ready ✓");
|
||
} catch (webNfcErr) {
|
||
debugLog("setup: Web NFC unavailable, continuing with USB HID — " + webNfcErr.message);
|
||
}
|
||
// USB HID listener: no permission needed, works on any platform.
|
||
startUsbHidListener();
|
||
// Local wedge daemon SSE listener (for ACR122U / PC/SC readers).
|
||
startWedgeSseListener();
|
||
// Camera: best-effort unless photoRequired forces it.
|
||
try {
|
||
await startCamera();
|
||
debugLog("setup: camera ready ✓");
|
||
} catch (camErr) {
|
||
debugLog("setup: camera failed: " + camErr.message);
|
||
if (photoRequired) {
|
||
// Only THIS path is a hard fail. Use the existing error
|
||
// render to keep DOM patterns consistent with the rest
|
||
// of this file.
|
||
stateContainer.innerHTML = `
|
||
<div class="nfc-kiosk__setup">
|
||
<h2 style="color:#d9374e">Setup failed</h2>
|
||
<p>${escapeHtml(camErr.message)}</p>
|
||
<p style="opacity:.7;font-size:.9em">Camera is required but unavailable. Either plug in a webcam, or disable "Photo Required" in Fusion Clock settings.</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
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: ✓");
|
||
});
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Mock-tap debug shortcut (only when fusion_clock.nfc_kiosk_debug = True)
|
||
// ──────────────────────────────────────────────────────────────
|
||
if (debugEnabled) {
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.ctrlKey && e.shiftKey && (e.key === "T" || e.key === "t")) {
|
||
e.preventDefault();
|
||
const stored = localStorage.getItem("nfc_mock_uid") || "04:DE:AD:BE:EF:01";
|
||
const uid = prompt(`Mock-tap UID (last used: ${stored}):`, stored);
|
||
if (!uid) return;
|
||
localStorage.setItem("nfc_mock_uid", uid);
|
||
if (currentState === STATE.ENROLL) {
|
||
_onEnrollTap(uid.toUpperCase());
|
||
} else if (currentState === STATE.IDLE) {
|
||
handleTap(uid.toUpperCase());
|
||
}
|
||
}
|
||
});
|
||
console.info("[nfc-kiosk] mock-tap debug enabled — Ctrl+Shift+T to fire a tap");
|
||
}
|
||
|
||
window.__nfcKiosk = {
|
||
setState, STATE, photoRequired, debugEnabled, locationConfigured,
|
||
handleTap, _onEnrollTap, // handleTap for mock-tap debug (Task 19)
|
||
};
|
||
})();
|