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/scss/nfc_kiosk.scss',
|
||||||
'fusion_clock/static/src/js/fusion_clock_portal.js',
|
'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_kiosk.js',
|
||||||
|
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
|
||||||
],
|
],
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
'fusion_clock/static/src/scss/fusion_clock.scss',
|
'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