# 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 after `nfc_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`). Create `hr.employee` via sudo with name + `x_fclk_enable_clock=True` + `company_id`. Return `{employee_id, employee_name}` or `{error}`. ```python @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). ```python 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 sets `stateContainer.innerHTML` **once** (title, `.pin-display`, numpad, cancel), keeps a local `let pin = ""`, and on digit/back/ok updates **only** `displayEl.textContent = "•".repeat(pin.length)` — never re-renders the panel. `ok` calls `onOk(pin)`; cancel calls `onCancel()`. Resets the enroll idle timer on each press. - [ ] **Step 2:** Rewrite `renderEnroll(phase:"password")` to call `mountPinPad({title:"Enter Manager PIN", onOk:(pin)=>{enrollPassword=pin; renderEnroll({phase:"search"});}, onCancel:exitEnrollMode})`. Remove the old per-digit `renderEnroll(...)` 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;`. In `handleTap`, when `result.error === "card_unknown"`, call `renderUnknownCard(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 existing `employee_search` debounced 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 → POST `create_employee` {name, enroll_password}; on success → `assignPendingCard({id, name})`; on error → inline message (escape). - [ ] **Step 4:** `assignPendingCard(emp)`: POST `nfc/enroll` {employee_id: emp.id, card_uid: pendingEnrollUid, enroll_password}. Render enroll `result` phase (reuse existing). On done/another → reset `pendingEnrollUid`, back to IDLE. - [ ] **Step 5:** SCSS — add `.nfc-kiosk__result--warn` (amber: `#e0a83e`-ish border/glow) and a `.employee-create` styling block (reuse `.nfc-kiosk__enroll-panel` patterns). 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`: success `setTimeout(... , 3000)` → `1800`; error `4000` → `3000`. - [ ] **Step 2 (SCSS):** `nfc-state-in` 400ms→200ms (the `#nfc_state_container > *` rule + keyframe usages); `.nfc-kiosk__result--success` `nfc-success-burst` 700ms→350ms; `.nfc-kiosk__avatar` `nfc-avatar-in` 600ms→300ms. Leave idle wave/chip + mesh drift unchanged. Keep `prefers-reduced-motion` block. - [ ] **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 `renderResult` success branch, for `action === "clock_out"`: compute `const mins = Math.round((payload.net_hours_today || 0) * 60); const h = Math.floor(mins/60); const m = mins%60;` and always render `