From a5ec79013a066f65d64f967af2ec5744a3be77be Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 31 May 2026 21:25:32 -0400 Subject: [PATCH] =?UTF-8?q?feat(fusion=5Fclock):=20PIN=20kiosk=20=E2=80=94?= =?UTF-8?q?=20polished=20photo-tile=20+=20PIN=20clock=20(opt-in)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A proper shared-device PIN kiosk for clients who don't want NFC: photo-tile grid (+search) -> tap -> PIN (or first-use create) -> optional master-gated selfie -> clock, in the NFC kiosk's dark glass + brand-gradient style. Built as an Odoo 19 Interaction; new pin_kiosk.scss (scoped); reworked clock_kiosk.py (search +avatar/has_pin, verify_pin needs_setup, set_pin, clock via kiosk location). Drops the redundant kiosk_pin_required (PIN always required); relabels the company kiosk location; adds a PIN-kiosk app icon. Opt-in via enable_kiosk (off by default). HttpCase tests added. Bump 19.0.4.0.0. Co-Authored-By: Claude Opus 4.8 --- fusion_clock/CLAUDE.md | 24 +- fusion_clock/__manifest__.py | 3 +- fusion_clock/controllers/clock_kiosk.py | 178 +++---- .../data/ir_config_parameter_data.xml | 4 - fusion_clock/models/res_company.py | 5 +- fusion_clock/models/res_config_settings.py | 6 - .../static/src/js/fusion_clock_kiosk.js | 496 ++++++++++-------- fusion_clock/static/src/scss/pin_kiosk.scss | 129 +++++ fusion_clock/tests/__init__.py | 1 + fusion_clock/tests/test_clock_kiosk.py | 130 +++++ fusion_clock/views/clock_menus.xml | 14 + fusion_clock/views/kiosk_templates.xml | 69 +-- .../views/res_config_settings_views.xml | 6 - 13 files changed, 672 insertions(+), 393 deletions(-) create mode 100644 fusion_clock/static/src/scss/pin_kiosk.scss create mode 100644 fusion_clock/tests/test_clock_kiosk.py diff --git a/fusion_clock/CLAUDE.md b/fusion_clock/CLAUDE.md index 1061d4b6..c92f57db 100644 --- a/fusion_clock/CLAUDE.md +++ b/fusion_clock/CLAUDE.md @@ -110,16 +110,23 @@ Location verification uses GPS when coordinates are available and geocoded locat ## 6. Kiosk And NFC -Classic kiosk: +PIN kiosk (opt-in alternative to NFC; v19.0.4.0.0+): -- Page: `/fusion_clock/kiosk` +- Page: `/fusion_clock/kiosk` — polished photo-tile → PIN flow (logo, brand-hue + gradient, live clock), matching the NFC kiosk style; built as an Odoo 19 + Interaction (`#pin_kiosk_root`, `static/src/js/fusion_clock_kiosk.js`, + `static/src/scss/pin_kiosk.scss`, brand-hue var `--pk-h`). - JSON routes: - - `/fusion_clock/kiosk/search` - - `/fusion_clock/kiosk/verify_pin` - - `/fusion_clock/kiosk/clock` -- Requires `fusion_clock.group_fusion_clock_manager`. -- Controlled by `fusion_clock.enable_kiosk` and `fusion_clock.kiosk_pin_required`. -- Uses `hr.employee.x_fclk_kiosk_pin`. + - `/fusion_clock/kiosk/search` (grid rows: +`avatar_url`, +`has_pin`; also used by the NFC kiosk's employee_search — keep additive) + - `/fusion_clock/kiosk/verify_pin` (returns `needs_setup` when the employee has no PIN) + - `/fusion_clock/kiosk/set_pin` (first-use PIN creation, 4–6 digits) + - `/fusion_clock/kiosk/clock` (uses the company kiosk location, no GPS geofence; optional master-gated selfie) +- Requires `group_fusion_clock_manager` or `group_fusion_clock_kiosk_app`; has its own app icon. +- Opt-in via `fusion_clock.enable_kiosk`. PIN is ALWAYS required (the old + `kiosk_pin_required` setting was removed). Selfie capture is gated by the + master `fusion_clock.enable_photo_verification`. Kiosk location = + `res.company.x_fclk_nfc_kiosk_location_id` (shared with the NFC kiosk). +- Uses `hr.employee.x_fclk_kiosk_pin` (manager-editable; created on first tap otherwise). NFC kiosk: @@ -264,7 +271,6 @@ fusion_clock.enable_ip_fallback fusion_clock.enable_photo_verification fusion_clock.google_maps_api_key fusion_clock.enable_kiosk -fusion_clock.kiosk_pin_required fusion_clock.enable_correction_requests fusion_clock.enable_sounds fusion_clock.pay_period_type diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index 3932ec0b..036bf779 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Clock', - 'version': '19.0.3.16.1', + 'version': '19.0.4.0.0', 'category': 'Human Resources/Attendances', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'description': """ @@ -87,6 +87,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil 'web.assets_frontend': [ 'fusion_clock/static/src/css/portal_clock.css', 'fusion_clock/static/src/scss/nfc_kiosk.scss', + 'fusion_clock/static/src/scss/pin_kiosk.scss', 'fusion_clock/static/src/js/fusion_clock_portal.js', 'fusion_clock/static/src/js/fusion_clock_kiosk.js', 'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js', diff --git a/fusion_clock/controllers/clock_kiosk.py b/fusion_clock/controllers/clock_kiosk.py index 3c19c04c..9b0441f0 100644 --- a/fusion_clock/controllers/clock_kiosk.py +++ b/fusion_clock/controllers/clock_kiosk.py @@ -17,11 +17,11 @@ def _is_kiosk_operator(user): class FusionClockKiosk(http.Controller): - """Kiosk mode controller for shared-device clock-in/out.""" + """PIN kiosk — shared-device clock-in/out: tap your photo, enter a PIN.""" @http.route('/fusion_clock/kiosk', type='http', auth='user', website=True) def kiosk_page(self, **kw): - """Kiosk clock-in/out page for shared tablets.""" + """Polished PIN kiosk page for shared tablets.""" user = request.env.user if not _is_kiosk_operator(user): return request.redirect('/my') @@ -30,74 +30,95 @@ class FusionClockKiosk(http.Controller): if ICP.get_param('fusion_clock.enable_kiosk', 'False') != 'True': return request.redirect('/my') + company = request.env.company.sudo() + location = company.x_fclk_nfc_kiosk_location_id values = { - 'pin_required': ICP.get_param('fusion_clock.kiosk_pin_required', 'True') == 'True', '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', } return request.render('fusion_clock.kiosk_page', values) @http.route('/fusion_clock/kiosk/search', type='jsonrpc', auth='user', methods=['POST']) def kiosk_search(self, query='', **kw): - """Search employees for kiosk identification.""" - user = request.env.user - if not _is_kiosk_operator(user): + """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=20) - - return { - 'employees': [{ + ], 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 '', - } for emp in employees], - } + '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): - """Verify employee PIN for kiosk mode.""" - user = request.env.user - if not _is_kiosk_operator(user): + """Verify a PIN. Employees with no PIN return needs_setup.""" + if not _is_kiosk_operator(request.env.user): return {'error': 'Access denied.'} - - employee = request.env['hr.employee'].sudo().browse(employee_id) + employee = request.env['hr.employee'].sudo().browse(int(employee_id)) if not employee.exists(): - return {'error': 'Employee not found.'} + 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'} - if employee.x_fclk_kiosk_pin and 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} @http.route('/fusion_clock/kiosk/clock', type='jsonrpc', auth='user', methods=['POST']) - def kiosk_clock(self, employee_id=0, latitude=0, longitude=0, **kw): - """Perform clock action from kiosk on behalf of an employee.""" - user = request.env.user - if not _is_kiosk_operator(user): + 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(employee_id) + employee = request.env['hr.employee'].sudo().browse(int(employee_id)) if not employee.exists() or not employee.x_fclk_enable_clock: - return {'error': 'Employee not found or clock not enabled.'} + return {'error': 'not_found'} - from .clock_api import FusionClockAPI, haversine_distance + 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() - location, distance, err, method = api._verify_location(latitude, longitude, employee) - if not location: - return { - 'error': api._location_error_message(err, distance), - 'allowed': False, - } + 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() @@ -105,75 +126,46 @@ class FusionClockKiosk(http.Controller): day_plan = employee._get_fclk_day_plan(today) is_scheduled_off = not day_plan.get('scheduled') - geo_info = { - 'latitude': latitude, - 'longitude': longitude, - 'browser': 'kiosk', - 'ip_address': request.httprequest.remote_addr or '', - } - + 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': round(distance, 1), + '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=latitude, longitude=longitude, distance=distance, - source='kiosk', - ) - + 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=latitude, longitude=longitude, distance=distance, - source='kiosk', - ) + 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}', - } + 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': round(distance, 1), + '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=latitude, longitude=longitude, distance=distance, - 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), - } - + 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 kiosk error: %s", str(e)) + _logger.error("Fusion Clock PIN kiosk error: %s", str(e)) return {'error': str(e)} diff --git a/fusion_clock/data/ir_config_parameter_data.xml b/fusion_clock/data/ir_config_parameter_data.xml index 6bb305ec..f024abec 100644 --- a/fusion_clock/data/ir_config_parameter_data.xml +++ b/fusion_clock/data/ir_config_parameter_data.xml @@ -104,10 +104,6 @@ fusion_clock.enable_kiosk False - - fusion_clock.kiosk_pin_required - True - diff --git a/fusion_clock/models/res_company.py b/fusion_clock/models/res_company.py index 0780fe43..7daa8453 100644 --- a/fusion_clock/models/res_company.py +++ b/fusion_clock/models/res_company.py @@ -10,8 +10,7 @@ class ResCompany(models.Model): x_fclk_nfc_kiosk_location_id = fields.Many2one( 'fusion.clock.location', - string='NFC Kiosk Location', + string='Kiosk Location', domain="[('company_id', '=', id)]", - help="Designates which fusion.clock.location is bound to the NFC kiosk " - "for this company. Required when NFC kiosk is enabled.", + help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.", ) diff --git a/fusion_clock/models/res_config_settings.py b/fusion_clock/models/res_config_settings.py index bcb6a401..c8f9e7c3 100644 --- a/fusion_clock/models/res_config_settings.py +++ b/fusion_clock/models/res_config_settings.py @@ -151,11 +151,6 @@ class ResConfigSettings(models.TransientModel): default=False, help="Allow employees to clock in/out from a shared device using their PIN code.", ) - fclk_kiosk_pin_required = fields.Boolean( - string='Require PIN for Kiosk', - default=True, - help="Require employees to enter a PIN when using kiosk mode.", - ) fclk_enable_correction_requests = fields.Boolean( string='Enable Correction Requests', default=True, @@ -276,7 +271,6 @@ class ResConfigSettings(models.TransientModel): ('fclk_enable_ip_fallback', 'fusion_clock.enable_ip_fallback', True), ('fclk_enable_photo_verification', 'fusion_clock.enable_photo_verification', False), ('fclk_enable_kiosk', 'fusion_clock.enable_kiosk', False), - ('fclk_kiosk_pin_required', 'fusion_clock.kiosk_pin_required', True), ('fclk_enable_correction_requests', 'fusion_clock.enable_correction_requests', True), ('fclk_enable_sounds', 'fusion_clock.enable_sounds', True), ('fclk_auto_generate_reports', 'fusion_clock.auto_generate_reports', True), diff --git a/fusion_clock/static/src/js/fusion_clock_kiosk.js b/fusion_clock/static/src/js/fusion_clock_kiosk.js index c72743e3..44647f55 100644 --- a/fusion_clock/static/src/js/fusion_clock_kiosk.js +++ b/fusion_clock/static/src/js/fusion_clock_kiosk.js @@ -1,242 +1,288 @@ /** @odoo-module **/ +// Fusion Clock PIN Kiosk — tap your photo, enter a PIN, clock in/out. +// Built as an Odoo 19 public Interaction. Employee-derived strings are always +// inserted via textContent (never interpolated into innerHTML) to avoid XSS. import { Interaction } from "@web/public/interaction"; import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; -export class FusionClockKiosk extends Interaction { - static selector = "#fclk-kiosk"; +export class PinKiosk extends Interaction { + static selector = "#pin_kiosk_root"; setup() { - this.selectedEmployeeId = 0; - this.resetTimer = null; - this.searchTimeout = null; - - const pinAttr = this.el.dataset.pinRequired; - this.pinRequired = pinAttr === "true" || pinAttr === "True"; - - this._startClock(); - this._bindEvents(); + 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 = []; + this.pinBuf = ""; + this.startClock(); + this.initBrandHue(); + this.searchEl.addEventListener("input", () => this.onSearch()); + const gear = this.el.querySelector("#pin_kiosk_settings"); + if (gear) gear.addEventListener("click", () => this.toggleFullscreen()); + this._load(); } - _startClock() { - const el = document.getElementById("fclk-kiosk-time"); - if (!el) return; - const update = () => { - el.textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - }; - update(); - setInterval(update, 1000); - } - - _bindEvents() { - const queryInput = document.getElementById("fclk-kiosk-query"); - if (queryInput) { - queryInput.addEventListener("input", (e) => this._onSearch(e.target.value)); - } - - const backBtn = document.getElementById("fclk-kiosk-back-btn"); - if (backBtn) { - backBtn.addEventListener("click", () => this._resetKiosk()); - } - - const clockBtn = document.getElementById("fclk-kiosk-clock-btn"); - if (clockBtn) { - clockBtn.addEventListener("click", () => this._onClock()); - } - } - - _resetKiosk() { - const search = document.getElementById("fclk-kiosk-search"); - const pin = document.getElementById("fclk-kiosk-pin"); - const result = document.getElementById("fclk-kiosk-result"); - const error = document.getElementById("fclk-kiosk-error"); - const query = document.getElementById("fclk-kiosk-query"); - const results = document.getElementById("fclk-kiosk-results"); - const pinInput = document.getElementById("fclk-kiosk-pin-input"); - - if (search) search.style.display = ""; - if (pin) pin.style.display = "none"; - if (result) result.style.display = "none"; - if (error) error.style.display = "none"; - if (query) query.value = ""; - if (results) results.innerHTML = ""; - if (pinInput) pinInput.value = ""; - - this.selectedEmployeeId = 0; - if (this.resetTimer) clearTimeout(this.resetTimer); - } - - _showError(msg) { - const el = document.getElementById("fclk-kiosk-error"); - if (el) { - el.textContent = msg; - el.style.display = ""; - } - } - - _onSearch(value) { - if (this.searchTimeout) clearTimeout(this.searchTimeout); - const q = value.trim(); - if (q.length < 2) { - const container = document.getElementById("fclk-kiosk-results"); - if (container) container.innerHTML = ""; - return; - } - this.searchTimeout = setTimeout(async () => { - try { - const resp = await fetch("/fusion_clock/kiosk/search", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { query: q } }), - }); - const data = await resp.json(); - const employees = (data.result || {}).employees || []; - const container = document.getElementById("fclk-kiosk-results"); - if (!container) return; - container.innerHTML = ""; - for (const emp of employees) { - const item = document.createElement("a"); - item.href = "#"; - item.className = "list-group-item list-group-item-action d-flex justify-content-between"; - const statusBadge = emp.is_checked_in ? "bg-success" : "bg-secondary"; - const statusText = emp.is_checked_in ? "In" : "Out"; - item.innerHTML = - `${emp.name} ${emp.department}` + - `${statusText}`; - item.addEventListener("click", (e) => { - e.preventDefault(); - this._selectEmployee(emp); - }); - container.appendChild(item); - } - } catch { - this._showError("Search failed."); - } - }, 300); - } - - _selectEmployee(emp) { - this.selectedEmployeeId = emp.id; - const nameEl = document.getElementById("fclk-kiosk-emp-name"); - if (nameEl) nameEl.textContent = emp.name; - - const searchEl = document.getElementById("fclk-kiosk-search"); - const pinEl = document.getElementById("fclk-kiosk-pin"); - const errorEl = document.getElementById("fclk-kiosk-error"); - if (searchEl) searchEl.style.display = "none"; - if (pinEl) pinEl.style.display = ""; - if (errorEl) errorEl.style.display = "none"; - - const clockBtn = document.getElementById("fclk-kiosk-clock-btn"); - if (clockBtn) { - clockBtn.textContent = emp.is_checked_in ? "Clock Out" : "Clock In"; - clockBtn.className = "btn btn-lg " + (emp.is_checked_in ? "btn-danger" : "btn-success"); - } - } - - async _onClock() { - if (!this.selectedEmployeeId) return; - - const btn = document.getElementById("fclk-kiosk-clock-btn"); - if (btn) btn.disabled = true; - - const pinInput = document.getElementById("fclk-kiosk-pin-input"); - const pin = pinInput ? pinInput.value : ""; - - if (this.pinRequired && pin.length === 0) { - this._showError("Please enter your PIN."); - if (btn) btn.disabled = false; - return; - } - + toggleFullscreen() { try { - if (this.pinRequired) { - const vResp = await fetch("/fusion_clock/kiosk/verify_pin", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "call", - params: { employee_id: this.selectedEmployeeId, pin }, - }), - }); - const vData = await vResp.json(); - if (vData.result && vData.result.error) { - this._showError(vData.result.error); - if (btn) btn.disabled = false; - return; - } + if (document.fullscreenElement) document.exitFullscreen(); + else if (this.el.requestFullscreen) this.el.requestFullscreen().catch(() => {}); + } catch (e) { /* unsupported */ } + } + + destroy() { + if (this._clockTimer) clearInterval(this._clockTimer); + if (this._stream) this._stream.getTracks().forEach((t) => t.stop()); + } + + async _load() { + const res = await rpc("/fusion_clock/kiosk/search", { query: "" }); + this.employees = res.employees || []; + this.filtered = this.employees; + this.renderGrid(); + } + + // ---- 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 = Math.min(img.naturalWidth, 200), h = Math.min(img.naturalHeight, 200); + if (!w || !h) return null; + const c = document.createElement("canvas"); c.width = w; c.height = h; + const ctx = c.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(img, 0, 0, w, h); + 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) { + if (data[i + 3] < 128) continue; + const r = data[i], g = data[i + 1], b = data[i + 2]; + const lum = (r + g + b) / 3; + if (lum > 235 || lum < 25) continue; + if (Math.max(r, g, b) - Math.min(r, g, b) < 25) continue; + rs += r; gs += g; bs += b; n++; } + if (n < 50) 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 && img.naturalWidth) apply(); + else img.addEventListener("load", apply); + } - let lat = 0; - let lng = 0; - try { - const pos = await new Promise((resolve, reject) => { - navigator.geolocation.getCurrentPosition(resolve, reject, { - timeout: 10000, - enableHighAccuracy: true, - }); - }); - lat = pos.coords.latitude; - lng = pos.coords.longitude; - } catch { - // Native GPS unavailable -- try IP geolocation - } - if (lat === 0 && lng === 0) { - try { - const ipResp = await fetch("https://ipapi.co/json/"); - if (ipResp.ok) { - const ipData = await ipResp.json(); - if (ipData.latitude && ipData.longitude) { - lat = ipData.latitude; - lng = ipData.longitude; - } - } - } catch { - // IP geolocation also unavailable - } - } + // ---- 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; + const clock = this.el.querySelector("#pin_kiosk_clock"); + clock.textContent = `${h}:${m}`; + const span = document.createElement("span"); + span.className = "ampm"; span.textContent = ap; + clock.appendChild(span); + this.el.querySelector("#pin_kiosk_date").textContent = + d.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }); + }; + tick(); this._clockTimer = setInterval(tick, 1000); + } - const resp = await fetch("/fusion_clock/kiosk/clock", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "call", - params: { employee_id: this.selectedEmployeeId, latitude: lat, longitude: lng }, - }), - }); - const data = await resp.json(); - const result = data.result || {}; - - if (result.error) { - this._showError(result.error); - if (btn) btn.disabled = false; - return; - } - - const pinEl = document.getElementById("fclk-kiosk-pin"); - const resultEl = document.getElementById("fclk-kiosk-result"); - if (pinEl) pinEl.style.display = "none"; - if (resultEl) resultEl.style.display = ""; - - const msgEl = document.getElementById("fclk-kiosk-result-msg"); - if (msgEl) { - const icon = result.action === "clock_in" ? "fa-check-circle text-success" : "fa-hand-paper-o text-warning"; - let html = `
`; - html += `
${result.message || "Done"}
`; - if (result.net_hours !== undefined) { - html += `
Net hours: ${result.net_hours}h
`; - } - msgEl.innerHTML = html; - } - - this.resetTimer = setTimeout(() => this._resetKiosk(), 10000); - } catch { - this._showError("Operation failed."); + // ---- 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.replaceChildren(); + 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(${encodeURI(emp.avatar_url)})`; + else av.textContent = 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); } - if (btn) btn.disabled = false; + } + + // ---- PIN / first-use setup ---- + onTile(emp) { + this.current = emp; this.pinBuf = ""; this.attempts = 0; this._newPin = null; + this.showPin(emp, emp.has_pin ? "Enter your PIN" : "Create a PIN", !emp.has_pin, false); + } + showPin(emp, sub, isSetup, confirming) { + this.isSetup = isSetup; this.confirming = confirming; this.pinBuf = ""; + this.stage.replaceChildren(); + const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay"; + const panel = document.createElement("div"); panel.className = "pin-kiosk__panel"; + panel.innerHTML = + '
' + + '
' + + '
' + + ''; + const av = panel.querySelector(".pin-kiosk__av"); + if (emp.avatar_url) av.style.backgroundImage = `url(${encodeURI(emp.avatar_url)})`; + else av.textContent = this.initials(emp.name); + panel.querySelector(".pin-kiosk__name").textContent = emp.name; + panel.querySelector(".pin-kiosk__sub").textContent = confirming ? "Re-enter to confirm" : sub; + const pad = panel.querySelector(".pin-kiosk__pad"); + for (const k of ["1", "2", "3", "4", "5", "6", "7", "8", "9", "⌫", "0", "✓"]) { + const b = document.createElement("button"); + b.className = "pin-kiosk__key" + (k === "✓" ? " ok" : ""); + b.textContent = k; + b.addEventListener("click", () => this.onKey(k)); + 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.replaceChildren(); + 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) { + this._panel.querySelector(".pin-kiosk__err").textContent = msg; + this._panel.classList.add("shake"); + setTimeout(() => this._panel.classList.remove("shake"), 360); + } + onKey(k) { + if (k === "⌫") { this.pinBuf = this.pinBuf.slice(0, -1); this.renderDots(); return; } + if (k === "✓") { this.submitPin(); return; } + if (this.pinBuf.length < 6) { this.pinBuf += k; this.renderDots(); } + } + async submitPin() { + const emp = this.current, pin = this.pinBuf; + if (pin.length < 4) return this.err("PIN must be at least 4 digits"); + if (this.isSetup && !this.confirming) { + this._newPin = pin; + return this.showPin(emp, "Create a PIN", true, true); + } + try { + if (this.isSetup && this.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"); + } catch (e) { + this.pinBuf = ""; this.renderDots(); + this.err("Connection error — try again"); + } + } + + // ---- photo (optional) then clock ---- + async afterPin(emp) { + let photo = ""; + if (this.photoRequired) { + try { photo = await this.capturePhoto(emp); } catch (e) { photo = ""; } + } + let r; + try { + r = await rpc("/fusion_clock/kiosk/clock", { employee_id: emp.id, photo_b64: photo }); + } catch (e) { + r = { error: "Connection error" }; + } + this.showResult(emp, r); + } + showResult(emp, r) { + this.stage.replaceChildren(); + const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay"; + const card = document.createElement("div"); + const success = !!(r && r.success); + card.className = "pin-kiosk__result" + (success ? "" : " pin-kiosk__result--error"); + card.innerHTML = + '
' + + '
'; + const check = card.querySelector(".pin-kiosk__check"); + if (success) { + check.textContent = "✓"; + card.querySelector(".action").textContent = r.action === "clock_out" ? "Clocked Out" : "Clocked In"; + card.querySelector(".meta").textContent = r.message || ""; + if (this.soundsOn) this.beep(); + } else { + check.textContent = "!"; + check.style.cssText = "color:#f87171;background:rgba(217,55,78,.18);border-color:rgba(217,55,78,.6)"; + const act = card.querySelector(".action"); act.textContent = "Couldn't clock"; act.style.color = "#f87171"; + card.querySelector(".meta").textContent = (r && r.error) || "Try again"; + } + card.querySelector(".name").textContent = emp.name; + 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) { /* no audio */ } + } + + // ---- camera capture (oval guide + 3s countdown; mirrors NFC kiosk) ---- + 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._stream = stream; + this.stage.replaceChildren(); + const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay"; + const panel = document.createElement("div"); panel.className = "pin-kiosk__photo"; + const h2 = document.createElement("h2"); h2.textContent = emp.name; panel.appendChild(h2); + const stage = document.createElement("div"); stage.className = "stage"; + stage.innerHTML = '
'; + panel.appendChild(stage); ov.appendChild(panel); this.stage.appendChild(ov); + const video = stage.querySelector("video"); video.srcObject = stream; + const cd = stage.querySelector(".countdown"); let n = 3; cd.textContent = String(n); + const timer = setInterval(() => { + n--; if (n > 0) { cd.textContent = String(n); return; } + clearInterval(timer); + const c = document.createElement("canvas"); c.width = video.videoWidth || 480; c.height = video.videoHeight || 640; + c.getContext("2d").drawImage(video, 0, 0, c.width, c.height); + stream.getTracks().forEach((t) => t.stop()); this._stream = null; + resolve(c.toDataURL("image/jpeg", 0.8)); + }, 1000); + }); + } + + reset() { + if (this._stream) { this._stream.getTracks().forEach((t) => t.stop()); this._stream = null; } + this.stage.replaceChildren(); + this.pinBuf = ""; this.current = null; this._newPin = null; + this.searchEl.value = ""; this.filtered = this.employees; this.renderGrid(); + rpc("/fusion_clock/kiosk/search", { query: "" }).then((res) => { + this.employees = res.employees || []; this.filtered = this.employees; + }); } } -registry.category("public.interactions").add("fusion_clock.kiosk", FusionClockKiosk); +registry.category("public.interactions").add("fusion_clock.pin_kiosk", PinKiosk); diff --git a/fusion_clock/static/src/scss/pin_kiosk.scss b/fusion_clock/static/src/scss/pin_kiosk.scss new file mode 100644 index 00000000..f6b8b7b4 --- /dev/null +++ b/fusion_clock/static/src/scss/pin_kiosk.scss @@ -0,0 +1,129 @@ +// PIN Clock Kiosk — premium glass + animated mesh, always-dark. +// Mirrors nfc_kiosk.scss; scoped under :has(#pin_kiosk_root) so it never leaks +// to other frontend pages. Brand hue --pk-h is set by JS from the company +// logo's dominant color; all colors interpolate from it via HSL. +: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: 92%; max-width: 440px; + 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; + background-color: hsl(var(--pk-h), 55%, 42%); + 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); -webkit-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%); -webkit-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: 90%; max-width: 360px; + 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: 90%; max-width: 420px; + 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 (oval guide, mirrors the NFC kiosk) +.pin-kiosk__photo { @extend %pk-glass; padding: 1.5rem; width: 86%; max-width: 540px; 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; } +} diff --git a/fusion_clock/tests/__init__.py b/fusion_clock/tests/__init__.py index fc76ef83..4bc1c411 100644 --- a/fusion_clock/tests/__init__.py +++ b/fusion_clock/tests/__init__.py @@ -8,3 +8,4 @@ from . import test_schedule_driven from . import test_dashboard from . import test_pay_period from . import test_settings +from . import test_clock_kiosk diff --git a/fusion_clock/tests/test_clock_kiosk.py b/fusion_clock/tests/test_clock_kiosk.py new file mode 100644 index 00000000..2d7cf74a --- /dev/null +++ b/fusion_clock/tests/test_clock_kiosk.py @@ -0,0 +1,130 @@ +# -*- 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') + 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') + + +@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, employee_id, 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': employee_id, 'photo_b64': photo_b64}, + }), headers={'Content-Type': 'application/json'}) + return resp.json().get('result', {}) + + def _latest(self, emp_id): + return self.env['hr.attendance'].search( + [('employee_id', '=', emp_id)], order='check_in desc', limit=1) + + def test_clock_in_uses_kiosk_location(self): + res = self._clock(self.emp.id) + self.assertTrue(res.get('success')) + self.assertEqual(res.get('action'), 'clock_in') + att = self._latest(self.emp.id) + self.assertEqual(att.x_fclk_clock_source, 'kiosk') + self.assertEqual(att.x_fclk_location_id, self.location) + + def test_photo_off_stores_nothing(self): + self.ICP.set_param('fusion_clock.enable_photo_verification', 'False') + emp = self.env['hr.employee'].create({ + 'name': 'Quinn Off', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234'}) + self._clock(emp.id, self.PNG) + self.assertFalse(self._latest(emp.id).x_fclk_check_in_photo) + + def test_photo_on_stores_selfie(self): + self.ICP.set_param('fusion_clock.enable_photo_verification', 'True') + emp = self.env['hr.employee'].create({ + 'name': 'Quinn On', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234'}) + self._clock(emp.id, self.PNG) + self.assertTrue(self._latest(emp.id).x_fclk_check_in_photo) + + def test_no_location_configured(self): + self.env.company.x_fclk_nfc_kiosk_location_id = False + emp = self.env['hr.employee'].create({ + 'name': 'Quinn NoLoc', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234'}) + res = self._clock(emp.id) + self.assertEqual(res.get('error'), 'no_location_configured') diff --git a/fusion_clock/views/clock_menus.xml b/fusion_clock/views/clock_menus.xml index 638a0218..bc4485fe 100644 --- a/fusion_clock/views/clock_menus.xml +++ b/fusion_clock/views/clock_menus.xml @@ -24,6 +24,20 @@ sequence="46" groups="group_fusion_clock_kiosk_app"/> + + + Fusion Clock PIN Kiosk + /fusion_clock/kiosk + self + + + + diff --git a/fusion_clock/views/kiosk_templates.xml b/fusion_clock/views/kiosk_templates.xml index cc90f2f2..6f4b2fb6 100644 --- a/fusion_clock/views/kiosk_templates.xml +++ b/fusion_clock/views/kiosk_templates.xml @@ -1,61 +1,38 @@ - -