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

50 KiB
Raw Blame History

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):

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:

from . import test_clock_kiosk
  • Step 2: Write the failing tests — create fusion_clock/tests/test_clock_kiosk.py:
# -*- 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:

    @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

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:

@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:

    @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

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):

    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:
    <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:
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
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:

// 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:
            'fusion_clock/static/src/scss/pin_kiosk.scss',
  • Step 3: Force-compile to verify the SCSS is valid — run:
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
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:

        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 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:
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
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:

/** @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:
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
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

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

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
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.