feat(fusion_clock): NFC kiosk — enrollment, manager page, sounds, lock, profile photos

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>
This commit is contained in:
gsinghpal
2026-05-30 17:21:33 -04:00
parent 2a16f80d8d
commit 55898dd1d4
10 changed files with 1002 additions and 84 deletions

View File

@@ -0,0 +1,122 @@
# 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 `<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 .hours` prominence (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__.py` `version` `19.0.3.6.0``19.0.3.7.0` (assets changed).
- [ ] **Step 2:** Local pre-flight: `pyflakes` controller, `xmllint`? (JS has no linter here — read carefully), manifest `ast.literal_eval`.
- [ ] **Step 3:** Deploy to entech (backup → push 4 files → `-u fusion_clock` stop/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_today` used consistently with the existing code read.

View File

@@ -0,0 +1,86 @@
# NFC Kiosk — Enrollment UX, PIN fix, Speed, Clock-out Hours
**Date:** 2026-05-30
**Module:** `fusion_clock` (NFC tap kiosk at `/fusion_clock/kiosk/nfc`)
**Status:** Approved design, ready for implementation plan.
## Context
The NFC kiosk (`static/src/js/fusion_clock_nfc_kiosk.js`, an IIFE state machine) handles
tap-to-clock on a wall tablet at the entech client. Four issues to address, all driven by
real shop-floor use (lines of 1020 people).
**Implementation approach:** extend the existing IIFE in place. A migration to an Odoo 19
`Interaction` (per repo CLAUDE.md guidance) is deliberately out of scope — the file is a
large, working state machine on a live client device and the four changes here are
surgical; a rewrite would be high-risk for no functional gain. Noted deviation.
## Requirements & Design
### 1. PIN entry: stop the per-digit full re-render
**Problem:** in `renderEnroll(phase:"password")`, every numpad press calls
`renderEnroll(...)` which rebuilds the whole panel via `stateContainer.innerHTML = ...` and
replays the 400ms `nfc-state-in` entrance animation → the screen visibly "refreshes" on each
digit (entry is preserved, but it flickers).
**Design:** a reusable PIN-pad component that renders the panel **once**, then on
digit/backspace mutates only the masked `.pin-display` text node + an in-memory buffer.
No `innerHTML` rebuild, no re-animation. Used by both the ⚙ enroll PIN and the new
Manager-PIN step (§2). OK/Cancel callbacks are parameters.
### 2. Program a tag from an unknown tap
**Problem:** an unknown card tap returns `{error:"card_unknown"}` and shows a red error that
auto-dismisses. Programming requires the separate ⚙ flow (enter password → search → **re-tap**).
**Design:** the tapped UID is already captured, so program *that* card with no re-tap:
1. Unknown tap → **amber** "This card isn't programmed yet" panel with **"Program this card"**
and **"Cancel"** buttons. Auto-cancel to idle after ~8s of inactivity.
2. **"Program this card"** → **Manager PIN** step (reuses §1 component; credential =
`fusion_clock.nfc_enroll_password`, currently `1120`; labelled "Manager PIN" in UI).
3. **Employee step**: search-and-pick an existing employee **or** "+ New employee" →
enter a name → create a minimal `hr.employee`.
4. **Assign**: bind the captured UID to that employee → success confirmation.
- The ⚙ enroll mode stays as a proactive path, reusing the same fixed PIN component.
**Backend:**
- Reuse `POST /fusion_clock/kiosk/nfc/enroll` (`employee_id`, `card_uid`, `enroll_password`)
for the bind. Already manager/Kiosk-Operator + password gated, sudo data ops.
- **New endpoint** `POST /fusion_clock/kiosk/nfc/create_employee` (`name`, `enroll_password`):
Kiosk-Operator-gated + password-gated; creates `hr.employee` via **sudo** with
`name`, `x_fclk_enable_clock=True`, `company_id = request.env.company.id`; returns
`{employee_id, employee_name}` (or `{error}`). JS then calls `enroll` with the captured UID.
Minimal fields only — department/contract/etc. are completed later in HR.
### 3. Faster clock-in/out ("Fast")
**Problem:** result card lingers 3s (errors 4s) and entrance animations are 0.40.7s →
slow throughput for long lines.
**Design (JS timers):** success result display **3000 → 1800 ms**; error **4000 → 3000 ms**.
**Design (SCSS durations):** `nfc-state-in` 400→200ms; `nfc-success-burst` 700→350ms;
`nfc-avatar-in` 600→300ms. Ambient idle wave/chip loop unchanged (does not gate throughput).
`prefers-reduced-motion` fallback preserved.
### 4. Clock-out shows shift hours, clearly
**Problem:** clock-out shows `${net_hours_today.toFixed(1)}h today` — mislabelled "today",
small, and hidden when it rounds to 0.
**Design:** on clock-out always show a prominent **"Worked Xh Ym this shift"** computed from
`net_hours_today` (the just-closed attendance's net hours = worked break). Render h+m;
show even when 0 (e.g. "Worked 0h 4m this shift"). Backend already returns the value; this is
a JS label/format + SCSS prominence change. Clock-in unchanged.
## Files
- `static/src/js/fusion_clock_nfc_kiosk.js` — PIN component; unknown-tap → program flow;
create-employee call; result timers; clock-out hours formatting.
- `static/src/scss/nfc_kiosk.scss` — animation durations; amber "unknown card" panel +
create-employee styles; prominent clock-out hours.
- `controllers/clock_nfc_kiosk.py` — new `nfc_create_employee` endpoint.
- `__manifest__.py` — version bump (assets changed).
## Out of scope / non-goals
- No migration of the kiosk JS to an `Interaction`.
- No new employee fields beyond name/clock-enabled/company at kiosk-create time.
- Classic PIN kiosk (`/fusion_clock/kiosk`) untouched (disabled at entech).
## Test / verify
- Local: `pyflakes` the controller; `xmllint`/manifest parse; review the JS by hand
(no local Odoo container available this session).
- entech: deploy, upgrade, then on the tablet — PIN entry no longer flickers; unknown tap →
program (existing + new employee) binds without re-tap; clock-in/out visibly faster;
clock-out shows "Worked Xh Ym this shift".