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>
8.7 KiB
NFC Kiosk — Enrollment UX / PIN fix / Speed / Clock-out Hours — Implementation Plan
For agentic workers: Steps use checkbox (
- [ ]) syntax. Executed inline this session.
Goal: Make NFC-tag enrollment programmable from an unknown tap (with create-new-employee), fix the per-digit PIN re-render, speed up clock-in/out for lines, and clearly show shift hours on clock-out.
Architecture: Extend the existing IIFE kiosk state machine (fusion_clock_nfc_kiosk.js) — no Interaction migration. Add one sudo controller endpoint for kiosk employee-create. SCSS-only changes for animation timing. Spec: docs/superpowers/specs/2026-05-30-nfc-kiosk-enroll-speed-design.md.
Tech Stack: Odoo 19 HTTP controller (jsonrpc), vanilla JS IIFE, SCSS. Verify: pyflakes, xmllint, manifest ast.literal_eval, on-device deploy on entech (LXC 111 / pve-worker5).
XSS note: the kiosk uses innerHTML; every dynamic value (employee names, the typed new-employee name, errors) MUST go through the existing escapeHtml(). The new-employee name is user input — escape it everywhere it renders.
Task 1: Backend — nfc_create_employee endpoint
Files:
-
Modify:
controllers/clock_nfc_kiosk.py(add route afternfc_enroll) -
Test:
tests/test_clock_nfc_kiosk.py(add a method) -
Step 1: Add the endpoint. Manager/Kiosk-Operator gated (
_is_kiosk_operator) + password gated (_check_enroll_password). Createhr.employeevia sudo with name +x_fclk_enable_clock=True+company_id. Return{employee_id, employee_name}or{error}.
@http.route('/fusion_clock/kiosk/nfc/create_employee', type='jsonrpc', auth='user', methods=['POST'])
def nfc_create_employee(self, name='', enroll_password='', **kw):
"""Create a minimal hr.employee from the kiosk (manager+password gated)."""
user = request.env.user
if not _is_kiosk_operator(user):
return {'error': 'access_denied'}
if not self._check_enroll_password(request.env, enroll_password):
return {'error': 'invalid_password'}
clean = (name or '').strip()
if len(clean) < 2:
return {'error': 'invalid_name'}
employee = request.env['hr.employee'].sudo().create({
'name': clean,
'x_fclk_enable_clock': True,
'company_id': request.env.company.id,
})
return {'employee_id': employee.id, 'employee_name': employee.name}
- Step 2: Add a unit test (runs when a test env is available; mirrors existing tests in the file).
def test_nfc_create_employee_creates_clock_enabled(self):
Ctrl = self._controller() # follow existing pattern in this file for instantiating
# password gate: wrong password rejected
bad = Ctrl.nfc_create_employee(name='Test Person', enroll_password='wrong')
self.assertEqual(bad.get('error'), 'invalid_password')
# happy path (set the configured password in the test env first)
self.env['ir.config_parameter'].sudo().set_param('fusion_clock.nfc_enroll_password', '1120')
res = Ctrl.nfc_create_employee(name='Test Person', enroll_password='1120')
emp = self.env['hr.employee'].browse(res['employee_id'])
self.assertTrue(emp.exists())
self.assertTrue(emp.x_fclk_enable_clock)
If the existing test file doesn't instantiate controllers directly, adapt to its harness (or assert via model behaviour). Keep parity with existing tests.
- Step 3: Verify.
docker exec ... pyflakes controllers/clock_nfc_kiosk.py(locally:python3 -m pyflakes). Expected: clean. Unit test runs in the next test invocation / on a Community dev box.
Task 2: JS — reusable fixed PIN-pad component (fixes per-digit re-render)
Files: Modify static/src/js/fusion_clock_nfc_kiosk.js
- Step 1: Add a
mountPinPad({title, onOk, onCancel})helper that setsstateContainer.innerHTMLonce (title,.pin-display, numpad, cancel), keeps a locallet pin = "", and on digit/back/ok updates onlydisplayEl.textContent = "•".repeat(pin.length)— never re-renders the panel.okcallsonOk(pin); cancel callsonCancel(). Resets the enroll idle timer on each press. - Step 2: Rewrite
renderEnroll(phase:"password")to callmountPinPad({title:"Enter Manager PIN", onOk:(pin)=>{enrollPassword=pin; renderEnroll({phase:"search"});}, onCancel:exitEnrollMode}). Remove the old per-digitrenderEnroll(...)rebuild. - Step 3: Verify. Manual on device: digits append with no flicker/screen refresh; backspace works; OK advances.
Task 3: JS+SCSS — program-a-tag from an unknown tap (with create-new-employee)
Files: Modify fusion_clock_nfc_kiosk.js, static/src/scss/nfc_kiosk.scss
- Step 1: Add module var
let pendingEnrollUid = null;. InhandleTap, whenresult.error === "card_unknown", callrenderUnknownCard(uid)instead of the generic error result. - Step 2:
renderUnknownCard(uid)renders an amber panel: "This card isn't programmed yet" + buttons "Program this card" / "Cancel". Auto-cancel to IDLE after 8s. "Program this card" →pendingEnrollUid = uid; enrollPassword=""; setState(STATE.ENROLL,{phase:"program_pin"}). - Step 3: Add enroll phases:
program_pin→mountPinPad({title:"Manager PIN", onOk:(pin)=>{enrollPassword=pin; renderEnroll({phase:"employee"});}, onCancel:exitEnrollMode}).employee→ search box (reuse existingemployee_searchdebounced fetch) + a "+ New employee" button. Picking an existing row →assignPendingCard(emp). "+ New employee" →renderEnroll({phase:"new_employee"}).new_employee→ a name input + "Create & assign" / back. On submit → POSTcreate_employee{name, enroll_password}; on success →assignPendingCard({id, name}); on error → inline message (escape).
- Step 4:
assignPendingCard(emp): POSTnfc/enroll{employee_id: emp.id, card_uid: pendingEnrollUid, enroll_password}. Render enrollresultphase (reuse existing). On done/another → resetpendingEnrollUid, back to IDLE. - Step 5: SCSS — add
.nfc-kiosk__result--warn(amber:#e0a83e-ish border/glow) and a.employee-createstyling block (reuse.nfc-kiosk__enroll-panelpatterns). Escape all dynamic strings. - Step 6: Verify.
xmllint/sass compile via deploy; device: unknown tap → program existing + new employee, card binds with no re-tap.
Task 4: Speed — "Fast" timers + animation durations
Files: Modify fusion_clock_nfc_kiosk.js, static/src/scss/nfc_kiosk.scss
- Step 1 (JS): In
renderResult: successsetTimeout(... , 3000)→1800; error4000→3000. - Step 2 (SCSS):
nfc-state-in400ms→200ms (the#nfc_state_container > *rule + keyframe usages);.nfc-kiosk__result--successnfc-success-burst700ms→350ms;.nfc-kiosk__avatarnfc-avatar-in600ms→300ms. Leave idle wave/chip + mesh drift unchanged. Keepprefers-reduced-motionblock. - Step 3: Verify. Device: noticeably snappier; result clears ~1.8s.
Task 5: Clock-out shift hours — prominent + correct label
Files: Modify fusion_clock_nfc_kiosk.js, static/src/scss/nfc_kiosk.scss
- Step 1 (JS): In
renderResultsuccess branch, foraction === "clock_out": computeconst mins = Math.round((payload.net_hours_today || 0) * 60); const h = Math.floor(mins/60); const m = mins%60;and always render<div class="hours">Worked ${h}h ${m}m this shift</div>(show even at 0). Clock-in: no hours line. - Step 2 (SCSS): Bump
.nfc-kiosk__result-text .hoursprominence (e.g.font-size: 1.35rem; opacity: 0.9; margin-top: 0.6rem;). - Step 3: Verify. Device: clock-out shows "Worked Xh Ym this shift".
Task 6: Version bump + deploy + verify
- Step 1: Bump
__manifest__.pyversion19.0.3.6.0→19.0.3.7.0(assets changed). - Step 2: Local pre-flight:
pyflakescontroller,xmllint? (JS has no linter here — read carefully), manifestast.literal_eval. - Step 3: Deploy to entech (backup → push 4 files →
-u fusion_clockstop/upgrade/start). Bump asset cache (version bump handles it;DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'+ restart if the bundle doesn't refresh). - Step 4: Verify: service active, version 19.0.3.7.0, manifest route 200. On tablet (hard refresh): PIN no flicker; unknown tap → program (existing + new); faster; clock-out hours.
Self-review
- Spec coverage: PIN fix (T2), unknown-tap+create-new (T1,T3), speed (T4), clock-out hours (T5), deploy (T6). All covered.
- Placeholders: none (test harness instantiation noted as adapt-to-existing — acceptable, file-specific).
- Consistency:
pendingEnrollUid,enrollPassword,mountPinPad,assignPendingCard,_is_kiosk_operator,_check_enroll_password,net_hours_todayused consistently with the existing code read.