Files
Odoo-Modules/fusion_clock/docs/superpowers/plans/2026-05-31-pin-kiosk.md
2026-05-31 21:09:03 -04:00

941 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PIN Kiosk Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a polished, opt-in PIN kiosk (photo-tile → PIN → optional selfie → clock) matching the NFC kiosk's premium dark/glass/brand-gradient style, gated by the existing `enable_kiosk` setting.
**Architecture:** Rework the existing `controllers/clock_kiosk.py` (4 routes + 1 new), rebuild `views/kiosk_templates.xml`, rewrite `static/src/js/fusion_clock_kiosk.js` as an Odoo-19 Interaction with a small state machine, and add a new `static/src/scss/pin_kiosk.scss` that mirrors `nfc_kiosk.scss` (scoped to `#pin_kiosk_root`, brand hue in `--pk-h`). Reuse the master photo gate, `hr.employee.public` avatars, and the company kiosk location.
**Tech Stack:** Odoo 19 HTTP controllers (`type='jsonrpc'` / `type='http'`), `@web/public/interaction` Interaction, SCSS (frontend bundle), `HttpCase`/`TransactionCase` tests.
**Reference (read first):** spec `fusion_clock/docs/superpowers/specs/2026-05-31-pin-kiosk-design.md`; mirror sources `static/src/scss/nfc_kiosk.scss` and `static/src/js/fusion_clock_nfc_kiosk.js` (hue extraction lines ~60-117, photo capture); repo `CLAUDE.md` + `fusion_clock/CLAUDE.md` (Interaction rule, scoped-SCSS rule).
**Test command** (substitute `odoo-modsdev-app` if that's your dev container):
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
```
**Commit discipline (shared tree):** stage explicit paths, verify `git diff --cached --name-only`, `git commit --only -- <paths>`, never `git add -A`, no `.pyc`/`.DS_Store`. Push **origin + gitea** at the end. Append `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` to messages.
**File structure:**
- `controllers/clock_kiosk.py` — rework `kiosk_search` (+avatar/has_pin), `kiosk_verify_pin` (→ needs_setup), new `kiosk_set_pin`, rework `kiosk_clock` (kiosk location + photo).
- `static/src/scss/pin_kiosk.scss` (new) — kiosk styling, scoped to `#pin_kiosk_root`.
- `views/kiosk_templates.xml` — rebuilt root + chrome + `#pin_state_container`.
- `static/src/js/fusion_clock_kiosk.js` — Interaction state machine.
- `models/res_config_settings.py`, `views/res_config_settings_views.xml`, `data/ir_config_parameter_data.xml` — drop `kiosk_pin_required`.
- `models/res_company.py` — relabel kiosk-location field string.
- `views/clock_menus.xml` — PIN kiosk app icon.
- `__manifest__.py` — register scss + version bump.
- `tests/test_clock_kiosk.py` (new).
---
## Task 1: Backend — employee list (+avatar/has_pin), verify_pin (needs_setup), set_pin
**Files:**
- Modify: `controllers/clock_kiosk.py`
- Create: `tests/test_clock_kiosk.py`
- Modify: `tests/__init__.py`
- [ ] **Step 1: Register the test module** — add to `fusion_clock/tests/__init__.py`:
```python
from . import test_clock_kiosk
```
- [ ] **Step 2: Write the failing tests** — create `fusion_clock/tests/test_clock_kiosk.py`:
```python
# -*- coding: utf-8 -*-
import json
from odoo.tests.common import HttpCase, tagged
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestPinKioskIdentity(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_kiosk', 'True')
cls.location = cls.env['fusion.clock.location'].create({
'name': 'PIN Plant', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.env['res.users'].create({
'name': 'PIN Kiosk Op', 'login': 'pin-kiosk-op', 'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.withpin = cls.env['hr.employee'].create({
'name': 'Pat WithPin', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234',
})
cls.nopin = cls.env['hr.employee'].create({
'name': 'Nora NoPin', 'x_fclk_enable_clock': True,
})
def _call(self, route, params):
self.authenticate('pin-kiosk-op', 'kioskpass123')
resp = self.url_open(route, data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'params': params,
}), headers={'Content-Type': 'application/json'})
return resp.json().get('result', {})
def test_search_returns_avatar_and_has_pin(self):
res = self._call('/fusion_clock/kiosk/search', {'query': ''})
rows = {e['name']: e for e in res['employees']}
self.assertIn('Pat WithPin', rows)
self.assertTrue(rows['Pat WithPin']['has_pin'])
self.assertFalse(rows['Nora NoPin']['has_pin'])
self.assertIn('/web/image/hr.employee.public/', rows['Pat WithPin']['avatar_url'])
def test_verify_pin_correct(self):
res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.withpin.id, 'pin': '1234'})
self.assertTrue(res.get('success'))
def test_verify_pin_incorrect(self):
res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.withpin.id, 'pin': '9999'})
self.assertEqual(res.get('error'), 'invalid_pin')
def test_verify_pin_needs_setup(self):
res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.nopin.id, 'pin': ''})
self.assertTrue(res.get('needs_setup'))
def test_set_pin_success_then_required(self):
res = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.nopin.id, 'pin': '4321'})
self.assertTrue(res.get('success'))
self.assertEqual(self.nopin.x_fclk_kiosk_pin, '4321')
# already set → reject
res2 = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.nopin.id, 'pin': '0000'})
self.assertEqual(res2.get('error'), 'already_set')
def test_set_pin_rejects_bad_format(self):
res = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.withpin.id, 'pin': '12'})
self.assertEqual(res.get('error'), 'bad_pin')
```
- [ ] **Step 3: Run the tests, verify they FAIL** — run the test command. Expected: FAIL (`search` lacks `has_pin`/`avatar_url`; `verify_pin` has no `needs_setup`; `set_pin` route 404).
- [ ] **Step 4: Implement** — in `controllers/clock_kiosk.py`, replace `kiosk_search` and `kiosk_verify_pin` and add `kiosk_set_pin`:
```python
@http.route('/fusion_clock/kiosk/search', type='jsonrpc', auth='user', methods=['POST'])
def kiosk_search(self, query='', **kw):
"""Employees for the kiosk grid. Also used by the NFC kiosk's
employee_search — keep the return shape additive."""
if not _is_kiosk_operator(request.env.user):
return {'error': 'Access denied.'}
employees = request.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
('name', 'ilike', query),
], limit=200, order='name')
rows = []
for emp in employees:
unique = emp.write_date.strftime('%Y%m%d%H%M%S') if emp.write_date else ''
rows.append({
'id': emp.id,
'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 '',
'has_pin': bool(emp.x_fclk_kiosk_pin),
'avatar_url': '/web/image/hr.employee.public/%s/avatar_128?unique=%s' % (emp.id, unique),
})
return {'employees': rows}
@http.route('/fusion_clock/kiosk/verify_pin', type='jsonrpc', auth='user', methods=['POST'])
def kiosk_verify_pin(self, employee_id=0, pin='', **kw):
if not _is_kiosk_operator(request.env.user):
return {'error': 'Access denied.'}
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
if not employee.exists():
return {'error': 'not_found'}
if not employee.x_fclk_kiosk_pin:
return {'needs_setup': True, 'employee_name': employee.name}
if employee.x_fclk_kiosk_pin != pin:
return {'error': 'invalid_pin'}
return {'success': True, 'employee_name': employee.name,
'is_checked_in': employee.attendance_state == 'checked_in'}
@http.route('/fusion_clock/kiosk/set_pin', type='jsonrpc', auth='user', methods=['POST'])
def kiosk_set_pin(self, employee_id=0, pin='', **kw):
"""First-use PIN creation. Rejects if the employee already has one."""
if not _is_kiosk_operator(request.env.user):
return {'error': 'Access denied.'}
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
if not employee.exists() or not employee.x_fclk_enable_clock:
return {'error': 'not_found'}
if employee.x_fclk_kiosk_pin:
return {'error': 'already_set'}
pin = (pin or '').strip()
if not (pin.isdigit() and 4 <= len(pin) <= 6):
return {'error': 'bad_pin'}
employee.write({'x_fclk_kiosk_pin': pin})
return {'success': True, 'employee_name': employee.name}
```
- [ ] **Step 5: Run the tests, verify they PASS** — run the test command. Expected: `TestPinKioskIdentity` passes.
- [ ] **Step 6: Commit**
```bash
cd /Users/gurpreet/Github/Odoo-Modules
git add -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py fusion_clock/tests/__init__.py
git diff --cached --name-only
git commit --only -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py fusion_clock/tests/__init__.py \
-m "feat(fusion_clock): PIN kiosk identity endpoints (grid list, verify, first-use set_pin)"
```
---
## Task 2: Backend — clock with kiosk location + photo gating
**Files:**
- Modify: `controllers/clock_kiosk.py` (rework `kiosk_clock`)
- Modify: `tests/test_clock_kiosk.py`
- [ ] **Step 1: Write the failing tests** — append to `test_clock_kiosk.py`:
```python
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestPinKioskClock(HttpCase):
PNG = ('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwC'
'AAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=')
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.ICP.set_param('fusion_clock.enable_kiosk', 'True')
cls.location = cls.env['fusion.clock.location'].create({
'name': 'PIN Plant 2', 'latitude': 43.65, 'longitude': -79.38, 'radius': 100,
})
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
cls.env['res.users'].create({
'name': 'PIN Op2', 'login': 'pin-op2', 'password': 'kioskpass123',
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
})
cls.emp = cls.env['hr.employee'].create({
'name': 'Quinn Clock', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234',
})
def _clock(self, photo_b64=''):
self.authenticate('pin-op2', 'kioskpass123')
resp = self.url_open('/fusion_clock/kiosk/clock', data=json.dumps({
'jsonrpc': '2.0', 'method': 'call',
'params': {'employee_id': self.emp.id, 'photo_b64': photo_b64},
}), headers={'Content-Type': 'application/json'})
return resp.json().get('result', {})
def _latest(self):
return self.env['hr.attendance'].search(
[('employee_id', '=', self.emp.id)], order='check_in desc', limit=1)
def test_clock_in_uses_kiosk_location(self):
res = self._clock()
self.assertTrue(res.get('success'))
self.assertEqual(res.get('action'), 'clock_in')
att = self._latest()
self.assertEqual(att.x_fclk_clock_source, 'kiosk')
self.assertEqual(att.x_fclk_location_id, self.location)
def test_photo_stored_only_when_master_on(self):
self.ICP.set_param('fusion_clock.enable_photo_verification', 'False')
self._clock(self.PNG)
self.assertFalse(self._latest().x_fclk_check_in_photo)
# new employee for an ON run (avoid debounce/clocked-in state)
emp2 = self.env['hr.employee'].create({
'name': 'Quinn Two', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234'})
self.ICP.set_param('fusion_clock.enable_photo_verification', 'True')
self.authenticate('pin-op2', 'kioskpass123')
self.url_open('/fusion_clock/kiosk/clock', data=json.dumps({
'jsonrpc': '2.0', 'method': 'call',
'params': {'employee_id': emp2.id, 'photo_b64': self.PNG}}),
headers={'Content-Type': 'application/json'})
att2 = self.env['hr.attendance'].search([('employee_id', '=', emp2.id)], limit=1)
self.assertTrue(att2.x_fclk_check_in_photo)
def test_no_location_configured(self):
self.env.company.x_fclk_nfc_kiosk_location_id = False
res = self._clock()
self.assertEqual(res.get('error'), 'no_location_configured')
```
- [ ] **Step 2: Run the tests, verify they FAIL** — run the test command. Expected: FAIL (current `kiosk_clock` uses `_verify_location` GPS, takes no `photo_b64`, no `no_location_configured`).
- [ ] **Step 3: Implement** — in `controllers/clock_kiosk.py`, replace the whole `kiosk_clock` method with:
```python
@http.route('/fusion_clock/kiosk/clock', type='jsonrpc', auth='user', methods=['POST'])
def kiosk_clock(self, employee_id=0, photo_b64='', **kw):
"""Clock the employee in/out from the shared kiosk. Fixed wall device:
uses the company kiosk location, no per-clock GPS geofence."""
if not _is_kiosk_operator(request.env.user):
return {'error': 'Access denied.'}
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
if not employee.exists() or not employee.x_fclk_enable_clock:
return {'error': 'not_found'}
ICP = request.env['ir.config_parameter'].sudo()
company = request.env.company.sudo()
location = company.x_fclk_nfc_kiosk_location_id
if not location:
return {'error': 'no_location_configured'}
from .clock_api import FusionClockAPI
from .clock_nfc_kiosk import _strip_data_url_prefix
api = FusionClockAPI()
photo_enabled = ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True'
photo_bytes = _strip_data_url_prefix(photo_b64) if (photo_enabled and photo_b64) else b''
is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = not day_plan.get('scheduled')
geo_info = {'latitude': 0, 'longitude': 0, 'browser': 'kiosk',
'ip_address': request.httprequest.remote_addr or ''}
try:
attendance = employee.sudo()._attendance_action_change(geo_info)
if not is_checked_in:
attendance.sudo().write({
'x_fclk_location_id': location.id,
'x_fclk_in_distance': 0.0,
'x_fclk_clock_source': 'kiosk',
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
})
api._log_activity(employee, 'clock_in', f"Kiosk clock-in at {location.name}",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0, source='kiosk')
if is_scheduled_off:
api._log_activity(employee, 'unscheduled_shift',
f"Kiosk clock-in on an unscheduled day at {location.name}",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0, source='kiosk')
else:
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
return {'success': True, 'action': 'clock_in', 'employee_name': employee.name,
'message': f'{employee.name} clocked in at {location.name}', 'worked_hours': 0.0}
else:
attendance.sudo().write({
'x_fclk_out_distance': 0.0,
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
})
api._apply_break_deduction(attendance, employee)
if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity(employee, 'clock_out',
f"Kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0, source='kiosk')
return {'success': True, 'action': 'clock_out', 'employee_name': employee.name,
'message': f'{employee.name} clocked out from {location.name}',
'net_hours': round(attendance.x_fclk_net_hours or 0, 2)}
except Exception as e:
_logger.error("Fusion Clock PIN kiosk error: %s", str(e))
return {'error': str(e)}
```
Confirm `_strip_data_url_prefix` exists in `controllers/clock_nfc_kiosk.py` (it does — used by the NFC tap). Confirm `kiosk_page` already imports `fields` and `get_local_today` at module top (it does).
- [ ] **Step 4: Run the tests, verify they PASS** — run the test command. Expected: `TestPinKioskClock` passes.
- [ ] **Step 5: Commit**
```bash
cd /Users/gurpreet/Github/Odoo-Modules
git add -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py
git diff --cached --name-only
git commit --only -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py \
-m "feat(fusion_clock): PIN kiosk clock — kiosk location + master-gated selfie"
```
---
## Task 3: Settings cleanup, company relabel, app icon
**Files:**
- Modify: `models/res_config_settings.py`, `views/res_config_settings_views.xml`, `data/ir_config_parameter_data.xml`, `models/res_company.py`, `views/clock_menus.xml`
- [ ] **Step 1: Drop `kiosk_pin_required`** (PIN always required now):
- `models/res_config_settings.py`: delete the `fclk_kiosk_pin_required = fields.Boolean(...)` field block AND its line in `_FCLK_BOOL_PARAMS` (`('fclk_kiosk_pin_required', 'fusion_clock.kiosk_pin_required', True),`).
- `views/res_config_settings_views.xml`: delete the `<field name="fclk_kiosk_pin_required"/>` and its surrounding `<setting>`/row.
- `data/ir_config_parameter_data.xml`: delete the `config_kiosk_pin_required` record.
- [ ] **Step 2: Relabel the kiosk location** — in `models/res_company.py`, change the field string/help (it now serves NFC + PIN):
```python
x_fclk_nfc_kiosk_location_id = fields.Many2one(
'fusion.clock.location',
string='Kiosk Location',
help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.",
)
```
- [ ] **Step 3: Add the PIN Kiosk app icon** — in `views/clock_menus.xml`, after the NFC kiosk app block, add:
```xml
<record id="action_fusion_clock_kiosk_pin" model="ir.actions.act_url">
<field name="name">Fusion Clock PIN Kiosk</field>
<field name="url">/fusion_clock/kiosk</field>
<field name="target">self</field>
</record>
<menuitem id="menu_fusion_clock_kiosk_pin_app_root"
name="Fusion Clock PIN Kiosk"
web_icon="fusion_clock,static/description/icon.png"
action="action_fusion_clock_kiosk_pin"
sequence="47"
groups="group_fusion_clock_kiosk_app"/>
```
- [ ] **Step 4: Apply + verify** — run:
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -20
```
Expected: no ParseError / no `Invalid field` for `fclk_kiosk_pin_required`.
- [ ] **Step 5: Commit**
```bash
cd /Users/gurpreet/Github/Odoo-Modules
git add -- fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/models/res_company.py fusion_clock/views/clock_menus.xml
git diff --cached --name-only
git commit --only -- fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/models/res_company.py fusion_clock/views/clock_menus.xml \
-m "feat(fusion_clock): drop kiosk_pin_required, relabel kiosk location, add PIN kiosk app icon"
```
---
## Task 4: SCSS — `pin_kiosk.scss` (mirror the NFC kiosk)
**Files:**
- Create: `static/src/scss/pin_kiosk.scss`
- Modify: `__manifest__.py` (register in `web.assets_frontend`)
- [ ] **Step 1: Create `static/src/scss/pin_kiosk.scss`** — mirror `nfc_kiosk.scss` exactly for the shared chrome, **but** scope every rule under `:has(#pin_kiosk_root)` / `.pin-kiosk`, rename the hue var to `--pk-h`, and replace the NFC idle/icon section with the **grid + tiles**. Full file:
```scss
// PIN Clock Kiosk — premium glass + animated mesh, always-dark.
// Mirrors nfc_kiosk.scss; scoped under :has(#pin_kiosk_root) so it never leaks.
// Brand hue --pk-h is set by JS from the company logo's dominant color.
:root {
--pk-h: 168;
--pk-bg: #0b0d10;
--pk-text: #ffffff;
--pk-text-muted: #9ba3ad;
--pk-success: #18a957;
--pk-error: #d9374e;
}
html:has(#pin_kiosk_root) {
overflow: hidden; height: 100%;
body { overflow: hidden; height: 100%; margin: 0; padding: 0;
background: var(--pk-bg) !important; color: var(--pk-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
.o_main_navbar, header, footer, .o_header_standard, .o_footer { display: none !important; }
}
.pin-kiosk {
position: fixed; inset: 0; width: 100vw; height: 100vh;
display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
padding: 1.25rem 2rem 2rem; box-sizing: border-box; user-select: none;
-webkit-tap-highlight-color: transparent; overflow: hidden; background: var(--pk-bg);
&::before { content: ""; position: absolute; inset: -15%;
background:
radial-gradient(circle at 20% 30%, hsla(var(--pk-h), 75%, 40%, 0.55) 0%, transparent 45%),
radial-gradient(circle at 80% 20%, hsla(calc(var(--pk-h) + 40), 65%, 35%, 0.50) 0%, transparent 50%),
radial-gradient(circle at 70% 75%, hsla(calc(var(--pk-h) - 25), 70%, 35%, 0.45) 0%, transparent 55%),
radial-gradient(circle at 15% 85%, hsla(calc(var(--pk-h) + 80), 60%, 30%, 0.40) 0%, transparent 50%);
filter: blur(60px) saturate(140%); animation: pk-mesh 28s ease-in-out infinite alternate; z-index: 0; }
&::after { content: ""; position: absolute; inset: 0;
background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.45) 100%); z-index: 1; pointer-events: none; }
> * { position: relative; z-index: 2; }
}
@keyframes pk-mesh {
0% { transform: translate(0,0) rotate(0) scale(1); }
50% { transform: translate(3%,-2%) rotate(2deg) scale(1.05); }
100% { transform: translate(-3%,3%) rotate(-1deg) scale(0.98); }
}
// Header chrome
.pin-kiosk__logo { max-height: 56px; max-width: 240px; object-fit: contain;
background: rgba(255,255,255,0.95); padding: 0.55rem 1rem; border-radius: 0.9rem;
border: 2px solid hsla(var(--pk-h), 85%, 72%, 0.95);
box-shadow: 0 8px 28px rgba(0,0,0,0.4), 0 0 26px hsla(var(--pk-h), 90%, 60%, 0.5); }
.pin-kiosk__clock { margin-top: 0.5rem; font-size: 2.1rem; font-weight: 300; font-variant-numeric: tabular-nums;
letter-spacing: -0.02em; text-shadow: 0 2px 12px rgba(0,0,0,0.4);
.ampm { font-size: 0.9rem; font-weight: 500; color: var(--pk-text-muted); margin-left: 0.3rem; } }
.pin-kiosk__date { font-size: 0.8rem; color: var(--pk-text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 0.1rem; }
// Search
.pin-kiosk__search { margin: 1rem 0 0.85rem; width: min(440px, 92%);
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 999px;
padding: 0.7rem 1.2rem; color: var(--pk-text); font-size: 1rem; outline: none;
&::placeholder { color: var(--pk-text-muted); }
&:focus { border-color: hsl(var(--pk-h), 80%, 55%); } }
// Tile grid
.pin-kiosk__grid { flex: 1; min-height: 0; overflow-y: auto; width: 100%; max-width: 1100px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.85rem; align-content: start; padding-bottom: 1rem; }
.pin-kiosk__tile { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.85rem 0.4rem;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); border-radius: 1rem;
box-shadow: 0 8px 24px rgba(0,0,0,0.25); cursor: pointer; transition: transform 120ms ease, background 150ms ease;
&:hover, &:active { background: rgba(255,255,255,0.1); transform: translateY(-2px); } }
.pin-kiosk__tile-av { width: 60px; height: 60px; border-radius: 50%; background-size: cover; background-position: center;
display: flex; align-items: center; justify-content: center; font-size: 1.25rem; font-weight: 700; color: #fff;
border: 2px solid rgba(255,255,255,0.25); box-shadow: 0 6px 16px rgba(0,0,0,0.35); }
.pin-kiosk__tile-nm { font-size: 0.8rem; text-align: center; line-height: 1.15; color: #e7ebf0; max-width: 100px; }
// Bottom chrome
.pin-kiosk__location { position: absolute; bottom: 1.5rem; left: 1.5rem; font-size: 0.85rem; color: var(--pk-text-muted);
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); padding: 0.5rem 1rem; border-radius: 999px; }
.pin-kiosk__settings { position: absolute; bottom: 1.5rem; right: 1.5rem; width: 2.75rem; height: 2.75rem; border-radius: 50%;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); color: var(--pk-text-muted);
display: flex; align-items: center; justify-content: center; font-size: 1.2rem; cursor: pointer; }
// Glass overlay (PIN pad / setup / result), centered
.pin-kiosk__overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55); backdrop-filter: blur(6px); padding: 2rem; animation: pk-fade 200ms ease-out; }
@keyframes pk-fade { from { opacity: 0; } to { opacity: 1; } }
%pk-glass { background: rgba(255,255,255,0.06); backdrop-filter: blur(24px) saturate(160%);
border: 1px solid rgba(255,255,255,0.12); box-shadow: 0 20px 60px rgba(0,0,0,0.5); border-radius: 1.5rem; }
.pin-kiosk__panel { @extend %pk-glass; padding: 1.75rem 2rem; width: min(360px, 90%);
display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
.pin-kiosk__av { width: 64px; height: 64px; border-radius: 50%; background-size: cover; background-position: center;
display: flex; align-items: center; justify-content: center; font-size: 1.4rem; font-weight: 700; color: #fff;
background-color: hsl(var(--pk-h), 60%, 45%); border: 2px solid rgba(255,255,255,0.25); }
.pin-kiosk__name { font-size: 1.25rem; font-weight: 600; }
.pin-kiosk__sub { font-size: 0.85rem; color: var(--pk-text-muted); margin-top: -0.3rem; }
.pin-kiosk__dots { display: flex; gap: 0.85rem; margin: 0.5rem 0; }
.pin-kiosk__dot { width: 0.85rem; height: 0.85rem; border-radius: 50%; border: 2px solid hsla(var(--pk-h),80%,70%,0.8);
&.on { background: hsl(var(--pk-h),80%,65%); border-color: hsl(var(--pk-h),80%,65%); } }
.pin-kiosk__pad { display: grid; grid-template-columns: repeat(3, 4rem); gap: 0.6rem; }
.pin-kiosk__key { height: 3.25rem; border-radius: 0.85rem; background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12); color: var(--pk-text); font-size: 1.4rem; font-weight: 300; cursor: pointer;
display: flex; align-items: center; justify-content: center;
&:active { transform: scale(0.95); background: rgba(255,255,255,0.14); }
&.ok { background: hsl(var(--pk-h),80%,45%); border-color: transparent; } }
.pin-kiosk__cancel { margin-top: 0.3rem; color: var(--pk-text-muted); font-size: 0.85rem; cursor: pointer; background: none; border: none; }
.pin-kiosk__err { min-height: 1.1rem; color: var(--pk-error); font-size: 0.9rem; }
.pin-kiosk__panel.shake { animation: pk-shake 350ms ease-in-out; }
@keyframes pk-shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-10px)} 40%{transform:translateX(10px)} 60%{transform:translateX(-6px)} 80%{transform:translateX(6px)} }
// Result card
.pin-kiosk__result { @extend %pk-glass; padding: 2.25rem 3rem; display: flex; flex-direction: column; align-items: center;
gap: 0.6rem; text-align: center; width: min(420px, 90%);
border-color: rgba(24,169,87,0.55); box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 80px rgba(24,169,87,0.35);
&--error { border-color: rgba(217,55,78,0.55); box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 60px rgba(217,55,78,0.3); } }
.pin-kiosk__check { width: 74px; height: 74px; border-radius: 50%; background: rgba(24,169,87,0.18);
border: 2px solid rgba(24,169,87,0.6); display: flex; align-items: center; justify-content: center; font-size: 2rem; color: #34d399; }
.pin-kiosk__result .name { font-size: 1.6rem; font-weight: 600; }
.pin-kiosk__result .action { font-size: 1.2rem; color: #34d399; font-weight: 500; }
.pin-kiosk__result .meta { font-size: 0.9rem; color: var(--pk-text-muted); }
// Photo capture (reuse the NFC oval-guide pattern)
.pin-kiosk__photo { @extend %pk-glass; padding: 1.5rem; width: min(540px,86%); text-align: center;
.stage { position: relative; aspect-ratio: 3/4; height: 56vh; max-height: 480px; margin: 0 auto; border-radius: 1rem; overflow: hidden; background: #000; }
video, img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
video { transform: scaleX(-1); }
.guide { position: absolute; top: 47%; left: 50%; width: 64%; aspect-ratio: 3/4; 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); }
.countdown { position: absolute; top: 47%; left: 50%; transform: translate(-50%,-50%); font-size: 5rem; font-weight: 200; color: #fff; text-shadow: 0 2px 24px rgba(0,0,0,0.85); } }
@media (prefers-reduced-motion: reduce) {
.pin-kiosk::before, .pin-kiosk__panel.shake, .pin-kiosk__result { animation: none; }
}
```
- [ ] **Step 2: Register in the manifest** — in `__manifest__.py` `web.assets_frontend`, add after `nfc_kiosk.scss`:
```python
'fusion_clock/static/src/scss/pin_kiosk.scss',
```
- [ ] **Step 3: Force-compile to verify the SCSS is valid** — run:
```bash
docker exec odoo-dev-app odoo shell -d fusion-dev --no-http 2>/dev/null <<'PY'
env['ir.qweb']._get_asset_bundle('web.assets_frontend').css()
print('FRONTEND BUNDLE OK')
PY
```
Expected: `FRONTEND BUNDLE OK`, no Sass error. (If `min()`/mixed-unit or `@extend` errors appear, fix before moving on.)
- [ ] **Step 4: Commit**
```bash
cd /Users/gurpreet/Github/Odoo-Modules
git add -- fusion_clock/static/src/scss/pin_kiosk.scss fusion_clock/__manifest__.py
git diff --cached --name-only
git commit --only -- fusion_clock/static/src/scss/pin_kiosk.scss fusion_clock/__manifest__.py \
-m "feat(fusion_clock): PIN kiosk SCSS (glass + brand-gradient, scoped)"
```
---
## Task 5: Template — rebuild `views/kiosk_templates.xml`
**Files:**
- Modify: `views/kiosk_templates.xml`
- Modify: `controllers/clock_kiosk.py` (`kiosk_page` context)
- [ ] **Step 1: Update `kiosk_page` context** — in `controllers/clock_kiosk.py`, replace the `values = {...}` in `kiosk_page` with:
```python
company = request.env.company.sudo()
location = company.x_fclk_nfc_kiosk_location_id
values = {
'page_name': 'kiosk',
'company_name': company.name,
'company_logo_url': '/web/image/res.company/%s/logo' % company.id if company.logo else '',
'location_name': location.name if location else 'No location configured',
'sounds_enabled': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True',
'photo_required': ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True',
}
```
- [ ] **Step 2: Replace `views/kiosk_templates.xml`** with:
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="kiosk_page" name="Fusion Clock PIN Kiosk">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<div id="pin_kiosk_root" class="pin-kiosk"
t-att-data-logo-url="company_logo_url"
t-att-data-location="location_name"
t-att-data-sounds="'1' if sounds_enabled else '0'"
t-att-data-photo="'1' if photo_required else '0'">
<img t-if="company_logo_url" id="pin_kiosk_logo" class="pin-kiosk__logo" t-att-src="company_logo_url" alt="Logo"/>
<div class="pin-kiosk__clock" id="pin_kiosk_clock"></div>
<div class="pin-kiosk__date" id="pin_kiosk_date"></div>
<input type="text" class="pin-kiosk__search" id="pin_kiosk_search" placeholder="Search your name…" autocomplete="off"/>
<div class="pin-kiosk__grid" id="pin_kiosk_grid"></div>
<div class="pin-kiosk__location" t-esc="location_name"/>
<div class="pin-kiosk__settings" id="pin_kiosk_settings"></div>
<div id="pin_state_container"></div>
</div>
</t>
</template>
</odoo>
```
- [ ] **Step 3: Apply + verify the template loads** — run:
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -15
```
Expected: no ParseError on `fusion_clock.kiosk_page`.
- [ ] **Step 4: Commit**
```bash
cd /Users/gurpreet/Github/Odoo-Modules
git add -- fusion_clock/views/kiosk_templates.xml fusion_clock/controllers/clock_kiosk.py
git diff --cached --name-only
git commit --only -- fusion_clock/views/kiosk_templates.xml fusion_clock/controllers/clock_kiosk.py \
-m "feat(fusion_clock): PIN kiosk template (logo, clock, search, grid, state container)"
```
---
## Task 6: JS — rewrite `fusion_clock_kiosk.js` as an Interaction
**Files:**
- Modify: `static/src/js/fusion_clock_kiosk.js`
- [ ] **Step 1: Replace the file** with an Odoo-19 Interaction. Full implementation:
```javascript
/** @odoo-module **/
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
export class PinKiosk extends Interaction {
static selector = "#pin_kiosk_root";
setup() {
this.root = this.el;
this.grid = this.el.querySelector("#pin_kiosk_grid");
this.searchEl = this.el.querySelector("#pin_kiosk_search");
this.stage = this.el.querySelector("#pin_state_container");
this.photoRequired = this.el.dataset.photo === "1";
this.soundsOn = this.el.dataset.sounds === "1";
this.employees = [];
this.filtered = [];
}
async willStart() {
const res = await rpc("/fusion_clock/kiosk/search", { query: "" });
this.employees = res.employees || [];
this.filtered = this.employees;
}
start() {
this.initBrandHue();
this.startClock();
this.renderGrid();
this.searchEl.addEventListener("input", () => this.onSearch());
}
// ---- brand hue (mirrors fusion_clock_nfc_kiosk.js) ----
rgbToHue(r, g, b) {
r /= 255; g /= 255; b /= 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
if (d === 0) return null;
let h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
h = Math.round(h * 60); if (h < 0) h += 360; return h;
}
extractHue(img) {
try {
const w = img.naturalWidth, h = img.naturalHeight; if (!w || !h) return null;
const c = document.createElement("canvas"); c.width = w; c.height = h;
const ctx = c.getContext("2d"); ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(0, 0, w, h).data;
let rs = 0, gs = 0, bs = 0, n = 0;
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i+1], b = data[i+2], a = data[i+3];
if (a < 128) continue;
if (Math.max(r,g,b) - Math.min(r,g,b) < 25) continue;
rs += r; gs += g; bs += b; n++;
}
if (n < 20) return null;
return this.rgbToHue(Math.round(rs/n), Math.round(gs/n), Math.round(bs/n));
} catch (e) { return null; }
}
initBrandHue() {
const img = this.el.querySelector("#pin_kiosk_logo");
if (!img) return;
const apply = () => { const hue = this.extractHue(img); if (hue != null) document.documentElement.style.setProperty("--pk-h", String(hue)); };
if (img.complete) apply(); else img.addEventListener("load", apply);
}
// ---- clock ----
startClock() {
const tick = () => {
const d = new Date();
let h = d.getHours(); const m = String(d.getMinutes()).padStart(2, "0");
const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12;
this.el.querySelector("#pin_kiosk_clock").innerHTML = `${h}:${m}<span class="ampm">${ap}</span>`;
this.el.querySelector("#pin_kiosk_date").textContent =
d.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
};
tick(); this._clockTimer = setInterval(tick, 1000);
}
// ---- grid ----
initials(name) { return (name||"").split(" ").filter(Boolean).slice(0,2).map(p=>p[0].toUpperCase()).join(""); }
onSearch() {
const q = this.searchEl.value.trim().toLowerCase();
this.filtered = q ? this.employees.filter(e => e.name.toLowerCase().includes(q)) : this.employees;
this.renderGrid();
}
renderGrid() {
this.grid.innerHTML = "";
for (const emp of this.filtered) {
const tile = document.createElement("div");
tile.className = "pin-kiosk__tile";
const av = document.createElement("div");
av.className = "pin-kiosk__tile-av";
if (emp.avatar_url) av.style.backgroundImage = `url(${emp.avatar_url})`;
av.textContent = emp.avatar_url ? "" : this.initials(emp.name);
const nm = document.createElement("div");
nm.className = "pin-kiosk__tile-nm"; nm.textContent = emp.name;
tile.append(av, nm);
tile.addEventListener("click", () => this.onTile(emp));
this.grid.appendChild(tile);
}
}
// ---- PIN / setup overlay ----
onTile(emp) {
this.current = emp; this.pinBuf = ""; this.attempts = 0;
if (emp.has_pin) this.showPin(emp, "Enter your PIN", false);
else this.showPin(emp, "Create a PIN", true); // first-use
}
showPin(emp, sub, isSetup, confirming) {
this.stage.innerHTML = "";
const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay";
const panel = document.createElement("div"); panel.className = "pin-kiosk__panel";
panel.innerHTML = `
<div class="pin-kiosk__av">${emp.avatar_url ? "" : this.initials(emp.name)}</div>
<div class="pin-kiosk__name">${emp.name}</div>
<div class="pin-kiosk__sub">${confirming ? "Re-enter to confirm" : sub}</div>
<div class="pin-kiosk__dots"></div>
<div class="pin-kiosk__err"></div>
<div class="pin-kiosk__pad"></div>
<button class="pin-kiosk__cancel">✕ Cancel</button>`;
if (emp.avatar_url) panel.querySelector(".pin-kiosk__av").style.backgroundImage = `url(${emp.avatar_url})`;
const pad = panel.querySelector(".pin-kiosk__pad");
const keys = ["1","2","3","4","5","6","7","8","9","⌫","0","✓"];
for (const k of keys) {
const b = document.createElement("button");
b.className = "pin-kiosk__key" + (k === "✓" ? " ok" : "");
b.textContent = k;
b.addEventListener("click", () => this.onKey(k, emp, isSetup, confirming));
pad.appendChild(b);
}
panel.querySelector(".pin-kiosk__cancel").addEventListener("click", () => this.reset());
ov.appendChild(panel); this.stage.appendChild(ov);
this._panel = panel; this.renderDots();
}
renderDots() {
const dots = this._panel.querySelector(".pin-kiosk__dots"); dots.innerHTML = "";
const len = Math.max(4, this.pinBuf.length);
for (let i = 0; i < len; i++) {
const d = document.createElement("span");
d.className = "pin-kiosk__dot" + (i < this.pinBuf.length ? " on" : "");
dots.appendChild(d);
}
}
err(msg) {
const e = this._panel.querySelector(".pin-kiosk__err"); e.textContent = msg;
this._panel.classList.add("shake"); setTimeout(() => this._panel.classList.remove("shake"), 360);
}
onKey(k, emp, isSetup, confirming) {
if (k === "⌫") { this.pinBuf = this.pinBuf.slice(0, -1); this.renderDots(); return; }
if (k === "✓") { this.submitPin(emp, isSetup, confirming); return; }
if (this.pinBuf.length < 6) { this.pinBuf += k; this.renderDots(); }
if (this.pinBuf.length >= 4 && !isSetup) { /* allow ✓; no auto-submit */ }
}
async submitPin(emp, isSetup, confirming) {
const pin = this.pinBuf;
if (pin.length < 4) return this.err("PIN must be at least 4 digits");
if (isSetup && !confirming) { // first entry of new PIN → confirm
this._newPin = pin; this.pinBuf = "";
return this.showPin(emp, "Create a PIN", true, true);
}
if (isSetup && confirming) {
if (pin !== this._newPin) { this.pinBuf = ""; this.renderDots(); return this.err("PINs didn't match"); }
const r = await rpc("/fusion_clock/kiosk/set_pin", { employee_id: emp.id, pin });
if (r.error) return this.err("Couldn't save PIN");
return this.afterPin(emp);
}
const v = await rpc("/fusion_clock/kiosk/verify_pin", { employee_id: emp.id, pin });
if (v.success) return this.afterPin(emp);
this.attempts++; this.pinBuf = ""; this.renderDots();
if (this.attempts >= 3) return this.reset();
this.err("Wrong PIN — try again");
}
// ---- photo (optional) then clock ----
async afterPin(emp) {
let photo = "";
if (this.photoRequired) {
try { photo = await this.capturePhoto(emp); } catch (e) { photo = ""; }
}
const r = await rpc("/fusion_clock/kiosk/clock", { employee_id: emp.id, photo_b64: photo });
this.showResult(emp, r);
}
showResult(emp, r) {
this.stage.innerHTML = "";
const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay";
const card = document.createElement("div");
if (r && r.success) {
card.className = "pin-kiosk__result";
const act = r.action === "clock_out" ? "Clocked Out" : "Clocked In";
card.innerHTML = `<div class="pin-kiosk__check">✓</div>
<div class="name">${emp.name}</div><div class="action">${act}</div>
<div class="meta">${r.message || ""}</div>`;
if (this.soundsOn) this.beep();
} else {
card.className = "pin-kiosk__result pin-kiosk__result--error";
card.innerHTML = `<div class="pin-kiosk__check" style="color:#f87171;background:rgba(217,55,78,.18);border-color:rgba(217,55,78,.6)">!</div>
<div class="name">${emp.name}</div><div class="action" style="color:#f87171">Couldn't clock</div>
<div class="meta">${(r && r.error) || "Try again"}</div>`;
}
ov.appendChild(card); this.stage.appendChild(ov);
setTimeout(() => this.reset(), 3000);
}
beep() { try { const a = new (window.AudioContext || window.webkitAudioContext)(); const o = a.createOscillator(); o.frequency.value = 880; o.connect(a.destination); o.start(); o.stop(a.currentTime + 0.12); } catch (e) {} }
// ---- camera capture (mirrors the NFC kiosk; oval guide + 3s countdown) ----
capturePhoto(emp) {
return new Promise(async (resolve, reject) => {
let stream;
try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" } }); }
catch (e) { return reject(e); }
this.stage.innerHTML = "";
const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay";
const panel = document.createElement("div"); panel.className = "pin-kiosk__photo";
panel.innerHTML = `<h2>${emp.name}</h2>
<div class="stage"><video autoplay playsinline></video><div class="guide"></div><div class="countdown"></div></div>`;
ov.appendChild(panel); this.stage.appendChild(ov);
const video = panel.querySelector("video"); video.srcObject = stream;
const cd = panel.querySelector(".countdown");
let n = 3; cd.textContent = n;
const timer = setInterval(() => {
n--; if (n > 0) { cd.textContent = n; return; }
clearInterval(timer);
const c = document.createElement("canvas"); c.width = video.videoWidth; c.height = video.videoHeight;
c.getContext("2d").drawImage(video, 0, 0);
stream.getTracks().forEach(t => t.stop());
resolve(c.toDataURL("image/jpeg", 0.8));
}, 1000);
});
}
reset() {
this.stage.innerHTML = ""; this.pinBuf = ""; this.current = null; this._newPin = null;
this.searchEl.value = ""; this.filtered = this.employees; this.renderGrid();
// refresh checked-in state in the background
rpc("/fusion_clock/kiosk/search", { query: "" }).then(res => { this.employees = res.employees || []; });
}
destroy() { if (this._clockTimer) clearInterval(this._clockTimer); }
}
registry.category("public.interactions").add("fusion_clock.pin_kiosk", PinKiosk);
```
- [ ] **Step 2: Syntax-check** — run:
```bash
docker exec odoo-dev-app node --check /mnt/extra-addons/custom/fusion_clock/static/src/js/fusion_clock_kiosk.js 2>&1 | tail -3 || echo "(node unavailable — rely on browser load in Task 7)"
```
Expected: no syntax error.
- [ ] **Step 3: Commit**
```bash
cd /Users/gurpreet/Github/Odoo-Modules
git add -- fusion_clock/static/src/js/fusion_clock_kiosk.js
git diff --cached --name-only
git commit --only -- fusion_clock/static/src/js/fusion_clock_kiosk.js \
-m "feat(fusion_clock): PIN kiosk Interaction (grid, PIN/setup, photo, clock)"
```
---
## Task 7: Version bump, full upgrade + tests, manual smoke, deploy
**Files:**
- Modify: `__manifest__.py` (version), `fusion_clock/CLAUDE.md`
- [ ] **Step 1: Bump version**`__manifest__.py` version → `19.0.4.0.0` (new feature).
- [ ] **Step 2: Update module docs** — in `fusion_clock/CLAUDE.md`: in the kiosk section note the classic kiosk is now the polished PIN kiosk (photo-tile → PIN → optional selfie, brand-gradient, app icon, opt-in via `enable_kiosk`); remove `fusion_clock.kiosk_pin_required` from the §11 settings-keys list.
- [ ] **Step 3: Full upgrade + run the suite**
```bash
docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
```
Expected: upgrade succeeds; `test_clock_kiosk` passes; existing tests still pass; `0 failed, 0 error`.
- [ ] **Step 4: Manual browser smoke (local)** — http://localhost:8082: as a manager, set `enable_kiosk` ON + a Kiosk Location, open `/fusion_clock/kiosk`. Confirm: logo pill + brand gradient + live clock; the photo-tile grid; search filters; tapping a tile opens the PIN pad; a no-PIN employee gets the create+confirm flow; correct PIN → (selfie if Photo Verification ON) → success card → auto-return; wrong PIN shakes. Toggle Photo Verification and confirm the selfie step appears/disappears.
- [ ] **Step 5: Commit**
```bash
cd /Users/gurpreet/Github/Odoo-Modules
git add -- fusion_clock/__manifest__.py fusion_clock/CLAUDE.md
git diff --cached --name-only
git commit --only -- fusion_clock/__manifest__.py fusion_clock/CLAUDE.md \
-m "chore(fusion_clock): bump 19.0.4.0.0 (PIN kiosk) + docs"
```
- [ ] **Step 6: Push both remotes + deploy entech**
```bash
cd /Users/gurpreet/Github/Odoo-Modules
git log origin/main..HEAD --oneline
git push origin main && git push gitea main
```
Then deploy the whole `fusion_clock` dir to entech (tar excluding `.superpowers`/`__pycache__`/`*.pyc`/`.DS_Store` → scp pve-worker5 → pct push 111 → extract → chown odoo:odoo → upgrade as `odoo` user with `--http-port=0 --gevent-port=0`). Verify web/login → 200, version `19.0.4.0.0`, and (read-only) the `/fusion_clock/kiosk` page renders for the operator. Hard-refresh the tablet. (entech can keep using the NFC kiosk; the PIN kiosk is opt-in via `enable_kiosk` + the PIN Kiosk app icon.)
---
## Self-Review (completed inline)
- **Spec coverage:** §3.1 flow → Tasks 5/6; §3.2 style → Task 4; §3.3 backend (search/verify/set_pin/clock) → Tasks 12; §3.4 JS Interaction → Task 6; §3.5 template → Task 5; §3.6 settings/menu/location/PIN → Task 3 (+ company relabel); photo master-gate → Task 2; tests → Tasks 12 + Task 7; deploy → Task 7.
- **Placeholder scan:** none — complete code for backend/tests/config/template/JS/SCSS; commands have expected output.
- **Type/name consistency:** routes `/fusion_clock/kiosk/{search,verify_pin,set_pin,clock}` match between controller (Tasks 12), JS `rpc(...)` calls (Task 6), and tests. Return keys (`employees[].{has_pin,avatar_url}`, `needs_setup`, `invalid_pin`, `already_set`, `bad_pin`, `no_location_configured`, `success/action/message/net_hours`) consistent across controller, JS, tests. DOM ids (`#pin_kiosk_root`, `#pin_kiosk_grid`, `#pin_kiosk_search`, `#pin_state_container`, `#pin_kiosk_logo`, `#pin_kiosk_clock`, `#pin_kiosk_date`) match between template (Task 5), JS (Task 6), and SCSS scoping (Task 4). `--pk-h` used in SCSS + set in JS. `x_fclk_nfc_kiosk_location_id` used consistently as the kiosk location.
- **Scope:** single feature; one plan.