feat(fusion_clock): NFC kiosk JS scaffold + state machine + clock display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'fusion_clock/static/src/scss/nfc_kiosk.scss',
|
||||
'fusion_clock/static/src/js/fusion_clock_portal.js',
|
||||
'fusion_clock/static/src/js/fusion_clock_kiosk.js',
|
||||
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'fusion_clock/static/src/scss/fusion_clock.scss',
|
||||
|
||||
131
fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
Normal file
131
fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/* @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";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 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">
|
||||
<div class="nfc-kiosk__icon">⌐■</div>
|
||||
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderProcessing() {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__processing">Reading card…</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
const isError = payload && payload.error;
|
||||
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
|
||||
|
||||
if (isError) {
|
||||
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), 4000);
|
||||
} else {
|
||||
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" });
|
||||
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>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => setState(STATE.IDLE), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || "").replace(/[&<>"']/g, c => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[c]));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Clock display (top-right time + date)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
const hh = String(now.getHours()).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) timeEl.textContent = `${hh}:${mm}`;
|
||||
if (dateEl) dateEl.textContent = dateStr;
|
||||
}
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Setup wizard
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
const setupBtn = document.getElementById("nfc_setup_start");
|
||||
if (setupBtn) {
|
||||
setupBtn.addEventListener("click", async () => {
|
||||
// Web NFC + camera activation lives in Tasks 16 + 17
|
||||
setState(STATE.IDLE);
|
||||
});
|
||||
}
|
||||
|
||||
// Expose a tiny API for later tasks
|
||||
window.__nfcKiosk = {
|
||||
setState,
|
||||
STATE,
|
||||
photoRequired,
|
||||
debugEnabled,
|
||||
locationConfigured,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user