feat(fusion_clock): PIN kiosk — polished photo-tile + PIN clock (opt-in)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -110,16 +110,23 @@ Location verification uses GPS when coordinates are available and geocoded locat
|
|||||||
|
|
||||||
## 6. Kiosk And NFC
|
## 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:
|
- JSON routes:
|
||||||
- `/fusion_clock/kiosk/search`
|
- `/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`
|
- `/fusion_clock/kiosk/verify_pin` (returns `needs_setup` when the employee has no PIN)
|
||||||
- `/fusion_clock/kiosk/clock`
|
- `/fusion_clock/kiosk/set_pin` (first-use PIN creation, 4–6 digits)
|
||||||
- Requires `fusion_clock.group_fusion_clock_manager`.
|
- `/fusion_clock/kiosk/clock` (uses the company kiosk location, no GPS geofence; optional master-gated selfie)
|
||||||
- Controlled by `fusion_clock.enable_kiosk` and `fusion_clock.kiosk_pin_required`.
|
- Requires `group_fusion_clock_manager` or `group_fusion_clock_kiosk_app`; has its own app icon.
|
||||||
- Uses `hr.employee.x_fclk_kiosk_pin`.
|
- 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:
|
NFC kiosk:
|
||||||
|
|
||||||
@@ -264,7 +271,6 @@ fusion_clock.enable_ip_fallback
|
|||||||
fusion_clock.enable_photo_verification
|
fusion_clock.enable_photo_verification
|
||||||
fusion_clock.google_maps_api_key
|
fusion_clock.google_maps_api_key
|
||||||
fusion_clock.enable_kiosk
|
fusion_clock.enable_kiosk
|
||||||
fusion_clock.kiosk_pin_required
|
|
||||||
fusion_clock.enable_correction_requests
|
fusion_clock.enable_correction_requests
|
||||||
fusion_clock.enable_sounds
|
fusion_clock.enable_sounds
|
||||||
fusion_clock.pay_period_type
|
fusion_clock.pay_period_type
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.3.16.1',
|
'version': '19.0.4.0.0',
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -87,6 +87,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
|||||||
'web.assets_frontend': [
|
'web.assets_frontend': [
|
||||||
'fusion_clock/static/src/css/portal_clock.css',
|
'fusion_clock/static/src/css/portal_clock.css',
|
||||||
'fusion_clock/static/src/scss/nfc_kiosk.scss',
|
'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_portal.js',
|
||||||
'fusion_clock/static/src/js/fusion_clock_kiosk.js',
|
'fusion_clock/static/src/js/fusion_clock_kiosk.js',
|
||||||
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
|
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ def _is_kiosk_operator(user):
|
|||||||
|
|
||||||
|
|
||||||
class FusionClockKiosk(http.Controller):
|
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)
|
@http.route('/fusion_clock/kiosk', type='http', auth='user', website=True)
|
||||||
def kiosk_page(self, **kw):
|
def kiosk_page(self, **kw):
|
||||||
"""Kiosk clock-in/out page for shared tablets."""
|
"""Polished PIN kiosk page for shared tablets."""
|
||||||
user = request.env.user
|
user = request.env.user
|
||||||
if not _is_kiosk_operator(user):
|
if not _is_kiosk_operator(user):
|
||||||
return request.redirect('/my')
|
return request.redirect('/my')
|
||||||
@@ -30,74 +30,95 @@ class FusionClockKiosk(http.Controller):
|
|||||||
if ICP.get_param('fusion_clock.enable_kiosk', 'False') != 'True':
|
if ICP.get_param('fusion_clock.enable_kiosk', 'False') != 'True':
|
||||||
return request.redirect('/my')
|
return request.redirect('/my')
|
||||||
|
|
||||||
|
company = request.env.company.sudo()
|
||||||
|
location = company.x_fclk_nfc_kiosk_location_id
|
||||||
values = {
|
values = {
|
||||||
'pin_required': ICP.get_param('fusion_clock.kiosk_pin_required', 'True') == 'True',
|
|
||||||
'page_name': 'kiosk',
|
'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)
|
return request.render('fusion_clock.kiosk_page', values)
|
||||||
|
|
||||||
@http.route('/fusion_clock/kiosk/search', type='jsonrpc', auth='user', methods=['POST'])
|
@http.route('/fusion_clock/kiosk/search', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
def kiosk_search(self, query='', **kw):
|
def kiosk_search(self, query='', **kw):
|
||||||
"""Search employees for kiosk identification."""
|
"""Employees for the kiosk grid. Also used by the NFC kiosk's
|
||||||
user = request.env.user
|
employee_search — keep the return shape additive."""
|
||||||
if not _is_kiosk_operator(user):
|
if not _is_kiosk_operator(request.env.user):
|
||||||
return {'error': 'Access denied.'}
|
return {'error': 'Access denied.'}
|
||||||
|
|
||||||
employees = request.env['hr.employee'].sudo().search([
|
employees = request.env['hr.employee'].sudo().search([
|
||||||
('x_fclk_enable_clock', '=', True),
|
('x_fclk_enable_clock', '=', True),
|
||||||
('name', 'ilike', query),
|
('name', 'ilike', query),
|
||||||
], limit=20)
|
], limit=200, order='name')
|
||||||
|
rows = []
|
||||||
return {
|
for emp in employees:
|
||||||
'employees': [{
|
unique = emp.write_date.strftime('%Y%m%d%H%M%S') if emp.write_date else ''
|
||||||
|
rows.append({
|
||||||
'id': emp.id,
|
'id': emp.id,
|
||||||
'name': emp.name,
|
'name': emp.name,
|
||||||
'department': emp.department_id.name or '',
|
'department': emp.department_id.name or '',
|
||||||
'is_checked_in': emp.attendance_state == 'checked_in',
|
'is_checked_in': emp.attendance_state == 'checked_in',
|
||||||
'card_uid': emp.x_fclk_nfc_card_uid or '',
|
'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'])
|
@http.route('/fusion_clock/kiosk/verify_pin', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
def kiosk_verify_pin(self, employee_id=0, pin='', **kw):
|
def kiosk_verify_pin(self, employee_id=0, pin='', **kw):
|
||||||
"""Verify employee PIN for kiosk mode."""
|
"""Verify a PIN. Employees with no PIN return needs_setup."""
|
||||||
user = request.env.user
|
if not _is_kiosk_operator(request.env.user):
|
||||||
if not _is_kiosk_operator(user):
|
|
||||||
return {'error': 'Access denied.'}
|
return {'error': 'Access denied.'}
|
||||||
|
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
|
||||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
|
||||||
if not employee.exists():
|
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:
|
@http.route('/fusion_clock/kiosk/set_pin', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
return {'error': 'Invalid PIN.'}
|
def kiosk_set_pin(self, employee_id=0, pin='', **kw):
|
||||||
|
"""First-use PIN creation. Rejects if the employee already has one."""
|
||||||
return {
|
if not _is_kiosk_operator(request.env.user):
|
||||||
'success': True,
|
return {'error': 'Access denied.'}
|
||||||
'employee_name': employee.name,
|
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
|
||||||
'is_checked_in': employee.attendance_state == 'checked_in',
|
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'])
|
@http.route('/fusion_clock/kiosk/clock', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
def kiosk_clock(self, employee_id=0, latitude=0, longitude=0, **kw):
|
def kiosk_clock(self, employee_id=0, photo_b64='', **kw):
|
||||||
"""Perform clock action from kiosk on behalf of an employee."""
|
"""Clock the employee in/out from the shared kiosk. Fixed wall device:
|
||||||
user = request.env.user
|
uses the company kiosk location, no per-clock GPS geofence."""
|
||||||
if not _is_kiosk_operator(user):
|
if not _is_kiosk_operator(request.env.user):
|
||||||
return {'error': 'Access denied.'}
|
return {'error': 'Access denied.'}
|
||||||
|
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
|
||||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
|
||||||
if not employee.exists() or not employee.x_fclk_enable_clock:
|
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()
|
api = FusionClockAPI()
|
||||||
|
|
||||||
location, distance, err, method = api._verify_location(latitude, longitude, employee)
|
photo_enabled = ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True'
|
||||||
if not location:
|
photo_bytes = _strip_data_url_prefix(photo_b64) if (photo_enabled and photo_b64) else b''
|
||||||
return {
|
|
||||||
'error': api._location_error_message(err, distance),
|
|
||||||
'allowed': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
is_checked_in = employee.attendance_state == 'checked_in'
|
is_checked_in = employee.attendance_state == 'checked_in'
|
||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
@@ -105,75 +126,46 @@ class FusionClockKiosk(http.Controller):
|
|||||||
day_plan = employee._get_fclk_day_plan(today)
|
day_plan = employee._get_fclk_day_plan(today)
|
||||||
is_scheduled_off = not day_plan.get('scheduled')
|
is_scheduled_off = not day_plan.get('scheduled')
|
||||||
|
|
||||||
geo_info = {
|
geo_info = {'latitude': 0, 'longitude': 0, 'browser': 'kiosk',
|
||||||
'latitude': latitude,
|
'ip_address': request.httprequest.remote_addr or ''}
|
||||||
'longitude': longitude,
|
|
||||||
'browser': 'kiosk',
|
|
||||||
'ip_address': request.httprequest.remote_addr or '',
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
attendance = employee.sudo()._attendance_action_change(geo_info)
|
attendance = employee.sudo()._attendance_action_change(geo_info)
|
||||||
|
|
||||||
if not is_checked_in:
|
if not is_checked_in:
|
||||||
attendance.sudo().write({
|
attendance.sudo().write({
|
||||||
'x_fclk_location_id': location.id,
|
'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_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}",
|
||||||
api._log_activity(
|
attendance=attendance, location=location,
|
||||||
employee, 'clock_in',
|
latitude=0, longitude=0, distance=0, source='kiosk')
|
||||||
f"Kiosk clock-in at {location.name}",
|
|
||||||
attendance=attendance, location=location,
|
|
||||||
latitude=latitude, longitude=longitude, distance=distance,
|
|
||||||
source='kiosk',
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_scheduled_off:
|
if is_scheduled_off:
|
||||||
api._log_activity(
|
api._log_activity(employee, 'unscheduled_shift',
|
||||||
employee, 'unscheduled_shift',
|
f"Kiosk clock-in on an unscheduled day at {location.name}",
|
||||||
f"Kiosk clock-in on an unscheduled day at {location.name}",
|
attendance=attendance, location=location,
|
||||||
attendance=attendance, location=location,
|
latitude=0, longitude=0, distance=0, source='kiosk')
|
||||||
latitude=latitude, longitude=longitude, distance=distance,
|
|
||||||
source='kiosk',
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||||
|
return {'success': True, 'action': 'clock_in', 'employee_name': employee.name,
|
||||||
return {
|
'message': f'{employee.name} clocked in at {location.name}', 'worked_hours': 0.0}
|
||||||
'success': True,
|
|
||||||
'action': 'clock_in',
|
|
||||||
'employee_name': employee.name,
|
|
||||||
'message': f'{employee.name} clocked in at {location.name}',
|
|
||||||
}
|
|
||||||
else:
|
else:
|
||||||
attendance.sudo().write({
|
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)
|
api._apply_break_deduction(attendance, employee)
|
||||||
|
|
||||||
if not is_scheduled_off:
|
if not is_scheduled_off:
|
||||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||||
|
api._log_activity(employee, 'clock_out',
|
||||||
api._log_activity(
|
f"Kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
||||||
employee, 'clock_out',
|
attendance=attendance, location=location,
|
||||||
f"Kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
latitude=0, longitude=0, distance=0, source='kiosk')
|
||||||
attendance=attendance, location=location,
|
return {'success': True, 'action': 'clock_out', 'employee_name': employee.name,
|
||||||
latitude=latitude, longitude=longitude, distance=distance,
|
'message': f'{employee.name} clocked out from {location.name}',
|
||||||
source='kiosk',
|
'net_hours': round(attendance.x_fclk_net_hours or 0, 2)}
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
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)}
|
return {'error': str(e)}
|
||||||
|
|||||||
@@ -104,10 +104,6 @@
|
|||||||
<field name="key">fusion_clock.enable_kiosk</field>
|
<field name="key">fusion_clock.enable_kiosk</field>
|
||||||
<field name="value">False</field>
|
<field name="value">False</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="config_kiosk_pin_required" model="ir.config_parameter">
|
|
||||||
<field name="key">fusion_clock.kiosk_pin_required</field>
|
|
||||||
<field name="value">True</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Corrections -->
|
<!-- Corrections -->
|
||||||
<record id="config_enable_corrections" model="ir.config_parameter">
|
<record id="config_enable_corrections" model="ir.config_parameter">
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ class ResCompany(models.Model):
|
|||||||
|
|
||||||
x_fclk_nfc_kiosk_location_id = fields.Many2one(
|
x_fclk_nfc_kiosk_location_id = fields.Many2one(
|
||||||
'fusion.clock.location',
|
'fusion.clock.location',
|
||||||
string='NFC Kiosk Location',
|
string='Kiosk Location',
|
||||||
domain="[('company_id', '=', id)]",
|
domain="[('company_id', '=', id)]",
|
||||||
help="Designates which fusion.clock.location is bound to the NFC kiosk "
|
help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.",
|
||||||
"for this company. Required when NFC kiosk is enabled.",
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -151,11 +151,6 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
default=False,
|
default=False,
|
||||||
help="Allow employees to clock in/out from a shared device using their PIN code.",
|
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(
|
fclk_enable_correction_requests = fields.Boolean(
|
||||||
string='Enable Correction Requests',
|
string='Enable Correction Requests',
|
||||||
default=True,
|
default=True,
|
||||||
@@ -276,7 +271,6 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
('fclk_enable_ip_fallback', 'fusion_clock.enable_ip_fallback', True),
|
('fclk_enable_ip_fallback', 'fusion_clock.enable_ip_fallback', True),
|
||||||
('fclk_enable_photo_verification', 'fusion_clock.enable_photo_verification', False),
|
('fclk_enable_photo_verification', 'fusion_clock.enable_photo_verification', False),
|
||||||
('fclk_enable_kiosk', 'fusion_clock.enable_kiosk', 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_correction_requests', 'fusion_clock.enable_correction_requests', True),
|
||||||
('fclk_enable_sounds', 'fusion_clock.enable_sounds', True),
|
('fclk_enable_sounds', 'fusion_clock.enable_sounds', True),
|
||||||
('fclk_auto_generate_reports', 'fusion_clock.auto_generate_reports', True),
|
('fclk_auto_generate_reports', 'fusion_clock.auto_generate_reports', True),
|
||||||
|
|||||||
@@ -1,242 +1,288 @@
|
|||||||
/** @odoo-module **/
|
/** @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 { Interaction } from "@web/public/interaction";
|
||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
|
||||||
export class FusionClockKiosk extends Interaction {
|
export class PinKiosk extends Interaction {
|
||||||
static selector = "#fclk-kiosk";
|
static selector = "#pin_kiosk_root";
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.selectedEmployeeId = 0;
|
this.grid = this.el.querySelector("#pin_kiosk_grid");
|
||||||
this.resetTimer = null;
|
this.searchEl = this.el.querySelector("#pin_kiosk_search");
|
||||||
this.searchTimeout = null;
|
this.stage = this.el.querySelector("#pin_state_container");
|
||||||
|
this.photoRequired = this.el.dataset.photo === "1";
|
||||||
const pinAttr = this.el.dataset.pinRequired;
|
this.soundsOn = this.el.dataset.sounds === "1";
|
||||||
this.pinRequired = pinAttr === "true" || pinAttr === "True";
|
this.employees = [];
|
||||||
|
this.filtered = [];
|
||||||
this._startClock();
|
this.pinBuf = "";
|
||||||
this._bindEvents();
|
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() {
|
toggleFullscreen() {
|
||||||
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 =
|
|
||||||
`<span>${emp.name} <small class="text-muted">${emp.department}</small></span>` +
|
|
||||||
`<span class="badge ${statusBadge}">${statusText}</span>`;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.pinRequired) {
|
if (document.fullscreenElement) document.exitFullscreen();
|
||||||
const vResp = await fetch("/fusion_clock/kiosk/verify_pin", {
|
else if (this.el.requestFullscreen) this.el.requestFullscreen().catch(() => {});
|
||||||
method: "POST",
|
} catch (e) { /* unsupported */ }
|
||||||
headers: { "Content-Type": "application/json" },
|
}
|
||||||
body: JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
destroy() {
|
||||||
method: "call",
|
if (this._clockTimer) clearInterval(this._clockTimer);
|
||||||
params: { employee_id: this.selectedEmployeeId, pin },
|
if (this._stream) this._stream.getTracks().forEach((t) => t.stop());
|
||||||
}),
|
}
|
||||||
});
|
|
||||||
const vData = await vResp.json();
|
async _load() {
|
||||||
if (vData.result && vData.result.error) {
|
const res = await rpc("/fusion_clock/kiosk/search", { query: "" });
|
||||||
this._showError(vData.result.error);
|
this.employees = res.employees || [];
|
||||||
if (btn) btn.disabled = false;
|
this.filtered = this.employees;
|
||||||
return;
|
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;
|
// ---- clock ----
|
||||||
let lng = 0;
|
startClock() {
|
||||||
try {
|
const tick = () => {
|
||||||
const pos = await new Promise((resolve, reject) => {
|
const d = new Date();
|
||||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
let h = d.getHours(); const m = String(d.getMinutes()).padStart(2, "0");
|
||||||
timeout: 10000,
|
const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12;
|
||||||
enableHighAccuracy: true,
|
const clock = this.el.querySelector("#pin_kiosk_clock");
|
||||||
});
|
clock.textContent = `${h}:${m}`;
|
||||||
});
|
const span = document.createElement("span");
|
||||||
lat = pos.coords.latitude;
|
span.className = "ampm"; span.textContent = ap;
|
||||||
lng = pos.coords.longitude;
|
clock.appendChild(span);
|
||||||
} catch {
|
this.el.querySelector("#pin_kiosk_date").textContent =
|
||||||
// Native GPS unavailable -- try IP geolocation
|
d.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
|
||||||
}
|
};
|
||||||
if (lat === 0 && lng === 0) {
|
tick(); this._clockTimer = setInterval(tick, 1000);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resp = await fetch("/fusion_clock/kiosk/clock", {
|
// ---- grid ----
|
||||||
method: "POST",
|
initials(name) { return (name || "").split(" ").filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join(""); }
|
||||||
headers: { "Content-Type": "application/json" },
|
onSearch() {
|
||||||
body: JSON.stringify({
|
const q = this.searchEl.value.trim().toLowerCase();
|
||||||
jsonrpc: "2.0",
|
this.filtered = q ? this.employees.filter((e) => e.name.toLowerCase().includes(q)) : this.employees;
|
||||||
method: "call",
|
this.renderGrid();
|
||||||
params: { employee_id: this.selectedEmployeeId, latitude: lat, longitude: lng },
|
}
|
||||||
}),
|
renderGrid() {
|
||||||
});
|
this.grid.replaceChildren();
|
||||||
const data = await resp.json();
|
for (const emp of this.filtered) {
|
||||||
const result = data.result || {};
|
const tile = document.createElement("div");
|
||||||
|
tile.className = "pin-kiosk__tile";
|
||||||
if (result.error) {
|
const av = document.createElement("div");
|
||||||
this._showError(result.error);
|
av.className = "pin-kiosk__tile-av";
|
||||||
if (btn) btn.disabled = false;
|
if (emp.avatar_url) av.style.backgroundImage = `url(${encodeURI(emp.avatar_url)})`;
|
||||||
return;
|
else av.textContent = this.initials(emp.name);
|
||||||
}
|
const nm = document.createElement("div");
|
||||||
|
nm.className = "pin-kiosk__tile-nm"; nm.textContent = emp.name;
|
||||||
const pinEl = document.getElementById("fclk-kiosk-pin");
|
tile.append(av, nm);
|
||||||
const resultEl = document.getElementById("fclk-kiosk-result");
|
tile.addEventListener("click", () => this.onTile(emp));
|
||||||
if (pinEl) pinEl.style.display = "none";
|
this.grid.appendChild(tile);
|
||||||
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 = `<div style="font-size:3rem"><i class="fa ${icon}"></i></div>`;
|
|
||||||
html += `<div class="mt-2">${result.message || "Done"}</div>`;
|
|
||||||
if (result.net_hours !== undefined) {
|
|
||||||
html += `<div class="text-muted mt-1">Net hours: ${result.net_hours}h</div>`;
|
|
||||||
}
|
|
||||||
msgEl.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resetTimer = setTimeout(() => this._resetKiosk(), 10000);
|
|
||||||
} catch {
|
|
||||||
this._showError("Operation failed.");
|
|
||||||
}
|
}
|
||||||
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 =
|
||||||
|
'<div class="pin-kiosk__av"></div><div class="pin-kiosk__name"></div>' +
|
||||||
|
'<div class="pin-kiosk__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>';
|
||||||
|
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 =
|
||||||
|
'<div class="pin-kiosk__check"></div><div class="name"></div>' +
|
||||||
|
'<div class="action"></div><div class="meta"></div>';
|
||||||
|
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 = '<video autoplay="autoplay" playsinline="playsinline"></video><div class="guide"></div><div class="countdown"></div>';
|
||||||
|
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);
|
||||||
|
|||||||
129
fusion_clock/static/src/scss/pin_kiosk.scss
Normal file
129
fusion_clock/static/src/scss/pin_kiosk.scss
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ from . import test_schedule_driven
|
|||||||
from . import test_dashboard
|
from . import test_dashboard
|
||||||
from . import test_pay_period
|
from . import test_pay_period
|
||||||
from . import test_settings
|
from . import test_settings
|
||||||
|
from . import test_clock_kiosk
|
||||||
|
|||||||
130
fusion_clock/tests/test_clock_kiosk.py
Normal file
130
fusion_clock/tests/test_clock_kiosk.py
Normal file
@@ -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')
|
||||||
@@ -24,6 +24,20 @@
|
|||||||
sequence="46"
|
sequence="46"
|
||||||
groups="group_fusion_clock_kiosk_app"/>
|
groups="group_fusion_clock_kiosk_app"/>
|
||||||
|
|
||||||
|
<!-- PIN kiosk app icon (opt-in alternative to NFC: tap photo + PIN). -->
|
||||||
|
<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"/>
|
||||||
|
|
||||||
<!-- Dashboard — layered & role-aware: personal band for everyone,
|
<!-- Dashboard — layered & role-aware: personal band for everyone,
|
||||||
team band for leads (direct reports), org band for managers.
|
team band for leads (direct reports), org band for managers.
|
||||||
Gated to the base clock-user group (lead/manager imply it). -->
|
Gated to the base clock-user group (lead/manager imply it). -->
|
||||||
|
|||||||
@@ -1,61 +1,38 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- Kiosk Page Template -->
|
<template id="kiosk_page" name="Fusion Clock PIN Kiosk">
|
||||||
<template id="kiosk_page" name="Fusion Clock Kiosk">
|
|
||||||
<t t-call="web.frontend_layout">
|
<t t-call="web.frontend_layout">
|
||||||
<t t-set="no_header" t-value="True"/>
|
<t t-set="no_header" t-value="True"/>
|
||||||
<t t-set="no_footer" t-value="True"/>
|
<t t-set="no_footer" t-value="True"/>
|
||||||
<div id="fclk-kiosk" class="container-fluid vh-100 d-flex flex-column align-items-center justify-content-center"
|
<t t-set="head">
|
||||||
style="background: var(--o-main-bg-color, #f8f9fa);"
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
|
||||||
t-att-data-pin-required="'true' if pin_required else 'false'">
|
<!-- Kiosk lockdown: hide Odoo's frontend->backend nav so a kiosk
|
||||||
|
user can't reach the backend. Page-scoped. -->
|
||||||
|
<style>.o_frontend_to_backend_nav { display: none !important; }</style>
|
||||||
|
</t>
|
||||||
|
|
||||||
<!-- Header -->
|
<div id="pin_kiosk_root" class="pin-kiosk"
|
||||||
<div class="text-center mb-4">
|
t-att-data-location="location_name"
|
||||||
<h1 style="font-size: 2.5rem;">Fusion Clock</h1>
|
t-att-data-sounds="'1' if sounds_enabled else '0'"
|
||||||
<p class="text-muted" style="font-size: 1.2rem;">Kiosk Mode</p>
|
t-att-data-photo="'1' if photo_required else '0'">
|
||||||
<div id="fclk-kiosk-time" style="font-size: 3rem; font-weight: 300;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search / PIN Entry -->
|
<img t-if="company_logo_url"
|
||||||
<div class="card shadow-sm" style="width: 100%; max-width: 500px;">
|
id="pin_kiosk_logo" class="pin-kiosk__logo"
|
||||||
<div class="card-body p-4">
|
t-att-src="company_logo_url" crossorigin="anonymous" alt="Company logo"/>
|
||||||
|
|
||||||
<!-- Step 1: Search Employee -->
|
<div class="pin-kiosk__clock" id="pin_kiosk_clock">--:--</div>
|
||||||
<div id="fclk-kiosk-search">
|
<div class="pin-kiosk__date" id="pin_kiosk_date">—</div>
|
||||||
<label class="form-label fw-bold">Employee Name</label>
|
|
||||||
<input type="text" id="fclk-kiosk-query" class="form-control form-control-lg mb-3"
|
|
||||||
placeholder="Type your name..." autocomplete="off"/>
|
|
||||||
<div id="fclk-kiosk-results" class="list-group mb-3" style="max-height: 300px; overflow-y: auto;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 2: PIN (if required) -->
|
<input type="text" class="pin-kiosk__search" id="pin_kiosk_search"
|
||||||
<div id="fclk-kiosk-pin" style="display: none;">
|
placeholder="Search your name…" autocomplete="off"/>
|
||||||
<h4 id="fclk-kiosk-emp-name" class="text-center mb-3"></h4>
|
<div class="pin-kiosk__grid" id="pin_kiosk_grid"></div>
|
||||||
<t t-if="pin_required">
|
|
||||||
<label class="form-label fw-bold">Enter PIN</label>
|
|
||||||
<input type="password" id="fclk-kiosk-pin-input" class="form-control form-control-lg text-center mb-3"
|
|
||||||
maxlength="6" placeholder="------"/>
|
|
||||||
</t>
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
<button id="fclk-kiosk-clock-btn" class="btn btn-lg btn-primary">
|
|
||||||
Clock In / Out
|
|
||||||
</button>
|
|
||||||
<button id="fclk-kiosk-back-btn" class="btn btn-outline-secondary">
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 3: Result -->
|
<div class="pin-kiosk__location" t-esc="location_name"/>
|
||||||
<div id="fclk-kiosk-result" style="display: none;">
|
<div class="pin-kiosk__settings" id="pin_kiosk_settings" title="Settings">⚙</div>
|
||||||
<div id="fclk-kiosk-result-msg" class="text-center py-4" style="font-size: 1.3rem;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error message -->
|
<!-- JS swaps overlays (PIN pad / setup / photo / result) in here -->
|
||||||
<div id="fclk-kiosk-error" class="alert alert-danger mt-3" style="display: none;"></div>
|
<div id="pin_state_container"></div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -163,12 +163,6 @@
|
|||||||
<setting id="fclk_kiosk" string="Kiosk Mode"
|
<setting id="fclk_kiosk" string="Kiosk Mode"
|
||||||
help="Allow employees to clock in/out from a shared device (tablet or computer).">
|
help="Allow employees to clock in/out from a shared device (tablet or computer).">
|
||||||
<field name="fclk_enable_kiosk"/>
|
<field name="fclk_enable_kiosk"/>
|
||||||
<div class="content-group" invisible="not fclk_enable_kiosk">
|
|
||||||
<div class="row mt16">
|
|
||||||
<field name="fclk_kiosk_pin_required"/>
|
|
||||||
<label for="fclk_kiosk_pin_required" class="ms-1"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</setting>
|
</setting>
|
||||||
<setting id="fclk_corrections" string="Correction Requests"
|
<setting id="fclk_corrections" string="Correction Requests"
|
||||||
help="Allow employees to request timesheet corrections from the portal.">
|
help="Allow employees to request timesheet corrections from the portal.">
|
||||||
|
|||||||
Reference in New Issue
Block a user