diff --git a/CLAUDE.md b/CLAUDE.md index bedaf02a..4e7c912e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,8 +92,8 @@ Odoo content-hashes the compiled bundle URL (`/web/assets//...`). When CSS - Canadian English for all user-facing text - Currency: `$` sign with Monetary fields + currency_id -## Cursor-Managed Modules -- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state +## Module-Specific Notes +- **fusion_clock** — developed in **Claude Code** (no longer Cursor; no concurrent-editing conflicts). Changed a lot recently (NFC kiosk: tap-to-clock, enrollment + program-from-unknown-tap, manager page, sounds, screen lock, guided profile-photo capture, faster animations). Still read files fresh before editing rather than assuming the layout. Live on entech (`odoo-entech` / LXC 111 on `pve-worker5`). - **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 1–11 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_authorizer_portal`. ## Workflow diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index 417a762a..0fb2291b 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Clock', - 'version': '19.0.3.6.0', + 'version': '19.0.3.10.0', 'category': 'Human Resources/Attendances', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'description': """ diff --git a/fusion_clock/controllers/clock_kiosk.py b/fusion_clock/controllers/clock_kiosk.py index efe4264e..55448d0d 100644 --- a/fusion_clock/controllers/clock_kiosk.py +++ b/fusion_clock/controllers/clock_kiosk.py @@ -54,6 +54,7 @@ class FusionClockKiosk(http.Controller): 'name': emp.name, 'department': emp.department_id.name or '', 'is_checked_in': emp.attendance_state == 'checked_in', + 'card_uid': emp.x_fclk_nfc_card_uid or '', } for emp in employees], } diff --git a/fusion_clock/controllers/clock_nfc_kiosk.py b/fusion_clock/controllers/clock_nfc_kiosk.py index 8699e1de..2716e8fd 100644 --- a/fusion_clock/controllers/clock_nfc_kiosk.py +++ b/fusion_clock/controllers/clock_nfc_kiosk.py @@ -93,6 +93,7 @@ class FusionClockNfcKiosk(http.Controller): 'location_configured': bool(location), 'photo_required': ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True', 'debug_enabled': ICP.get_param('fusion_clock.nfc_kiosk_debug', 'False') == 'True', + 'sounds_enabled': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True', } return request.render('fusion_clock.nfc_kiosk_page', values) @@ -141,8 +142,9 @@ class FusionClockNfcKiosk(http.Controller): return (supplied or '') == configured @http.route('/fusion_clock/kiosk/nfc/enroll', type='jsonrpc', auth='user', methods=['POST']) - def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', **kw): - """Bind an NFC card UID to an employee. Manager-gated, password-gated.""" + def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', force=False, **kw): + """Bind an NFC card UID to an employee. Manager-gated, password-gated. + With force=True, a card already held by another employee is moved (reassigned).""" user = request.env.user if not _is_kiosk_operator(user): return {'error': 'access_denied'} @@ -164,10 +166,12 @@ class FusionClockNfcKiosk(http.Controller): ('id', '!=', target.id), ], limit=1) if existing: - return { - 'error': 'card_already_assigned', - 'existing_employee': existing.name, - } + if not force: + return { + 'error': 'card_already_assigned', + 'existing_employee': existing.name, + } + existing.x_fclk_nfc_card_uid = False # reassign: clear the previous holder target.x_fclk_nfc_card_uid = normalized @@ -181,10 +185,87 @@ class FusionClockNfcKiosk(http.Controller): return { 'success': True, + 'employee_id': target.id, 'employee_name': target.name, 'card_uid': normalized, + 'needs_photo': not target.image_1920, } + @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; the caller then enrolls the card. + + Manager/Kiosk-Operator gated + enroll-password gated. Creates the employee via + sudo with just a name, clock enabled, and the current company — HR fills in the + rest (department, contract, etc.) later. + """ + 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} + + @http.route('/fusion_clock/kiosk/nfc/clear_tag', type='jsonrpc', auth='user', methods=['POST']) + def nfc_clear_tag(self, employee_id=0, enroll_password='', **kw): + """Unbind the NFC card from an employee. Manager/operator + password gated.""" + if not _is_kiosk_operator(request.env.user): + return {'error': 'access_denied'} + if not self._check_enroll_password(request.env, enroll_password): + return {'error': 'invalid_password'} + emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0)) + if not emp.exists(): + return {'error': 'employee_not_found'} + emp.x_fclk_nfc_card_uid = False + return {'success': True, 'employee_name': emp.name} + + @http.route('/fusion_clock/kiosk/nfc/delete_employee', type='jsonrpc', auth='user', methods=['POST']) + def nfc_delete_employee(self, employee_id=0, enroll_password='', **kw): + """Archive an employee (active=False) and clear their tag — a safe 'delete' that + preserves attendance history. Manager/operator + password gated.""" + if not _is_kiosk_operator(request.env.user): + return {'error': 'access_denied'} + if not self._check_enroll_password(request.env, enroll_password): + return {'error': 'invalid_password'} + emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0)) + if not emp.exists(): + return {'error': 'employee_not_found'} + name = emp.name + emp.x_fclk_nfc_card_uid = False + emp.active = False + return {'success': True, 'employee_name': name} + + @http.route('/fusion_clock/kiosk/nfc/save_profile_photo', type='jsonrpc', auth='user', methods=['POST']) + def nfc_save_profile_photo(self, employee_id=0, photo_b64='', **kw): + """Save a captured photo to the employee's profile image. Operator-gated (the + trusted kiosk device); no separate PIN, so it also works on self clock-in.""" + if not _is_kiosk_operator(request.env.user): + return {'error': 'access_denied'} + photo = _strip_data_url_prefix(photo_b64) + if not photo: + return {'error': 'no_photo'} + emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0)) + if not emp.exists(): + return {'error': 'employee_not_found'} + emp.image_1920 = photo + return {'success': True} + + @http.route('/fusion_clock/kiosk/nfc/verify_pin', type='jsonrpc', auth='user', methods=['POST']) + def nfc_verify_pin(self, pin='', **kw): + """Verify the Manager PIN (enroll password) — used to unlock the kiosk screen. + Returns only a boolean so the PIN itself never reaches the client.""" + if not _is_kiosk_operator(request.env.user): + return {'ok': False} + return {'ok': self._check_enroll_password(request.env, pin)} + @http.route('/fusion_clock/kiosk/nfc/tap', type='jsonrpc', auth='user', methods=['POST']) def nfc_tap(self, card_uid='', photo_b64='', **kw): """Toggle attendance state for the employee owning this card UID.""" @@ -268,10 +349,12 @@ class FusionClockNfcKiosk(http.Controller): return { 'success': True, 'action': 'clock_in', + 'employee_id': employee.id, 'employee_name': employee.name, 'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128', 'message': f'{employee.name} clocked in at {location.name}', 'net_hours_today': 0.0, + 'needs_photo': not employee.image_1920, } else: attendance.sudo().write({ @@ -292,10 +375,12 @@ class FusionClockNfcKiosk(http.Controller): return { 'success': True, 'action': 'clock_out', + 'employee_id': employee.id, 'employee_name': employee.name, 'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128', 'message': f'{employee.name} clocked out', 'net_hours_today': round(attendance.x_fclk_net_hours or 0, 2), + 'needs_photo': not employee.image_1920, } @http.route('/fusion_clock/kiosk/nfc/employee_search', type='jsonrpc', auth='user', methods=['POST']) diff --git a/fusion_clock/docs/superpowers/plans/2026-05-30-nfc-kiosk-enroll-speed.md b/fusion_clock/docs/superpowers/plans/2026-05-30-nfc-kiosk-enroll-speed.md new file mode 100644 index 00000000..ece66e99 --- /dev/null +++ b/fusion_clock/docs/superpowers/plans/2026-05-30-nfc-kiosk-enroll-speed.md @@ -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 `
Worked ${h}h ${m}m this shift
` (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. diff --git a/fusion_clock/docs/superpowers/specs/2026-05-30-nfc-kiosk-enroll-speed-design.md b/fusion_clock/docs/superpowers/specs/2026-05-30-nfc-kiosk-enroll-speed-design.md new file mode 100644 index 00000000..5c91761d --- /dev/null +++ b/fusion_clock/docs/superpowers/specs/2026-05-30-nfc-kiosk-enroll-speed-design.md @@ -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 10–20 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.4–0.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". diff --git a/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js b/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js index 7db74715..86ec5b67 100644 --- a/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js +++ b/fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js @@ -15,6 +15,20 @@ const photoRequired = root.dataset.photoRequired === "1"; const debugEnabled = root.dataset.debugEnabled === "1"; const locationConfigured = root.dataset.locationConfigured === "1"; + const soundsEnabled = root.dataset.soundsEnabled === "1"; + + // On a known device (set up before) the browser already remembers camera/NFC + // permission, so slim the prompt to a simple resume tap. + try { + if (localStorage.getItem("nfc_setup_done") === "1") { + const _h2 = document.querySelector(".nfc-kiosk__setup h2"); + const _p = document.querySelector(".nfc-kiosk__setup p"); + const _btn = document.getElementById("nfc_setup_start"); + if (_h2) _h2.textContent = "Fusion Clock Kiosk"; + if (_p) _p.textContent = "Tap to resume."; + if (_btn) _btn.textContent = "Tap to resume"; + } + } catch (e) {} // ────────────────────────────────────────────────────────────── // Debug overlay (visible only when fusion_clock.nfc_kiosk_debug = True) @@ -140,8 +154,10 @@ stroke="currentColor" stroke-width="4" fill="none"/> - + + + +
Tap your card to clock in or out
@@ -157,11 +173,67 @@ `; } + // ────────────────────────────────────────────────────────────── + // Clock sounds (Web Audio — synthesized, loud + distinct in/out). + // AudioContext is created/resumed on the setup tap (a user gesture), + // after which it can play on each clock event. + // ────────────────────────────────────────────────────────────── + let _audioCtx = null; + function unlockAudio() { + try { + if (!_audioCtx) { + const AC = window.AudioContext || window.webkitAudioContext; + if (AC) _audioCtx = new AC(); + } + if (_audioCtx && _audioCtx.state === "suspended") _audioCtx.resume(); + } catch (e) { debugLog("audio: unlock failed " + e.message); } + } + function _note(freq, startAt, dur, peak, type) { + const osc = _audioCtx.createOscillator(); + const g = _audioCtx.createGain(); + osc.type = type || "sine"; + osc.frequency.setValueAtTime(freq, startAt); + g.gain.setValueAtTime(0.0001, startAt); + g.gain.exponentialRampToValueAtTime(peak, startAt + 0.015); // soft attack (no click) + g.gain.exponentialRampToValueAtTime(0.0001, startAt + dur); // smooth decay + osc.connect(g); g.connect(_audioCtx.destination); + osc.start(startAt); osc.stop(startAt + dur + 0.04); + } + function playClockSound(action) { + if (!soundsEnabled || !_audioCtx) return; + try { + if (_audioCtx.state === "suspended") _audioCtx.resume(); + const t = _audioCtx.currentTime; + if (action === "clock_out") { + // warm descending major triad (G–E–C) — a pleasant "goodbye" + _note(783.99, t, 0.20, 0.6, "sine"); // G5 + _note(659.25, t + 0.13, 0.20, 0.6, "sine"); // E5 + _note(523.25, t + 0.26, 0.42, 0.7, "sine"); // C5 + } else { + // bright ascending major triad (C–E–G) — a cheerful "welcome" + _note(523.25, t, 0.18, 0.6, "sine"); // C5 + _note(659.25, t + 0.13, 0.18, 0.6, "sine"); // E5 + _note(783.99, t + 0.26, 0.42, 0.72, "sine"); // G5 + } + } catch (e) { debugLog("audio: play failed " + e.message); } + } + // Distinct low "denied" tone for wrong / unknown taps — clearly not a success chime. + function playErrorSound() { + if (!soundsEnabled || !_audioCtx) return; + try { + if (_audioCtx.state === "suspended") _audioCtx.resume(); + const t = _audioCtx.currentTime; + _note(311.13, t, 0.20, 0.55, "triangle"); // Eb4 + _note(207.65, t + 0.18, 0.36, 0.6, "triangle"); // Ab3 (low → "wrong") + } catch (e) { debugLog("audio: play failed " + e.message); } + } + function renderResult(payload) { const isError = payload && payload.error; const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success"; if (isError) { + playErrorSound(); stateContainer.innerHTML = `
@@ -169,25 +241,36 @@
`; - setTimeout(() => setState(STATE.IDLE), 4000); + setTimeout(() => setState(STATE.IDLE), 3000); } else { + playClockSound(payload.action); 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" }); + let hoursLine = ""; + if (payload.action === "clock_out") { + const mins = Math.round((payload.net_hours_today || 0) * 60); + const h = Math.floor(mins / 60); + const m = mins % 60; + hoursLine = `
Worked ${h}h ${m}m this shift
`; + } + const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: true }); stateContainer.innerHTML = `
${escapeHtml(payload.employee_name)}
${action} at ${time}
- ${hours ? `
${hours}
` : ""} + ${hoursLine}
`; - setTimeout(() => setState(STATE.IDLE), 3000); + setTimeout(() => { + if (payload.action === "clock_in" && payload.needs_photo && payload.employee_id) { + openPhotoCapture(payload.employee_id, payload.employee_name, () => setState(STATE.IDLE)); + } else { + setState(STATE.IDLE); + } + }, 1800); } } @@ -196,6 +279,7 @@ // ────────────────────────────────────────────────────────────── let enrollPassword = ""; let enrollSelectedEmployee = null; + let pendingEnrollUid = null; // set when programming a just-tapped unknown card let enrollIdleTimer = null; function resetEnrollIdleTimer() { @@ -209,60 +293,175 @@ function exitEnrollMode() { if (enrollIdleTimer) clearTimeout(enrollIdleTimer); enrollIdleTimer = null; - enrollPassword = ""; + if (kioskLocked) enrollPassword = ""; // keep the PIN while unlocked (no re-prompt) enrollSelectedEmployee = null; + pendingEnrollUid = null; setState(STATE.IDLE); } + // Fixed PIN pad: renders the panel ONCE, then mutates only the masked + // display on each press — no innerHTML rebuild, no replayed entrance + // animation. (Fixes the per-digit "screen refresh" bug.) + function mountPinPad(opts) { + let pin = ""; + stateContainer.innerHTML = ` +
+
+

${escapeHtml(opts.title || "Enter PIN")}

+
+
+ ${[1,2,3,4,5,6,7,8,9].map(n => ``).join("")} + + + +
+
+ +
+
+
+ `; + const displayEl = stateContainer.querySelector("#nfc_pin_display"); + const paint = () => { displayEl.textContent = "•".repeat(pin.length); }; + paint(); + stateContainer.querySelectorAll(".numpad button").forEach(btn => { + btn.addEventListener("click", () => { + resetEnrollIdleTimer(); + const n = btn.dataset.n; + if (n === "back") { pin = pin.slice(0, -1); paint(); } + else if (n === "ok") { if (pin.length) opts.onOk(pin); } + else { pin += n; paint(); } + }); + }); + stateContainer.querySelector("#nfc_pin_cancel").addEventListener("click", opts.onCancel); + } + + // Reactive flow: an unknown card was tapped — offer to program it now. + function renderUnknownCard(uid) { + playErrorSound(); + currentState = STATE.RESULT; // block taps while this prompt is up + if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; } + const autoCancel = setTimeout(() => { + if (currentState === STATE.RESULT) setState(STATE.IDLE); + }, 8000); + stateContainer.innerHTML = ` +
+
+
+

This card isn't programmed yet

+

Program it now, or ask a manager.

+
+ + +
+
+
+ `; + stateContainer.querySelector("#uc_program").addEventListener("click", () => { + clearTimeout(autoCancel); + pendingEnrollUid = uid; + enrollSelectedEmployee = null; + // If a manager already unlocked, skip the PIN; otherwise ask for it. + setState(STATE.ENROLL, { phase: enrollPassword ? "employee" : "password" }); + }); + stateContainer.querySelector("#uc_cancel").addEventListener("click", () => { + clearTimeout(autoCancel); + setState(STATE.IDLE); + }); + } + function renderEnroll(payload) { const phase = (payload && payload.phase) || "password"; resetEnrollIdleTimer(); if (phase === "password") { - const masked = "•".repeat(enrollPassword.length); + mountPinPad({ + title: "Manager PIN", + onOk: (pin) => { enrollPassword = pin; renderEnroll({ phase: "employee" }); }, + onCancel: exitEnrollMode, + }); + return; + } + + if (phase === "manager") { stateContainer.innerHTML = `
-

Enter Enroll Mode Password

-
${masked}
-
- ${[1,2,3,4,5,6,7,8,9].map(n => ``).join("")} - - - -
-
- +

Manage employees

+ +
+
+ +
`; - stateContainer.querySelectorAll(".numpad button").forEach(btn => { - btn.addEventListener("click", async () => { + const searchEl = document.getElementById("mgr_search"); + const listEl = document.getElementById("mgr_list"); + let confirmDeleteId = null; + let debounceTimer = null; + async function refresh() { + resetEnrollIdleTimer(); + let emps = []; + try { emps = (await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value })).employees || []; } + catch (e) { listEl.innerHTML = `
Connection error.
`; return; } + if (!emps.length) { listEl.innerHTML = `
No employees found.
`; return; } + listEl.innerHTML = emps.map(e => { + const tag = e.card_uid + ? `● ${escapeHtml(e.card_uid)}` + : `○ no tag`; + const actions = (confirmDeleteId === e.id) + ? ` + ` + : ` + ${e.card_uid ? `` : ""} + `; + return `
+
${escapeHtml(e.name)}${escapeHtml(e.department || "")} ${tag}
+
${actions}
+
`; + }).join(""); + listEl.querySelectorAll(".m-btn").forEach(btn => btn.addEventListener("click", async () => { resetEnrollIdleTimer(); - const n = btn.dataset.n; - if (n === "back") enrollPassword = enrollPassword.slice(0, -1); - else if (n === "ok") { - if (enrollPassword.length === 0) return; - renderEnroll({ phase: "search" }); - return; + const id = parseInt(btn.dataset.id, 10); + const act = btn.dataset.act; + if (act === "assign") { + enrollSelectedEmployee = { id, name: btn.dataset.name }; + pendingEnrollUid = null; + renderEnroll({ phase: "tap" }); + } else if (act === "clear") { + try { await postJson("/fusion_clock/kiosk/nfc/clear_tag", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {} + refresh(); + } else if (act === "del") { + confirmDeleteId = id; refresh(); + } else if (act === "delno") { + confirmDeleteId = null; refresh(); + } else if (act === "delok") { + try { await postJson("/fusion_clock/kiosk/nfc/delete_employee", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {} + confirmDeleteId = null; refresh(); } - else enrollPassword += n; - renderEnroll({ phase: "password" }); - }); + })); + } + searchEl.addEventListener("input", () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(refresh, 200); }); - document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode); + document.getElementById("mgr_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" })); + document.getElementById("mgr_close").addEventListener("click", exitEnrollMode); + refresh(); return; } - if (phase === "search") { + if (phase === "employee") { stateContainer.innerHTML = `
-

Pick the employee to enroll

+

Who is this card for?

-
+
+
@@ -281,17 +480,61 @@ ).join(""); listEl.querySelectorAll(".employee-row").forEach(row => { row.addEventListener("click", () => { - enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name }; - renderEnroll({ phase: "tap" }); + chooseEmployee({ id: parseInt(row.dataset.id, 10), name: row.dataset.name }); }); }); }, 200); }); searchEl.focus(); + document.getElementById("enroll_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" })); document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode); return; } + if (phase === "new_employee") { + stateContainer.innerHTML = ` +
+
+

New employee

+ +
+
+ + +
+
+
+ `; + const nameEl = document.getElementById("new_emp_name"); + const msgEl = document.getElementById("new_emp_msg"); + nameEl.addEventListener("input", resetEnrollIdleTimer); + nameEl.focus(); + const doCreate = async () => { + resetEnrollIdleTimer(); + const nm = nameEl.value.trim(); + if (nm.length < 2) { msgEl.textContent = "Enter the employee's full name."; return; } + msgEl.textContent = "Creating…"; + let res; + try { + res = await postJson("/fusion_clock/kiosk/nfc/create_employee", { name: nm, enroll_password: enrollPassword }); + } catch (e) { + msgEl.textContent = "No connection. Try again."; + return; + } + if (res.error) { + msgEl.textContent = res.error === "invalid_password" ? "Wrong Manager PIN." + : res.error === "invalid_name" ? "Enter a valid name." + : "Could not create employee."; + return; + } + chooseEmployee({ id: res.employee_id, name: res.employee_name }); + }; + document.getElementById("new_emp_create").addEventListener("click", doCreate); + nameEl.addEventListener("keydown", (e) => { if (e.key === "Enter") doCreate(); }); + document.getElementById("new_emp_back").addEventListener("click", () => renderEnroll({ phase: "employee" })); + return; + } + if (phase === "tap") { stateContainer.innerHTML = `
@@ -312,52 +555,163 @@ if (phase === "result") { const ok = !payload.error; const msg = ok - ? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}` + ? `✓ Card enrolled to ${escapeHtml(payload.employee_name)}` : (payload.error === "invalid_password" - ? "Wrong password. Try again." + ? "Wrong Manager PIN. Try again." : payload.error === "card_already_assigned" ? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.` : `Enroll failed: ${escapeHtml(payload.error)}`); stateContainer.innerHTML = `
-

${msg}

+

${msg}

+ ${ok && payload.needs_photo && payload.employee_id ? `` : ""}
`; + if (ok && payload.needs_photo && payload.employee_id) { + document.getElementById("enroll_photo").addEventListener("click", () => { + openPhotoCapture(payload.employee_id, payload.employee_name, () => { + if (enrollPassword) renderEnroll({ phase: "manager" }); else exitEnrollMode(); + }); + }); + } document.getElementById("enroll_another").addEventListener("click", () => { enrollSelectedEmployee = null; - renderEnroll({ phase: ok ? "search" : "password" }); + pendingEnrollUid = null; + renderEnroll({ phase: ok ? "employee" : "password" }); }); document.getElementById("enroll_done").addEventListener("click", exitEnrollMode); } } - async function _onEnrollTap(uid) { - if (!enrollSelectedEmployee) return; - const result = await postJson("/fusion_clock/kiosk/nfc/enroll", { - employee_id: enrollSelectedEmployee.id, - card_uid: uid, - enroll_password: enrollPassword, - }); - renderEnroll({ phase: "result", ...result }); + // Existing employee picked → if we already hold a tapped UID, bind it now + // (no re-tap); otherwise fall back to the proactive ⚙ "tap the card" step. + function chooseEmployee(emp) { + if (pendingEnrollUid) { + doEnroll(emp.id, emp.name, pendingEnrollUid, false); + } else { + enrollSelectedEmployee = emp; + renderEnroll({ phase: "tap" }); + } } - // ⚙ button → enter Enroll Mode + // Single enroll path (program flow, ⚙ tap flow, manager re-tag). A card already + // held by someone else triggers a reassign confirm rather than a hard error. + async function doEnroll(empId, empName, uid, force) { + resetEnrollIdleTimer(); + let result; + try { + result = await postJson("/fusion_clock/kiosk/nfc/enroll", { + employee_id: empId, card_uid: uid, enroll_password: enrollPassword, force: !!force, + }); + } catch (e) { + renderEnroll({ phase: "result", employee_name: empName, error: "network" }); + return; + } + if (result.error === "card_already_assigned" && !force) { + renderReassignConfirm(empId, empName, uid, result.existing_employee); + return; + } + renderEnroll({ phase: "result", employee_name: empName, ...result }); + } + + function renderReassignConfirm(empId, empName, uid, existingName) { + resetEnrollIdleTimer(); + stateContainer.innerHTML = ` +
+
+

Reassign card?

+

This card belongs to ${escapeHtml(existingName || "another employee")}. Move it to ${escapeHtml(empName)}?

+
+ + +
+
+
+ `; + stateContainer.querySelector("#ra_move").addEventListener("click", () => doEnroll(empId, empName, uid, true)); + stateContainer.querySelector("#ra_cancel").addEventListener("click", () => renderEnroll({ phase: "manager" })); + } + + async function _onEnrollTap(uid) { + if (!enrollSelectedEmployee) return; + doEnroll(enrollSelectedEmployee.id, enrollSelectedEmployee.name, uid, false); + } + + // ⚙ button → enter Enroll Mode (only when unlocked) const settingsBtn = document.getElementById("nfc_settings_btn"); if (settingsBtn) { settingsBtn.addEventListener("click", () => { - if (currentState !== STATE.IDLE) return; - enrollPassword = ""; + if (kioskLocked || currentState !== STATE.IDLE) return; enrollSelectedEmployee = null; - setState(STATE.ENROLL, { phase: "password" }); + pendingEnrollUid = null; + // Already unlocked → reuse that PIN, open the manager page (no re-prompt). + setState(STATE.ENROLL, { phase: "manager" }); }); } + // ────────────────────────────────────────────────────────────── + // Screen lock — the kiosk starts LOCKED: only card taps work, ⚙ hidden. + // A manager long-presses the bottom-right corner and enters the Manager + // PIN to unlock (revealing ⚙ + 🔒). Re-locks via 🔒, on reload, or after + // inactivity. Unlock is in-memory only, so every reload starts locked. + // ────────────────────────────────────────────────────────────── + let kioskLocked = true; + let relockTimer = null; + const lockBtn = document.getElementById("nfc_lock_btn"); + const unlockBtn = document.getElementById("nfc_unlock_btn"); + + function applyLockState() { + if (settingsBtn) settingsBtn.style.display = kioskLocked ? "none" : "flex"; + if (lockBtn) lockBtn.style.display = kioskLocked ? "none" : "flex"; + if (unlockBtn) unlockBtn.style.display = kioskLocked ? "flex" : "none"; + } + function armRelock() { + if (relockTimer) clearTimeout(relockTimer); + relockTimer = setTimeout(() => { + relockTimer = null; + if (currentState === STATE.IDLE) lockKiosk(); + else armRelock(); // don't re-lock mid-enroll + }, 120000); + } + function lockKiosk() { + kioskLocked = true; + enrollPassword = ""; // forget the PIN → unlocking requires it again + if (relockTimer) { clearTimeout(relockTimer); relockTimer = null; } + applyLockState(); + } + function unlockKiosk() { + kioskLocked = false; + applyLockState(); + armRelock(); + } + if (lockBtn) lockBtn.addEventListener("click", lockKiosk); + + function openUnlockPin() { + currentState = STATE.RESULT; // block card taps during PIN entry + mountPinPad({ + title: "Manager PIN — unlock", + onOk: async (pin) => { + let ok = false; + try { ok = (await postJson("/fusion_clock/kiosk/nfc/verify_pin", { pin })).ok; } catch (e) {} + if (ok) { enrollPassword = pin; unlockKiosk(); setState(STATE.IDLE); } + else { const d = document.getElementById("nfc_pin_display"); if (d) d.textContent = "✕ wrong"; } + }, + onCancel: () => setState(STATE.IDLE), + }); + } + + if (unlockBtn) unlockBtn.addEventListener("click", () => { + if (kioskLocked && currentState === STATE.IDLE) openUnlockPin(); + }); + + applyLockState(); // start locked + function escapeHtml(s) { return String(s || "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" @@ -387,6 +741,9 @@ updateClock(); setInterval(updateClock, 1000); + // Keep-alive: refresh the session every 4 min so the kiosk login never expires. + setInterval(() => { postJson("/fusion_clock/get_settings", {}).catch(() => {}); }, 240000); + // ────────────────────────────────────────────────────────────── // Setup wizard // ────────────────────────────────────────────────────────────── @@ -577,6 +934,10 @@ setState(STATE.IDLE); return; } + if (result.error === "card_unknown") { + renderUnknownCard(uid); + return; + } setState(STATE.RESULT, result); } catch (e) { debugLog("handleTap: POST failed: " + e.message); @@ -624,6 +985,82 @@ return canvasEl.toDataURL("image/jpeg", 0.7); } + // ────────────────────────────────────────────────────────────── + // Guided profile-photo capture (for employees with no picture). + // Live camera + oval face guide → Capture → preview → Use/Retake → + // saves a centered 512² square to the employee's profile image. + // ────────────────────────────────────────────────────────────── + function _captureProfileSquare(video) { + const vw = video.videoWidth, vh = video.videoHeight; + if (!vw || !vh) return ""; + const side = Math.min(vw, vh); + const sx = (vw - side) / 2, sy = (vh - side) / 2; + const c = document.createElement("canvas"); + c.width = 512; c.height = 512; + c.getContext("2d").drawImage(video, sx, sy, side, side, 0, 0, 512, 512); + return c.toDataURL("image/jpeg", 0.85); + } + + function openPhotoCapture(employeeId, employeeName, onDone) { + currentState = STATE.RESULT; // block card taps during capture + if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; } + const finish = () => { if (onDone) onDone(); }; + if (!cameraStream) { finish(); return; } // no camera → skip silently + let captured = ""; + const renderPreview = () => { + stateContainer.innerHTML = ` +
+
+

Use this photo?

+
+ +
+
+
+ + +
+
+
+ `; + document.getElementById("photo_retake").addEventListener("click", renderLive); + document.getElementById("photo_use").addEventListener("click", async () => { + try { await postJson("/fusion_clock/kiosk/nfc/save_profile_photo", { employee_id: employeeId, photo_b64: captured }); } catch (e) {} + finish(); + }); + }; + function renderLive() { + stateContainer.innerHTML = ` +
+
+

Take ${escapeHtml(employeeName)}'s photo

+
+
+
Center the face in the oval
+
+
+ + +
+
+
+ `; + const stage = document.getElementById("photo_stage"); + const v = document.createElement("video"); + v.autoplay = true; v.muted = true; v.playsInline = true; + v.className = "nfc-photo-video"; + v.srcObject = cameraStream; + stage.insertBefore(v, stage.firstChild); + v.play().catch(() => {}); + document.getElementById("photo_capture").addEventListener("click", () => { + captured = _captureProfileSquare(v); + if (captured) renderPreview(); else finish(); + }); + document.getElementById("photo_skip").addEventListener("click", finish); + } + renderLive(); + } + // ────────────────────────────────────────────────────────────── // Wake Lock — keeps the screen on while the kiosk page is active. // Released automatically on tab close/navigation; re-acquired on @@ -668,6 +1105,7 @@ if (setupBtn) { setupBtn.addEventListener("click", async () => { debugLog("setup button clicked"); + unlockAudio(); // user gesture → unlock Web Audio for clock sounds // Try Web NFC, but don't fail if absent — USB HID reader is a // first-class alternative (works on desktops/iOS too). let webNfcOk = false; @@ -704,6 +1142,7 @@ console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr); } await acquireWakeLock(); + try { localStorage.setItem("nfc_setup_done", "1"); } catch (e) {} setState(STATE.IDLE); debugLog("setup: IDLE — Web NFC: " + (webNfcOk ? "✓" : "✗") + " · USB HID: ✓"); }); diff --git a/fusion_clock/static/src/scss/nfc_kiosk.scss b/fusion_clock/static/src/scss/nfc_kiosk.scss index 64e0fd6c..f9e7c2a0 100644 --- a/fusion_clock/static/src/scss/nfc_kiosk.scss +++ b/fusion_clock/static/src/scss/nfc_kiosk.scss @@ -57,6 +57,7 @@ html:has(#nfc_kiosk_root) { padding: 2rem; box-sizing: border-box; user-select: none; + -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; overflow: hidden; background: var(--nfc-bg); @@ -106,18 +107,19 @@ html:has(#nfc_kiosk_root) { top: 1.25rem; left: 50%; transform: translateX(-50%); - max-height: 52px; - max-width: 220px; + max-height: 64px; + max-width: 260px; object-fit: contain; - background: rgba(255, 255, 255, 0.20); + background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(24px) saturate(180%); -webkit-backdrop-filter: blur(24px) saturate(180%); - padding: 0.5rem 0.85rem; - border-radius: 0.85rem; - border: 1px solid rgba(255, 255, 255, 0.18); + padding: 0.6rem 1.1rem; + border-radius: 1rem; + border: 2px solid hsla(var(--nfc-h), 85%, 72%, 0.95); box-shadow: - 0 6px 24px rgba(0, 0, 0, 0.3), - inset 0 1px 0 rgba(255, 255, 255, 0.25); + 0 8px 28px rgba(0, 0, 0, 0.4), + 0 0 30px hsla(var(--nfc-h), 90%, 62%, 0.6), + inset 0 1px 0 rgba(255, 255, 255, 0.7); box-sizing: content-box; animation: nfc-logo-in 1.2s cubic-bezier(0.16, 1, 0.3, 1) both; } @@ -190,6 +192,29 @@ html:has(#nfc_kiosk_root) { border-radius: 999px; } +.nfc-kiosk__lock { + position: absolute; + bottom: 1.5rem; + right: 4.85rem; // sits just left of the ⚙ + width: 2.75rem; + height: 2.75rem; + border-radius: 50%; + background: rgba(255,255,255,0.04); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + color: var(--nfc-text-muted); + border: 1px solid rgba(255,255,255,0.08); + cursor: pointer; + font-size: 1.05rem; + display: none; // JS shows it only when unlocked + align-items: center; + justify-content: center; + z-index: 3; +} + +// Unlock button sits in the primary bottom-right corner (shown only while locked) +#nfc_unlock_btn { right: 1.5rem; } + .nfc-kiosk__settings { position: absolute; bottom: 1.5rem; @@ -234,7 +259,7 @@ html:has(#nfc_kiosk_root) { // State container — base fade-in for whatever child renders // ───────────────────────────────────────────────────────────────────── #nfc_state_container > * { - animation: nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1); + animation: nfc-state-in 200ms cubic-bezier(0.16, 1, 0.3, 1); } @keyframes nfc-state-in { @@ -251,6 +276,7 @@ html:has(#nfc_kiosk_root) { flex-direction: column; align-items: center; gap: 2rem; + margin-top: 7rem; // push icon + prompt down so waves clear the clock/time } .nfc-kiosk__icon-svg { @@ -263,6 +289,7 @@ html:has(#nfc_kiosk_root) { .nfc-chip { animation: nfc-chip-pulse 2.5s ease-in-out infinite; transform-origin: center; + transform-box: fill-box; } .nfc-wave { @@ -340,8 +367,10 @@ html:has(#nfc_kiosk_root) { max-width: 720px; padding: 2.5rem 3rem; display: flex; + flex-direction: column; align-items: center; - gap: 2rem; + text-align: center; + gap: 1.25rem; position: relative; &--success { @@ -350,7 +379,7 @@ html:has(#nfc_kiosk_root) { 0 20px 60px rgba(0,0,0,0.5), 0 0 80px rgba(24,169,87,0.35), inset 0 1px 0 rgba(255,255,255,0.1); - animation: nfc-success-burst 700ms cubic-bezier(0.16, 1, 0.3, 1); + animation: nfc-success-burst 350ms cubic-bezier(0.16, 1, 0.3, 1); } &--error { @@ -359,7 +388,7 @@ html:has(#nfc_kiosk_root) { 0 20px 60px rgba(0,0,0,0.5), 0 0 60px rgba(217,55,78,0.3), inset 0 1px 0 rgba(255,255,255,0.1); - animation: nfc-shake 350ms ease-in-out, nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1); + animation: nfc-shake 350ms ease-in-out, nfc-state-in 200ms cubic-bezier(0.16, 1, 0.3, 1); } } @@ -406,7 +435,7 @@ html:has(#nfc_kiosk_root) { flex-shrink: 0; border: 2px solid rgba(255,255,255,0.2); box-shadow: 0 8px 24px rgba(0,0,0,0.4); - animation: nfc-avatar-in 600ms cubic-bezier(0.34, 1.56, 0.64, 1) both; + animation: nfc-avatar-in 300ms cubic-bezier(0.34, 1.56, 0.64, 1) both; } @keyframes nfc-avatar-in { @@ -415,11 +444,11 @@ html:has(#nfc_kiosk_root) { } .nfc-kiosk__result-text { - flex: 1; + text-align: center; .name { font-size: 2.25rem; font-weight: 600; letter-spacing: -0.02em; } .action { font-size: 1.5rem; margin-top: 0.5rem; opacity: 0.95; font-weight: 400; } - .hours { font-size: 1.05rem; opacity: 0.75; margin-top: 0.5rem; } + .hours { font-size: 1.35rem; opacity: 0.9; margin-top: 0.6rem; font-weight: 500; } } // ───────────────────────────────────────────────────────────────────── @@ -477,9 +506,11 @@ html:has(#nfc_kiosk_root) { .nfc-kiosk__enroll-panel { @extend %nfc-glass; - padding: 2.5rem; + padding: 2rem; width: 80vw; max-width: 720px; + max-height: 92vh; + overflow-y: auto; h2 { font-size: 1.5rem; @@ -495,8 +526,8 @@ html:has(#nfc_kiosk_root) { margin: 1rem 0; button { - font-size: 2rem; - padding: 1.5rem 0; + font-size: 1.7rem; + padding: 1.1rem 0; background: rgba(255,255,255,0.05); color: var(--nfc-text); border: 1px solid rgba(255,255,255,0.1); @@ -511,12 +542,12 @@ html:has(#nfc_kiosk_root) { } .pin-display { - font-size: 2.5rem; + font-size: 2.2rem; letter-spacing: 0.5rem; text-align: center; - margin: 1rem 0 1.5rem; + margin: 0.75rem 0 1rem; font-variant-numeric: tabular-nums; - min-height: 3rem; + min-height: 2.6rem; color: hsl(var(--nfc-h), 80%, 70%); } @@ -578,6 +609,114 @@ html:has(#nfc_kiosk_root) { } } +// Amber accent for the "unknown card → program it" prompt + the inline +// status line in the new-employee form. +.nfc-kiosk__enroll-panel.nfc-kiosk__unknown { + border-color: rgba(224, 168, 62, 0.55); + box-shadow: + 0 20px 60px rgba(0,0,0,0.5), + 0 0 60px rgba(224, 168, 62, 0.28), + inset 0 1px 0 rgba(255,255,255,0.08); + + .unknown-icon { font-size: 3.5rem; line-height: 1; margin-bottom: 0.5rem; color: #e0a83e; } + h2 { color: #e0a83e; } +} + +.nfc-kiosk__enroll-panel .enroll-msg { + min-height: 1.4rem; + margin: 0.25rem 0 0.5rem; + color: var(--nfc-error); + font-size: 0.95rem; + text-align: center; +} + +// Manager page rows (employee + tag status + per-row actions) +.nfc-kiosk__enroll-panel .manager-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.7rem 0.4rem; + border-bottom: 1px solid rgba(255,255,255,0.06); + flex-wrap: wrap; + + .m-info { display: flex; align-items: baseline; gap: 0.5rem; flex: 1; min-width: 12rem; } + .m-name { font-size: 1.05rem; } + .m-dept { color: var(--nfc-text-muted); font-size: 0.8rem; } + .m-tag { font-size: 0.78rem; color: var(--nfc-text-muted); white-space: nowrap; } + .m-tag--on { color: hsl(var(--nfc-h), 70%, 66%); } + .m-actions { display: flex; gap: 0.4rem; flex-shrink: 0; flex-wrap: wrap; } + .m-btn { + font-size: 0.85rem; + padding: 0.45rem 0.85rem; + border-radius: 999px; + background: rgba(255,255,255,0.06); + color: var(--nfc-text); + border: 1px solid rgba(255,255,255,0.1); + cursor: pointer; + + &.m-danger { color: #ff8b9a; border-color: rgba(217,55,78,0.45); } + &:active { transform: scale(0.96); } + } +} + +// ───────────────────────────────────────────────────────────────────── +// Guided profile-photo capture — live camera with an oval face guide +// ───────────────────────────────────────────────────────────────────── +.nfc-kiosk__photo-panel { + @extend %nfc-glass; + padding: 1.5rem; + width: 80vw; + max-width: 540px; + max-height: 92vh; + overflow-y: auto; + text-align: center; + + h2 { font-size: 1.4rem; margin: 0 0 1rem; font-weight: 400; } +} +.nfc-photo-stage { + position: relative; + width: 100%; + aspect-ratio: 3 / 4; + max-height: 56vh; + margin: 0 auto; + border-radius: 1rem; + overflow: hidden; + background: #000; +} +.nfc-photo-video, +.nfc-photo-preview { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.nfc-photo-video { transform: scaleX(-1); } // mirror for a natural selfie view +.nfc-photo-guide { + position: absolute; + top: 48%; + left: 50%; + width: 60%; + height: 66%; + transform: translate(-50%, -50%); + border: 3px dashed rgba(255, 255, 255, 0.92); + border-radius: 50%; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); // dim everything outside the oval + pointer-events: none; +} +.nfc-photo-hint { + position: absolute; + left: 0; + right: 0; + bottom: 0.75rem; + color: #fff; + font-size: 0.95rem; + text-shadow: 0 1px 5px rgba(0, 0, 0, 0.9); + pointer-events: none; +} + // ───────────────────────────────────────────────────────────────────── // Reduced-motion fallback — respect users who prefer no animation // ───────────────────────────────────────────────────────────────────── diff --git a/fusion_clock/tests/test_clock_nfc_kiosk.py b/fusion_clock/tests/test_clock_nfc_kiosk.py index 7a47e9ec..13090332 100644 --- a/fusion_clock/tests/test_clock_nfc_kiosk.py +++ b/fusion_clock/tests/test_clock_nfc_kiosk.py @@ -411,3 +411,46 @@ class TestEmployeeSearch(HttpCase): self.assertIn('employees', result) names = [e['name'] for e in result['employees']] self.assertIn('Searchable Steve', names) + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestCreateEmployeeEndpoint(HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ICP = cls.env['ir.config_parameter'].sudo() + cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True') + cls.ICP.set_param('fusion_clock.nfc_enroll_password', '1234') + cls.kiosk_user = cls.env['res.users'].create({ + 'name': 'Create Kiosk User', + 'login': 'nfc-kiosk-create', + 'password': 'kioskpass123', + 'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)], + }) + + def _call(self, payload): + self.authenticate('nfc-kiosk-create', 'kioskpass123') + response = self.url_open( + '/fusion_clock/kiosk/nfc/create_employee', + data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}), + headers={'Content-Type': 'application/json'}, + ) + return response.json().get('result', {}) + + def test_create_success_clock_enabled(self): + result = self._call({'name': 'Newhire Nancy', 'enroll_password': '1234'}) + self.assertIn('employee_id', result) + emp = self.env['hr.employee'].browse(result['employee_id']) + self.assertTrue(emp.exists()) + self.assertEqual(emp.name, 'Newhire Nancy') + self.assertTrue(emp.x_fclk_enable_clock) + + def test_create_wrong_password(self): + result = self._call({'name': 'Should Not Exist', 'enroll_password': 'wrong'}) + self.assertEqual(result.get('error'), 'invalid_password') + self.assertFalse(self.env['hr.employee'].search([('name', '=', 'Should Not Exist')])) + + def test_create_invalid_name(self): + result = self._call({'name': 'X', 'enroll_password': '1234'}) + self.assertEqual(result.get('error'), 'invalid_name') diff --git a/fusion_clock/views/kiosk_nfc_templates.xml b/fusion_clock/views/kiosk_nfc_templates.xml index eab268ce..91b005f4 100644 --- a/fusion_clock/views/kiosk_nfc_templates.xml +++ b/fusion_clock/views/kiosk_nfc_templates.xml @@ -20,6 +20,7 @@ t-att-data-photo-required="'1' if photo_required else '0'" t-att-data-debug-enabled="'1' if debug_enabled else '0'" t-att-data-location-configured="'1' if location_configured else '0'" + t-att-data-sounds-enabled="'1' if sounds_enabled else '0'" t-att-data-company-logo-url="company_logo_url or ''"> @@ -38,6 +39,8 @@ Clock at: ⚠ No location configured
+ +