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:
@@ -92,8 +92,8 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
|||||||
- Canadian English for all user-facing text
|
- Canadian English for all user-facing text
|
||||||
- Currency: `$` sign with Monetary fields + currency_id
|
- Currency: `$` sign with Monetary fields + currency_id
|
||||||
|
|
||||||
## Cursor-Managed Modules
|
## Module-Specific Notes
|
||||||
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
- **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`.
|
- **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
|
## Workflow
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.3.6.0',
|
'version': '19.0.3.10.0',
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class FusionClockKiosk(http.Controller):
|
|||||||
'name': emp.name,
|
'name': emp.name,
|
||||||
'department': emp.department_id.name or '',
|
'department': emp.department_id.name or '',
|
||||||
'is_checked_in': emp.attendance_state == 'checked_in',
|
'is_checked_in': emp.attendance_state == 'checked_in',
|
||||||
|
'card_uid': emp.x_fclk_nfc_card_uid or '',
|
||||||
} for emp in employees],
|
} for emp in employees],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
'location_configured': bool(location),
|
'location_configured': bool(location),
|
||||||
'photo_required': ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True',
|
'photo_required': ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True',
|
||||||
'debug_enabled': ICP.get_param('fusion_clock.nfc_kiosk_debug', 'False') == '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)
|
return request.render('fusion_clock.nfc_kiosk_page', values)
|
||||||
|
|
||||||
@@ -141,8 +142,9 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
return (supplied or '') == configured
|
return (supplied or '') == configured
|
||||||
|
|
||||||
@http.route('/fusion_clock/kiosk/nfc/enroll', type='jsonrpc', auth='user', methods=['POST'])
|
@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):
|
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."""
|
"""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
|
user = request.env.user
|
||||||
if not _is_kiosk_operator(user):
|
if not _is_kiosk_operator(user):
|
||||||
return {'error': 'access_denied'}
|
return {'error': 'access_denied'}
|
||||||
@@ -164,10 +166,12 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
('id', '!=', target.id),
|
('id', '!=', target.id),
|
||||||
], limit=1)
|
], limit=1)
|
||||||
if existing:
|
if existing:
|
||||||
|
if not force:
|
||||||
return {
|
return {
|
||||||
'error': 'card_already_assigned',
|
'error': 'card_already_assigned',
|
||||||
'existing_employee': existing.name,
|
'existing_employee': existing.name,
|
||||||
}
|
}
|
||||||
|
existing.x_fclk_nfc_card_uid = False # reassign: clear the previous holder
|
||||||
|
|
||||||
target.x_fclk_nfc_card_uid = normalized
|
target.x_fclk_nfc_card_uid = normalized
|
||||||
|
|
||||||
@@ -181,10 +185,87 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
|
'employee_id': target.id,
|
||||||
'employee_name': target.name,
|
'employee_name': target.name,
|
||||||
'card_uid': normalized,
|
'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'])
|
@http.route('/fusion_clock/kiosk/nfc/tap', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
def nfc_tap(self, card_uid='', photo_b64='', **kw):
|
def nfc_tap(self, card_uid='', photo_b64='', **kw):
|
||||||
"""Toggle attendance state for the employee owning this card UID."""
|
"""Toggle attendance state for the employee owning this card UID."""
|
||||||
@@ -268,10 +349,12 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'action': 'clock_in',
|
'action': 'clock_in',
|
||||||
|
'employee_id': employee.id,
|
||||||
'employee_name': employee.name,
|
'employee_name': employee.name,
|
||||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||||
'message': f'{employee.name} clocked in at {location.name}',
|
'message': f'{employee.name} clocked in at {location.name}',
|
||||||
'net_hours_today': 0.0,
|
'net_hours_today': 0.0,
|
||||||
|
'needs_photo': not employee.image_1920,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
attendance.sudo().write({
|
attendance.sudo().write({
|
||||||
@@ -292,10 +375,12 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'action': 'clock_out',
|
'action': 'clock_out',
|
||||||
|
'employee_id': employee.id,
|
||||||
'employee_name': employee.name,
|
'employee_name': employee.name,
|
||||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||||
'message': f'{employee.name} clocked out',
|
'message': f'{employee.name} clocked out',
|
||||||
'net_hours_today': round(attendance.x_fclk_net_hours or 0, 2),
|
'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'])
|
@http.route('/fusion_clock/kiosk/nfc/employee_search', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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".
|
||||||
@@ -15,6 +15,20 @@
|
|||||||
const photoRequired = root.dataset.photoRequired === "1";
|
const photoRequired = root.dataset.photoRequired === "1";
|
||||||
const debugEnabled = root.dataset.debugEnabled === "1";
|
const debugEnabled = root.dataset.debugEnabled === "1";
|
||||||
const locationConfigured = root.dataset.locationConfigured === "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)
|
// Debug overlay (visible only when fusion_clock.nfc_kiosk_debug = True)
|
||||||
@@ -140,8 +154,10 @@
|
|||||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||||
<circle class="nfc-wave nfc-wave-1" cx="100" cy="100" r="58"
|
<circle class="nfc-wave nfc-wave-1" cx="100" cy="100" r="58"
|
||||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||||
<rect class="nfc-chip" x="68" y="68" width="64" height="64"
|
<g class="nfc-chip" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
rx="11" fill="currentColor"/>
|
<circle cx="100" cy="100" r="34"/>
|
||||||
|
<polyline points="100,80 100,100 116,108"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
|
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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) {
|
function renderResult(payload) {
|
||||||
const isError = payload && payload.error;
|
const isError = payload && payload.error;
|
||||||
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
|
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
|
playErrorSound();
|
||||||
stateContainer.innerHTML = `
|
stateContainer.innerHTML = `
|
||||||
<div class="nfc-kiosk__result ${cls}">
|
<div class="nfc-kiosk__result ${cls}">
|
||||||
<div class="nfc-kiosk__result-text">
|
<div class="nfc-kiosk__result-text">
|
||||||
@@ -169,25 +241,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
setTimeout(() => setState(STATE.IDLE), 4000);
|
setTimeout(() => setState(STATE.IDLE), 3000);
|
||||||
} else {
|
} else {
|
||||||
|
playClockSound(payload.action);
|
||||||
const avatar = payload.employee_avatar_url || "";
|
const avatar = payload.employee_avatar_url || "";
|
||||||
const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
|
const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
|
||||||
const hours = payload.action === "clock_out" && payload.net_hours_today
|
let hoursLine = "";
|
||||||
? `${payload.net_hours_today.toFixed(1)}h today`
|
if (payload.action === "clock_out") {
|
||||||
: "";
|
const mins = Math.round((payload.net_hours_today || 0) * 60);
|
||||||
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
const h = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
hoursLine = `<div class="hours">Worked ${h}h ${m}m this shift</div>`;
|
||||||
|
}
|
||||||
|
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||||
stateContainer.innerHTML = `
|
stateContainer.innerHTML = `
|
||||||
<div class="nfc-kiosk__result ${cls}">
|
<div class="nfc-kiosk__result ${cls}">
|
||||||
<div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
|
<div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
|
||||||
<div class="nfc-kiosk__result-text">
|
<div class="nfc-kiosk__result-text">
|
||||||
<div class="name">${escapeHtml(payload.employee_name)}</div>
|
<div class="name">${escapeHtml(payload.employee_name)}</div>
|
||||||
<div class="action">${action} at ${time}</div>
|
<div class="action">${action} at ${time}</div>
|
||||||
${hours ? `<div class="hours">${hours}</div>` : ""}
|
${hoursLine}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
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 enrollPassword = "";
|
||||||
let enrollSelectedEmployee = null;
|
let enrollSelectedEmployee = null;
|
||||||
|
let pendingEnrollUid = null; // set when programming a just-tapped unknown card
|
||||||
let enrollIdleTimer = null;
|
let enrollIdleTimer = null;
|
||||||
|
|
||||||
function resetEnrollIdleTimer() {
|
function resetEnrollIdleTimer() {
|
||||||
@@ -209,22 +293,22 @@
|
|||||||
function exitEnrollMode() {
|
function exitEnrollMode() {
|
||||||
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
|
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
|
||||||
enrollIdleTimer = null;
|
enrollIdleTimer = null;
|
||||||
enrollPassword = "";
|
if (kioskLocked) enrollPassword = ""; // keep the PIN while unlocked (no re-prompt)
|
||||||
enrollSelectedEmployee = null;
|
enrollSelectedEmployee = null;
|
||||||
|
pendingEnrollUid = null;
|
||||||
setState(STATE.IDLE);
|
setState(STATE.IDLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEnroll(payload) {
|
// Fixed PIN pad: renders the panel ONCE, then mutates only the masked
|
||||||
const phase = (payload && payload.phase) || "password";
|
// display on each press — no innerHTML rebuild, no replayed entrance
|
||||||
resetEnrollIdleTimer();
|
// animation. (Fixes the per-digit "screen refresh" bug.)
|
||||||
|
function mountPinPad(opts) {
|
||||||
if (phase === "password") {
|
let pin = "";
|
||||||
const masked = "•".repeat(enrollPassword.length);
|
|
||||||
stateContainer.innerHTML = `
|
stateContainer.innerHTML = `
|
||||||
<div class="nfc-kiosk__enroll-overlay">
|
<div class="nfc-kiosk__enroll-overlay">
|
||||||
<div class="nfc-kiosk__enroll-panel">
|
<div class="nfc-kiosk__enroll-panel">
|
||||||
<h2>Enter Enroll Mode Password</h2>
|
<h2>${escapeHtml(opts.title || "Enter PIN")}</h2>
|
||||||
<div class="pin-display">${masked}</div>
|
<div class="pin-display" id="nfc_pin_display"></div>
|
||||||
<div class="numpad">
|
<div class="numpad">
|
||||||
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
|
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
|
||||||
<button data-n="back">⌫</button>
|
<button data-n="back">⌫</button>
|
||||||
@@ -232,37 +316,152 @@
|
|||||||
<button data-n="ok">OK</button>
|
<button data-n="ok">OK</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="cancel" id="enroll_cancel">Cancel</button>
|
<button class="cancel" id="nfc_pin_cancel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
const displayEl = stateContainer.querySelector("#nfc_pin_display");
|
||||||
|
const paint = () => { displayEl.textContent = "•".repeat(pin.length); };
|
||||||
|
paint();
|
||||||
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
|
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", () => {
|
||||||
resetEnrollIdleTimer();
|
resetEnrollIdleTimer();
|
||||||
const n = btn.dataset.n;
|
const n = btn.dataset.n;
|
||||||
if (n === "back") enrollPassword = enrollPassword.slice(0, -1);
|
if (n === "back") { pin = pin.slice(0, -1); paint(); }
|
||||||
else if (n === "ok") {
|
else if (n === "ok") { if (pin.length) opts.onOk(pin); }
|
||||||
if (enrollPassword.length === 0) return;
|
else { pin += n; paint(); }
|
||||||
renderEnroll({ phase: "search" });
|
});
|
||||||
return;
|
});
|
||||||
|
stateContainer.querySelector("#nfc_pin_cancel").addEventListener("click", opts.onCancel);
|
||||||
}
|
}
|
||||||
else enrollPassword += n;
|
|
||||||
renderEnroll({ phase: "password" });
|
// 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 = `
|
||||||
|
<div class="nfc-kiosk__enroll-overlay">
|
||||||
|
<div class="nfc-kiosk__enroll-panel nfc-kiosk__unknown" style="text-align:center">
|
||||||
|
<div class="unknown-icon">⚠</div>
|
||||||
|
<h2>This card isn't programmed yet</h2>
|
||||||
|
<p style="color:var(--nfc-text-muted)">Program it now, or ask a manager.</p>
|
||||||
|
<div class="actions" style="justify-content:center">
|
||||||
|
<button class="confirm" id="uc_program">Program this card</button>
|
||||||
|
<button class="cancel" id="uc_cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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") {
|
||||||
|
mountPinPad({
|
||||||
|
title: "Manager PIN",
|
||||||
|
onOk: (pin) => { enrollPassword = pin; renderEnroll({ phase: "employee" }); },
|
||||||
|
onCancel: exitEnrollMode,
|
||||||
});
|
});
|
||||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === "search") {
|
if (phase === "manager") {
|
||||||
stateContainer.innerHTML = `
|
stateContainer.innerHTML = `
|
||||||
<div class="nfc-kiosk__enroll-overlay">
|
<div class="nfc-kiosk__enroll-overlay">
|
||||||
<div class="nfc-kiosk__enroll-panel">
|
<div class="nfc-kiosk__enroll-panel">
|
||||||
<h2>Pick the employee to enroll</h2>
|
<h2>Manage employees</h2>
|
||||||
|
<input class="employee-search" id="mgr_search" placeholder="Search by name…" autocomplete="off"/>
|
||||||
|
<div class="employee-list" id="mgr_list"></div>
|
||||||
|
<div class="actions" style="justify-content:space-between">
|
||||||
|
<button class="confirm" id="mgr_new">+ New employee</button>
|
||||||
|
<button class="cancel" id="mgr_close">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `<div style="opacity:.6;padding:1rem">Connection error.</div>`; return; }
|
||||||
|
if (!emps.length) { listEl.innerHTML = `<div style="opacity:.6;padding:1rem">No employees found.</div>`; return; }
|
||||||
|
listEl.innerHTML = emps.map(e => {
|
||||||
|
const tag = e.card_uid
|
||||||
|
? `<span class="m-tag m-tag--on">● ${escapeHtml(e.card_uid)}</span>`
|
||||||
|
: `<span class="m-tag">○ no tag</span>`;
|
||||||
|
const actions = (confirmDeleteId === e.id)
|
||||||
|
? `<button class="m-btn m-danger" data-act="delok" data-id="${e.id}">Confirm delete</button>
|
||||||
|
<button class="m-btn" data-act="delno" data-id="${e.id}">Cancel</button>`
|
||||||
|
: `<button class="m-btn" data-act="assign" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${e.card_uid ? "Re-tag" : "Assign"}</button>
|
||||||
|
${e.card_uid ? `<button class="m-btn" data-act="clear" data-id="${e.id}">Clear tag</button>` : ""}
|
||||||
|
<button class="m-btn m-danger" data-act="del" data-id="${e.id}">Delete</button>`;
|
||||||
|
return `<div class="manager-row">
|
||||||
|
<div class="m-info"><span class="m-name">${escapeHtml(e.name)}</span><small class="m-dept">${escapeHtml(e.department || "")}</small> ${tag}</div>
|
||||||
|
<div class="m-actions">${actions}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
listEl.querySelectorAll(".m-btn").forEach(btn => btn.addEventListener("click", async () => {
|
||||||
|
resetEnrollIdleTimer();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
searchEl.addEventListener("input", () => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(refresh, 200);
|
||||||
|
});
|
||||||
|
document.getElementById("mgr_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
|
||||||
|
document.getElementById("mgr_close").addEventListener("click", exitEnrollMode);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "employee") {
|
||||||
|
stateContainer.innerHTML = `
|
||||||
|
<div class="nfc-kiosk__enroll-overlay">
|
||||||
|
<div class="nfc-kiosk__enroll-panel">
|
||||||
|
<h2>Who is this card for?</h2>
|
||||||
<input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
|
<input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
|
||||||
<div class="employee-list" id="enroll_list"></div>
|
<div class="employee-list" id="enroll_list"></div>
|
||||||
<div class="actions">
|
<div class="actions" style="justify-content:space-between">
|
||||||
|
<button class="confirm" id="enroll_new">+ New employee</button>
|
||||||
<button class="cancel" id="enroll_cancel">Cancel</button>
|
<button class="cancel" id="enroll_cancel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,17 +480,61 @@
|
|||||||
).join("");
|
).join("");
|
||||||
listEl.querySelectorAll(".employee-row").forEach(row => {
|
listEl.querySelectorAll(".employee-row").forEach(row => {
|
||||||
row.addEventListener("click", () => {
|
row.addEventListener("click", () => {
|
||||||
enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name };
|
chooseEmployee({ id: parseInt(row.dataset.id, 10), name: row.dataset.name });
|
||||||
renderEnroll({ phase: "tap" });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 200);
|
||||||
});
|
});
|
||||||
searchEl.focus();
|
searchEl.focus();
|
||||||
|
document.getElementById("enroll_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
|
||||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (phase === "new_employee") {
|
||||||
|
stateContainer.innerHTML = `
|
||||||
|
<div class="nfc-kiosk__enroll-overlay">
|
||||||
|
<div class="nfc-kiosk__enroll-panel">
|
||||||
|
<h2>New employee</h2>
|
||||||
|
<input class="employee-search" id="new_emp_name" placeholder="Full name…" autocomplete="off"/>
|
||||||
|
<div class="enroll-msg" id="new_emp_msg"></div>
|
||||||
|
<div class="actions" style="justify-content:space-between">
|
||||||
|
<button class="cancel" id="new_emp_back">Back</button>
|
||||||
|
<button class="confirm" id="new_emp_create">Create & assign</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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") {
|
if (phase === "tap") {
|
||||||
stateContainer.innerHTML = `
|
stateContainer.innerHTML = `
|
||||||
<div class="nfc-kiosk__enroll-overlay">
|
<div class="nfc-kiosk__enroll-overlay">
|
||||||
@@ -312,52 +555,163 @@
|
|||||||
if (phase === "result") {
|
if (phase === "result") {
|
||||||
const ok = !payload.error;
|
const ok = !payload.error;
|
||||||
const msg = ok
|
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"
|
: (payload.error === "invalid_password"
|
||||||
? "Wrong password. Try again."
|
? "Wrong Manager PIN. Try again."
|
||||||
: payload.error === "card_already_assigned"
|
: payload.error === "card_already_assigned"
|
||||||
? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.`
|
? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.`
|
||||||
: `Enroll failed: ${escapeHtml(payload.error)}`);
|
: `Enroll failed: ${escapeHtml(payload.error)}`);
|
||||||
stateContainer.innerHTML = `
|
stateContainer.innerHTML = `
|
||||||
<div class="nfc-kiosk__enroll-overlay">
|
<div class="nfc-kiosk__enroll-overlay">
|
||||||
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
|
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
|
||||||
<h2 style="color:${ok ? "#18a957" : "#d9374e"}">${msg}</h2>
|
<h2 style="color:${ok ? "var(--nfc-success)" : "var(--nfc-error)"}">${msg}</h2>
|
||||||
<div class="actions" style="justify-content:center">
|
<div class="actions" style="justify-content:center">
|
||||||
|
${ok && payload.needs_photo && payload.employee_id ? `<button class="confirm" id="enroll_photo">📷 Take photo</button>` : ""}
|
||||||
<button class="confirm" id="enroll_another">Enroll another</button>
|
<button class="confirm" id="enroll_another">Enroll another</button>
|
||||||
<button class="cancel" id="enroll_done">Done</button>
|
<button class="cancel" id="enroll_done">Done</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
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", () => {
|
document.getElementById("enroll_another").addEventListener("click", () => {
|
||||||
enrollSelectedEmployee = null;
|
enrollSelectedEmployee = null;
|
||||||
renderEnroll({ phase: ok ? "search" : "password" });
|
pendingEnrollUid = null;
|
||||||
|
renderEnroll({ phase: ok ? "employee" : "password" });
|
||||||
});
|
});
|
||||||
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
|
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _onEnrollTap(uid) {
|
// Existing employee picked → if we already hold a tapped UID, bind it now
|
||||||
if (!enrollSelectedEmployee) return;
|
// (no re-tap); otherwise fall back to the proactive ⚙ "tap the card" step.
|
||||||
const result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
|
function chooseEmployee(emp) {
|
||||||
employee_id: enrollSelectedEmployee.id,
|
if (pendingEnrollUid) {
|
||||||
card_uid: uid,
|
doEnroll(emp.id, emp.name, pendingEnrollUid, false);
|
||||||
enroll_password: enrollPassword,
|
} else {
|
||||||
});
|
enrollSelectedEmployee = emp;
|
||||||
renderEnroll({ phase: "result", ...result });
|
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 = `
|
||||||
|
<div class="nfc-kiosk__enroll-overlay">
|
||||||
|
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
|
||||||
|
<h2>Reassign card?</h2>
|
||||||
|
<p style="color:var(--nfc-text-muted)">This card belongs to <b>${escapeHtml(existingName || "another employee")}</b>. Move it to <b>${escapeHtml(empName)}</b>?</p>
|
||||||
|
<div class="actions" style="justify-content:center">
|
||||||
|
<button class="cancel" id="ra_cancel">Cancel</button>
|
||||||
|
<button class="confirm" id="ra_move">Move</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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");
|
const settingsBtn = document.getElementById("nfc_settings_btn");
|
||||||
if (settingsBtn) {
|
if (settingsBtn) {
|
||||||
settingsBtn.addEventListener("click", () => {
|
settingsBtn.addEventListener("click", () => {
|
||||||
if (currentState !== STATE.IDLE) return;
|
if (kioskLocked || currentState !== STATE.IDLE) return;
|
||||||
enrollPassword = "";
|
|
||||||
enrollSelectedEmployee = null;
|
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) {
|
function escapeHtml(s) {
|
||||||
return String(s || "").replace(/[&<>"']/g, c => ({
|
return String(s || "").replace(/[&<>"']/g, c => ({
|
||||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
@@ -387,6 +741,9 @@
|
|||||||
updateClock();
|
updateClock();
|
||||||
setInterval(updateClock, 1000);
|
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
|
// Setup wizard
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
@@ -577,6 +934,10 @@
|
|||||||
setState(STATE.IDLE);
|
setState(STATE.IDLE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.error === "card_unknown") {
|
||||||
|
renderUnknownCard(uid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(STATE.RESULT, result);
|
setState(STATE.RESULT, result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugLog("handleTap: POST failed: " + e.message);
|
debugLog("handleTap: POST failed: " + e.message);
|
||||||
@@ -624,6 +985,82 @@
|
|||||||
return canvasEl.toDataURL("image/jpeg", 0.7);
|
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 = `
|
||||||
|
<div class="nfc-kiosk__enroll-overlay">
|
||||||
|
<div class="nfc-kiosk__photo-panel">
|
||||||
|
<h2>Use this photo?</h2>
|
||||||
|
<div class="nfc-photo-stage">
|
||||||
|
<img class="nfc-photo-preview" src="${captured}"/>
|
||||||
|
<div class="nfc-photo-guide"></div>
|
||||||
|
</div>
|
||||||
|
<div class="actions" style="justify-content:space-between">
|
||||||
|
<button class="cancel" id="photo_retake">Retake</button>
|
||||||
|
<button class="confirm" id="photo_use">Use photo</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="nfc-kiosk__enroll-overlay">
|
||||||
|
<div class="nfc-kiosk__photo-panel">
|
||||||
|
<h2>Take ${escapeHtml(employeeName)}'s photo</h2>
|
||||||
|
<div class="nfc-photo-stage" id="photo_stage">
|
||||||
|
<div class="nfc-photo-guide"></div>
|
||||||
|
<div class="nfc-photo-hint">Center the face in the oval</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions" style="justify-content:space-between">
|
||||||
|
<button class="cancel" id="photo_skip">Skip</button>
|
||||||
|
<button class="confirm" id="photo_capture">Capture</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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.
|
// Wake Lock — keeps the screen on while the kiosk page is active.
|
||||||
// Released automatically on tab close/navigation; re-acquired on
|
// Released automatically on tab close/navigation; re-acquired on
|
||||||
@@ -668,6 +1105,7 @@
|
|||||||
if (setupBtn) {
|
if (setupBtn) {
|
||||||
setupBtn.addEventListener("click", async () => {
|
setupBtn.addEventListener("click", async () => {
|
||||||
debugLog("setup button clicked");
|
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
|
// Try Web NFC, but don't fail if absent — USB HID reader is a
|
||||||
// first-class alternative (works on desktops/iOS too).
|
// first-class alternative (works on desktops/iOS too).
|
||||||
let webNfcOk = false;
|
let webNfcOk = false;
|
||||||
@@ -704,6 +1142,7 @@
|
|||||||
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
|
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
|
||||||
}
|
}
|
||||||
await acquireWakeLock();
|
await acquireWakeLock();
|
||||||
|
try { localStorage.setItem("nfc_setup_done", "1"); } catch (e) {}
|
||||||
setState(STATE.IDLE);
|
setState(STATE.IDLE);
|
||||||
debugLog("setup: IDLE — Web NFC: " + (webNfcOk ? "✓" : "✗") + " · USB HID: ✓");
|
debugLog("setup: IDLE — Web NFC: " + (webNfcOk ? "✓" : "✗") + " · USB HID: ✓");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ html:has(#nfc_kiosk_root) {
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--nfc-bg);
|
background: var(--nfc-bg);
|
||||||
@@ -106,18 +107,19 @@ html:has(#nfc_kiosk_root) {
|
|||||||
top: 1.25rem;
|
top: 1.25rem;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
max-height: 52px;
|
max-height: 64px;
|
||||||
max-width: 220px;
|
max-width: 260px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: rgba(255, 255, 255, 0.20);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
backdrop-filter: blur(24px) saturate(180%);
|
backdrop-filter: blur(24px) saturate(180%);
|
||||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||||
padding: 0.5rem 0.85rem;
|
padding: 0.6rem 1.1rem;
|
||||||
border-radius: 0.85rem;
|
border-radius: 1rem;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: 2px solid hsla(var(--nfc-h), 85%, 72%, 0.95);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 6px 24px rgba(0, 0, 0, 0.3),
|
0 8px 28px rgba(0, 0, 0, 0.4),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
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;
|
box-sizing: content-box;
|
||||||
animation: nfc-logo-in 1.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
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;
|
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 {
|
.nfc-kiosk__settings {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 1.5rem;
|
bottom: 1.5rem;
|
||||||
@@ -234,7 +259,7 @@ html:has(#nfc_kiosk_root) {
|
|||||||
// State container — base fade-in for whatever child renders
|
// State container — base fade-in for whatever child renders
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
#nfc_state_container > * {
|
#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 {
|
@keyframes nfc-state-in {
|
||||||
@@ -251,6 +276,7 @@ html:has(#nfc_kiosk_root) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
margin-top: 7rem; // push icon + prompt down so waves clear the clock/time
|
||||||
}
|
}
|
||||||
|
|
||||||
.nfc-kiosk__icon-svg {
|
.nfc-kiosk__icon-svg {
|
||||||
@@ -263,6 +289,7 @@ html:has(#nfc_kiosk_root) {
|
|||||||
.nfc-chip {
|
.nfc-chip {
|
||||||
animation: nfc-chip-pulse 2.5s ease-in-out infinite;
|
animation: nfc-chip-pulse 2.5s ease-in-out infinite;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
|
transform-box: fill-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nfc-wave {
|
.nfc-wave {
|
||||||
@@ -340,8 +367,10 @@ html:has(#nfc_kiosk_root) {
|
|||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
padding: 2.5rem 3rem;
|
padding: 2.5rem 3rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2rem;
|
text-align: center;
|
||||||
|
gap: 1.25rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&--success {
|
&--success {
|
||||||
@@ -350,7 +379,7 @@ html:has(#nfc_kiosk_root) {
|
|||||||
0 20px 60px rgba(0,0,0,0.5),
|
0 20px 60px rgba(0,0,0,0.5),
|
||||||
0 0 80px rgba(24,169,87,0.35),
|
0 0 80px rgba(24,169,87,0.35),
|
||||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
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 {
|
&--error {
|
||||||
@@ -359,7 +388,7 @@ html:has(#nfc_kiosk_root) {
|
|||||||
0 20px 60px rgba(0,0,0,0.5),
|
0 20px 60px rgba(0,0,0,0.5),
|
||||||
0 0 60px rgba(217,55,78,0.3),
|
0 0 60px rgba(217,55,78,0.3),
|
||||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
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;
|
flex-shrink: 0;
|
||||||
border: 2px solid rgba(255,255,255,0.2);
|
border: 2px solid rgba(255,255,255,0.2);
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
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 {
|
@keyframes nfc-avatar-in {
|
||||||
@@ -415,11 +444,11 @@ html:has(#nfc_kiosk_root) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nfc-kiosk__result-text {
|
.nfc-kiosk__result-text {
|
||||||
flex: 1;
|
text-align: center;
|
||||||
|
|
||||||
.name { font-size: 2.25rem; font-weight: 600; letter-spacing: -0.02em; }
|
.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; }
|
.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 {
|
.nfc-kiosk__enroll-panel {
|
||||||
@extend %nfc-glass;
|
@extend %nfc-glass;
|
||||||
padding: 2.5rem;
|
padding: 2rem;
|
||||||
width: 80vw;
|
width: 80vw;
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
|
max-height: 92vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@@ -495,8 +526,8 @@ html:has(#nfc_kiosk_root) {
|
|||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
font-size: 2rem;
|
font-size: 1.7rem;
|
||||||
padding: 1.5rem 0;
|
padding: 1.1rem 0;
|
||||||
background: rgba(255,255,255,0.05);
|
background: rgba(255,255,255,0.05);
|
||||||
color: var(--nfc-text);
|
color: var(--nfc-text);
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
@@ -511,12 +542,12 @@ html:has(#nfc_kiosk_root) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pin-display {
|
.pin-display {
|
||||||
font-size: 2.5rem;
|
font-size: 2.2rem;
|
||||||
letter-spacing: 0.5rem;
|
letter-spacing: 0.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 1rem 0 1.5rem;
|
margin: 0.75rem 0 1rem;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
min-height: 3rem;
|
min-height: 2.6rem;
|
||||||
color: hsl(var(--nfc-h), 80%, 70%);
|
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
|
// Reduced-motion fallback — respect users who prefer no animation
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -411,3 +411,46 @@ class TestEmployeeSearch(HttpCase):
|
|||||||
self.assertIn('employees', result)
|
self.assertIn('employees', result)
|
||||||
names = [e['name'] for e in result['employees']]
|
names = [e['name'] for e in result['employees']]
|
||||||
self.assertIn('Searchable Steve', names)
|
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')
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
t-att-data-photo-required="'1' if photo_required else '0'"
|
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-debug-enabled="'1' if debug_enabled else '0'"
|
||||||
t-att-data-location-configured="'1' if location_configured 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 ''">
|
t-att-data-company-logo-url="company_logo_url or ''">
|
||||||
|
|
||||||
<!-- Company logo (also drives the dominant-hue palette via JS) -->
|
<!-- Company logo (also drives the dominant-hue palette via JS) -->
|
||||||
@@ -38,6 +39,8 @@
|
|||||||
<span t-if="location_configured">Clock at: <t t-esc="location_name"/></span>
|
<span t-if="location_configured">Clock at: <t t-esc="location_name"/></span>
|
||||||
<span t-else="" style="color:#d9374e">⚠ No location configured</span>
|
<span t-else="" style="color:#d9374e">⚠ No location configured</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="nfc-kiosk__lock" id="nfc_lock_btn" title="Lock screen">🔒</button>
|
||||||
|
<button class="nfc-kiosk__lock" id="nfc_unlock_btn" title="Unlock (manager PIN)">🔓</button>
|
||||||
<button class="nfc-kiosk__settings" id="nfc_settings_btn" title="Enroll Mode">⚙</button>
|
<button class="nfc-kiosk__settings" id="nfc_settings_btn" title="Enroll Mode">⚙</button>
|
||||||
|
|
||||||
<!-- Dynamic state container (JS swaps inner HTML based on state) -->
|
<!-- Dynamic state container (JS swaps inner HTML based on state) -->
|
||||||
|
|||||||
Reference in New Issue
Block a user