updates
This commit is contained in:
19
fusion_clock/fusion_clock/__init__.py
Normal file
19
fusion_clock/fusion_clock/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
|
||||
|
||||
def _post_init_backfill_reports(env):
|
||||
"""Backfill reports for historical pay periods on first install."""
|
||||
existing = env['fusion.clock.report'].search_count([])
|
||||
if existing:
|
||||
return
|
||||
has_data = env['hr.attendance'].search_count([
|
||||
('check_out', '!=', False),
|
||||
('x_fclk_pay_period_start', '!=', False),
|
||||
], limit=1)
|
||||
if has_data:
|
||||
env['fusion.clock.report']._backfill_historical_reports()
|
||||
81
fusion_clock/fusion_clock/__manifest__.py
Normal file
81
fusion_clock/fusion_clock/__manifest__.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Clock product family.
|
||||
|
||||
{
|
||||
'name': 'Fusion Clock',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Human Resources/Attendances',
|
||||
'summary': 'Geofenced Clock-In/Out with Portal UI, Auto Clock-Out, Penalties, and Pay Period Reporting',
|
||||
'description': """
|
||||
Fusion Clock - Geofenced Employee Attendance
|
||||
=============================================
|
||||
|
||||
A modern, mobile-first attendance module that provides:
|
||||
|
||||
* **Geofenced Clock-In/Out** - Employees can only clock in/out within configured location radiuses
|
||||
* **Google Maps Integration** - Visual location management with geocoding and radius preview
|
||||
* **Dark-Themed Portal** - Beautiful mobile-first portal page with animations and sound effects
|
||||
* **Auto Clock-Out** - Automatic clock-out after shift + grace period expires
|
||||
* **Break Deduction** - Configurable unpaid break auto-deduction
|
||||
* **Late/Early Penalties** - Tracks late clock-in and early clock-out with grace periods
|
||||
* **Pay Period Reports** - Weekly, bi-weekly, semi-monthly, or monthly report generation
|
||||
* **Email Automation** - Batch reports to managers, individual reports to employees
|
||||
* **Persistent State** - Clock stays active even when browser is closed
|
||||
* **Systray Widget** - Backend users can clock in/out from any Odoo page
|
||||
|
||||
Integrates natively with Odoo's hr.attendance module for full payroll compatibility.
|
||||
""",
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.io',
|
||||
'license': 'OPL-1',
|
||||
'depends': [
|
||||
'hr_attendance',
|
||||
'hr',
|
||||
'portal',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
# Security
|
||||
'security/security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
# Data
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
# Reports (must load before mail templates that reference them)
|
||||
'report/clock_report_template.xml',
|
||||
'report/clock_employee_report.xml',
|
||||
# Mail templates
|
||||
'data/mail_template_data.xml',
|
||||
# Views - Backend
|
||||
'views/clock_location_views.xml',
|
||||
'views/hr_attendance_views.xml',
|
||||
'views/clock_report_views.xml',
|
||||
'views/clock_penalty_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/clock_menus.xml',
|
||||
# Views - Portal
|
||||
'views/portal_clock_templates.xml',
|
||||
'views/portal_timesheet_templates.xml',
|
||||
'views/portal_report_templates.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'fusion_clock/static/src/css/portal_clock.css',
|
||||
'fusion_clock/static/src/js/fusion_clock_portal.js',
|
||||
'fusion_clock/static/src/js/fusion_clock_portal_fab.js',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'fusion_clock/static/src/scss/fusion_clock.scss',
|
||||
'fusion_clock/static/src/js/fusion_clock_systray.js',
|
||||
'fusion_clock/static/src/xml/systray_clock.xml',
|
||||
'fusion_clock/static/src/js/fclk_location_map.js',
|
||||
'fusion_clock/static/src/xml/location_map.xml',
|
||||
],
|
||||
},
|
||||
'post_init_hook': '_post_init_backfill_reports',
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': True,
|
||||
}
|
||||
4
fusion_clock/fusion_clock/controllers/__init__.py
Normal file
4
fusion_clock/fusion_clock/controllers/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import portal_clock
|
||||
from . import clock_api
|
||||
366
fusion_clock/fusion_clock/controllers/clock_api.py
Normal file
366
fusion_clock/fusion_clock/controllers/clock_api.py
Normal file
@@ -0,0 +1,366 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import math
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def haversine_distance(lat1, lon1, lat2, lon2):
|
||||
"""Calculate the great-circle distance between two points on Earth (in meters)."""
|
||||
R = 6371000 # Earth radius in meters
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
delta_phi = math.radians(lat2 - lat1)
|
||||
delta_lambda = math.radians(lon2 - lon1)
|
||||
|
||||
a = (math.sin(delta_phi / 2) ** 2
|
||||
+ math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2)
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
return R * c
|
||||
|
||||
|
||||
class FusionClockAPI(http.Controller):
|
||||
"""JSON API endpoints for Fusion Clock operations."""
|
||||
|
||||
def _get_employee(self):
|
||||
"""Get the current user's employee record."""
|
||||
user = request.env.user
|
||||
employee = request.env['hr.employee'].sudo().search([
|
||||
('user_id', '=', user.id),
|
||||
], limit=1)
|
||||
return employee
|
||||
|
||||
def _get_locations_for_employee(self, employee):
|
||||
"""Get all clock locations available to this employee."""
|
||||
Location = request.env['fusion.clock.location'].sudo()
|
||||
locations = Location.search([
|
||||
('active', '=', True),
|
||||
('company_id', '=', employee.company_id.id),
|
||||
])
|
||||
# Filter: all_employees OR employee in employee_ids
|
||||
result = locations.filtered(
|
||||
lambda loc: loc.all_employees or employee.id in loc.employee_ids.ids
|
||||
)
|
||||
return result
|
||||
|
||||
def _verify_location(self, latitude, longitude, employee):
|
||||
"""Verify if GPS coordinates are within any allowed geofence.
|
||||
|
||||
Returns (location_record, distance, error_detail) tuple.
|
||||
"""
|
||||
locations = self._get_locations_for_employee(employee)
|
||||
|
||||
if not locations:
|
||||
return False, 0, 'no_locations'
|
||||
|
||||
geocoded = locations.filtered(lambda l: l.latitude is not None and l.longitude is not None
|
||||
and not (l.latitude == 0.0 and l.longitude == 0.0))
|
||||
if not geocoded:
|
||||
return False, 0, 'no_geocoded'
|
||||
|
||||
nearest_location = False
|
||||
nearest_distance = float('inf')
|
||||
|
||||
for loc in geocoded:
|
||||
dist = haversine_distance(latitude, longitude, loc.latitude, loc.longitude)
|
||||
if dist <= loc.radius:
|
||||
return loc, dist, None
|
||||
if dist < nearest_distance:
|
||||
nearest_distance = dist
|
||||
nearest_location = loc
|
||||
|
||||
return False, nearest_distance, 'outside'
|
||||
|
||||
def _location_error_message(self, error_type, distance=0):
|
||||
"""Return a user-friendly error message based on the location check result."""
|
||||
if error_type == 'no_locations':
|
||||
return 'No clock locations configured. Ask your manager to set up locations in Fusion Clock > Locations.'
|
||||
elif error_type == 'no_geocoded':
|
||||
return 'Clock locations exist but have no GPS coordinates. Ask your manager to geocode them.'
|
||||
else:
|
||||
dist_str = f"{int(distance)}m" if distance < 1000 else f"{distance/1000:.1f}km"
|
||||
return f'You are {dist_str} away from the nearest clock location.'
|
||||
|
||||
def _get_scheduled_times(self, employee, date):
|
||||
"""Get scheduled clock-in and clock-out datetime for an employee on a date."""
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
clock_in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
clock_out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
|
||||
# Convert float hours to time
|
||||
in_h = int(clock_in_hour)
|
||||
in_m = int((clock_in_hour - in_h) * 60)
|
||||
out_h = int(clock_out_hour)
|
||||
out_m = int((clock_out_hour - out_h) * 60)
|
||||
|
||||
scheduled_in = datetime.combine(date, datetime.min.time().replace(hour=in_h, minute=in_m))
|
||||
scheduled_out = datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m))
|
||||
|
||||
return scheduled_in, scheduled_out
|
||||
|
||||
def _check_and_create_penalty(self, employee, attendance, penalty_type, scheduled_dt, actual_dt):
|
||||
"""Check if a penalty should be created and create it if needed."""
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
||||
return
|
||||
|
||||
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
|
||||
diff_minutes = abs((actual_dt - scheduled_dt).total_seconds()) / 60.0
|
||||
|
||||
should_penalize = False
|
||||
if penalty_type == 'late_in' and actual_dt > scheduled_dt:
|
||||
should_penalize = diff_minutes > grace
|
||||
elif penalty_type == 'early_out' and actual_dt < scheduled_dt:
|
||||
should_penalize = diff_minutes > grace
|
||||
|
||||
if should_penalize:
|
||||
request.env['fusion.clock.penalty'].sudo().create({
|
||||
'attendance_id': attendance.id,
|
||||
'employee_id': employee.id,
|
||||
'penalty_type': penalty_type,
|
||||
'scheduled_time': scheduled_dt,
|
||||
'actual_time': actual_dt,
|
||||
'date': actual_dt.date() if isinstance(actual_dt, datetime) else fields.Date.today(),
|
||||
})
|
||||
|
||||
def _apply_break_deduction(self, attendance, employee):
|
||||
"""Apply automatic break deduction if configured."""
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
auto_deduct = ICP.get_param('fusion_clock.auto_deduct_break', 'True')
|
||||
if auto_deduct != 'True':
|
||||
return
|
||||
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0'))
|
||||
worked = attendance.worked_hours or 0.0
|
||||
|
||||
if worked >= threshold:
|
||||
break_min = employee._get_fclk_break_minutes()
|
||||
attendance.sudo().write({'x_fclk_break_minutes': break_min})
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API Endpoints
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion_clock/verify_location', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def verify_location(self, latitude=0, longitude=0, accuracy=0, **kw):
|
||||
"""Verify if the user's GPS position is within a valid geofence."""
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found for current user.'}
|
||||
|
||||
location, distance, err = self._verify_location(latitude, longitude, employee)
|
||||
|
||||
if location:
|
||||
return {
|
||||
'allowed': True,
|
||||
'location_id': location.id,
|
||||
'location_name': location.name,
|
||||
'location_address': location.address or '',
|
||||
'distance': round(distance, 1),
|
||||
'radius': location.radius,
|
||||
}
|
||||
else:
|
||||
msg = self._location_error_message(err, distance)
|
||||
return {
|
||||
'allowed': False,
|
||||
'nearest_distance': round(distance, 1) if distance != float('inf') else None,
|
||||
'message': msg,
|
||||
'error_type': err,
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/clock_action', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def clock_action(self, latitude=0, longitude=0, accuracy=0, source='portal', **kw):
|
||||
"""Perform clock-in or clock-out action."""
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found for current user.'}
|
||||
|
||||
if not employee.x_fclk_enable_clock:
|
||||
return {'error': 'Fusion Clock is not enabled for your account.'}
|
||||
|
||||
# Server-side location verification
|
||||
location, distance, err = self._verify_location(latitude, longitude, employee)
|
||||
if not location:
|
||||
return {
|
||||
'error': self._location_error_message(err, distance),
|
||||
'allowed': False,
|
||||
'error_type': err,
|
||||
}
|
||||
|
||||
# Determine if clocking in or out
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
now = fields.Datetime.now()
|
||||
today = now.date()
|
||||
|
||||
geo_info = {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'browser': kw.get('browser', ''),
|
||||
'ip_address': kw.get('ip_address', ''),
|
||||
}
|
||||
|
||||
try:
|
||||
if not is_checked_in:
|
||||
# CLOCK IN
|
||||
attendance = employee.sudo()._attendance_action_change(geo_info)
|
||||
attendance.sudo().write({
|
||||
'x_fclk_location_id': location.id,
|
||||
'x_fclk_in_distance': round(distance, 1),
|
||||
'x_fclk_clock_source': source,
|
||||
})
|
||||
|
||||
# Check for late clock-in penalty
|
||||
scheduled_in, _ = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
'attendance_id': attendance.id,
|
||||
'check_in': fields.Datetime.to_string(attendance.check_in),
|
||||
'location_name': location.name,
|
||||
'message': f'Clocked in at {location.name}',
|
||||
}
|
||||
|
||||
else:
|
||||
# CLOCK OUT
|
||||
attendance = employee.sudo()._attendance_action_change(geo_info)
|
||||
attendance.sudo().write({
|
||||
'x_fclk_out_location_id': location.id,
|
||||
'x_fclk_out_distance': round(distance, 1),
|
||||
})
|
||||
|
||||
# Apply break deduction
|
||||
self._apply_break_deduction(attendance, employee)
|
||||
|
||||
# Check for early clock-out penalty
|
||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_out',
|
||||
'attendance_id': attendance.id,
|
||||
'check_in': fields.Datetime.to_string(attendance.check_in),
|
||||
'check_out': fields.Datetime.to_string(attendance.check_out),
|
||||
'worked_hours': round(attendance.worked_hours or 0, 2),
|
||||
'net_hours': round(attendance.x_fclk_net_hours or 0, 2),
|
||||
'break_minutes': attendance.x_fclk_break_minutes,
|
||||
'location_name': location.name,
|
||||
'message': f'Clocked out from {location.name}',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock error: %s", str(e))
|
||||
return {'error': str(e)}
|
||||
|
||||
@http.route('/fusion_clock/get_status', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def get_status(self, **kw):
|
||||
"""Get current clock status for the authenticated user."""
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found for current user.'}
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
|
||||
result = {
|
||||
'is_checked_in': is_checked_in,
|
||||
'employee_name': employee.name,
|
||||
'enable_clock': employee.x_fclk_enable_clock,
|
||||
}
|
||||
|
||||
if is_checked_in:
|
||||
# Find the open attendance
|
||||
att = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '=', False),
|
||||
], limit=1)
|
||||
if att:
|
||||
result.update({
|
||||
'attendance_id': att.id,
|
||||
'check_in': fields.Datetime.to_string(att.check_in),
|
||||
'location_name': att.x_fclk_location_id.name or '',
|
||||
'location_id': att.x_fclk_location_id.id or False,
|
||||
})
|
||||
|
||||
# Today's stats
|
||||
today_start = fields.Datetime.to_string(
|
||||
datetime.combine(fields.Date.today(), datetime.min.time())
|
||||
)
|
||||
today_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', today_start),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
result['today_hours'] = round(sum(a.x_fclk_net_hours or 0 for a in today_atts), 2)
|
||||
|
||||
# This week stats
|
||||
today = fields.Date.today()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_start_dt = fields.Datetime.to_string(
|
||||
datetime.combine(week_start, datetime.min.time())
|
||||
)
|
||||
week_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', week_start_dt),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
result['week_hours'] = round(sum(a.x_fclk_net_hours or 0 for a in week_atts), 2)
|
||||
|
||||
# Recent activity (last 10)
|
||||
recent = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '!=', False),
|
||||
], order='check_in desc', limit=10)
|
||||
result['recent_activity'] = [{
|
||||
'id': a.id,
|
||||
'check_in': fields.Datetime.to_string(a.check_in),
|
||||
'check_out': fields.Datetime.to_string(a.check_out),
|
||||
'worked_hours': round(a.worked_hours or 0, 2),
|
||||
'net_hours': round(a.x_fclk_net_hours or 0, 2),
|
||||
'location': a.x_fclk_location_id.name or '',
|
||||
} for a in recent]
|
||||
|
||||
return result
|
||||
|
||||
@http.route('/fusion_clock/get_locations', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def get_locations(self, **kw):
|
||||
"""Get all available clock locations for the current user."""
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found for current user.'}
|
||||
|
||||
locations = self._get_locations_for_employee(employee)
|
||||
default_id = employee.x_fclk_default_location_id.id if employee.x_fclk_default_location_id else False
|
||||
|
||||
return {
|
||||
'locations': [{
|
||||
'id': loc.id,
|
||||
'name': loc.name,
|
||||
'address': loc.address or '',
|
||||
'latitude': loc.latitude,
|
||||
'longitude': loc.longitude,
|
||||
'radius': loc.radius,
|
||||
'is_default': loc.id == default_id,
|
||||
} for loc in locations],
|
||||
'default_location_id': default_id,
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/get_settings', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def get_settings(self, **kw):
|
||||
"""Get Fusion Clock settings for the frontend."""
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
return {
|
||||
'enable_sounds': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True',
|
||||
'google_maps_api_key': ICP.get_param('fusion_clock.google_maps_api_key', ''),
|
||||
'default_clock_in': float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0')),
|
||||
'default_clock_out': float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0')),
|
||||
'target_daily_hours': 8.0,
|
||||
'target_weekly_hours': 40.0,
|
||||
}
|
||||
263
fusion_clock/fusion_clock/controllers/portal_clock.py
Normal file
263
fusion_clock/fusion_clock/controllers/portal_clock.py
Normal file
@@ -0,0 +1,263 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockPortal(CustomerPortal):
|
||||
"""Portal controller for Fusion Clock pages."""
|
||||
|
||||
def _prepare_portal_layout_values(self):
|
||||
"""Inject clock FAB data into every portal page context."""
|
||||
values = super()._prepare_portal_layout_values()
|
||||
employee = self._get_portal_employee()
|
||||
if employee and employee.x_fclk_enable_clock:
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
check_in_time = ''
|
||||
location_name = ''
|
||||
if is_checked_in:
|
||||
att = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '=', False),
|
||||
], limit=1)
|
||||
if att:
|
||||
check_in_time = att.check_in.isoformat() if att.check_in else ''
|
||||
location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else ''
|
||||
values.update({
|
||||
'fclk_employee': employee,
|
||||
'fclk_checked_in': is_checked_in,
|
||||
'fclk_check_in_time': check_in_time,
|
||||
'fclk_location_name': location_name,
|
||||
})
|
||||
else:
|
||||
values['fclk_employee'] = False
|
||||
return values
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
"""Add clock counters to the portal home page."""
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
if 'clock_count' in counters:
|
||||
employee = self._get_portal_employee()
|
||||
if employee:
|
||||
today_start = datetime.combine(fields.Date.today(), datetime.min.time())
|
||||
count = request.env['hr.attendance'].sudo().search_count([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
values['clock_count'] = count
|
||||
return values
|
||||
|
||||
def _get_portal_employee(self):
|
||||
"""Get the employee record for the current portal/internal user."""
|
||||
user = request.env.user
|
||||
employee = request.env['hr.employee'].sudo().search([
|
||||
('user_id', '=', user.id),
|
||||
], limit=1)
|
||||
return employee
|
||||
|
||||
# =========================================================================
|
||||
# Clock Page
|
||||
# =========================================================================
|
||||
|
||||
@http.route('/my/clock', type='http', auth='user', website=True)
|
||||
def portal_clock(self, **kw):
|
||||
"""Main clock-in/out portal page."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee:
|
||||
return request.redirect('/my')
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
google_maps_key = ICP.get_param('fusion_clock.google_maps_api_key', '')
|
||||
enable_sounds = ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True'
|
||||
|
||||
# Get locations
|
||||
Location = request.env['fusion.clock.location'].sudo()
|
||||
locations = Location.search([
|
||||
('active', '=', True),
|
||||
('company_id', '=', employee.company_id.id),
|
||||
])
|
||||
locations = locations.filtered(
|
||||
lambda loc: loc.all_employees or employee.id in loc.employee_ids.ids
|
||||
)
|
||||
|
||||
# Current attendance status
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
current_attendance = False
|
||||
if is_checked_in:
|
||||
current_attendance = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '=', False),
|
||||
], limit=1)
|
||||
|
||||
# Today stats
|
||||
today_start = datetime.combine(fields.Date.today(), datetime.min.time())
|
||||
today_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', today_start),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
|
||||
|
||||
# Week stats
|
||||
today = fields.Date.today()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_start_dt = datetime.combine(week_start, datetime.min.time())
|
||||
week_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', week_start_dt),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
week_hours = sum(a.x_fclk_net_hours or 0 for a in week_atts)
|
||||
|
||||
# Recent activity
|
||||
recent = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '!=', False),
|
||||
], order='check_in desc', limit=10)
|
||||
|
||||
# Prepare locations JSON for JS
|
||||
locations_json = json.dumps([{
|
||||
'id': loc.id,
|
||||
'name': loc.name,
|
||||
'address': loc.address or '',
|
||||
'latitude': loc.latitude,
|
||||
'longitude': loc.longitude,
|
||||
'radius': loc.radius,
|
||||
} for loc in locations])
|
||||
|
||||
values = {
|
||||
'employee': employee,
|
||||
'locations': locations,
|
||||
'is_checked_in': is_checked_in,
|
||||
'current_attendance': current_attendance,
|
||||
'today_hours': round(today_hours, 1),
|
||||
'week_hours': round(week_hours, 1),
|
||||
'recent_attendances': recent,
|
||||
'google_maps_key': google_maps_key,
|
||||
'enable_sounds': enable_sounds,
|
||||
'locations_json': locations_json,
|
||||
'page_name': 'clock',
|
||||
}
|
||||
return request.render('fusion_clock.portal_clock_page', values)
|
||||
|
||||
# =========================================================================
|
||||
# Timesheet Page
|
||||
# =========================================================================
|
||||
|
||||
@http.route('/my/clock/timesheets', type='http', auth='user', website=True)
|
||||
def portal_timesheets(self, period='current', **kw):
|
||||
"""Read-only timesheet view."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee:
|
||||
return request.redirect('/my')
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
period_start_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
|
||||
today = fields.Date.today()
|
||||
|
||||
# Calculate period dates
|
||||
FusionReport = request.env['fusion.clock.report'].sudo()
|
||||
period_start, period_end = FusionReport._calculate_current_period(
|
||||
schedule_type, period_start_str, today
|
||||
)
|
||||
|
||||
if period == 'last':
|
||||
# Go back one period
|
||||
if schedule_type == 'weekly':
|
||||
period_start -= timedelta(days=7)
|
||||
period_end -= timedelta(days=7)
|
||||
elif schedule_type == 'biweekly':
|
||||
period_start -= timedelta(days=14)
|
||||
period_end -= timedelta(days=14)
|
||||
elif schedule_type == 'monthly':
|
||||
from dateutil.relativedelta import relativedelta
|
||||
period_start -= relativedelta(months=1)
|
||||
period_end = period_start.replace(day=28) + timedelta(days=4)
|
||||
period_end -= timedelta(days=period_end.day)
|
||||
else:
|
||||
period_start -= timedelta(days=14)
|
||||
period_end -= timedelta(days=14)
|
||||
|
||||
# Get attendance records
|
||||
attendances = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', datetime.combine(period_start, datetime.min.time())),
|
||||
('check_in', '<', datetime.combine(period_end + timedelta(days=1), datetime.min.time())),
|
||||
], order='check_in desc')
|
||||
|
||||
total_hours = sum(a.worked_hours or 0 for a in attendances if a.check_out)
|
||||
net_hours = sum(a.x_fclk_net_hours or 0 for a in attendances if a.check_out)
|
||||
total_breaks = sum(a.x_fclk_break_minutes or 0 for a in attendances if a.check_out)
|
||||
|
||||
values = {
|
||||
'employee': employee,
|
||||
'attendances': attendances,
|
||||
'period_start': period_start,
|
||||
'period_end': period_end,
|
||||
'period': period,
|
||||
'schedule_type': schedule_type,
|
||||
'total_hours': round(total_hours, 1),
|
||||
'net_hours': round(net_hours, 1),
|
||||
'total_breaks': round(total_breaks, 0),
|
||||
'page_name': 'timesheets',
|
||||
}
|
||||
return request.render('fusion_clock.portal_timesheet_page', values)
|
||||
|
||||
# =========================================================================
|
||||
# Reports Page
|
||||
# =========================================================================
|
||||
|
||||
@http.route('/my/clock/reports', type='http', auth='user', website=True)
|
||||
def portal_reports(self, **kw):
|
||||
"""View and download attendance reports."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee:
|
||||
return request.redirect('/my')
|
||||
|
||||
reports = request.env['fusion.clock.report'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('state', 'in', ['generated', 'sent']),
|
||||
], order='date_end desc')
|
||||
|
||||
values = {
|
||||
'employee': employee,
|
||||
'reports': reports,
|
||||
'page_name': 'clock_reports',
|
||||
}
|
||||
return request.render('fusion_clock.portal_report_page', values)
|
||||
|
||||
@http.route('/my/clock/reports/<int:report_id>/download', type='http', auth='user', website=True)
|
||||
def portal_report_download(self, report_id, **kw):
|
||||
"""Download a specific report PDF."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee:
|
||||
return request.redirect('/my')
|
||||
|
||||
report = request.env['fusion.clock.report'].sudo().browse(report_id)
|
||||
if not report.exists() or report.employee_id.id != employee.id:
|
||||
return request.redirect('/my/clock/reports')
|
||||
|
||||
if not report.report_pdf:
|
||||
return request.redirect('/my/clock/reports')
|
||||
|
||||
pdf_data = base64.b64decode(report.report_pdf)
|
||||
filename = report.report_pdf_filename or f"report_{report.id}.pdf"
|
||||
|
||||
return request.make_response(
|
||||
pdf_data,
|
||||
headers=[
|
||||
('Content-Type', 'application/pdf'),
|
||||
('Content-Disposition', f'attachment; filename="{filename}"'),
|
||||
],
|
||||
)
|
||||
74
fusion_clock/fusion_clock/data/ir_config_parameter_data.xml
Normal file
74
fusion_clock/fusion_clock/data/ir_config_parameter_data.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- Work Schedule Defaults -->
|
||||
<record id="config_default_clock_in_time" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.default_clock_in_time</field>
|
||||
<field name="value">9.0</field>
|
||||
</record>
|
||||
<record id="config_default_clock_out_time" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.default_clock_out_time</field>
|
||||
<field name="value">17.0</field>
|
||||
</record>
|
||||
|
||||
<!-- Break Defaults -->
|
||||
<record id="config_default_break_minutes" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.default_break_minutes</field>
|
||||
<field name="value">30</field>
|
||||
</record>
|
||||
<record id="config_auto_deduct_break" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.auto_deduct_break</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
<record id="config_break_threshold_hours" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.break_threshold_hours</field>
|
||||
<field name="value">5.0</field>
|
||||
</record>
|
||||
|
||||
<!-- Grace Period & Auto Clock-Out -->
|
||||
<record id="config_grace_period_minutes" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.grace_period_minutes</field>
|
||||
<field name="value">15</field>
|
||||
</record>
|
||||
<record id="config_enable_auto_clockout" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_auto_clockout</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
<record id="config_max_shift_hours" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.max_shift_hours</field>
|
||||
<field name="value">12.0</field>
|
||||
</record>
|
||||
|
||||
<!-- Penalties -->
|
||||
<record id="config_enable_penalties" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_penalties</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
<record id="config_penalty_grace_minutes" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.penalty_grace_minutes</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
|
||||
<!-- Pay Period -->
|
||||
<record id="config_pay_period_type" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.pay_period_type</field>
|
||||
<field name="value">biweekly</field>
|
||||
</record>
|
||||
|
||||
<!-- Reports -->
|
||||
<record id="config_auto_generate_reports" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.auto_generate_reports</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
<record id="config_send_employee_reports" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.send_employee_reports</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Sounds -->
|
||||
<record id="config_enable_sounds" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_sounds</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
28
fusion_clock/fusion_clock/data/ir_cron_data.xml
Normal file
28
fusion_clock/fusion_clock/data/ir_cron_data.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- Auto Clock-Out Cron: runs every 15 minutes -->
|
||||
<record id="cron_auto_clock_out" model="ir.cron">
|
||||
<field name="name">Fusion Clock: Auto Clock-Out</field>
|
||||
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_fusion_auto_clock_out()</field>
|
||||
<field name="interval_number">15</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active">True</field>
|
||||
<field name="priority">50</field>
|
||||
</record>
|
||||
|
||||
<!-- Report Generation Cron: runs daily at 1:00 AM -->
|
||||
<record id="cron_generate_reports" model="ir.cron">
|
||||
<field name="name">Fusion Clock: Generate Period Reports</field>
|
||||
<field name="model_id" ref="fusion_clock.model_fusion_clock_report"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_generate_period_reports()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
<field name="priority">60</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
85
fusion_clock/fusion_clock/data/mail_template_data.xml
Normal file
85
fusion_clock/fusion_clock/data/mail_template_data.xml
Normal file
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- Employee Individual Report Email -->
|
||||
<record id="mail_template_clock_employee_report" model="mail.template">
|
||||
<field name="name">Fusion Clock: Employee Report</field>
|
||||
<field name="model_id" ref="fusion_clock.model_fusion_clock_report"/>
|
||||
<field name="subject">Your Attendance Report - {{ object.date_start }} to {{ object.date_end }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</field>
|
||||
<field name="email_to">{{ object.employee_id.work_email or '' }}</field>
|
||||
<field name="body_html"><![CDATA[
|
||||
<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>
|
||||
<p style="color:#9ca3af;margin:4px 0 0;">Attendance Report</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p>Hello <strong>{{ object.employee_id.name }}</strong>,</p>
|
||||
<p>Your attendance report for the period <strong>{{ object.date_start }}</strong> to <strong>{{ object.date_end }}</strong> is ready.</p>
|
||||
|
||||
<table width="100%" style="margin:16px 0;border-collapse:collapse;">
|
||||
<tr style="background:#f8f9fa;">
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Days Worked</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ object.days_worked }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Total Hours</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ '%.1f' % object.total_hours }}h</td>
|
||||
</tr>
|
||||
<tr style="background:#f8f9fa;">
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Net Hours</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ '%.1f' % object.net_hours }}h</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Total Breaks</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ '%.0f' % object.total_breaks }} min</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>The full PDF report is attached. You can also download it from your portal at any time.</p>
|
||||
<p style="color:#6b7280;font-size:12px;">This is an automated message from Fusion Clock.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
]]></field>
|
||||
<field name="report_template_ids" eval="[(4, ref('fusion_clock.action_report_clock_employee'))]"/>
|
||||
<field name="auto_delete" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Batch Report Email (to managers) -->
|
||||
<record id="mail_template_clock_batch_report" model="mail.template">
|
||||
<field name="name">Fusion Clock: Batch Report</field>
|
||||
<field name="model_id" ref="fusion_clock.model_fusion_clock_report"/>
|
||||
<field name="subject">Employee Attendance Batch Report - {{ object.date_start }} to {{ object.date_end }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</field>
|
||||
<field name="email_to">{{ (object.company_id.sudo().env['ir.config_parameter'].get_param('fusion_clock.report_recipient_emails') or '') }}</field>
|
||||
<field name="body_html"><![CDATA[
|
||||
<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>
|
||||
<p style="color:#9ca3af;margin:4px 0 0;">Batch Attendance Report</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p>The attendance batch report for <strong>{{ object.date_start }}</strong> to <strong>{{ object.date_end }}</strong> is attached.</p>
|
||||
<p>This report includes all employees' attendance summaries with daily breakdowns, total hours, and penalty information.</p>
|
||||
<p style="color:#6b7280;font-size:12px;">This is an automated message from Fusion Clock.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
]]></field>
|
||||
<field name="report_template_ids" eval="[(4, ref('fusion_clock.action_report_clock_batch'))]"/>
|
||||
<field name="auto_delete" eval="False"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
8
fusion_clock/fusion_clock/models/__init__.py
Normal file
8
fusion_clock/fusion_clock/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import clock_location
|
||||
from . import hr_attendance
|
||||
from . import hr_employee
|
||||
from . import clock_penalty
|
||||
from . import clock_report
|
||||
from . import res_config_settings
|
||||
182
fusion_clock/fusion_clock/models/clock_location.py
Normal file
182
fusion_clock/fusion_clock/models/clock_location.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockLocation(models.Model):
|
||||
_name = 'fusion.clock.location'
|
||||
_description = 'Clock-In Location'
|
||||
_order = 'sequence, name'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='Location Name', required=True)
|
||||
address = fields.Char(string='Address')
|
||||
latitude = fields.Float(string='Latitude', digits=(10, 7))
|
||||
longitude = fields.Float(string='Longitude', digits=(10, 7))
|
||||
radius = fields.Integer(
|
||||
string='Radius (meters)',
|
||||
default=100,
|
||||
help="Geofence radius in meters. Employees must be within this distance to clock in/out.",
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
required=True,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
employee_ids = fields.Many2many(
|
||||
'hr.employee',
|
||||
'fusion_clock_location_employee_rel',
|
||||
'location_id',
|
||||
'employee_id',
|
||||
string='Allowed Employees',
|
||||
)
|
||||
all_employees = fields.Boolean(
|
||||
string='All Employees',
|
||||
default=True,
|
||||
help="If checked, all employees can clock in/out at this location.",
|
||||
)
|
||||
color = fields.Char(string='Map Color', default='#10B981')
|
||||
note = fields.Text(string='Notes')
|
||||
timezone = fields.Selection(
|
||||
'_tz_get',
|
||||
string='Timezone',
|
||||
default=lambda self: self.env.user.tz or 'UTC',
|
||||
)
|
||||
|
||||
# Computed
|
||||
attendance_count = fields.Integer(
|
||||
string='Total Attendances',
|
||||
compute='_compute_attendance_count',
|
||||
)
|
||||
map_url = fields.Char(
|
||||
string='Map Preview URL',
|
||||
compute='_compute_map_url',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _tz_get(self):
|
||||
import pytz
|
||||
return [(tz, tz) for tz in sorted(pytz.all_timezones_set)]
|
||||
|
||||
@api.depends('latitude', 'longitude', 'radius')
|
||||
def _compute_map_url(self):
|
||||
api_key = self.env['ir.config_parameter'].sudo().get_param('fusion_clock.google_maps_api_key', '')
|
||||
for rec in self:
|
||||
if rec.latitude and rec.longitude and api_key:
|
||||
rec.map_url = (
|
||||
f"https://maps.googleapis.com/maps/api/staticmap?"
|
||||
f"center={rec.latitude},{rec.longitude}&zoom=16&size=600x300&maptype=roadmap"
|
||||
f"&markers=color:green%7C{rec.latitude},{rec.longitude}"
|
||||
f"&key={api_key}"
|
||||
)
|
||||
else:
|
||||
rec.map_url = False
|
||||
|
||||
def _compute_attendance_count(self):
|
||||
for rec in self:
|
||||
rec.attendance_count = self.env['hr.attendance'].search_count([
|
||||
('x_fclk_location_id', '=', rec.id),
|
||||
])
|
||||
|
||||
def action_geocode_address(self):
|
||||
"""Geocode the address to get lat/lng using Google Geocoding API.
|
||||
Falls back to Nominatim (OpenStreetMap) if Google fails.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.address:
|
||||
raise UserError(_("Please enter an address first."))
|
||||
|
||||
# Try Google first
|
||||
api_key = self.env['ir.config_parameter'].sudo().get_param('fusion_clock.google_maps_api_key', '')
|
||||
if api_key:
|
||||
try:
|
||||
url = 'https://maps.googleapis.com/maps/api/geocode/json'
|
||||
params = {'address': self.address, 'key': api_key}
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
data = response.json()
|
||||
|
||||
if data.get('status') == 'OK' and data.get('results'):
|
||||
location = data['results'][0]['geometry']['location']
|
||||
self.write({
|
||||
'latitude': location['lat'],
|
||||
'longitude': location['lng'],
|
||||
})
|
||||
formatted = data['results'][0].get('formatted_address', '')
|
||||
if formatted:
|
||||
self.address = formatted
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Geocoding Successful'),
|
||||
'message': _('Location set to: %s, %s') % (location['lat'], location['lng']),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
elif data.get('status') == 'REQUEST_DENIED':
|
||||
_logger.warning("Google Geocoding API denied. Enable the Geocoding API in Google Cloud Console. Falling back to Nominatim.")
|
||||
else:
|
||||
_logger.warning("Google geocoding returned: %s. Trying Nominatim fallback.", data.get('status'))
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.warning("Google geocoding network error: %s. Trying Nominatim fallback.", e)
|
||||
|
||||
# Fallback: Nominatim (OpenStreetMap) - free, no API key needed
|
||||
try:
|
||||
url = 'https://nominatim.openstreetmap.org/search'
|
||||
params = {
|
||||
'q': self.address,
|
||||
'format': 'json',
|
||||
'limit': 1,
|
||||
}
|
||||
headers = {'User-Agent': 'FusionClock/1.0 (Odoo Module)'}
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
data = response.json()
|
||||
|
||||
if data and len(data) > 0:
|
||||
lat = float(data[0]['lat'])
|
||||
lng = float(data[0]['lon'])
|
||||
display_name = data[0].get('display_name', '')
|
||||
self.write({
|
||||
'latitude': lat,
|
||||
'longitude': lng,
|
||||
})
|
||||
if display_name:
|
||||
self.address = display_name
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Geocoding Successful (via OpenStreetMap)'),
|
||||
'message': _('Location set to: %s, %s') % (lat, lng),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
else:
|
||||
raise UserError(_("Could not geocode address. No results found. Try a more specific address."))
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Network error during geocoding: %s") % str(e))
|
||||
|
||||
def action_view_attendances(self):
|
||||
"""Open attendance records for this location."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Attendances at %s') % self.name,
|
||||
'res_model': 'hr.attendance',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('x_fclk_location_id', '=', self.id)],
|
||||
'context': {'default_x_fclk_location_id': self.id},
|
||||
}
|
||||
67
fusion_clock/fusion_clock/models/clock_penalty.py
Normal file
67
fusion_clock/fusion_clock/models/clock_penalty.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class FusionClockPenalty(models.Model):
|
||||
_name = 'fusion.clock.penalty'
|
||||
_description = 'Clock Penalty'
|
||||
_order = 'date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
attendance_id = fields.Many2one(
|
||||
'hr.attendance',
|
||||
string='Attendance',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
penalty_type = fields.Selection(
|
||||
[
|
||||
('late_in', 'Late Clock-In'),
|
||||
('early_out', 'Early Clock-Out'),
|
||||
],
|
||||
string='Penalty Type',
|
||||
required=True,
|
||||
)
|
||||
scheduled_time = fields.Datetime(string='Scheduled Time')
|
||||
actual_time = fields.Datetime(string='Actual Time')
|
||||
difference_minutes = fields.Float(
|
||||
string='Difference (min)',
|
||||
compute='_compute_difference',
|
||||
store=True,
|
||||
)
|
||||
date = fields.Date(string='Date', required=True, index=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
)
|
||||
note = fields.Text(string='Note')
|
||||
|
||||
display_name = fields.Char(compute='_compute_display_name', store=True)
|
||||
|
||||
@api.depends('scheduled_time', 'actual_time')
|
||||
def _compute_difference(self):
|
||||
for rec in self:
|
||||
if rec.scheduled_time and rec.actual_time:
|
||||
delta = abs((rec.actual_time - rec.scheduled_time).total_seconds())
|
||||
rec.difference_minutes = round(delta / 60.0, 1)
|
||||
else:
|
||||
rec.difference_minutes = 0.0
|
||||
|
||||
@api.depends('employee_id', 'penalty_type', 'date')
|
||||
def _compute_display_name(self):
|
||||
labels = dict(self._fields['penalty_type'].selection)
|
||||
for rec in self:
|
||||
emp = rec.employee_id.name or ''
|
||||
ptype = labels.get(rec.penalty_type, '')
|
||||
rec.display_name = f"{emp} - {ptype} ({rec.date})" if rec.date else f"{emp} - {ptype}"
|
||||
425
fusion_clock/fusion_clock/models/clock_report.py
Normal file
425
fusion_clock/fusion_clock/models/clock_report.py
Normal file
@@ -0,0 +1,425 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockReport(models.Model):
|
||||
_name = 'fusion.clock.report'
|
||||
_description = 'Clock Period Report'
|
||||
_order = 'date_end desc, employee_id'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Report Name',
|
||||
compute='_compute_name',
|
||||
store=True,
|
||||
)
|
||||
date_start = fields.Date(string='Period Start', required=True, index=True)
|
||||
date_end = fields.Date(string='Period End', required=True, index=True)
|
||||
schedule_type = fields.Selection(
|
||||
[
|
||||
('weekly', 'Weekly'),
|
||||
('biweekly', 'Bi-Weekly'),
|
||||
('semi_monthly', 'Semi-Monthly'),
|
||||
('monthly', 'Monthly'),
|
||||
],
|
||||
string='Schedule Type',
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
('generated', 'Generated'),
|
||||
('sent', 'Sent'),
|
||||
],
|
||||
string='Status',
|
||||
default='draft',
|
||||
index=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
index=True,
|
||||
help="Empty = batch report for all employees.",
|
||||
)
|
||||
is_batch = fields.Boolean(
|
||||
string='Batch Report',
|
||||
compute='_compute_is_batch',
|
||||
store=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
required=True,
|
||||
)
|
||||
|
||||
# Computed totals
|
||||
total_hours = fields.Float(string='Total Worked Hours', compute='_compute_totals', store=True)
|
||||
net_hours = fields.Float(string='Net Hours', compute='_compute_totals', store=True)
|
||||
total_breaks = fields.Float(string='Total Breaks (min)', compute='_compute_totals', store=True)
|
||||
total_penalties = fields.Integer(string='Penalty Count', compute='_compute_totals', store=True)
|
||||
days_worked = fields.Integer(string='Days Worked', compute='_compute_totals', store=True)
|
||||
attendance_ids = fields.Many2many(
|
||||
'hr.attendance',
|
||||
'fusion_clock_report_attendance_rel',
|
||||
'report_id',
|
||||
'attendance_id',
|
||||
string='Attendance Records',
|
||||
)
|
||||
|
||||
# PDF
|
||||
report_pdf = fields.Binary(string='Report PDF', attachment=True)
|
||||
report_pdf_filename = fields.Char(string='PDF Filename')
|
||||
|
||||
@api.depends('employee_id', 'date_start', 'date_end')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
if rec.employee_id:
|
||||
rec.name = f"{rec.employee_id.name} - {rec.date_start} to {rec.date_end}"
|
||||
else:
|
||||
rec.name = f"Batch Report - {rec.date_start} to {rec.date_end}"
|
||||
|
||||
@api.depends('employee_id')
|
||||
def _compute_is_batch(self):
|
||||
for rec in self:
|
||||
rec.is_batch = not bool(rec.employee_id)
|
||||
|
||||
@api.depends('attendance_ids', 'attendance_ids.worked_hours',
|
||||
'attendance_ids.x_fclk_net_hours', 'attendance_ids.x_fclk_break_minutes')
|
||||
def _compute_totals(self):
|
||||
for rec in self:
|
||||
atts = rec.attendance_ids
|
||||
rec.total_hours = sum(a.worked_hours or 0.0 for a in atts)
|
||||
rec.net_hours = sum(a.x_fclk_net_hours or 0.0 for a in atts)
|
||||
rec.total_breaks = sum(a.x_fclk_break_minutes or 0.0 for a in atts)
|
||||
rec.total_penalties = self.env['fusion.clock.penalty'].search_count([
|
||||
('employee_id', '=', rec.employee_id.id),
|
||||
('date', '>=', rec.date_start),
|
||||
('date', '<=', rec.date_end),
|
||||
]) if rec.employee_id else 0
|
||||
# Count unique dates
|
||||
dates = set()
|
||||
for a in atts:
|
||||
if a.check_in:
|
||||
dates.add(a.check_in.date())
|
||||
rec.days_worked = len(dates)
|
||||
|
||||
def action_generate_report(self):
|
||||
"""Generate the PDF report for this record."""
|
||||
self.ensure_one()
|
||||
self._collect_attendance_records()
|
||||
self._generate_pdf()
|
||||
self.state = 'generated'
|
||||
|
||||
def action_send_report(self):
|
||||
"""Send the report via email."""
|
||||
self.ensure_one()
|
||||
if self.state != 'generated':
|
||||
raise UserError(_("Please generate the report first."))
|
||||
self._send_report_email()
|
||||
self.state = 'sent'
|
||||
|
||||
def _collect_attendance_records(self):
|
||||
"""Link attendance records for the period and employee."""
|
||||
self.ensure_one()
|
||||
domain = [
|
||||
('check_in', '>=', fields.Datetime.to_datetime(self.date_start)),
|
||||
('check_in', '<', fields.Datetime.to_datetime(self.date_end + timedelta(days=1))),
|
||||
('check_out', '!=', False),
|
||||
]
|
||||
if self.employee_id:
|
||||
domain.append(('employee_id', '=', self.employee_id.id))
|
||||
else:
|
||||
domain.append(('employee_id.company_id', '=', self.company_id.id))
|
||||
|
||||
attendances = self.env['hr.attendance'].search(domain)
|
||||
self.attendance_ids = [(6, 0, attendances.ids)]
|
||||
|
||||
def _generate_pdf(self):
|
||||
"""Render the QWeb report to PDF and store it."""
|
||||
self.ensure_one()
|
||||
if self.employee_id:
|
||||
report_ref = 'fusion_clock.action_report_clock_employee'
|
||||
else:
|
||||
report_ref = 'fusion_clock.action_report_clock_batch'
|
||||
|
||||
report = self.env['ir.actions.report']._get_report_from_name(report_ref)
|
||||
if not report:
|
||||
# Fallback to employee report
|
||||
report_ref = 'fusion_clock.action_report_clock_employee'
|
||||
|
||||
pdf_content, _ = self.env['ir.actions.report']._render_qweb_pdf(
|
||||
report_ref, [self.id]
|
||||
)
|
||||
filename = f"clock_report_{self.date_start}_{self.date_end}"
|
||||
if self.employee_id:
|
||||
filename += f"_{self.employee_id.name.replace(' ', '_')}"
|
||||
filename += ".pdf"
|
||||
|
||||
self.write({
|
||||
'report_pdf': base64.b64encode(pdf_content),
|
||||
'report_pdf_filename': filename,
|
||||
})
|
||||
|
||||
def _send_report_email(self):
|
||||
"""Send the report via mail template."""
|
||||
self.ensure_one()
|
||||
if self.employee_id:
|
||||
template = self.env.ref('fusion_clock.mail_template_clock_employee_report', raise_if_not_found=False)
|
||||
else:
|
||||
template = self.env.ref('fusion_clock.mail_template_clock_batch_report', raise_if_not_found=False)
|
||||
|
||||
if template:
|
||||
template.send_mail(self.id, force_send=True)
|
||||
else:
|
||||
_logger.warning("Fusion Clock: Mail template not found for report %s", self.id)
|
||||
|
||||
@api.model
|
||||
def _cron_generate_period_reports(self):
|
||||
"""Cron: Generate reports when a pay period ends."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
auto_generate = ICP.get_param('fusion_clock.auto_generate_reports', 'True')
|
||||
if auto_generate != 'True':
|
||||
return
|
||||
|
||||
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
period_start_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
|
||||
today = fields.Date.today()
|
||||
period_start, period_end = self._calculate_current_period(schedule_type, period_start_str, today)
|
||||
|
||||
# Only generate if yesterday was the end of a period
|
||||
if period_end != today - timedelta(days=1):
|
||||
return
|
||||
|
||||
_logger.info("Fusion Clock: Generating reports for period %s to %s", period_start, period_end)
|
||||
|
||||
companies = self.env['res.company'].search([])
|
||||
for company in companies:
|
||||
employees = self.env['hr.employee'].search([
|
||||
('company_id', '=', company.id),
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
# Generate individual reports
|
||||
for employee in employees:
|
||||
existing = self.search([
|
||||
('employee_id', '=', employee.id),
|
||||
('date_start', '=', period_start),
|
||||
('date_end', '=', period_end),
|
||||
], limit=1)
|
||||
if not existing:
|
||||
report = self.create({
|
||||
'date_start': period_start,
|
||||
'date_end': period_end,
|
||||
'schedule_type': schedule_type,
|
||||
'employee_id': employee.id,
|
||||
'company_id': company.id,
|
||||
})
|
||||
try:
|
||||
report.action_generate_report()
|
||||
# Auto-send if configured
|
||||
send_employee = ICP.get_param('fusion_clock.send_employee_reports', 'True')
|
||||
if send_employee == 'True':
|
||||
report.action_send_report()
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Error generating report for %s: %s", employee.name, e)
|
||||
|
||||
# Generate batch report
|
||||
existing_batch = self.search([
|
||||
('employee_id', '=', False),
|
||||
('date_start', '=', period_start),
|
||||
('date_end', '=', period_end),
|
||||
('company_id', '=', company.id),
|
||||
], limit=1)
|
||||
if not existing_batch:
|
||||
batch = self.create({
|
||||
'date_start': period_start,
|
||||
'date_end': period_end,
|
||||
'schedule_type': schedule_type,
|
||||
'employee_id': False,
|
||||
'company_id': company.id,
|
||||
})
|
||||
try:
|
||||
batch.action_generate_report()
|
||||
batch.action_send_report()
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Error generating batch report: %s", e)
|
||||
|
||||
@api.model
|
||||
def _backfill_historical_reports(self):
|
||||
"""Create reports for all past pay periods that have attendance data
|
||||
but no corresponding report. Does NOT send emails.
|
||||
Called from settings button or post_init_hook on install.
|
||||
"""
|
||||
HrAtt = self.env['hr.attendance']
|
||||
today = fields.Date.today()
|
||||
|
||||
# Get all distinct periods that have completed attendance records
|
||||
all_atts = HrAtt.search([
|
||||
('check_out', '!=', False),
|
||||
('x_fclk_pay_period_start', '!=', False),
|
||||
], order='x_fclk_pay_period_start')
|
||||
|
||||
# Group by (period_start, pay_period_label) to find distinct periods
|
||||
period_map = {}
|
||||
for att in all_atts:
|
||||
key = att.x_fclk_pay_period_start
|
||||
if key and key not in period_map:
|
||||
period_map[key] = att.x_fclk_pay_period
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
anchor_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
if anchor_str:
|
||||
try:
|
||||
anchor = fields.Date.from_string(anchor_str)
|
||||
except Exception:
|
||||
anchor = None
|
||||
else:
|
||||
anchor = None
|
||||
|
||||
created_count = 0
|
||||
for period_start_date, period_label in period_map.items():
|
||||
# Calculate period end from the start date
|
||||
_, period_end = HrAtt._calc_period(
|
||||
schedule_type, anchor, period_start_date,
|
||||
)
|
||||
|
||||
# Skip the current (incomplete) period
|
||||
if period_end >= today:
|
||||
continue
|
||||
|
||||
# Find all employees with attendance in this period
|
||||
period_atts = HrAtt.search([
|
||||
('check_out', '!=', False),
|
||||
('x_fclk_pay_period_start', '=', period_start_date),
|
||||
])
|
||||
employee_ids = period_atts.mapped('employee_id')
|
||||
|
||||
for employee in employee_ids:
|
||||
# Check if report already exists
|
||||
existing = self.search([
|
||||
('employee_id', '=', employee.id),
|
||||
('date_start', '=', period_start_date),
|
||||
('date_end', '=', period_end),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
emp_atts = period_atts.filtered(
|
||||
lambda a, e=employee: a.employee_id == e
|
||||
)
|
||||
report = self.create({
|
||||
'date_start': period_start_date,
|
||||
'date_end': period_end,
|
||||
'schedule_type': schedule_type,
|
||||
'employee_id': employee.id,
|
||||
'company_id': employee.company_id.id,
|
||||
'attendance_ids': [(6, 0, emp_atts.ids)],
|
||||
})
|
||||
try:
|
||||
report._generate_pdf()
|
||||
report.state = 'generated'
|
||||
created_count += 1
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Fusion Clock backfill: PDF failed for %s period %s: %s",
|
||||
employee.name, period_label, e,
|
||||
)
|
||||
report.state = 'generated'
|
||||
created_count += 1
|
||||
|
||||
# Batch report for this period
|
||||
existing_batch = self.search([
|
||||
('employee_id', '=', False),
|
||||
('date_start', '=', period_start_date),
|
||||
('date_end', '=', period_end),
|
||||
], limit=1)
|
||||
if not existing_batch and period_atts:
|
||||
company = period_atts[0].employee_id.company_id
|
||||
batch = self.create({
|
||||
'date_start': period_start_date,
|
||||
'date_end': period_end,
|
||||
'schedule_type': schedule_type,
|
||||
'employee_id': False,
|
||||
'company_id': company.id,
|
||||
'attendance_ids': [(6, 0, period_atts.ids)],
|
||||
})
|
||||
try:
|
||||
batch._generate_pdf()
|
||||
batch.state = 'generated'
|
||||
created_count += 1
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Fusion Clock backfill: batch PDF failed for %s: %s",
|
||||
period_label, e,
|
||||
)
|
||||
batch.state = 'generated'
|
||||
created_count += 1
|
||||
|
||||
# Commit after each period to avoid losing everything on error
|
||||
self.env.cr.commit() # pylint: disable=invalid-commit
|
||||
|
||||
_logger.info(
|
||||
"Fusion Clock: Backfill complete. Created %d reports.", created_count,
|
||||
)
|
||||
return created_count
|
||||
|
||||
@api.model
|
||||
def _calculate_current_period(self, schedule_type, period_start_str, reference_date):
|
||||
"""Calculate the period start/end dates based on schedule type."""
|
||||
from dateutil.relativedelta import relativedelta
|
||||
import datetime
|
||||
|
||||
if period_start_str:
|
||||
try:
|
||||
anchor = fields.Date.from_string(period_start_str)
|
||||
except Exception:
|
||||
anchor = reference_date.replace(day=1)
|
||||
else:
|
||||
anchor = reference_date.replace(day=1)
|
||||
|
||||
if schedule_type == 'weekly':
|
||||
days_diff = (reference_date - anchor).days
|
||||
period_num = days_diff // 7
|
||||
period_start = anchor + timedelta(days=period_num * 7)
|
||||
period_end = period_start + timedelta(days=6)
|
||||
|
||||
elif schedule_type == 'biweekly':
|
||||
days_diff = (reference_date - anchor).days
|
||||
period_num = days_diff // 14
|
||||
period_start = anchor + timedelta(days=period_num * 14)
|
||||
period_end = period_start + timedelta(days=13)
|
||||
|
||||
elif schedule_type == 'semi_monthly':
|
||||
if reference_date.day <= 15:
|
||||
period_start = reference_date.replace(day=1)
|
||||
period_end = reference_date.replace(day=15)
|
||||
else:
|
||||
period_start = reference_date.replace(day=16)
|
||||
# Last day of month
|
||||
next_month = reference_date.replace(day=28) + timedelta(days=4)
|
||||
period_end = next_month - timedelta(days=next_month.day)
|
||||
|
||||
elif schedule_type == 'monthly':
|
||||
period_start = reference_date.replace(day=1)
|
||||
next_month = reference_date.replace(day=28) + timedelta(days=4)
|
||||
period_end = next_month - timedelta(days=next_month.day)
|
||||
|
||||
else:
|
||||
# Default biweekly
|
||||
days_diff = (reference_date - anchor).days
|
||||
period_num = days_diff // 14
|
||||
period_start = anchor + timedelta(days=period_num * 14)
|
||||
period_end = period_start + timedelta(days=13)
|
||||
|
||||
return period_start, period_end
|
||||
330
fusion_clock/fusion_clock/models/hr_attendance.py
Normal file
330
fusion_clock/fusion_clock/models/hr_attendance.py
Normal file
@@ -0,0 +1,330 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import models, fields, api
|
||||
from odoo.tools import float_round
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HrAttendance(models.Model):
|
||||
_inherit = 'hr.attendance'
|
||||
|
||||
x_fclk_location_id = fields.Many2one(
|
||||
'fusion.clock.location',
|
||||
string='Clock-In Location',
|
||||
help="The geofenced location where employee clocked in.",
|
||||
)
|
||||
x_fclk_out_location_id = fields.Many2one(
|
||||
'fusion.clock.location',
|
||||
string='Clock-Out Location',
|
||||
help="The geofenced location where employee clocked out.",
|
||||
)
|
||||
x_fclk_clock_source = fields.Selection(
|
||||
[
|
||||
('portal', 'Portal'),
|
||||
('portal_fab', 'Portal FAB'),
|
||||
('systray', 'Systray'),
|
||||
('backend_fab', 'Backend FAB'),
|
||||
('kiosk', 'Kiosk'),
|
||||
('manual', 'Manual'),
|
||||
('auto', 'Auto Clock-Out'),
|
||||
],
|
||||
string='Clock Source',
|
||||
help="How this attendance was recorded.",
|
||||
)
|
||||
x_fclk_in_distance = fields.Float(
|
||||
string='Check-In Distance (m)',
|
||||
digits=(10, 2),
|
||||
help="Distance from location center at clock-in, in meters.",
|
||||
)
|
||||
x_fclk_out_distance = fields.Float(
|
||||
string='Check-Out Distance (m)',
|
||||
digits=(10, 2),
|
||||
help="Distance from location center at clock-out, in meters.",
|
||||
)
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
default=0.0,
|
||||
help="Break duration in minutes to deduct from worked hours.",
|
||||
)
|
||||
x_fclk_net_hours = fields.Float(
|
||||
string='Net Hours',
|
||||
compute='_compute_net_hours',
|
||||
store=True,
|
||||
help="Worked hours minus break deduction.",
|
||||
)
|
||||
x_fclk_penalty_ids = fields.One2many(
|
||||
'fusion.clock.penalty',
|
||||
'attendance_id',
|
||||
string='Penalties',
|
||||
)
|
||||
x_fclk_auto_clocked_out = fields.Boolean(
|
||||
string='Auto Clocked Out',
|
||||
default=False,
|
||||
help="Set to true if this attendance was automatically closed.",
|
||||
)
|
||||
x_fclk_grace_used = fields.Boolean(
|
||||
string='Grace Period Used',
|
||||
default=False,
|
||||
help="Whether the grace period was consumed before auto clock-out.",
|
||||
)
|
||||
|
||||
# -- Pay period grouping fields --
|
||||
x_fclk_pay_period = fields.Char(
|
||||
string='Pay Period',
|
||||
compute='_compute_pay_period',
|
||||
store=True,
|
||||
help="Human-readable pay period label for grouping.",
|
||||
)
|
||||
x_fclk_pay_period_start = fields.Date(
|
||||
string='Period Start',
|
||||
compute='_compute_pay_period',
|
||||
store=True,
|
||||
help="Pay period start date, used for chronological ordering.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD overrides: auto-apply break on any attendance with check_out
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
records._auto_apply_break()
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if 'check_out' in vals or 'worked_hours' in vals:
|
||||
self._auto_apply_break()
|
||||
return res
|
||||
|
||||
def _auto_apply_break(self):
|
||||
"""Apply break deduction to completed attendances that don't have it.
|
||||
|
||||
Only applies when:
|
||||
- auto_deduct_break setting is enabled
|
||||
- check_out is set (completed shift)
|
||||
- worked_hours >= break threshold
|
||||
- break_minutes is still 0 (not manually set or already applied)
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
|
||||
return
|
||||
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0'))
|
||||
|
||||
for att in self:
|
||||
if not att.check_out:
|
||||
continue
|
||||
if (att.x_fclk_break_minutes or 0) > 0:
|
||||
continue
|
||||
if (att.worked_hours or 0) < threshold:
|
||||
continue
|
||||
|
||||
emp = att.employee_id
|
||||
if emp:
|
||||
break_min = emp._get_fclk_break_minutes()
|
||||
att.sudo().write({'x_fclk_break_minutes': break_min})
|
||||
|
||||
@api.model
|
||||
def action_backfill_breaks(self):
|
||||
"""Apply break deduction to all historical records that are missing it."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0'))
|
||||
|
||||
records = self.sudo().search([
|
||||
('check_out', '!=', False),
|
||||
('x_fclk_break_minutes', '=', 0),
|
||||
])
|
||||
|
||||
count = 0
|
||||
for att in records:
|
||||
if (att.worked_hours or 0) < threshold:
|
||||
continue
|
||||
emp = att.employee_id
|
||||
if emp:
|
||||
break_min = emp._get_fclk_break_minutes()
|
||||
att.write({'x_fclk_break_minutes': break_min})
|
||||
count += 1
|
||||
|
||||
_logger.info("Fusion Clock: Backfilled break on %d attendance records.", count)
|
||||
return count
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.depends('worked_hours', 'x_fclk_break_minutes')
|
||||
def _compute_net_hours(self):
|
||||
for att in self:
|
||||
break_hours = (att.x_fclk_break_minutes or 0.0) / 60.0
|
||||
raw = att.worked_hours or 0.0
|
||||
att.x_fclk_net_hours = max(raw - break_hours, 0.0)
|
||||
|
||||
@api.depends('check_in')
|
||||
def _compute_pay_period(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
anchor_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
|
||||
if anchor_str:
|
||||
try:
|
||||
anchor = fields.Date.from_string(anchor_str)
|
||||
except Exception:
|
||||
anchor = None
|
||||
else:
|
||||
anchor = None
|
||||
|
||||
for att in self:
|
||||
if not att.check_in:
|
||||
att.x_fclk_pay_period = False
|
||||
att.x_fclk_pay_period_start = False
|
||||
continue
|
||||
|
||||
ref_date = att.check_in.date()
|
||||
period_start, period_end = self._calc_period(
|
||||
schedule_type, anchor, ref_date,
|
||||
)
|
||||
att.x_fclk_pay_period_start = period_start
|
||||
att.x_fclk_pay_period = (
|
||||
f"{period_start.strftime('%b %d')} - "
|
||||
f"{period_end.strftime('%b %d, %Y')}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _calc_period(schedule_type, anchor, ref_date):
|
||||
"""Calculate pay period start/end for a given date."""
|
||||
if not anchor:
|
||||
anchor = ref_date.replace(day=1)
|
||||
|
||||
if schedule_type == 'weekly':
|
||||
days_diff = (ref_date - anchor).days
|
||||
period_num = days_diff // 7
|
||||
period_start = anchor + timedelta(days=period_num * 7)
|
||||
period_end = period_start + timedelta(days=6)
|
||||
|
||||
elif schedule_type == 'biweekly':
|
||||
days_diff = (ref_date - anchor).days
|
||||
period_num = days_diff // 14
|
||||
period_start = anchor + timedelta(days=period_num * 14)
|
||||
period_end = period_start + timedelta(days=13)
|
||||
|
||||
elif schedule_type == 'semi_monthly':
|
||||
if ref_date.day <= 15:
|
||||
period_start = ref_date.replace(day=1)
|
||||
period_end = ref_date.replace(day=15)
|
||||
else:
|
||||
period_start = ref_date.replace(day=16)
|
||||
next_month = ref_date.replace(day=28) + timedelta(days=4)
|
||||
period_end = next_month - timedelta(days=next_month.day)
|
||||
|
||||
elif schedule_type == 'monthly':
|
||||
period_start = ref_date.replace(day=1)
|
||||
next_month = ref_date.replace(day=28) + timedelta(days=4)
|
||||
period_end = next_month - timedelta(days=next_month.day)
|
||||
|
||||
else:
|
||||
days_diff = (ref_date - anchor).days
|
||||
period_num = days_diff // 14
|
||||
period_start = anchor + timedelta(days=period_num * 14)
|
||||
period_end = period_start + timedelta(days=13)
|
||||
|
||||
return period_start, period_end
|
||||
|
||||
@api.model
|
||||
def _read_group(self, domain, groupby=(), aggregates=(), having=(),
|
||||
offset=0, limit=None, order=None):
|
||||
"""Sort pay period groups chronologically (newest first) using the
|
||||
stored Date field instead of alphabetical Char order."""
|
||||
if 'x_fclk_pay_period' in groupby:
|
||||
order = 'x_fclk_pay_period_start:max desc'
|
||||
return super()._read_group(
|
||||
domain, groupby, aggregates, having, offset, limit, order,
|
||||
)
|
||||
|
||||
def action_recompute_pay_periods(self):
|
||||
"""Recompute pay period for all attendance records. Called from settings."""
|
||||
all_atts = self.sudo().search([])
|
||||
all_atts._compute_pay_period()
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_auto_clock_out(self):
|
||||
"""Cron job: auto clock-out employees after shift + grace period.
|
||||
|
||||
Runs every 15 minutes. Finds open attendances that have exceeded
|
||||
the maximum shift length plus grace period, and closes them.
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_auto_clockout', 'True') != 'True':
|
||||
return
|
||||
|
||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '12.0'))
|
||||
grace_min = float(ICP.get_param('fusion_clock.grace_period_minutes', '15'))
|
||||
clock_out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
|
||||
now = fields.Datetime.now()
|
||||
|
||||
# Find all open attendances (no check_out)
|
||||
open_attendances = self.sudo().search([
|
||||
('check_out', '=', False),
|
||||
])
|
||||
|
||||
for att in open_attendances:
|
||||
check_in = att.check_in
|
||||
if not check_in:
|
||||
continue
|
||||
|
||||
# Calculate the scheduled end + grace for this attendance
|
||||
check_in_date = check_in.date()
|
||||
out_h = int(clock_out_hour)
|
||||
out_m = int((clock_out_hour - out_h) * 60)
|
||||
scheduled_end = datetime.combine(
|
||||
check_in_date,
|
||||
datetime.min.time().replace(hour=out_h, minute=out_m),
|
||||
)
|
||||
deadline = scheduled_end + timedelta(minutes=grace_min)
|
||||
|
||||
# Also check max shift safety net
|
||||
max_deadline = check_in + timedelta(hours=max_shift)
|
||||
|
||||
# Use the earlier of the two deadlines
|
||||
effective_deadline = min(deadline, max_deadline)
|
||||
|
||||
if now > effective_deadline:
|
||||
# Auto clock-out at the deadline time (not now)
|
||||
clock_out_time = min(effective_deadline, now)
|
||||
try:
|
||||
att.sudo().write({
|
||||
'check_out': clock_out_time,
|
||||
'x_fclk_auto_clocked_out': True,
|
||||
'x_fclk_grace_used': True,
|
||||
'x_fclk_clock_source': 'auto',
|
||||
})
|
||||
|
||||
# Apply break deduction
|
||||
employee = att.employee_id
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0'))
|
||||
if (att.worked_hours or 0) >= threshold:
|
||||
break_min = employee._get_fclk_break_minutes()
|
||||
att.sudo().write({'x_fclk_break_minutes': break_min})
|
||||
|
||||
# Post chatter message
|
||||
att.sudo().message_post(
|
||||
body=f"Auto clocked out at {clock_out_time.strftime('%H:%M')} "
|
||||
f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
_logger.info(
|
||||
"Fusion Clock: Auto clocked out %s (attendance %s)",
|
||||
employee.name, att.id,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
|
||||
att.id, str(e),
|
||||
)
|
||||
100
fusion_clock/fusion_clock/models/hr_employee.py
Normal file
100
fusion_clock/fusion_clock/models/hr_employee.py
Normal file
@@ -0,0 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
x_fclk_default_location_id = fields.Many2one(
|
||||
'fusion.clock.location',
|
||||
string='Default Clock Location',
|
||||
help="The default location shown on this employee's clock page.",
|
||||
)
|
||||
x_fclk_enable_clock = fields.Boolean(
|
||||
string='Enable Fusion Clock',
|
||||
default=True,
|
||||
help="If unchecked, this employee cannot use the Fusion Clock portal/systray.",
|
||||
)
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Custom Break (min)',
|
||||
default=0.0,
|
||||
help="Override default break duration for this employee. 0 = use company default.",
|
||||
)
|
||||
x_fclk_period_hours = fields.Float(
|
||||
string='Period Hours',
|
||||
compute='_compute_fclk_period_hours',
|
||||
help="Net hours worked in the current pay period.",
|
||||
)
|
||||
x_fclk_period_label = fields.Char(
|
||||
string='Current Period',
|
||||
compute='_compute_fclk_period_hours',
|
||||
help="Label for the current pay period.",
|
||||
)
|
||||
|
||||
def _get_fclk_break_minutes(self):
|
||||
"""Return effective break minutes for this employee."""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_break_minutes > 0:
|
||||
return self.x_fclk_break_minutes
|
||||
return float(
|
||||
self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_clock.default_break_minutes', '30'
|
||||
)
|
||||
)
|
||||
|
||||
def action_fclk_open_attendance(self):
|
||||
"""Open attendance list for this employee, grouped by pay period."""
|
||||
self.ensure_one()
|
||||
list_view = self.env.ref('fusion_clock.view_hr_attendance_list_by_period')
|
||||
search_view = self.env.ref('fusion_clock.view_hr_attendance_search_by_period')
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'{self.name} - Attendance',
|
||||
'res_model': 'hr.attendance',
|
||||
'view_mode': 'list,form',
|
||||
'views': [(list_view.id, 'list'), (False, 'form')],
|
||||
'search_view_id': (search_view.id, search_view.name),
|
||||
'domain': [('employee_id', '=', self.id)],
|
||||
'context': {
|
||||
'search_default_group_pay_period': 1,
|
||||
'default_employee_id': self.id,
|
||||
},
|
||||
}
|
||||
|
||||
def _compute_fclk_period_hours(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
anchor_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
anchor = None
|
||||
if anchor_str:
|
||||
try:
|
||||
anchor = fields.Date.from_string(anchor_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
today = fields.Date.today()
|
||||
Attendance = self.env['hr.attendance']
|
||||
period_start, period_end = Attendance._calc_period(schedule_type, anchor, today)
|
||||
|
||||
period_label = (
|
||||
f"{period_start.strftime('%b %d')} - "
|
||||
f"{period_end.strftime('%b %d, %Y')}"
|
||||
)
|
||||
start_dt = datetime.combine(period_start, datetime.min.time())
|
||||
end_dt = datetime.combine(period_end + timedelta(days=1), datetime.min.time())
|
||||
|
||||
for emp in self:
|
||||
atts = Attendance.sudo().search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', start_dt),
|
||||
('check_in', '<', end_dt),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
emp.x_fclk_period_hours = round(
|
||||
sum(a.x_fclk_net_hours or 0 for a in atts), 1
|
||||
)
|
||||
emp.x_fclk_period_label = period_label
|
||||
215
fusion_clock/fusion_clock/models/res_config_settings.py
Normal file
215
fusion_clock/fusion_clock/models/res_config_settings.py
Normal file
@@ -0,0 +1,215 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import timedelta
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
# -- Work Schedule --
|
||||
fclk_default_clock_in_time = fields.Float(
|
||||
string='Default Clock-In Time',
|
||||
config_parameter='fusion_clock.default_clock_in_time',
|
||||
default=9.0,
|
||||
help="Default scheduled clock-in time (24h format, e.g. 9.0 = 9:00 AM).",
|
||||
)
|
||||
fclk_default_clock_out_time = fields.Float(
|
||||
string='Default Clock-Out Time',
|
||||
config_parameter='fusion_clock.default_clock_out_time',
|
||||
default=17.0,
|
||||
help="Default scheduled clock-out time (24h format, e.g. 17.0 = 5:00 PM).",
|
||||
)
|
||||
|
||||
# -- Break --
|
||||
fclk_default_break_minutes = fields.Float(
|
||||
string='Default Break Duration (min)',
|
||||
config_parameter='fusion_clock.default_break_minutes',
|
||||
default=30.0,
|
||||
help="Default unpaid break duration in minutes.",
|
||||
)
|
||||
fclk_auto_deduct_break = fields.Boolean(
|
||||
string='Auto-Deduct Break',
|
||||
config_parameter='fusion_clock.auto_deduct_break',
|
||||
default=True,
|
||||
help="Automatically deduct break from worked hours on clock-out.",
|
||||
)
|
||||
fclk_break_threshold_hours = fields.Float(
|
||||
string='Break Threshold (hours)',
|
||||
config_parameter='fusion_clock.break_threshold_hours',
|
||||
default=5.0,
|
||||
help="Only deduct break if shift is longer than this many hours.",
|
||||
)
|
||||
|
||||
# -- Grace Period & Auto Clock-Out --
|
||||
fclk_grace_period_minutes = fields.Float(
|
||||
string='Grace Period (min)',
|
||||
config_parameter='fusion_clock.grace_period_minutes',
|
||||
default=15.0,
|
||||
help="Minutes allowed after scheduled end before auto clock-out.",
|
||||
)
|
||||
fclk_enable_auto_clockout = fields.Boolean(
|
||||
string='Enable Auto Clock-Out',
|
||||
config_parameter='fusion_clock.enable_auto_clockout',
|
||||
default=True,
|
||||
)
|
||||
fclk_max_shift_hours = fields.Float(
|
||||
string='Max Shift Length (hours)',
|
||||
config_parameter='fusion_clock.max_shift_hours',
|
||||
default=12.0,
|
||||
help="Maximum shift length before auto clock-out (safety net).",
|
||||
)
|
||||
|
||||
# -- Penalties --
|
||||
fclk_enable_penalties = fields.Boolean(
|
||||
string='Enable Penalty Tracking',
|
||||
config_parameter='fusion_clock.enable_penalties',
|
||||
default=True,
|
||||
)
|
||||
fclk_penalty_grace_minutes = fields.Float(
|
||||
string='Penalty Grace (min)',
|
||||
config_parameter='fusion_clock.penalty_grace_minutes',
|
||||
default=5.0,
|
||||
help="Minutes of grace before a late/early penalty is recorded.",
|
||||
)
|
||||
|
||||
# -- Pay Period --
|
||||
fclk_pay_period_type = fields.Selection(
|
||||
[
|
||||
('weekly', 'Weekly'),
|
||||
('biweekly', 'Bi-Weekly'),
|
||||
('semi_monthly', 'Semi-Monthly'),
|
||||
('monthly', 'Monthly'),
|
||||
],
|
||||
string='Pay Period',
|
||||
config_parameter='fusion_clock.pay_period_type',
|
||||
default='biweekly',
|
||||
help="How often pay periods repeat. Semi-Monthly uses 1st-15th and 16th-end; "
|
||||
"Weekly and Bi-Weekly use the anchor date below.",
|
||||
)
|
||||
fclk_pay_period_start = fields.Date(
|
||||
string='Pay Period Anchor Date',
|
||||
help="The first day of any real pay period. All periods are calculated "
|
||||
"forward and backward from this date. For example, if your biweekly "
|
||||
"pay period runs Jan 17 - Jan 30, set this to Jan 17.",
|
||||
)
|
||||
fclk_pay_period_preview = fields.Char(
|
||||
string='Current Period Preview',
|
||||
compute='_compute_pay_period_preview',
|
||||
help="Shows the current pay period based on today's date and your settings.",
|
||||
)
|
||||
|
||||
# -- Reports --
|
||||
fclk_auto_generate_reports = fields.Boolean(
|
||||
string='Auto-Generate Reports',
|
||||
config_parameter='fusion_clock.auto_generate_reports',
|
||||
default=True,
|
||||
)
|
||||
fclk_report_recipient_emails = fields.Char(
|
||||
string='Report Recipient Emails',
|
||||
config_parameter='fusion_clock.report_recipient_emails',
|
||||
help="Comma-separated email addresses for batch report delivery.",
|
||||
)
|
||||
fclk_send_employee_reports = fields.Boolean(
|
||||
string='Send Employee Copies',
|
||||
config_parameter='fusion_clock.send_employee_reports',
|
||||
default=True,
|
||||
help="Send individual report copies to each employee's work email.",
|
||||
)
|
||||
|
||||
# -- Google Maps --
|
||||
fclk_google_maps_api_key = fields.Char(
|
||||
string='Google Maps API Key',
|
||||
config_parameter='fusion_clock.google_maps_api_key',
|
||||
)
|
||||
|
||||
# -- Sounds --
|
||||
fclk_enable_sounds = fields.Boolean(
|
||||
string='Enable Clock Sounds',
|
||||
config_parameter='fusion_clock.enable_sounds',
|
||||
default=True,
|
||||
)
|
||||
|
||||
@api.depends('fclk_pay_period_type', 'fclk_pay_period_start')
|
||||
def _compute_pay_period_preview(self):
|
||||
HrAtt = self.env['hr.attendance']
|
||||
today = fields.Date.context_today(self)
|
||||
for rec in self:
|
||||
schedule = rec.fclk_pay_period_type or 'biweekly'
|
||||
anchor = rec.fclk_pay_period_start
|
||||
if not anchor and schedule in ('weekly', 'biweekly'):
|
||||
rec.fclk_pay_period_preview = 'Set anchor date to see preview'
|
||||
continue
|
||||
period_start, period_end = HrAtt._calc_period(schedule, anchor, today)
|
||||
rec.fclk_pay_period_preview = (
|
||||
f"{period_start.strftime('%b %d, %Y')} - "
|
||||
f"{period_end.strftime('%b %d, %Y')}"
|
||||
)
|
||||
|
||||
def action_backfill_reports(self):
|
||||
"""Generate reports for all historical pay periods without sending email."""
|
||||
count = self.env['fusion.clock.report'].sudo()._backfill_historical_reports()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Historical Reports',
|
||||
'message': f'Created {count} reports for past pay periods.',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
def action_backfill_breaks(self):
|
||||
"""Apply break deduction to all past attendance records missing it,
|
||||
then regenerate any existing report PDFs so they reflect the new totals."""
|
||||
count = self.env['hr.attendance'].sudo().action_backfill_breaks()
|
||||
|
||||
# Regenerate existing report PDFs so stored files match updated totals
|
||||
if count:
|
||||
reports = self.env['fusion.clock.report'].sudo().search([
|
||||
('state', 'in', ['generated', 'sent']),
|
||||
])
|
||||
for report in reports:
|
||||
try:
|
||||
report._generate_pdf()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Break Backfill',
|
||||
'message': f'Applied break deduction to {count} attendance records. Report PDFs regenerated.',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
res = super().get_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
anchor_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
if anchor_str:
|
||||
try:
|
||||
res['fclk_pay_period_start'] = fields.Date.from_string(anchor_str)
|
||||
except Exception:
|
||||
res['fclk_pay_period_start'] = False
|
||||
return res
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
val = self.fclk_pay_period_start
|
||||
ICP.set_param(
|
||||
'fusion_clock.pay_period_start',
|
||||
fields.Date.to_string(val) if val else '',
|
||||
)
|
||||
# Recompute all pay periods so existing records match current settings
|
||||
self.env['hr.attendance'].sudo().search([
|
||||
('check_in', '!=', False),
|
||||
])._compute_pay_period()
|
||||
145
fusion_clock/fusion_clock/report/clock_employee_report.xml
Normal file
145
fusion_clock/fusion_clock/report/clock_employee_report.xml
Normal file
@@ -0,0 +1,145 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Employee Report Action -->
|
||||
<record id="action_report_clock_employee" model="ir.actions.report">
|
||||
<field name="name">Employee Attendance Report</field>
|
||||
<field name="model">fusion.clock.report</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_clock.report_clock_employee</field>
|
||||
<field name="report_file">fusion_clock.report_clock_employee</field>
|
||||
<field name="binding_model_id" ref="model_fusion_clock_report"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
|
||||
<!-- Employee Report Template -->
|
||||
<template id="report_clock_employee">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page" style="font-family: Arial, sans-serif;">
|
||||
<!-- Header -->
|
||||
<div style="text-align:center; margin-bottom:24px;">
|
||||
<h2 style="color:#1a1d23; margin:0;">Attendance Report</h2>
|
||||
<p style="color:#6b7280; margin:4px 0 0;">
|
||||
<t t-esc="doc.date_start"/> to <t t-esc="doc.date_end"/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Employee Info -->
|
||||
<table style="width:100%; margin-bottom:16px;">
|
||||
<tr>
|
||||
<td style="width:50%;">
|
||||
<strong>Employee:</strong> <t t-esc="doc.employee_id.name"/>
|
||||
</td>
|
||||
<td style="width:50%; text-align:right;">
|
||||
<strong>Company:</strong> <t t-esc="doc.company_id.name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Department:</strong> <t t-esc="doc.employee_id.department_id.name or 'N/A'"/>
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<strong>Schedule:</strong> <t t-esc="dict(doc._fields['schedule_type'].selection).get(doc.schedule_type, '')"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Summary Box -->
|
||||
<table style="width:100%; border-collapse:collapse; margin-bottom:24px; background:#f8f9fa; border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:12px; text-align:center; border:1px solid #e0e0e0;">
|
||||
<div style="font-size:20px; font-weight:bold; color:#1a1d23;">
|
||||
<t t-esc="doc.days_worked"/>
|
||||
</div>
|
||||
<div style="font-size:11px; color:#6b7280;">Days Worked</div>
|
||||
</td>
|
||||
<td style="padding:12px; text-align:center; border:1px solid #e0e0e0;">
|
||||
<div style="font-size:20px; font-weight:bold; color:#1a1d23;">
|
||||
<t t-esc="'%.1f' % doc.total_hours"/>h
|
||||
</div>
|
||||
<div style="font-size:11px; color:#6b7280;">Total Hours</div>
|
||||
</td>
|
||||
<td style="padding:12px; text-align:center; border:1px solid #e0e0e0;">
|
||||
<div style="font-size:20px; font-weight:bold; color:#10B981;">
|
||||
<t t-esc="'%.1f' % doc.net_hours"/>h
|
||||
</div>
|
||||
<div style="font-size:11px; color:#6b7280;">Net Hours</div>
|
||||
</td>
|
||||
<td style="padding:12px; text-align:center; border:1px solid #e0e0e0;">
|
||||
<div style="font-size:20px; font-weight:bold; color:#1a1d23;">
|
||||
<t t-esc="'%.0f' % doc.total_breaks"/> min
|
||||
</div>
|
||||
<div style="font-size:11px; color:#6b7280;">Total Breaks</div>
|
||||
</td>
|
||||
<td style="padding:12px; text-align:center; border:1px solid #e0e0e0;">
|
||||
<div style="font-size:20px; font-weight:bold;"
|
||||
t-attf-style="color: {{ '#ef4444' if doc.total_penalties > 0 else '#1a1d23' }};">
|
||||
<t t-esc="doc.total_penalties"/>
|
||||
</div>
|
||||
<div style="font-size:11px; color:#6b7280;">Penalties</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Daily Breakdown -->
|
||||
<h4 style="color:#1a1d23; margin-bottom:8px;">Daily Breakdown</h4>
|
||||
<table style="width:100%; border-collapse:collapse; font-size:11px;">
|
||||
<thead>
|
||||
<tr style="background:#1a1d23; color:white;">
|
||||
<th style="padding:8px; text-align:left;">Date</th>
|
||||
<th style="padding:8px; text-align:center;">Clock In</th>
|
||||
<th style="padding:8px; text-align:center;">Clock Out</th>
|
||||
<th style="padding:8px; text-align:center;">Worked</th>
|
||||
<th style="padding:8px; text-align:center;">Break</th>
|
||||
<th style="padding:8px; text-align:center;">Net Hours</th>
|
||||
<th style="padding:8px; text-align:left;">Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="doc.attendance_ids.sorted(key=lambda a: a.check_in)" t-as="att">
|
||||
<tr t-attf-style="background: {{ '#f8f9fa' if att_index % 2 == 0 else '#ffffff' }};">
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0;">
|
||||
<t t-esc="att.check_in" t-options="{'widget': 'date'}"/>
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;">
|
||||
<t t-esc="att.check_in" t-options="{'widget': 'datetime', 'format': 'HH:mm'}"/>
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;">
|
||||
<t t-if="att.check_out">
|
||||
<t t-esc="att.check_out" t-options="{'widget': 'datetime', 'format': 'HH:mm'}"/>
|
||||
</t>
|
||||
<t t-else="">--</t>
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;">
|
||||
<t t-esc="'%.1f' % (att.worked_hours or 0)"/>h
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;">
|
||||
<t t-esc="'%.0f' % (att.x_fclk_break_minutes or 0)"/> min
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center; font-weight:bold;">
|
||||
<t t-esc="'%.1f' % (att.x_fclk_net_hours or 0)"/>h
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0;">
|
||||
<t t-esc="att.x_fclk_location_id.name or ''"/>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="margin-top:24px; padding-top:12px; border-top:1px solid #e0e0e0; text-align:center;">
|
||||
<p style="color:#9ca3af; font-size:10px;">
|
||||
Generated by Fusion Clock |
|
||||
<t t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
90
fusion_clock/fusion_clock/report/clock_report_template.xml
Normal file
90
fusion_clock/fusion_clock/report/clock_report_template.xml
Normal file
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Batch Report Action -->
|
||||
<record id="action_report_clock_batch" model="ir.actions.report">
|
||||
<field name="name">Batch Attendance Report</field>
|
||||
<field name="model">fusion.clock.report</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_clock.report_clock_batch</field>
|
||||
<field name="report_file">fusion_clock.report_clock_batch</field>
|
||||
</record>
|
||||
|
||||
<!-- Batch Report Template -->
|
||||
<template id="report_clock_batch">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page" style="font-family: Arial, sans-serif;">
|
||||
<!-- Header -->
|
||||
<div style="text-align:center; margin-bottom:24px;">
|
||||
<h2 style="color:#1a1d23; margin:0;">Employee Attendance Summary</h2>
|
||||
<p style="color:#6b7280; margin:4px 0 0;">
|
||||
<t t-esc="doc.date_start"/> to <t t-esc="doc.date_end"/>
|
||||
| <t t-esc="doc.company_id.name"/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary Table - Group by Employee -->
|
||||
<t t-set="employees" t-value="doc.attendance_ids.mapped('employee_id')"/>
|
||||
<table style="width:100%; border-collapse:collapse; font-size:11px; margin-bottom:24px;">
|
||||
<thead>
|
||||
<tr style="background:#1a1d23; color:white;">
|
||||
<th style="padding:8px; text-align:left;">Employee</th>
|
||||
<th style="padding:8px; text-align:center;">Days</th>
|
||||
<th style="padding:8px; text-align:center;">Total Hours</th>
|
||||
<th style="padding:8px; text-align:center;">Net Hours</th>
|
||||
<th style="padding:8px; text-align:center;">Breaks (min)</th>
|
||||
<th style="padding:8px; text-align:center;">Late In</th>
|
||||
<th style="padding:8px; text-align:center;">Early Out</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="employees.sorted(key=lambda e: e.name)" t-as="emp">
|
||||
<t t-set="emp_atts" t-value="doc.attendance_ids.filtered(lambda a: a.employee_id == emp)"/>
|
||||
<t t-set="emp_dates" t-value="set(a.check_in.date() for a in emp_atts if a.check_in)"/>
|
||||
<t t-set="late_count" t-value="len(doc.env['fusion.clock.penalty'].search([('employee_id', '=', emp.id), ('date', '>=', doc.date_start), ('date', '<=', doc.date_end), ('penalty_type', '=', 'late_in')]))"/>
|
||||
<t t-set="early_count" t-value="len(doc.env['fusion.clock.penalty'].search([('employee_id', '=', emp.id), ('date', '>=', doc.date_start), ('date', '<=', doc.date_end), ('penalty_type', '=', 'early_out')]))"/>
|
||||
<tr t-attf-style="background: {{ '#f8f9fa' if emp_index % 2 == 0 else '#ffffff' }};">
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; font-weight:bold;">
|
||||
<t t-esc="emp.name"/>
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;">
|
||||
<t t-esc="len(emp_dates)"/>
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;">
|
||||
<t t-esc="'%.1f' % sum(a.worked_hours or 0 for a in emp_atts)"/>h
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center; font-weight:bold; color:#10B981;">
|
||||
<t t-esc="'%.1f' % sum(a.x_fclk_net_hours or 0 for a in emp_atts)"/>h
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;">
|
||||
<t t-esc="'%.0f' % sum(a.x_fclk_break_minutes or 0 for a in emp_atts)"/>
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;"
|
||||
t-attf-style="color: {{ '#ef4444' if late_count else '#1a1d23' }};">
|
||||
<t t-esc="late_count"/>
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;"
|
||||
t-attf-style="color: {{ '#ef4444' if early_count else '#1a1d23' }};">
|
||||
<t t-esc="early_count"/>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="margin-top:24px; padding-top:12px; border-top:1px solid #e0e0e0; text-align:center;">
|
||||
<p style="color:#9ca3af; font-size:10px;">
|
||||
Generated by Fusion Clock |
|
||||
<t t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
12
fusion_clock/fusion_clock/security/ir.model.access.csv
Normal file
12
fusion_clock/fusion_clock/security/ir.model.access.csv
Normal file
@@ -0,0 +1,12 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_clock_location_user,fusion.clock.location.user,model_fusion_clock_location,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_location_manager,fusion.clock.location.manager,model_fusion_clock_location,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_penalty_user,fusion.clock.penalty.user,model_fusion_clock_penalty,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_penalty_manager,fusion.clock.penalty.manager,model_fusion_clock_penalty,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_report_user,fusion.clock.report.user,model_fusion_clock_report,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_report_manager,fusion.clock.report.manager,model_fusion_clock_report,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_penalty_portal,fusion.clock.penalty.portal,model_fusion_clock_penalty,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_report_portal,fusion.clock.report.portal,model_fusion_clock_report,base.group_portal,1,0,0,0
|
||||
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
|
||||
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
|
||||
|
129
fusion_clock/fusion_clock/security/security.xml
Normal file
129
fusion_clock/fusion_clock/security/security.xml
Normal file
@@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Groups -->
|
||||
<record id="group_fusion_clock_user" model="res.groups">
|
||||
<field name="name">Fusion Clock / User</field>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="comment">Can clock in/out and view own attendance</field>
|
||||
</record>
|
||||
|
||||
<record id="group_fusion_clock_manager" model="res.groups">
|
||||
<field name="name">Fusion Clock / Manager</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
||||
<field name="comment">Can manage locations, view all attendance, generate reports</field>
|
||||
</record>
|
||||
|
||||
<!-- Auto-assign admin to Manager group -->
|
||||
<record id="base.user_admin" model="res.users">
|
||||
<field name="groups_id" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Record Rules -->
|
||||
|
||||
<!-- Clock Location: Managers see all, Users see active ones for their company -->
|
||||
<record id="rule_clock_location_user" model="ir.rule">
|
||||
<field name="name">Clock Location: User sees active company locations</field>
|
||||
<field name="model_id" ref="model_fusion_clock_location"/>
|
||||
<field name="domain_force">[('company_id', 'in', company_ids), ('active', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_clock_location_manager" model="ir.rule">
|
||||
<field name="name">Clock Location: Manager full access</field>
|
||||
<field name="model_id" ref="model_fusion_clock_location"/>
|
||||
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Clock Penalty: Users see own, Managers see all -->
|
||||
<record id="rule_clock_penalty_user" model="ir.rule">
|
||||
<field name="name">Clock Penalty: User sees own penalties</field>
|
||||
<field name="model_id" ref="model_fusion_clock_penalty"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_clock_penalty_manager" model="ir.rule">
|
||||
<field name="name">Clock Penalty: Manager full access</field>
|
||||
<field name="model_id" ref="model_fusion_clock_penalty"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Clock Report: Users see own, Managers see all -->
|
||||
<record id="rule_clock_report_user" model="ir.rule">
|
||||
<field name="name">Clock Report: User sees own reports</field>
|
||||
<field name="model_id" ref="model_fusion_clock_report"/>
|
||||
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id', '=', False)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_clock_report_manager" model="ir.rule">
|
||||
<field name="name">Clock Report: Manager full access</field>
|
||||
<field name="model_id" ref="model_fusion_clock_report"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Portal access for attendance records -->
|
||||
<record id="rule_hr_attendance_portal" model="ir.rule">
|
||||
<field name="name">HR Attendance: Portal user sees own</field>
|
||||
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Portal access for clock locations -->
|
||||
<record id="rule_clock_location_portal" model="ir.rule">
|
||||
<field name="name">Clock Location: Portal user sees active</field>
|
||||
<field name="model_id" ref="model_fusion_clock_location"/>
|
||||
<field name="domain_force">[('active', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Portal access for clock reports -->
|
||||
<record id="rule_clock_report_portal" model="ir.rule">
|
||||
<field name="name">Clock Report: Portal user sees own</field>
|
||||
<field name="model_id" ref="model_fusion_clock_report"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Portal access for clock penalties -->
|
||||
<record id="rule_clock_penalty_portal" model="ir.rule">
|
||||
<field name="name">Clock Penalty: Portal user sees own</field>
|
||||
<field name="model_id" ref="model_fusion_clock_penalty"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
BIN
fusion_clock/fusion_clock/static/description/icon.png
Normal file
BIN
fusion_clock/fusion_clock/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
1259
fusion_clock/fusion_clock/static/src/css/portal_clock.css
Normal file
1259
fusion_clock/fusion_clock/static/src/css/portal_clock.css
Normal file
File diff suppressed because it is too large
Load Diff
300
fusion_clock/fusion_clock/static/src/js/fclk_location_map.js
Normal file
300
fusion_clock/fusion_clock/static/src/js/fclk_location_map.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/** @odoo-module **/
|
||||
/**
|
||||
* Fusion Clock - Location Widgets (Odoo 19)
|
||||
*
|
||||
* 1) fclk_places_address - CharField with Google Places Autocomplete.
|
||||
* Selecting a place auto-fills lat/lng on the record (no manual geocode).
|
||||
*
|
||||
* 2) fclk_map - Interactive map view widget.
|
||||
* Reacts live to lat/lng/radius changes on the record.
|
||||
* Dragging the pin updates the record directly (no separate save).
|
||||
*/
|
||||
|
||||
import { Component, onMounted, onWillUnmount, useRef, useState, useEffect } from "@odoo/owl";
|
||||
import { CharField, charField } from "@web/views/fields/char/char_field";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Google Maps JS API singleton loader
|
||||
// ---------------------------------------------------------------------------
|
||||
let _gmapsPromise = null;
|
||||
|
||||
function loadGoogleMapsAPI(apiKey) {
|
||||
if (window.google?.maps?.places) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (_gmapsPromise) {
|
||||
return _gmapsPromise;
|
||||
}
|
||||
_gmapsPromise = new Promise((resolve, reject) => {
|
||||
window.__fclkGmapsReady = () => {
|
||||
delete window.__fclkGmapsReady;
|
||||
resolve();
|
||||
};
|
||||
const s = document.createElement("script");
|
||||
s.src =
|
||||
"https://maps.googleapis.com/maps/api/js?key=" +
|
||||
encodeURIComponent(apiKey) +
|
||||
"&libraries=places&callback=__fclkGmapsReady";
|
||||
s.async = true;
|
||||
s.onerror = () => reject(new Error("Failed to load Google Maps JS API"));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return _gmapsPromise;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 1) fclk_map - Interactive map view widget
|
||||
// ===========================================================================
|
||||
|
||||
export class FclkLocationMap extends Component {
|
||||
static template = "fusion_clock.LocationMap";
|
||||
static props = { ...standardWidgetProps };
|
||||
|
||||
setup() {
|
||||
this.mapContainerRef = useRef("mapContainer");
|
||||
this.searchRef = useRef("searchInput");
|
||||
this.notification = useService("notification");
|
||||
|
||||
this.state = useState({
|
||||
loaded: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
this.map = null;
|
||||
this.marker = null;
|
||||
this.circle = null;
|
||||
this._skipSync = false;
|
||||
|
||||
onMounted(() => this._init());
|
||||
onWillUnmount(() => this._cleanup());
|
||||
|
||||
// React to record data changes (lat, lng, radius)
|
||||
useEffect(
|
||||
() => {
|
||||
this._syncMapToRecord();
|
||||
},
|
||||
() => {
|
||||
const d = this.props.record.data;
|
||||
return [d.latitude, d.longitude, d.radius];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
get record() {
|
||||
return this.props.record;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Initialise
|
||||
// ------------------------------------------------------------------
|
||||
async _init() {
|
||||
try {
|
||||
const settings = await rpc("/fusion_clock/get_settings");
|
||||
const apiKey = settings?.google_maps_api_key;
|
||||
if (!apiKey) {
|
||||
this.state.error =
|
||||
"Google Maps API key not configured. Go to Fusion Clock Settings.";
|
||||
return;
|
||||
}
|
||||
await loadGoogleMapsAPI(apiKey);
|
||||
this.state.loaded = true;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
this._buildMap();
|
||||
} catch (e) {
|
||||
this.state.error = "Could not load Google Maps: " + (e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Build the map + marker + circle + search
|
||||
// ------------------------------------------------------------------
|
||||
_buildMap() {
|
||||
const lat = this.record.data.latitude || 43.65;
|
||||
const lng = this.record.data.longitude || -79.38;
|
||||
const radius = this.record.data.radius || 100;
|
||||
const hasCoords =
|
||||
this.record.data.latitude !== 0 || this.record.data.longitude !== 0;
|
||||
|
||||
const el = this.mapContainerRef.el;
|
||||
if (!el) return;
|
||||
|
||||
this.map = new google.maps.Map(el, {
|
||||
center: { lat, lng },
|
||||
zoom: hasCoords ? 17 : 12,
|
||||
mapTypeControl: true,
|
||||
streetViewControl: false,
|
||||
fullscreenControl: true,
|
||||
});
|
||||
|
||||
this.marker = new google.maps.Marker({
|
||||
position: { lat, lng },
|
||||
map: this.map,
|
||||
draggable: !this.props.readonly,
|
||||
animation: google.maps.Animation.DROP,
|
||||
title: "Drag to fine-tune location",
|
||||
});
|
||||
|
||||
this.circle = new google.maps.Circle({
|
||||
map: this.map,
|
||||
center: { lat, lng },
|
||||
radius,
|
||||
fillColor: "#10B981",
|
||||
fillOpacity: 0.12,
|
||||
strokeColor: "#10B981",
|
||||
strokeWeight: 2,
|
||||
strokeOpacity: 0.45,
|
||||
});
|
||||
|
||||
// Pin drag -> update record directly (saves with form Save button)
|
||||
this.marker.addListener("dragend", () => {
|
||||
const pos = this.marker.getPosition();
|
||||
this.circle.setCenter(pos);
|
||||
this._skipSync = true;
|
||||
this.record.update({
|
||||
latitude: pos.lat(),
|
||||
longitude: pos.lng(),
|
||||
});
|
||||
});
|
||||
|
||||
// Places Autocomplete search inside the map widget
|
||||
if (this.searchRef.el && !this.props.readonly) {
|
||||
const autocomplete = new google.maps.places.Autocomplete(
|
||||
this.searchRef.el,
|
||||
{ types: ["establishment", "geocode"] }
|
||||
);
|
||||
autocomplete.bindTo("bounds", this.map);
|
||||
|
||||
autocomplete.addListener("place_changed", () => {
|
||||
const place = autocomplete.getPlace();
|
||||
if (!place.geometry?.location) return;
|
||||
|
||||
const loc = place.geometry.location;
|
||||
this.map.setCenter(loc);
|
||||
this.map.setZoom(17);
|
||||
this.marker.setPosition(loc);
|
||||
this.circle.setCenter(loc);
|
||||
|
||||
this._skipSync = true;
|
||||
const updates = {
|
||||
latitude: loc.lat(),
|
||||
longitude: loc.lng(),
|
||||
};
|
||||
if (place.formatted_address) {
|
||||
updates.address = place.formatted_address;
|
||||
}
|
||||
this.record.update(updates);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Sync map when record lat/lng/radius changes externally
|
||||
// (e.g. from the address field's Places autocomplete)
|
||||
// ------------------------------------------------------------------
|
||||
_syncMapToRecord() {
|
||||
if (!this.map || !this.marker || !this.circle) return;
|
||||
|
||||
// Skip if this change originated from our own map interaction
|
||||
if (this._skipSync) {
|
||||
this._skipSync = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const lat = this.record.data.latitude || 43.65;
|
||||
const lng = this.record.data.longitude || -79.38;
|
||||
const radius = this.record.data.radius || 100;
|
||||
const hasCoords =
|
||||
this.record.data.latitude !== 0 || this.record.data.longitude !== 0;
|
||||
|
||||
const pos = { lat, lng };
|
||||
this.map.setCenter(pos);
|
||||
if (hasCoords) {
|
||||
this.map.setZoom(17);
|
||||
}
|
||||
this.marker.setPosition(pos);
|
||||
this.circle.setCenter(pos);
|
||||
this.circle.setRadius(radius);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ------------------------------------------------------------------
|
||||
_cleanup() {
|
||||
this.map = null;
|
||||
this.marker = null;
|
||||
this.circle = null;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("view_widgets").add("fclk_map", {
|
||||
component: FclkLocationMap,
|
||||
});
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// 2) fclk_places_address - CharField with Google Places Autocomplete
|
||||
// ===========================================================================
|
||||
|
||||
export class FclkPlacesAddress extends CharField {
|
||||
static template = "fusion_clock.PlacesAddress";
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
this._autocomplete = null;
|
||||
onMounted(() => this._attachPlaces());
|
||||
onWillUnmount(() => this._detachPlaces());
|
||||
}
|
||||
|
||||
async _attachPlaces() {
|
||||
if (this.props.readonly) return;
|
||||
try {
|
||||
const settings = await rpc("/fusion_clock/get_settings");
|
||||
const apiKey = settings?.google_maps_api_key;
|
||||
if (!apiKey) return;
|
||||
await loadGoogleMapsAPI(apiKey);
|
||||
|
||||
const inputEl = this.input?.el;
|
||||
if (!inputEl) return;
|
||||
|
||||
this._autocomplete = new google.maps.places.Autocomplete(inputEl, {
|
||||
types: ["establishment", "geocode"],
|
||||
});
|
||||
|
||||
this._autocomplete.addListener("place_changed", () => {
|
||||
const place = this._autocomplete.getPlace();
|
||||
if (!place.geometry?.location) return;
|
||||
|
||||
const lat = place.geometry.location.lat();
|
||||
const lng = place.geometry.location.lng();
|
||||
const addr = place.formatted_address || place.name || "";
|
||||
|
||||
// Auto-fill address + coordinates on the record
|
||||
this.props.record.update({
|
||||
[this.props.name]: addr,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
// Silently degrade - Places is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
_detachPlaces() {
|
||||
if (this._autocomplete) {
|
||||
google.maps.event.clearInstanceListeners(this._autocomplete);
|
||||
this._autocomplete = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fclkPlacesAddress = {
|
||||
...charField,
|
||||
component: FclkPlacesAddress,
|
||||
};
|
||||
|
||||
registry.category("fields").add("fclk_places_address", fclkPlacesAddress);
|
||||
502
fusion_clock/fusion_clock/static/src/js/fusion_clock_portal.js
Normal file
502
fusion_clock/fusion_clock/static/src/js/fusion_clock_portal.js
Normal file
@@ -0,0 +1,502 @@
|
||||
/** @odoo-module **/
|
||||
/**
|
||||
* Fusion Clock - Portal Clock-In/Out Interaction (Odoo 19)
|
||||
*
|
||||
* Handles: GPS verification, clock in/out actions, live timer,
|
||||
* sound effects, persistent state, location selection, and UI animations.
|
||||
*/
|
||||
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
export class FusionClockPortal extends Interaction {
|
||||
static selector = "#fusion-clock-app";
|
||||
|
||||
setup() {
|
||||
this.isCheckedIn = this.el.dataset.checkedIn === "true";
|
||||
this.enableSounds = this.el.dataset.enableSounds === "true";
|
||||
this.checkInTime = null;
|
||||
this.timerInterval = null;
|
||||
this.selectedLocationId = null;
|
||||
|
||||
if (this.el.dataset.checkInTime) {
|
||||
this.checkInTime = new Date(this.el.dataset.checkInTime + "Z");
|
||||
}
|
||||
|
||||
// Load locations
|
||||
const locDataEl = document.getElementById("fclk-locations-data");
|
||||
this.locations = [];
|
||||
if (locDataEl) {
|
||||
try { this.locations = JSON.parse(locDataEl.textContent); } catch (e) {}
|
||||
}
|
||||
if (this.locations.length > 0) {
|
||||
this.selectedLocationId = this.locations[0].id;
|
||||
}
|
||||
|
||||
// Auto-detect nearest location in background
|
||||
this._autoSelectNearestLocation();
|
||||
|
||||
// Restore localStorage state
|
||||
this._restoreState();
|
||||
|
||||
// Start live clock
|
||||
this._updateCurrentTime();
|
||||
this.clockInterval = setInterval(() => this._updateCurrentTime(), 1000);
|
||||
|
||||
// Start timer if checked in
|
||||
if (this.isCheckedIn && this.checkInTime) {
|
||||
this._startTimer();
|
||||
}
|
||||
|
||||
this._updateDateDisplay();
|
||||
|
||||
// Event listeners
|
||||
this._setupEventListeners();
|
||||
|
||||
// Visibility sync
|
||||
this._onVisibilityChange = () => this._syncOnVisibilityChange();
|
||||
document.addEventListener("visibilitychange", this._onVisibilityChange);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._stopTimer();
|
||||
if (this.clockInterval) clearInterval(this.clockInterval);
|
||||
if (this._onVisibilityChange) {
|
||||
document.removeEventListener("visibilitychange", this._onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
_setupEventListeners() {
|
||||
const clockBtn = document.getElementById("fclk-clock-btn");
|
||||
if (clockBtn) {
|
||||
this._onClockClick = (e) => this._onClockButtonClick(e);
|
||||
clockBtn.addEventListener("click", this._onClockClick);
|
||||
}
|
||||
|
||||
const locationCard = document.getElementById("fclk-location-card");
|
||||
if (locationCard && this.locations.length > 1) {
|
||||
locationCard.addEventListener("click", () => {
|
||||
const modal = document.getElementById("fclk-location-modal");
|
||||
if (modal) modal.style.display = "flex";
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".fclk-modal-item").forEach((item) => {
|
||||
item.addEventListener("click", () => {
|
||||
this.selectedLocationId = parseInt(item.dataset.id);
|
||||
const nameEl = document.getElementById("fclk-location-name");
|
||||
const addrEl = document.getElementById("fclk-location-address");
|
||||
if (nameEl) nameEl.textContent = item.dataset.name;
|
||||
if (addrEl) addrEl.textContent = item.dataset.address;
|
||||
const modal = document.getElementById("fclk-location-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Clock Action
|
||||
// =========================================================================
|
||||
|
||||
_onClockButtonClick(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("fclk-clock-btn");
|
||||
if (!btn || btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
|
||||
// Ripple effect
|
||||
const ripple = btn.querySelector(".fclk-btn-ripple");
|
||||
if (ripple) {
|
||||
ripple.classList.remove("fclk-ripple-active");
|
||||
void ripple.offsetWidth;
|
||||
ripple.classList.add("fclk-ripple-active");
|
||||
}
|
||||
|
||||
this._showGPSOverlay();
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
this._hideGPSOverlay();
|
||||
this._showToast("Geolocation is not supported by your browser.", "error");
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
this._performClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
|
||||
},
|
||||
(err) => {
|
||||
this._hideGPSOverlay();
|
||||
let msg = "Could not get your location. ";
|
||||
if (err.code === 1) msg += "Please allow location access.";
|
||||
else if (err.code === 2) msg += "Location unavailable.";
|
||||
else if (err.code === 3) msg += "Location request timed out.";
|
||||
this._showToast(msg, "error");
|
||||
this._shakeButton();
|
||||
btn.disabled = false;
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
async _performClockAction(lat, lng, accuracy) {
|
||||
const btn = document.getElementById("fclk-clock-btn");
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/clock_action", {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
accuracy: accuracy,
|
||||
source: "portal",
|
||||
});
|
||||
|
||||
this._hideGPSOverlay();
|
||||
if (btn) btn.disabled = false;
|
||||
|
||||
if (result.error) {
|
||||
this._showToast(result.error, "error");
|
||||
this._shakeButton();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.action === "clock_in") {
|
||||
this.isCheckedIn = true;
|
||||
this.checkInTime = new Date(result.check_in + "Z");
|
||||
this._updateUIForClockIn(result);
|
||||
this._startTimer();
|
||||
this._playSound("in");
|
||||
this._showToast(result.message, "success");
|
||||
this._saveState();
|
||||
} else if (result.action === "clock_out") {
|
||||
this.isCheckedIn = false;
|
||||
this._updateUIForClockOut(result);
|
||||
this._stopTimer();
|
||||
this._playSound("out");
|
||||
this._showToast(result.message, "success");
|
||||
this._clearState();
|
||||
}
|
||||
} catch (err) {
|
||||
this._hideGPSOverlay();
|
||||
this._showToast("Network error. Please try again.", "error");
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// UI Updates
|
||||
// =========================================================================
|
||||
|
||||
_updateUIForClockIn(data) {
|
||||
const dot = document.getElementById("fclk-status-dot");
|
||||
const statusText = document.getElementById("fclk-status-text");
|
||||
const timerLabel = document.getElementById("fclk-timer-label");
|
||||
const btnLabel = document.getElementById("fclk-btn-label");
|
||||
const btn = document.getElementById("fclk-clock-btn");
|
||||
const playIcon = document.getElementById("fclk-btn-icon-play");
|
||||
const stopIcon = document.getElementById("fclk-btn-icon-stop");
|
||||
|
||||
if (dot) dot.classList.add("fclk-dot-active");
|
||||
if (statusText) statusText.textContent = "Clocked In";
|
||||
if (timerLabel) timerLabel.textContent = "Time Elapsed";
|
||||
if (btnLabel) btnLabel.textContent = "Tap to Clock Out";
|
||||
if (btn) btn.classList.add("fclk-clock-btn-out");
|
||||
if (playIcon) playIcon.style.display = "none";
|
||||
if (stopIcon) stopIcon.style.display = "block";
|
||||
|
||||
if (data.location_name) {
|
||||
const locEl = document.getElementById("fclk-location-name");
|
||||
if (locEl) locEl.textContent = data.location_name;
|
||||
}
|
||||
}
|
||||
|
||||
_updateUIForClockOut(data) {
|
||||
const dot = document.getElementById("fclk-status-dot");
|
||||
const statusText = document.getElementById("fclk-status-text");
|
||||
const timerLabel = document.getElementById("fclk-timer-label");
|
||||
const btnLabel = document.getElementById("fclk-btn-label");
|
||||
const btn = document.getElementById("fclk-clock-btn");
|
||||
const playIcon = document.getElementById("fclk-btn-icon-play");
|
||||
const stopIcon = document.getElementById("fclk-btn-icon-stop");
|
||||
const timer = document.getElementById("fclk-timer");
|
||||
|
||||
if (dot) dot.classList.remove("fclk-dot-active");
|
||||
if (statusText) statusText.textContent = "Not Clocked In";
|
||||
if (timerLabel) timerLabel.textContent = "Ready to Clock In";
|
||||
if (btnLabel) btnLabel.textContent = "Tap to Clock In";
|
||||
if (btn) btn.classList.remove("fclk-clock-btn-out");
|
||||
if (playIcon) playIcon.style.display = "block";
|
||||
if (stopIcon) stopIcon.style.display = "none";
|
||||
if (timer) timer.textContent = "00:00:00";
|
||||
|
||||
if (data.net_hours !== undefined) {
|
||||
const todayEl = document.getElementById("fclk-today-hours");
|
||||
if (todayEl) {
|
||||
const current = parseFloat(todayEl.textContent) || 0;
|
||||
todayEl.textContent = (current + data.net_hours).toFixed(1) + "h";
|
||||
}
|
||||
const weekEl = document.getElementById("fclk-week-hours");
|
||||
if (weekEl) {
|
||||
const currentW = parseFloat(weekEl.textContent) || 0;
|
||||
weekEl.textContent = (currentW + data.net_hours).toFixed(1) + "h";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Timer
|
||||
// =========================================================================
|
||||
|
||||
_startTimer() {
|
||||
this._stopTimer();
|
||||
this._updateTimer();
|
||||
this.timerInterval = setInterval(() => this._updateTimer(), 1000);
|
||||
}
|
||||
|
||||
_stopTimer() {
|
||||
if (this.timerInterval) {
|
||||
clearInterval(this.timerInterval);
|
||||
this.timerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
_updateTimer() {
|
||||
if (!this.checkInTime) return;
|
||||
const now = new Date();
|
||||
let diff = Math.max(0, Math.floor((now - this.checkInTime) / 1000));
|
||||
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const s = diff % 60;
|
||||
|
||||
const pad = (n) => (n < 10 ? "0" + n : "" + n);
|
||||
const timerEl = document.getElementById("fclk-timer");
|
||||
if (timerEl) {
|
||||
timerEl.textContent = pad(h) + ":" + pad(m) + ":" + pad(s);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Date & Time Display
|
||||
// =========================================================================
|
||||
|
||||
_updateDateDisplay() {
|
||||
const el = document.getElementById("fclk-date-display");
|
||||
if (!el) return;
|
||||
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
const months = ["January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"];
|
||||
const now = new Date();
|
||||
el.textContent = days[now.getDay()] + ", " + months[now.getMonth()] + " " + now.getDate();
|
||||
}
|
||||
|
||||
_updateCurrentTime() {
|
||||
const el = document.getElementById("fclk-current-time");
|
||||
if (!el) return;
|
||||
const now = new Date();
|
||||
let h = now.getHours();
|
||||
const m = now.getMinutes();
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
h = h % 12 || 12;
|
||||
el.textContent = h + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Auto-detect nearest location
|
||||
// =========================================================================
|
||||
|
||||
_autoSelectNearestLocation() {
|
||||
if (this.locations.length < 1) return;
|
||||
if (!navigator.geolocation) return;
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const userLat = pos.coords.latitude;
|
||||
const userLng = pos.coords.longitude;
|
||||
let nearest = this.locations[0];
|
||||
let minDist = Infinity;
|
||||
|
||||
for (const loc of this.locations) {
|
||||
const d = this._haversine(userLat, userLng, loc.latitude, loc.longitude);
|
||||
if (d < minDist) {
|
||||
minDist = d;
|
||||
nearest = loc;
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedLocationId = nearest.id;
|
||||
const nameEl = document.getElementById("fclk-location-name");
|
||||
const addrEl = document.getElementById("fclk-location-address");
|
||||
if (nameEl) nameEl.textContent = nearest.name;
|
||||
if (addrEl) addrEl.textContent = nearest.address || "";
|
||||
},
|
||||
() => {
|
||||
// Silently fall back to the first location
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }
|
||||
);
|
||||
}
|
||||
|
||||
_haversine(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371000;
|
||||
const toRad = (v) => (v * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sound Effects
|
||||
// =========================================================================
|
||||
|
||||
_playSound(type) {
|
||||
if (!this.enableSounds) return;
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
if (type === "in") {
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(523, ctx.currentTime);
|
||||
osc.frequency.setValueAtTime(659, ctx.currentTime + 0.1);
|
||||
osc.frequency.setValueAtTime(784, ctx.currentTime + 0.2);
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.5);
|
||||
} else {
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(784, ctx.currentTime);
|
||||
osc.frequency.setValueAtTime(523, ctx.currentTime + 0.15);
|
||||
gain.gain.setValueAtTime(0.25, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.4);
|
||||
}
|
||||
} catch (e) {
|
||||
// Sounds are non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Toast Notifications
|
||||
// =========================================================================
|
||||
|
||||
_showToast(msg, type) {
|
||||
const toast = document.getElementById("fclk-toast");
|
||||
const toastMsg = document.getElementById("fclk-toast-msg");
|
||||
const toastIcon = document.getElementById("fclk-toast-icon");
|
||||
if (!toast) return;
|
||||
|
||||
toast.className = "fclk-toast fclk-toast-" + (type || "success");
|
||||
if (toastMsg) toastMsg.textContent = msg;
|
||||
if (toastIcon) {
|
||||
toastIcon.innerHTML = type === "error"
|
||||
? '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>'
|
||||
: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
|
||||
}
|
||||
|
||||
toast.style.display = "flex";
|
||||
toast.style.animation = "fclk-toast-in 0.3s ease-out";
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = "fclk-toast-out 0.3s ease-in forwards";
|
||||
setTimeout(() => { toast.style.display = "none"; }, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GPS Overlay
|
||||
// =========================================================================
|
||||
|
||||
_showGPSOverlay() {
|
||||
const el = document.getElementById("fclk-gps-overlay");
|
||||
if (el) el.style.display = "flex";
|
||||
}
|
||||
|
||||
_hideGPSOverlay() {
|
||||
const el = document.getElementById("fclk-gps-overlay");
|
||||
if (el) el.style.display = "none";
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shake Animation
|
||||
// =========================================================================
|
||||
|
||||
_shakeButton() {
|
||||
const btn = document.getElementById("fclk-clock-btn");
|
||||
if (!btn) return;
|
||||
btn.classList.add("fclk-shake");
|
||||
setTimeout(() => { btn.classList.remove("fclk-shake"); }, 500);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Persistent State
|
||||
// =========================================================================
|
||||
|
||||
_saveState() {
|
||||
try {
|
||||
localStorage.setItem("fclk_checked_in", "true");
|
||||
if (this.checkInTime) {
|
||||
localStorage.setItem("fclk_check_in_time", this.checkInTime.toISOString());
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
_clearState() {
|
||||
try {
|
||||
localStorage.removeItem("fclk_checked_in");
|
||||
localStorage.removeItem("fclk_check_in_time");
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
_restoreState() {
|
||||
try {
|
||||
if (!this.isCheckedIn && localStorage.getItem("fclk_checked_in") === "true") {
|
||||
this._clearState();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sync on visibility change
|
||||
// =========================================================================
|
||||
|
||||
async _syncOnVisibilityChange() {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/get_status", {});
|
||||
if (result.error) return;
|
||||
|
||||
if (result.is_checked_in && !this.isCheckedIn) {
|
||||
this.isCheckedIn = true;
|
||||
this.checkInTime = new Date(result.check_in + "Z");
|
||||
this._updateUIForClockIn({ location_name: result.location_name });
|
||||
this._startTimer();
|
||||
this._saveState();
|
||||
} else if (!result.is_checked_in && this.isCheckedIn) {
|
||||
this.isCheckedIn = false;
|
||||
this._updateUIForClockOut({});
|
||||
this._stopTimer();
|
||||
this._clearState();
|
||||
}
|
||||
|
||||
const todayEl = document.getElementById("fclk-today-hours");
|
||||
if (todayEl && result.today_hours !== undefined) {
|
||||
todayEl.textContent = result.today_hours.toFixed(1) + "h";
|
||||
}
|
||||
const weekEl = document.getElementById("fclk-week-hours");
|
||||
if (weekEl && result.week_hours !== undefined) {
|
||||
weekEl.textContent = result.week_hours.toFixed(1) + "h";
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("fusion_clock.portal", FusionClockPortal);
|
||||
@@ -0,0 +1,330 @@
|
||||
/** @odoo-module **/
|
||||
/**
|
||||
* Fusion Clock - Portal Floating Action Button (Odoo 19)
|
||||
*
|
||||
* A persistent clock-in/out FAB that appears on ALL portal pages.
|
||||
* Uses the Interaction class pattern required by Odoo 19 frontend.
|
||||
* Shares RPC endpoints with the backend FAB and full clock page.
|
||||
*/
|
||||
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
export class FusionClockPortalFAB extends Interaction {
|
||||
static selector = "#fclk-portal-fab";
|
||||
|
||||
setup() {
|
||||
// Hide on /my/clock page to avoid duplication with the full clock UI
|
||||
if (window.location.pathname === "/my/clock") {
|
||||
this.el.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
this.isCheckedIn = this.el.dataset.checkedIn === "true";
|
||||
this.checkInTime = null;
|
||||
this.expanded = false;
|
||||
this._timerInterval = null;
|
||||
this._pollInterval = null;
|
||||
|
||||
if (this.el.dataset.checkInTime) {
|
||||
this.checkInTime = new Date(this.el.dataset.checkInTime + "Z");
|
||||
}
|
||||
|
||||
// Cache DOM references
|
||||
this.fabBtn = this.el.querySelector(".fclk-pfab-btn");
|
||||
this.panel = this.el.querySelector(".fclk-pfab-panel");
|
||||
this.statusDot = this.el.querySelector(".fclk-pfab-status-dot");
|
||||
this.statusText = this.el.querySelector(".fclk-pfab-status-text");
|
||||
this.locationRow = this.el.querySelector(".fclk-pfab-location");
|
||||
this.locationText = this.el.querySelector(".fclk-pfab-location-text");
|
||||
this.timerEl = this.el.querySelector(".fclk-pfab-timer");
|
||||
this.badgeEl = this.el.querySelector(".fclk-pfab-badge");
|
||||
this.actionBtn = this.el.querySelector(".fclk-pfab-action");
|
||||
this.errorEl = this.el.querySelector(".fclk-pfab-error");
|
||||
this.errorText = this.el.querySelector(".fclk-pfab-error-text");
|
||||
this.todayEl = this.el.querySelector(".fclk-pfab-today");
|
||||
this.weekEl = this.el.querySelector(".fclk-pfab-week");
|
||||
|
||||
// Bind events
|
||||
if (this.fabBtn) {
|
||||
this._onFabClick = () => this._togglePanel();
|
||||
this.fabBtn.addEventListener("click", this._onFabClick);
|
||||
}
|
||||
if (this.actionBtn) {
|
||||
this._onActionClick = (e) => {
|
||||
e.preventDefault();
|
||||
this._onClockAction();
|
||||
};
|
||||
this.actionBtn.addEventListener("click", this._onActionClick);
|
||||
}
|
||||
|
||||
// Close panel on outside click
|
||||
this._onDocClick = (ev) => {
|
||||
if (!this.expanded) return;
|
||||
if (!ev.target.closest("#fclk-portal-fab")) {
|
||||
this._closePanel();
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", this._onDocClick, true);
|
||||
|
||||
// Re-sync when tab gains focus
|
||||
this._onFocus = () => this._fetchStatus();
|
||||
window.addEventListener("focus", this._onFocus);
|
||||
|
||||
// Visibility change sync
|
||||
this._onVisibility = () => {
|
||||
if (document.visibilityState === "visible") this._fetchStatus();
|
||||
};
|
||||
document.addEventListener("visibilitychange", this._onVisibility);
|
||||
|
||||
// Initial state
|
||||
this._applyState();
|
||||
|
||||
// Start timer if already checked in
|
||||
if (this.isCheckedIn && this.checkInTime) {
|
||||
this._startTimer();
|
||||
}
|
||||
|
||||
// Poll every 30s to stay in sync
|
||||
this._pollInterval = setInterval(() => this._fetchStatus(), 30000);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._stopTimer();
|
||||
if (this._pollInterval) clearInterval(this._pollInterval);
|
||||
if (this._onDocClick) document.removeEventListener("click", this._onDocClick, true);
|
||||
if (this._onFocus) window.removeEventListener("focus", this._onFocus);
|
||||
if (this._onVisibility) document.removeEventListener("visibilitychange", this._onVisibility);
|
||||
if (this.fabBtn && this._onFabClick) this.fabBtn.removeEventListener("click", this._onFabClick);
|
||||
if (this.actionBtn && this._onActionClick) this.actionBtn.removeEventListener("click", this._onActionClick);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Panel Toggle
|
||||
// =========================================================================
|
||||
|
||||
_togglePanel() {
|
||||
if (this.expanded) {
|
||||
this._closePanel();
|
||||
} else {
|
||||
this._openPanel();
|
||||
}
|
||||
}
|
||||
|
||||
_openPanel() {
|
||||
this.expanded = true;
|
||||
if (this.panel) {
|
||||
this.panel.style.display = "block";
|
||||
this.panel.classList.add("fclk-pfab-panel--open");
|
||||
}
|
||||
this._clearError();
|
||||
this._fetchStatus();
|
||||
}
|
||||
|
||||
_closePanel() {
|
||||
this.expanded = false;
|
||||
if (this.panel) {
|
||||
this.panel.classList.remove("fclk-pfab-panel--open");
|
||||
setTimeout(() => {
|
||||
if (!this.expanded && this.panel) this.panel.style.display = "none";
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// State Management
|
||||
// =========================================================================
|
||||
|
||||
_applyState() {
|
||||
// FAB button state
|
||||
if (this.fabBtn) {
|
||||
this.fabBtn.classList.toggle("fclk-pfab-btn--active", this.isCheckedIn);
|
||||
}
|
||||
this.el.classList.toggle("fclk-pfab--active", this.isCheckedIn);
|
||||
|
||||
// Status dot and text
|
||||
if (this.statusDot) {
|
||||
this.statusDot.classList.toggle("fclk-pfab-status-dot--active", this.isCheckedIn);
|
||||
}
|
||||
if (this.statusText) {
|
||||
this.statusText.textContent = this.isCheckedIn ? "Clocked In" : "Not Clocked In";
|
||||
}
|
||||
|
||||
// Location
|
||||
const locName = this.el.dataset.locationName || "";
|
||||
if (this.locationRow) {
|
||||
this.locationRow.style.display = (this.isCheckedIn && locName) ? "flex" : "none";
|
||||
}
|
||||
if (this.locationText) {
|
||||
this.locationText.textContent = locName;
|
||||
}
|
||||
|
||||
// Action button
|
||||
if (this.actionBtn) {
|
||||
if (this.isCheckedIn) {
|
||||
this.actionBtn.classList.remove("fclk-pfab-action--in");
|
||||
this.actionBtn.classList.add("fclk-pfab-action--out");
|
||||
this.actionBtn.innerHTML = '<i class="fa fa-stop-circle-o"/> Clock Out';
|
||||
} else {
|
||||
this.actionBtn.classList.remove("fclk-pfab-action--out");
|
||||
this.actionBtn.classList.add("fclk-pfab-action--in");
|
||||
this.actionBtn.innerHTML = '<i class="fa fa-play-circle-o"/> Clock In';
|
||||
}
|
||||
}
|
||||
|
||||
// Badge
|
||||
if (this.badgeEl) {
|
||||
this.badgeEl.style.display = this.isCheckedIn ? "block" : "none";
|
||||
}
|
||||
|
||||
// Timer reset when not checked in
|
||||
if (!this.isCheckedIn && this.timerEl) {
|
||||
this.timerEl.textContent = "00:00:00";
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Fetch Status from Server
|
||||
// =========================================================================
|
||||
|
||||
async _fetchStatus() {
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/get_status", {});
|
||||
if (result.error) return;
|
||||
|
||||
const wasCheckedIn = this.isCheckedIn;
|
||||
this.isCheckedIn = result.is_checked_in;
|
||||
|
||||
if (result.is_checked_in && result.check_in) {
|
||||
this.checkInTime = new Date(result.check_in + "Z");
|
||||
this.el.dataset.locationName = result.location_name || "";
|
||||
if (!wasCheckedIn) this._startTimer();
|
||||
} else {
|
||||
this.checkInTime = null;
|
||||
this._stopTimer();
|
||||
}
|
||||
|
||||
if (this.todayEl) {
|
||||
this.todayEl.textContent = (result.today_hours || 0).toFixed(1) + "h";
|
||||
}
|
||||
if (this.weekEl) {
|
||||
this.weekEl.textContent = (result.week_hours || 0).toFixed(1) + "h";
|
||||
}
|
||||
|
||||
this._applyState();
|
||||
} catch (e) {
|
||||
// Silent fail - will retry on next poll
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Clock Action
|
||||
// =========================================================================
|
||||
|
||||
async _onClockAction() {
|
||||
if (this.actionBtn) this.actionBtn.disabled = true;
|
||||
this._clearError();
|
||||
|
||||
try {
|
||||
let lat = 0, lng = 0, acc = 0;
|
||||
|
||||
if (navigator.geolocation) {
|
||||
try {
|
||||
const pos = await new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 15000,
|
||||
maximumAge: 0,
|
||||
});
|
||||
});
|
||||
lat = pos.coords.latitude;
|
||||
lng = pos.coords.longitude;
|
||||
acc = pos.coords.accuracy;
|
||||
} catch (geoErr) {
|
||||
this._showError("Location access denied. Please enable GPS.");
|
||||
if (this.actionBtn) this.actionBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await rpc("/fusion_clock/clock_action", {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
accuracy: acc,
|
||||
source: "portal_fab",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
this._showError(result.error);
|
||||
if (this.actionBtn) this.actionBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.action === "clock_in") {
|
||||
this.isCheckedIn = true;
|
||||
this.checkInTime = new Date(result.check_in + "Z");
|
||||
this.el.dataset.locationName = result.location_name || "";
|
||||
this._startTimer();
|
||||
} else if (result.action === "clock_out") {
|
||||
this.isCheckedIn = false;
|
||||
this.checkInTime = null;
|
||||
this._stopTimer();
|
||||
await this._fetchStatus();
|
||||
}
|
||||
|
||||
this._applyState();
|
||||
} catch (e) {
|
||||
this._showError("Network error. Please try again.");
|
||||
}
|
||||
|
||||
if (this.actionBtn) this.actionBtn.disabled = false;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Timer
|
||||
// =========================================================================
|
||||
|
||||
_startTimer() {
|
||||
this._stopTimer();
|
||||
this._updateTimer();
|
||||
this._timerInterval = setInterval(() => this._updateTimer(), 1000);
|
||||
}
|
||||
|
||||
_stopTimer() {
|
||||
if (this._timerInterval) {
|
||||
clearInterval(this._timerInterval);
|
||||
this._timerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
_updateTimer() {
|
||||
if (!this.checkInTime) return;
|
||||
const now = new Date();
|
||||
let diff = Math.max(0, Math.floor((now - this.checkInTime) / 1000));
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const s = diff % 60;
|
||||
const pad = (n) => (n < 10 ? "0" + n : "" + n);
|
||||
const display = pad(h) + ":" + pad(m) + ":" + pad(s);
|
||||
|
||||
if (this.timerEl) this.timerEl.textContent = display;
|
||||
if (this.badgeEl) this.badgeEl.textContent = display;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Error Display
|
||||
// =========================================================================
|
||||
|
||||
_showError(msg) {
|
||||
if (this.errorEl) this.errorEl.style.display = "flex";
|
||||
if (this.errorText) this.errorText.textContent = msg;
|
||||
}
|
||||
|
||||
_clearError() {
|
||||
if (this.errorEl) this.errorEl.style.display = "none";
|
||||
if (this.errorText) this.errorText.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("fusion_clock.portal_fab", FusionClockPortalFAB);
|
||||
183
fusion_clock/fusion_clock/static/src/js/fusion_clock_systray.js
Normal file
183
fusion_clock/fusion_clock/static/src/js/fusion_clock_systray.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class FusionClockFAB extends Component {
|
||||
static props = {};
|
||||
static template = "fusion_clock.ClockFAB";
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
|
||||
this.state = useState({
|
||||
isCheckedIn: false,
|
||||
isDisplayed: false,
|
||||
expanded: false,
|
||||
checkInTime: null,
|
||||
locationName: "",
|
||||
timerDisplay: "00:00:00",
|
||||
todayHours: "0.0",
|
||||
weekHours: "0.0",
|
||||
loading: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
this._timerInterval = null;
|
||||
|
||||
onWillStart(async () => {
|
||||
await this._fetchStatus();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (this.state.isCheckedIn) {
|
||||
this._startTimer();
|
||||
}
|
||||
// Poll every 15s to stay in sync with portal clock-outs
|
||||
this._pollInterval = setInterval(() => this._fetchStatus(), 15000);
|
||||
|
||||
// Re-sync immediately when browser tab regains focus
|
||||
this._onFocus = () => this._fetchStatus();
|
||||
window.addEventListener("focus", this._onFocus);
|
||||
|
||||
// Close panel when clicking outside
|
||||
this._onDocClick = (ev) => {
|
||||
if (!this.state.expanded) return;
|
||||
const el = ev.target.closest(".fclk-fab-wrapper");
|
||||
if (!el) this.state.expanded = false;
|
||||
};
|
||||
document.addEventListener("click", this._onDocClick, true);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
this._stopTimer();
|
||||
if (this._pollInterval) clearInterval(this._pollInterval);
|
||||
if (this._onDocClick) document.removeEventListener("click", this._onDocClick, true);
|
||||
if (this._onFocus) window.removeEventListener("focus", this._onFocus);
|
||||
});
|
||||
}
|
||||
|
||||
togglePanel() {
|
||||
this.state.expanded = !this.state.expanded;
|
||||
this.state.error = "";
|
||||
// Always re-fetch when opening the panel
|
||||
if (this.state.expanded) {
|
||||
this._fetchStatus();
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchStatus() {
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/get_status", {});
|
||||
if (result.error) {
|
||||
this.state.isDisplayed = false;
|
||||
return;
|
||||
}
|
||||
this.state.isDisplayed = result.enable_clock !== false;
|
||||
this.state.isCheckedIn = result.is_checked_in;
|
||||
this.state.locationName = result.location_name || "";
|
||||
this.state.todayHours = (result.today_hours || 0).toFixed(1);
|
||||
this.state.weekHours = (result.week_hours || 0).toFixed(1);
|
||||
|
||||
if (result.is_checked_in && result.check_in) {
|
||||
this.state.checkInTime = new Date(result.check_in + "Z");
|
||||
this._startTimer();
|
||||
} else {
|
||||
this.state.checkInTime = null;
|
||||
this._stopTimer();
|
||||
this.state.timerDisplay = "00:00:00";
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.isDisplayed = false;
|
||||
}
|
||||
}
|
||||
|
||||
async onClockAction() {
|
||||
this.state.loading = true;
|
||||
this.state.error = "";
|
||||
|
||||
try {
|
||||
let lat = 0, lng = 0, acc = 0;
|
||||
if (navigator.geolocation) {
|
||||
try {
|
||||
const pos = await new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 15000,
|
||||
maximumAge: 0,
|
||||
});
|
||||
});
|
||||
lat = pos.coords.latitude;
|
||||
lng = pos.coords.longitude;
|
||||
acc = pos.coords.accuracy;
|
||||
} catch (geoErr) {
|
||||
this.state.error = "Location access denied.";
|
||||
this.state.loading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await rpc("/fusion_clock/clock_action", {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
accuracy: acc,
|
||||
source: "backend_fab",
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
this.state.error = result.error;
|
||||
this.state.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.action === "clock_in") {
|
||||
this.state.isCheckedIn = true;
|
||||
this.state.checkInTime = new Date(result.check_in + "Z");
|
||||
this.state.locationName = result.location_name || "";
|
||||
this._startTimer();
|
||||
this.notification.add(result.message || "Clocked in!", { type: "success" });
|
||||
} else if (result.action === "clock_out") {
|
||||
this.state.isCheckedIn = false;
|
||||
this.state.checkInTime = null;
|
||||
this._stopTimer();
|
||||
this.state.timerDisplay = "00:00:00";
|
||||
this.notification.add(result.message || "Clocked out!", { type: "success" });
|
||||
await this._fetchStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.error = e.message || "Clock action failed.";
|
||||
}
|
||||
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
_startTimer() {
|
||||
this._stopTimer();
|
||||
this._updateTimer();
|
||||
this._timerInterval = setInterval(() => this._updateTimer(), 1000);
|
||||
}
|
||||
|
||||
_stopTimer() {
|
||||
if (this._timerInterval) {
|
||||
clearInterval(this._timerInterval);
|
||||
this._timerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
_updateTimer() {
|
||||
if (!this.state.checkInTime) return;
|
||||
const now = new Date();
|
||||
let diff = Math.max(0, Math.floor((now - this.state.checkInTime) / 1000));
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const s = diff % 60;
|
||||
const pad = (n) => (n < 10 ? "0" + n : "" + n);
|
||||
this.state.timerDisplay = pad(h) + ":" + pad(m) + ":" + pad(s);
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("main_components").add("FusionClockFAB", {
|
||||
Component: FusionClockFAB,
|
||||
});
|
||||
378
fusion_clock/fusion_clock/static/src/scss/fusion_clock.scss
Normal file
378
fusion_clock/fusion_clock/static/src/scss/fusion_clock.scss
Normal file
@@ -0,0 +1,378 @@
|
||||
/* ============================================================
|
||||
Fusion Clock - Floating Action Button (FAB)
|
||||
Bottom-left corner clock widget with ripple animation
|
||||
Theme-aware: adapts to Odoo light / dark mode
|
||||
============================================================ */
|
||||
|
||||
// ---- Light-mode tokens (default) ----
|
||||
:root {
|
||||
--fclk-fab-panel-bg: #ffffff;
|
||||
--fclk-fab-panel-border: #e5e7eb;
|
||||
--fclk-fab-panel-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
--fclk-fab-text: #1f2937;
|
||||
--fclk-fab-muted: #6b7280;
|
||||
--fclk-fab-divider: #e5e7eb;
|
||||
--fclk-fab-location-bg: rgba(16, 185, 129, 0.08);
|
||||
--fclk-fab-error-bg: rgba(239, 68, 68, 0.06);
|
||||
--fclk-fab-arrow-bg: #ffffff;
|
||||
}
|
||||
|
||||
// ---- Dark-mode tokens ----
|
||||
html.o_dark {
|
||||
--fclk-fab-panel-bg: #1e2028;
|
||||
--fclk-fab-panel-border: #3a3d48;
|
||||
--fclk-fab-panel-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
--fclk-fab-text: #f1f1f4;
|
||||
--fclk-fab-muted: #9ca3af;
|
||||
--fclk-fab-divider: #3a3d48;
|
||||
--fclk-fab-location-bg: rgba(16, 185, 129, 0.1);
|
||||
--fclk-fab-error-bg: rgba(239, 68, 68, 0.1);
|
||||
--fclk-fab-arrow-bg: #1e2028;
|
||||
}
|
||||
|
||||
// Static color palette
|
||||
$fclk-teal: #0d9488;
|
||||
$fclk-blue: #3b82f6;
|
||||
$fclk-green: #10B981;
|
||||
$fclk-red: #ef4444;
|
||||
|
||||
// Gradient used on the FAB (teal-to-blue like the portal header)
|
||||
$fclk-gradient: linear-gradient(135deg, $fclk-teal 0%, #2563eb 100%);
|
||||
$fclk-gradient-active: linear-gradient(135deg, $fclk-green 0%, $fclk-teal 100%);
|
||||
|
||||
// ===========================================================
|
||||
// Wrapper - anchored bottom-LEFT
|
||||
// ===========================================================
|
||||
.fclk-fab-wrapper {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
|
||||
> * { pointer-events: auto; }
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// FAB Button
|
||||
// ===========================================================
|
||||
.fclk-fab-btn {
|
||||
position: relative;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: $fclk-gradient;
|
||||
color: #fff;
|
||||
font-size: 21px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 20px rgba($fclk-teal, 0.35);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
overflow: visible;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 28px rgba($fclk-teal, 0.45);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.93);
|
||||
}
|
||||
|
||||
// Clocked-in: green-teal gradient
|
||||
&.fclk-fab-btn--active {
|
||||
background: $fclk-gradient-active;
|
||||
box-shadow: 0 4px 20px rgba($fclk-green, 0.4);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 28px rgba($fclk-green, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Panel-open: muted
|
||||
&.fclk-fab-btn--open {
|
||||
background: #374151;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.fclk-fab-icon {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition: transform 0.3s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&.fclk-fab-btn--open .fclk-fab-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Ripple rings radiating outward from the FAB ----
|
||||
.fclk-fab-ripple-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba($fclk-green, 0.5);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
|
||||
&.fclk-fab-ripple-ring--1 {
|
||||
animation: fclk-ripple-out 2.4s ease-out infinite;
|
||||
}
|
||||
&.fclk-fab-ripple-ring--2 {
|
||||
animation: fclk-ripple-out 2.4s ease-out 0.8s infinite;
|
||||
}
|
||||
&.fclk-fab-ripple-ring--3 {
|
||||
animation: fclk-ripple-out 2.4s ease-out 1.6s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fclk-ripple-out {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(2.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mini timer badge ----
|
||||
.fclk-fab-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #111827;
|
||||
color: $fclk-green;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid rgba($fclk-green, 0.35);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
animation: fclk-badge-in 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fclk-badge-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(4px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// Expanded Panel
|
||||
// ===========================================================
|
||||
.fclk-fab-panel {
|
||||
width: 280px;
|
||||
background: var(--fclk-fab-panel-bg);
|
||||
border: 1px solid var(--fclk-fab-panel-border);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
box-shadow: var(--fclk-fab-panel-shadow);
|
||||
animation: fclk-panel-slide-up 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes fclk-panel-slide-up {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
// Arrow pointing down toward the FAB
|
||||
.fclk-fab-panel-arrow {
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 22px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--fclk-fab-arrow-bg);
|
||||
border-right: 1px solid var(--fclk-fab-panel-border);
|
||||
border-bottom: 1px solid var(--fclk-fab-panel-border);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
// ---- Header row ----
|
||||
.fclk-fab-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.fclk-fab-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.fclk-fab-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.active {
|
||||
background: $fclk-green;
|
||||
box-shadow: 0 0 6px rgba($fclk-green, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-fab-open-link {
|
||||
color: var(--fclk-fab-muted);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover { color: $fclk-blue; }
|
||||
}
|
||||
|
||||
// ---- Location chip ----
|
||||
.fclk-fab-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: $fclk-green;
|
||||
background: var(--fclk-fab-location-bg);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.fa { font-size: 12px; }
|
||||
}
|
||||
|
||||
// ---- Timer ----
|
||||
.fclk-fab-timer {
|
||||
text-align: center;
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
letter-spacing: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-bottom: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
// ---- Stats row ----
|
||||
.fclk-fab-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.fclk-fab-stat {
|
||||
text-align: center;
|
||||
|
||||
.fclk-fab-stat-val {
|
||||
display: block;
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fclk-fab-stat-lbl {
|
||||
display: block;
|
||||
color: var(--fclk-fab-muted);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-fab-stat-divider {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background: var(--fclk-fab-divider);
|
||||
}
|
||||
|
||||
// ---- Action button ----
|
||||
.fclk-fab-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 11px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
.fa { font-size: 15px; }
|
||||
|
||||
&.fclk-fab-action--in {
|
||||
background: $fclk-gradient;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba($fclk-teal, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&.fclk-fab-action--out {
|
||||
background: $fclk-red;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba($fclk-red, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Error ----
|
||||
.fclk-fab-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
color: $fclk-red;
|
||||
font-size: 11px;
|
||||
background: var(--fclk-fab-error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-top: 10px;
|
||||
animation: fclk-shake 0.35s ease;
|
||||
line-height: 1.4;
|
||||
|
||||
.fa { font-size: 12px; margin-top: 1px; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
@keyframes fclk-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
47
fusion_clock/fusion_clock/static/src/xml/location_map.xml
Normal file
47
fusion_clock/fusion_clock/static/src/xml/location_map.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- Places Autocomplete Address Field (inherits CharField, turns off browser autocomplete) -->
|
||||
<t t-name="fusion_clock.PlacesAddress" t-inherit="web.CharField" t-inherit-mode="primary">
|
||||
<xpath expr="//input" position="attributes">
|
||||
<attribute name="autocomplete">off</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<!-- Interactive Map Widget -->
|
||||
<t t-name="fusion_clock.LocationMap">
|
||||
<div class="fclk-map-widget">
|
||||
<!-- Search box (edit mode only) -->
|
||||
<div t-if="state.loaded and !props.readonly" class="mb-2">
|
||||
<input t-ref="searchInput" type="text"
|
||||
class="form-control"
|
||||
placeholder="Search for a place or address..."/>
|
||||
</div>
|
||||
|
||||
<!-- Map container -->
|
||||
<div t-ref="mapContainer"
|
||||
style="width:100%; height:400px; border-radius:8px; border:1px solid var(--o-border-color, #dee2e6);"/>
|
||||
|
||||
<!-- Coordinate display -->
|
||||
<div t-if="state.loaded and !props.readonly"
|
||||
class="mt-2 text-muted small">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
Drag the pin or use the search box to adjust. Changes save with the form.
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div t-if="!state.loaded and !state.error"
|
||||
class="text-center p-4 text-muted">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<div class="mt-2">Loading map...</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div t-if="state.error" class="alert alert-warning mt-2 mb-0">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<t t-esc="state.error"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
88
fusion_clock/fusion_clock/static/src/xml/systray_clock.xml
Normal file
88
fusion_clock/fusion_clock/static/src/xml/systray_clock.xml
Normal file
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_clock.ClockFAB">
|
||||
<div t-if="state.isDisplayed" class="fclk-fab-wrapper">
|
||||
|
||||
<!-- Expanded Panel (above the button) -->
|
||||
<div t-if="state.expanded" class="fclk-fab-panel">
|
||||
<!-- Header -->
|
||||
<div class="fclk-fab-panel-header">
|
||||
<div class="fclk-fab-panel-title">
|
||||
<span t-attf-class="fclk-fab-status-dot {{ state.isCheckedIn ? 'active' : '' }}"/>
|
||||
<span t-if="state.isCheckedIn">Clocked In</span>
|
||||
<span t-else="">Ready</span>
|
||||
</div>
|
||||
<a href="/my/clock" class="fclk-fab-open-link" target="_blank" title="Open Full Clock">
|
||||
<i class="fa fa-external-link"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div t-if="state.isCheckedIn and state.locationName" class="fclk-fab-location">
|
||||
<i class="fa fa-map-marker"/>
|
||||
<span t-esc="state.locationName"/>
|
||||
</div>
|
||||
|
||||
<!-- Timer -->
|
||||
<div class="fclk-fab-timer" t-esc="state.timerDisplay"/>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="fclk-fab-stats">
|
||||
<div class="fclk-fab-stat">
|
||||
<span class="fclk-fab-stat-val"><t t-esc="state.todayHours"/>h</span>
|
||||
<span class="fclk-fab-stat-lbl">Today</span>
|
||||
</div>
|
||||
<div class="fclk-fab-stat-divider"/>
|
||||
<div class="fclk-fab-stat">
|
||||
<span class="fclk-fab-stat-val"><t t-esc="state.weekHours"/>h</span>
|
||||
<span class="fclk-fab-stat-lbl">Week</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clock Action Button -->
|
||||
<button t-attf-class="fclk-fab-action {{ state.isCheckedIn ? 'fclk-fab-action--out' : 'fclk-fab-action--in' }}"
|
||||
t-on-click="onClockAction"
|
||||
t-att-disabled="state.loading">
|
||||
<t t-if="state.loading">
|
||||
<i class="fa fa-circle-o-notch fa-spin"/> Working...
|
||||
</t>
|
||||
<t t-elif="state.isCheckedIn">
|
||||
<i class="fa fa-stop-circle-o"/> Clock Out
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-play-circle-o"/> Clock In
|
||||
</t>
|
||||
</button>
|
||||
|
||||
<!-- Error -->
|
||||
<div t-if="state.error" class="fclk-fab-error">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<t t-esc="state.error"/>
|
||||
</div>
|
||||
|
||||
<!-- Arrow pointing to button -->
|
||||
<div class="fclk-fab-panel-arrow"/>
|
||||
</div>
|
||||
|
||||
<!-- Floating Action Button -->
|
||||
<button t-attf-class="fclk-fab-btn {{ state.isCheckedIn ? 'fclk-fab-btn--active' : '' }} {{ state.expanded ? 'fclk-fab-btn--open' : '' }}"
|
||||
t-on-click="togglePanel">
|
||||
<!-- Ripple rings (always animate) -->
|
||||
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--1"/>
|
||||
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--2"/>
|
||||
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--3"/>
|
||||
<!-- Icon -->
|
||||
<span class="fclk-fab-icon">
|
||||
<i t-if="!state.expanded" class="fa fa-clock-o"/>
|
||||
<i t-else="" class="fa fa-times"/>
|
||||
</span>
|
||||
<!-- Mini timer badge -->
|
||||
<span t-if="state.isCheckedIn and !state.expanded" class="fclk-fab-badge">
|
||||
<t t-esc="state.timerDisplay"/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
118
fusion_clock/fusion_clock/views/clock_location_views.xml
Normal file
118
fusion_clock/fusion_clock/views/clock_location_views.xml
Normal file
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Location Form View -->
|
||||
<record id="view_fusion_clock_location_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.location.form</field>
|
||||
<field name="model">fusion.clock.location</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Clock Location">
|
||||
<header/>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_attendances" type="object"
|
||||
class="oe_stat_button" icon="fa-clock-o">
|
||||
<field name="attendance_count" widget="statinfo" string="Attendances"/>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1>
|
||||
<field name="name" placeholder="e.g. Main Office"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Address">
|
||||
<field name="address" widget="fclk_places_address"
|
||||
placeholder="Start typing an address or place name..."/>
|
||||
<field name="timezone"/>
|
||||
</group>
|
||||
<group string="Geofence">
|
||||
<field name="latitude"/>
|
||||
<field name="longitude"/>
|
||||
<field name="radius"/>
|
||||
<field name="color" widget="color"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Access">
|
||||
<field name="all_employees"/>
|
||||
<field name="employee_ids" widget="many2many_tags"
|
||||
invisible="all_employees"/>
|
||||
</group>
|
||||
<group string="Other">
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="active" invisible="1"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Interactive Map (drag pin to adjust, search places) -->
|
||||
<group string="Map">
|
||||
<widget name="fclk_map" colspan="2"/>
|
||||
</group>
|
||||
|
||||
<group string="Notes">
|
||||
<field name="note" nolabel="1" colspan="2" placeholder="Additional notes about this location..."/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Location List View -->
|
||||
<record id="view_fusion_clock_location_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.location.list</field>
|
||||
<field name="model">fusion.clock.location</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Clock Locations" default_order="sequence, name">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="address"/>
|
||||
<field name="radius" string="Radius (m)"/>
|
||||
<field name="all_employees"/>
|
||||
<field name="attendance_count" string="Attendances"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="active" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Location Search View -->
|
||||
<record id="view_fusion_clock_location_search" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.location.search</field>
|
||||
<field name="model">fusion.clock.location</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Locations">
|
||||
<field name="name"/>
|
||||
<field name="address"/>
|
||||
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
|
||||
<filter name="archived" string="Archived" domain="[('active', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter name="all_employees" string="All Employees" domain="[('all_employees', '=', True)]"/>
|
||||
<separator/>
|
||||
<filter name="group_company" string="Company" context="{'group_by': 'company_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Location Action -->
|
||||
<record id="action_fusion_clock_location" model="ir.actions.act_window">
|
||||
<field name="name">Clock Locations</field>
|
||||
<field name="res_model">fusion.clock.location</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_clock_location_search"/>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first clock location
|
||||
</p>
|
||||
<p>
|
||||
Define geofenced locations where employees can clock in and out.
|
||||
Set the address, radius, and which employees are allowed.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
91
fusion_clock/fusion_clock/views/clock_menus.xml
Normal file
91
fusion_clock/fusion_clock/views/clock_menus.xml
Normal file
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Top-Level Menu -->
|
||||
<menuitem id="menu_fusion_clock_root"
|
||||
name="Fusion Clock"
|
||||
web_icon="fusion_clock,static/description/icon.png"
|
||||
sequence="45"
|
||||
groups="group_fusion_clock_user"/>
|
||||
|
||||
<!-- Dashboard / Attendance Sub-Menu -->
|
||||
<menuitem id="menu_fusion_clock_attendance"
|
||||
name="Attendance"
|
||||
parent="menu_fusion_clock_root"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_employee_overview"
|
||||
name="Employee Overview"
|
||||
parent="menu_fusion_clock_attendance"
|
||||
action="action_fusion_clock_employee_overview"
|
||||
sequence="5"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_by_pay_period"
|
||||
name="By Pay Period"
|
||||
parent="menu_fusion_clock_attendance"
|
||||
action="action_fusion_clock_attendance_by_period"
|
||||
sequence="8"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_attendance_list"
|
||||
name="All Attendances"
|
||||
parent="menu_fusion_clock_attendance"
|
||||
action="hr_attendance.hr_attendance_action"
|
||||
sequence="10"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<!-- Locations Sub-Menu -->
|
||||
<menuitem id="menu_fusion_clock_locations"
|
||||
name="Locations"
|
||||
parent="menu_fusion_clock_root"
|
||||
action="action_fusion_clock_location"
|
||||
sequence="20"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<!-- Penalties Sub-Menu -->
|
||||
<menuitem id="menu_fusion_clock_penalties"
|
||||
name="Penalties"
|
||||
parent="menu_fusion_clock_root"
|
||||
action="action_fusion_clock_penalty"
|
||||
sequence="30"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<!-- Reports Sub-Menu -->
|
||||
<menuitem id="menu_fusion_clock_reports"
|
||||
name="Reports"
|
||||
parent="menu_fusion_clock_root"
|
||||
action="action_fusion_clock_report"
|
||||
sequence="40"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<!-- Configuration Sub-Menu -->
|
||||
<menuitem id="menu_fusion_clock_config"
|
||||
name="Configuration"
|
||||
parent="menu_fusion_clock_root"
|
||||
sequence="90"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<record id="action_fusion_clock_config_settings" model="ir.actions.act_window">
|
||||
<field name="name">Fusion Clock Settings</field>
|
||||
<field name="res_model">res.config.settings</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">current</field>
|
||||
<field name="context">{'module': 'fusion_clock'}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_clock_settings"
|
||||
name="Settings"
|
||||
parent="menu_fusion_clock_config"
|
||||
action="action_fusion_clock_config_settings"
|
||||
sequence="10"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_locations_config"
|
||||
name="Locations"
|
||||
parent="menu_fusion_clock_config"
|
||||
action="action_fusion_clock_location"
|
||||
sequence="20"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
</odoo>
|
||||
88
fusion_clock/fusion_clock/views/clock_penalty_views.xml
Normal file
88
fusion_clock/fusion_clock/views/clock_penalty_views.xml
Normal file
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Penalty List View -->
|
||||
<record id="view_fusion_clock_penalty_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.penalty.list</field>
|
||||
<field name="model">fusion.clock.penalty</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Clock Penalties" default_order="date desc">
|
||||
<field name="date"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="penalty_type"/>
|
||||
<field name="scheduled_time" widget="datetime"/>
|
||||
<field name="actual_time" widget="datetime"/>
|
||||
<field name="difference_minutes" string="Diff (min)"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Penalty Form View -->
|
||||
<record id="view_fusion_clock_penalty_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.penalty.form</field>
|
||||
<field name="model">fusion.clock.penalty</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Clock Penalty">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="employee_id"/>
|
||||
<field name="penalty_type"/>
|
||||
<field name="date"/>
|
||||
<field name="attendance_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="scheduled_time"/>
|
||||
<field name="actual_time"/>
|
||||
<field name="difference_minutes"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="note" nolabel="1" colspan="2" placeholder="Additional notes..."/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Penalty Search View -->
|
||||
<record id="view_fusion_clock_penalty_search" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.penalty.search</field>
|
||||
<field name="model">fusion.clock.penalty</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Penalties">
|
||||
<field name="employee_id"/>
|
||||
<field name="penalty_type"/>
|
||||
<filter name="late_in" string="Late Clock-In" domain="[('penalty_type', '=', 'late_in')]"/>
|
||||
<filter name="early_out" string="Early Clock-Out" domain="[('penalty_type', '=', 'early_out')]"/>
|
||||
<separator/>
|
||||
<filter name="this_week" string="This Week"
|
||||
domain="[('date', '>=', (context_today() - datetime.timedelta(days=context_today().weekday())).strftime('%Y-%m-%d'))]"/>
|
||||
<filter name="this_month" string="This Month"
|
||||
domain="[('date', '>=', context_today().strftime('%Y-%m-01'))]"/>
|
||||
<separator/>
|
||||
<filter name="group_employee" string="Employee" context="{'group_by': 'employee_id'}"/>
|
||||
<filter name="group_type" string="Penalty Type" context="{'group_by': 'penalty_type'}"/>
|
||||
<filter name="group_date" string="Date" context="{'group_by': 'date:month'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Penalty Action -->
|
||||
<record id="action_fusion_clock_penalty" model="ir.actions.act_window">
|
||||
<field name="name">Penalties</field>
|
||||
<field name="res_model">fusion.clock.penalty</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_clock_penalty_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No penalties recorded
|
||||
</p>
|
||||
<p>
|
||||
Penalties are automatically created when employees clock in late or clock out early.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
135
fusion_clock/fusion_clock/views/clock_report_views.xml
Normal file
135
fusion_clock/fusion_clock/views/clock_report_views.xml
Normal file
@@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Report List View -->
|
||||
<record id="view_fusion_clock_report_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.report.list</field>
|
||||
<field name="model">fusion.clock.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Clock Reports" default_order="date_end desc">
|
||||
<field name="name"/>
|
||||
<field name="date_start"/>
|
||||
<field name="date_end"/>
|
||||
<field name="schedule_type"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="is_batch"/>
|
||||
<field name="days_worked"/>
|
||||
<field name="total_hours" widget="float_time"/>
|
||||
<field name="net_hours" widget="float_time"/>
|
||||
<field name="total_penalties"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-info="state == 'draft'"
|
||||
decoration-success="state == 'generated'"
|
||||
decoration-warning="state == 'sent'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Report Form View -->
|
||||
<record id="view_fusion_clock_report_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.report.form</field>
|
||||
<field name="model">fusion.clock.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Clock Report">
|
||||
<header>
|
||||
<button name="action_generate_report" type="object"
|
||||
string="Generate Report" class="btn-primary"
|
||||
invisible="state != 'draft'"
|
||||
icon="fa-file-pdf-o"/>
|
||||
<button name="action_send_report" type="object"
|
||||
string="Send Report" class="btn-secondary"
|
||||
invisible="state != 'generated'"
|
||||
icon="fa-envelope"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,generated,sent"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Period">
|
||||
<field name="date_start"/>
|
||||
<field name="date_end"/>
|
||||
<field name="schedule_type"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="is_batch"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group string="Summary">
|
||||
<field name="days_worked"/>
|
||||
<field name="total_hours" widget="float_time"/>
|
||||
<field name="net_hours" widget="float_time"/>
|
||||
<field name="total_breaks"/>
|
||||
<field name="total_penalties"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- PDF Download -->
|
||||
<group string="Report File" invisible="not report_pdf">
|
||||
<field name="report_pdf" filename="report_pdf_filename"/>
|
||||
<field name="report_pdf_filename" invisible="1"/>
|
||||
</group>
|
||||
|
||||
<!-- Attendance Records -->
|
||||
<notebook>
|
||||
<page string="Attendance Records" name="attendances">
|
||||
<field name="attendance_ids" nolabel="1">
|
||||
<list>
|
||||
<field name="employee_id"/>
|
||||
<field name="check_in" widget="datetime"/>
|
||||
<field name="check_out" widget="datetime"/>
|
||||
<field name="worked_hours" widget="float_time"/>
|
||||
<field name="x_fclk_break_minutes" string="Break"/>
|
||||
<field name="x_fclk_net_hours" string="Net" widget="float_time"/>
|
||||
<field name="x_fclk_location_id" string="Location"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Report Search View -->
|
||||
<record id="view_fusion_clock_report_search" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.report.search</field>
|
||||
<field name="model">fusion.clock.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Reports">
|
||||
<field name="employee_id"/>
|
||||
<field name="name"/>
|
||||
<filter name="individual" string="Individual" domain="[('employee_id', '!=', False)]"/>
|
||||
<filter name="batch" string="Batch" domain="[('employee_id', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter name="draft" string="Draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter name="generated" string="Generated" domain="[('state', '=', 'generated')]"/>
|
||||
<filter name="sent" string="Sent" domain="[('state', '=', 'sent')]"/>
|
||||
<separator/>
|
||||
<filter name="group_employee" string="Employee" context="{'group_by': 'employee_id'}"/>
|
||||
<filter name="group_period" string="Schedule Type" context="{'group_by': 'schedule_type'}"/>
|
||||
<filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Report Action -->
|
||||
<record id="action_fusion_clock_report" model="ir.actions.act_window">
|
||||
<field name="name">Reports</field>
|
||||
<field name="res_model">fusion.clock.report</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_clock_report_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No reports generated yet
|
||||
</p>
|
||||
<p>
|
||||
Reports are automatically generated at the end of each pay period.
|
||||
You can also create them manually.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
269
fusion_clock/fusion_clock/views/hr_attendance_views.xml
Normal file
269
fusion_clock/fusion_clock/views/hr_attendance_views.xml
Normal file
@@ -0,0 +1,269 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ================================================================
|
||||
Extend Attendance List View - add Fusion Clock columns
|
||||
================================================================ -->
|
||||
<record id="view_hr_attendance_list_fusion_clock" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.list.fusion.clock</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="inherit_id" ref="hr_attendance.view_attendance_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='worked_hours']" position="after">
|
||||
<field name="x_fclk_net_hours" string="Net Hours" widget="float_time" optional="show"/>
|
||||
<field name="x_fclk_break_minutes" string="Break (min)" optional="show"/>
|
||||
<field name="x_fclk_location_id" string="In Location" optional="show"/>
|
||||
<field name="x_fclk_out_location_id" string="Out Location" optional="hide"/>
|
||||
<field name="x_fclk_pay_period" string="Pay Period" optional="hide"/>
|
||||
<field name="x_fclk_clock_source" string="Source" optional="hide"/>
|
||||
<field name="x_fclk_auto_clocked_out" string="Auto Out" optional="hide"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Dedicated list view for pay-period views
|
||||
Shows the columns needed for attendance detail
|
||||
================================================================ -->
|
||||
<record id="view_hr_attendance_list_by_period" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.list.by.period</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<list default_order="check_in desc">
|
||||
<field name="employee_id" optional="show"/>
|
||||
<field name="check_in" string="Clock In"/>
|
||||
<field name="check_out" string="Clock Out"/>
|
||||
<field name="x_fclk_location_id" string="In Location" optional="show"/>
|
||||
<field name="x_fclk_out_location_id" string="Out Location" optional="show"/>
|
||||
<field name="worked_hours" string="Total Hours" widget="float_time"/>
|
||||
<field name="x_fclk_break_minutes" string="Break (min)"/>
|
||||
<field name="x_fclk_net_hours" string="Net Hours" widget="float_time"/>
|
||||
<field name="overtime_hours" string="Extra Hours" widget="float_time" optional="show"/>
|
||||
<field name="x_fclk_pay_period" string="Pay Period" optional="show"/>
|
||||
<field name="x_fclk_clock_source" string="Source" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Dedicated search view for pay-period actions
|
||||
Group-by filters in correct order: Pay Period first, Employee second
|
||||
================================================================ -->
|
||||
<record id="view_hr_attendance_search_by_period" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.search.by.period</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Attendance">
|
||||
<field name="employee_id"/>
|
||||
<field name="department_id" operator="child_of"/>
|
||||
<field name="x_fclk_location_id" string="Location"/>
|
||||
<field name="x_fclk_pay_period" string="Pay Period"/>
|
||||
<separator/>
|
||||
<filter string="My Attendances" name="myattendances"
|
||||
domain="[('employee_id.user_id', '=', uid)]"/>
|
||||
<filter string="At Work" name="nocheckout"
|
||||
domain="[('check_out', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter string="Date" name="check_in_filter" date="check_in"/>
|
||||
<separator/>
|
||||
<filter string="Pay Period" name="group_pay_period"
|
||||
context="{'group_by': 'x_fclk_pay_period'}"/>
|
||||
<filter string="Employee" name="group_employee"
|
||||
context="{'group_by': 'employee_id'}"/>
|
||||
<filter string="Month" name="group_month"
|
||||
context="{'group_by': 'check_in:month'}"/>
|
||||
<filter string="Location" name="group_location"
|
||||
context="{'group_by': 'x_fclk_location_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Extend base Attendance Search View - add our extra filters
|
||||
================================================================ -->
|
||||
<record id="view_hr_attendance_search_fusion_clock" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.search.fusion.clock</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="inherit_id" ref="hr_attendance.hr_attendance_view_filter"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//search" position="inside">
|
||||
<field name="x_fclk_location_id"/>
|
||||
<field name="x_fclk_pay_period" string="Pay Period"/>
|
||||
<separator/>
|
||||
<filter name="fclk_portal" string="Portal" domain="[('x_fclk_clock_source', '=', 'portal')]"/>
|
||||
<filter name="fclk_systray" string="Systray" domain="[('x_fclk_clock_source', '=', 'systray')]"/>
|
||||
<filter name="fclk_auto" string="Auto Clock-Out" domain="[('x_fclk_auto_clocked_out', '=', True)]"/>
|
||||
<separator/>
|
||||
<filter name="fclk_has_penalty" string="Has Penalty" domain="[('x_fclk_penalty_ids', '!=', False)]"/>
|
||||
<separator/>
|
||||
<filter name="group_pay_period" string="Pay Period" context="{'group_by': 'x_fclk_pay_period'}"/>
|
||||
<filter name="group_location" string="Location" context="{'group_by': 'x_fclk_location_id'}"/>
|
||||
<filter name="group_source" string="Source" context="{'group_by': 'x_fclk_clock_source'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Extend Attendance Form View - add Fusion Clock details
|
||||
================================================================ -->
|
||||
<record id="view_hr_attendance_form_fusion_clock" model="ir.ui.view">
|
||||
<field name="name">hr.attendance.form.fusion.clock</field>
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="inherit_id" ref="hr_attendance.hr_attendance_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Fusion Clock Details" name="fusion_clock_details">
|
||||
<group>
|
||||
<field name="x_fclk_location_id"/>
|
||||
<field name="x_fclk_out_location_id"/>
|
||||
<field name="x_fclk_clock_source"/>
|
||||
<field name="x_fclk_break_minutes"/>
|
||||
<field name="x_fclk_net_hours" widget="float_time"/>
|
||||
<field name="x_fclk_pay_period"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fclk_in_distance"/>
|
||||
<field name="x_fclk_out_distance"/>
|
||||
<field name="x_fclk_auto_clocked_out"/>
|
||||
<field name="x_fclk_grace_used"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Penalties" name="fusion_clock_penalties"
|
||||
invisible="not x_fclk_penalty_ids">
|
||||
<field name="x_fclk_penalty_ids" nolabel="1" colspan="2">
|
||||
<list>
|
||||
<field name="penalty_type"/>
|
||||
<field name="scheduled_time"/>
|
||||
<field name="actual_time"/>
|
||||
<field name="difference_minutes"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Employee Kanban View - card per employee showing clock stats
|
||||
================================================================ -->
|
||||
<record id="view_hr_employee_kanban_fusion_clock" model="ir.ui.view">
|
||||
<field name="name">hr.employee.kanban.fusion.clock</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban class="o_hr_employee_kanban" sample="1">
|
||||
<field name="id"/>
|
||||
<field name="name"/>
|
||||
<field name="job_title"/>
|
||||
<field name="department_id"/>
|
||||
<field name="attendance_state"/>
|
||||
<field name="x_fclk_period_hours"/>
|
||||
<field name="x_fclk_period_label"/>
|
||||
<field name="avatar_128"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="d-flex flex-column h-100">
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<field name="avatar_128" widget="image"
|
||||
options="{'size': [48, 48]}"
|
||||
class="rounded-circle flex-shrink-0"/>
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<field name="name" class="fw-bold text-truncate"/>
|
||||
<span t-if="record.attendance_state.raw_value == 'checked_in'"
|
||||
class="badge rounded-pill text-bg-success flex-shrink-0">IN</span>
|
||||
<span t-if="record.attendance_state.raw_value == 'checked_out'"
|
||||
class="badge rounded-pill text-bg-secondary flex-shrink-0">OUT</span>
|
||||
</div>
|
||||
<div class="text-muted small text-truncate">
|
||||
<field name="job_title"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2 px-1">
|
||||
<div class="d-flex justify-content-between align-items-baseline">
|
||||
<span class="text-muted small">Period Hours</span>
|
||||
<span class="fw-bold">
|
||||
<field name="x_fclk_period_hours" widget="float_time"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-muted mt-1" style="font-size: 0.75rem;">
|
||||
<field name="x_fclk_period_label"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1"/>
|
||||
<button name="action_fclk_open_attendance" type="object"
|
||||
class="btn btn-sm btn-outline-primary w-100 mt-2">
|
||||
<i class="fa fa-list-ul me-1"/>View Attendance
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Employee Kanban Search View
|
||||
================================================================ -->
|
||||
<record id="view_hr_employee_search_fusion_clock" model="ir.ui.view">
|
||||
<field name="name">hr.employee.search.fusion.clock</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Employees">
|
||||
<field name="name"/>
|
||||
<field name="department_id"/>
|
||||
<field name="job_title"/>
|
||||
<separator/>
|
||||
<filter name="group_department" string="Department"
|
||||
context="{'group_by': 'department_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Action: Employee Overview (Kanban cards)
|
||||
================================================================ -->
|
||||
<record id="action_fusion_clock_employee_overview" model="ir.actions.act_window">
|
||||
<field name="name">Employee Overview</field>
|
||||
<field name="res_model">hr.employee</field>
|
||||
<field name="view_mode">kanban</field>
|
||||
<field name="domain">[('x_fclk_enable_clock', '=', True)]</field>
|
||||
<field name="search_view_id" ref="view_hr_employee_search_fusion_clock"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">No clock-enabled employees found.</p>
|
||||
<p>Enable Fusion Clock on employee records to see them here.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_clock_employee_overview_kanban" model="ir.actions.act_window.view">
|
||||
<field name="sequence">1</field>
|
||||
<field name="view_mode">kanban</field>
|
||||
<field name="view_id" ref="view_hr_employee_kanban_fusion_clock"/>
|
||||
<field name="act_window_id" ref="action_fusion_clock_employee_overview"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Action: Attendance By Pay Period (list grouped by period then employee)
|
||||
================================================================ -->
|
||||
<record id="action_fusion_clock_attendance_by_period" model="ir.actions.act_window">
|
||||
<field name="name">Attendance by Pay Period</field>
|
||||
<field name="res_model">hr.attendance</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_hr_attendance_search_by_period"/>
|
||||
<field name="context">{
|
||||
'search_default_group_pay_period': 1,
|
||||
'search_default_group_employee': 1,
|
||||
}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">No attendance records found.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_clock_attendance_by_period_list" model="ir.actions.act_window.view">
|
||||
<field name="sequence">1</field>
|
||||
<field name="view_mode">list</field>
|
||||
<field name="view_id" ref="view_hr_attendance_list_by_period"/>
|
||||
<field name="act_window_id" ref="action_fusion_clock_attendance_by_period"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
344
fusion_clock/fusion_clock/views/portal_clock_templates.xml
Normal file
344
fusion_clock/fusion_clock/views/portal_clock_templates.xml
Normal file
@@ -0,0 +1,344 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Add Clock In/Out tile to the Fusion portal home grid -->
|
||||
<template id="portal_my_home_clock" name="Portal My Home: Clock"
|
||||
inherit_id="fusion_authorizer_portal.portal_my_home_authorizer" priority="60">
|
||||
<xpath expr="//div[hasclass('row')][hasclass('g-3')][hasclass('mb-4')]" position="inside">
|
||||
<t t-set="pfab_emp_home" t-value="request.env['hr.employee'].sudo().search([('user_id','=',request.env.uid)], limit=1)"/>
|
||||
<t t-if="pfab_emp_home and pfab_emp_home.x_fclk_enable_clock">
|
||||
<div class="col-md-6">
|
||||
<a href="/my/clock" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
|
||||
<div class="card-body d-flex align-items-center p-4">
|
||||
<div class="me-3">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center" style="width: 50px; height: 50px; background: linear-gradient(135deg, #0d9488 0%, #2563eb 100%);">
|
||||
<i class="fa fa-clock-o fa-lg text-white"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="mb-1 text-dark">Clock In / Out</h5>
|
||||
<small class="text-muted">Punch in, view timesheets, and reports</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- ================================================================
|
||||
Portal FAB - Floating clock button on ALL portal pages
|
||||
Injected into portal.portal_layout so it appears everywhere.
|
||||
================================================================ -->
|
||||
<template id="portal_layout_clock_fab" name="Portal Clock FAB"
|
||||
inherit_id="portal.portal_layout">
|
||||
<xpath expr="//div[@id='wrap']" position="after">
|
||||
<t t-set="pfab_emp" t-value="fclk_employee if fclk_employee is defined else False"/>
|
||||
<t t-if="pfab_emp and pfab_emp.x_fclk_enable_clock">
|
||||
<div id="fclk-portal-fab"
|
||||
t-att-data-checked-in="'true' if fclk_checked_in else 'false'"
|
||||
t-att-data-check-in-time="fclk_check_in_time or ''"
|
||||
t-att-data-location-name="fclk_location_name or ''">
|
||||
|
||||
<!-- Expandable panel (above button) -->
|
||||
<div class="fclk-pfab-panel" style="display:none;">
|
||||
<div class="fclk-pfab-panel-header">
|
||||
<div class="fclk-pfab-panel-title">
|
||||
<span class="fclk-pfab-status-dot"/>
|
||||
<span class="fclk-pfab-status-text">Ready</span>
|
||||
</div>
|
||||
<a href="/my/clock" class="fclk-pfab-open-link" title="Open Full Clock">
|
||||
<i class="fa fa-external-link"/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="fclk-pfab-location" style="display:none;">
|
||||
<i class="fa fa-map-marker"/>
|
||||
<span class="fclk-pfab-location-text"/>
|
||||
</div>
|
||||
<div class="fclk-pfab-timer">00:00:00</div>
|
||||
<div class="fclk-pfab-stats">
|
||||
<div class="fclk-pfab-stat">
|
||||
<span class="fclk-pfab-stat-val fclk-pfab-today">0.0h</span>
|
||||
<span class="fclk-pfab-stat-lbl">Today</span>
|
||||
</div>
|
||||
<div class="fclk-pfab-stat-divider"/>
|
||||
<div class="fclk-pfab-stat">
|
||||
<span class="fclk-pfab-stat-val fclk-pfab-week">0.0h</span>
|
||||
<span class="fclk-pfab-stat-lbl">Week</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="fclk-pfab-action fclk-pfab-action--in">
|
||||
<i class="fa fa-play-circle-o"/> Clock In
|
||||
</button>
|
||||
<div class="fclk-pfab-error" style="display:none;">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<span class="fclk-pfab-error-text"/>
|
||||
</div>
|
||||
<div class="fclk-pfab-panel-arrow"/>
|
||||
</div>
|
||||
|
||||
<!-- Floating button -->
|
||||
<button class="fclk-pfab-btn">
|
||||
<span class="fclk-pfab-ripple-ring fclk-pfab-ripple-ring--1"/>
|
||||
<span class="fclk-pfab-ripple-ring fclk-pfab-ripple-ring--2"/>
|
||||
<span class="fclk-pfab-ripple-ring fclk-pfab-ripple-ring--3"/>
|
||||
<span class="fclk-pfab-icon">
|
||||
<i class="fa fa-clock-o"/>
|
||||
</span>
|
||||
<span class="fclk-pfab-badge" style="display:none;">00:00:00</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Main Clock Page -->
|
||||
<template id="portal_clock_page" name="Fusion Clock Portal">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="False"/>
|
||||
<t t-set="no_breadcrumbs" t-value="True"/>
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
|
||||
<div id="fusion-clock-app" class="fclk-app"
|
||||
t-att-data-checked-in="'true' if is_checked_in else 'false'"
|
||||
t-att-data-check-in-time="current_attendance.check_in.isoformat() if current_attendance and current_attendance.check_in else ''"
|
||||
t-att-data-location-name="current_attendance.x_fclk_location_id.name if current_attendance and current_attendance.x_fclk_location_id else ''"
|
||||
t-att-data-enable-sounds="'true' if enable_sounds else 'false'"
|
||||
t-att-data-google-maps-key="google_maps_key or ''">
|
||||
|
||||
<!-- Dark Background Container -->
|
||||
<div class="fclk-container">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="fclk-header">
|
||||
<div class="fclk-date" id="fclk-date-display"></div>
|
||||
<h1 class="fclk-greeting">Hello, <t t-esc="employee.name.split(' ')[0]"/></h1>
|
||||
</div>
|
||||
|
||||
<!-- Status Card -->
|
||||
<div class="fclk-status-card">
|
||||
<div class="fclk-status-row">
|
||||
<div class="fclk-status-indicator">
|
||||
<span class="fclk-dot" id="fclk-status-dot"
|
||||
t-attf-class="fclk-dot {{ 'fclk-dot-active' if is_checked_in else '' }}"></span>
|
||||
<span class="fclk-status-text" id="fclk-status-text">
|
||||
<t t-if="is_checked_in">Clocked In</t>
|
||||
<t t-else="">Not Clocked In</t>
|
||||
</span>
|
||||
</div>
|
||||
<span class="fclk-current-time" id="fclk-current-time"></span>
|
||||
</div>
|
||||
|
||||
<!-- Location Selector -->
|
||||
<div class="fclk-location-card" id="fclk-location-card">
|
||||
<div class="fclk-location-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="fclk-location-info">
|
||||
<div class="fclk-location-name" id="fclk-location-name">
|
||||
<t t-if="locations">
|
||||
<t t-esc="locations[0].name"/>
|
||||
</t>
|
||||
<t t-else="">No locations configured</t>
|
||||
</div>
|
||||
<div class="fclk-location-address" id="fclk-location-address">
|
||||
<t t-if="locations">
|
||||
<t t-esc="locations[0].address or 'No address'"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-location-arrow" t-if="len(locations) > 1">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timer Section -->
|
||||
<div class="fclk-timer-section">
|
||||
<div class="fclk-timer-label" id="fclk-timer-label">
|
||||
<t t-if="is_checked_in">Time Elapsed</t>
|
||||
<t t-else="">Ready to Clock In</t>
|
||||
</div>
|
||||
<div class="fclk-timer" id="fclk-timer">00:00:00</div>
|
||||
</div>
|
||||
|
||||
<!-- Clock Button -->
|
||||
<div class="fclk-button-section">
|
||||
<button class="fclk-clock-btn" id="fclk-clock-btn"
|
||||
t-attf-class="fclk-clock-btn {{ 'fclk-clock-btn-out' if is_checked_in else '' }}">
|
||||
<div class="fclk-btn-ripple"></div>
|
||||
<svg id="fclk-btn-icon-play" class="fclk-btn-icon"
|
||||
t-attf-style="display: {{ 'none' if is_checked_in else 'block' }}; margin-left: 4px;"
|
||||
width="40" height="40" viewBox="0 0 24 24" fill="white">
|
||||
<polygon points="6 3 20 12 6 21 6 3"/>
|
||||
</svg>
|
||||
<svg id="fclk-btn-icon-stop" class="fclk-btn-icon"
|
||||
t-attf-style="display: {{ 'block' if is_checked_in else 'none' }}"
|
||||
width="36" height="36" viewBox="0 0 24 24" fill="white">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="fclk-btn-label" id="fclk-btn-label">
|
||||
<t t-if="is_checked_in">Tap to Clock Out</t>
|
||||
<t t-else="">Tap to Clock In</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="fclk-stats-row">
|
||||
<div class="fclk-stat-card">
|
||||
<div class="fclk-stat-header">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
<div class="fclk-stat-value" id="fclk-today-hours">
|
||||
<t t-esc="'%.1f' % today_hours"/>h
|
||||
</div>
|
||||
<div class="fclk-stat-target">of 8h target</div>
|
||||
</div>
|
||||
<div class="fclk-stat-card">
|
||||
<div class="fclk-stat-header">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>This Week</span>
|
||||
</div>
|
||||
<div class="fclk-stat-value" id="fclk-week-hours">
|
||||
<t t-esc="'%.1f' % week_hours"/>h
|
||||
</div>
|
||||
<div class="fclk-stat-target">of 40h target</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="fclk-recent-section">
|
||||
<div class="fclk-recent-header">
|
||||
<h3>Recent Activity</h3>
|
||||
<a href="/my/clock/timesheets" class="fclk-view-all">View All</a>
|
||||
</div>
|
||||
<div class="fclk-recent-list" id="fclk-recent-list">
|
||||
<t t-foreach="recent_attendances" t-as="att">
|
||||
<div class="fclk-recent-item">
|
||||
<div class="fclk-recent-date">
|
||||
<div class="fclk-recent-day-name">
|
||||
<t t-esc="att.check_in.strftime('%a')"/>
|
||||
</div>
|
||||
<div class="fclk-recent-day-num">
|
||||
<t t-esc="att.check_in.strftime('%d')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-recent-info">
|
||||
<div class="fclk-recent-location">
|
||||
<t t-esc="att.x_fclk_location_id.name or 'Unknown'"/>
|
||||
</div>
|
||||
<div class="fclk-recent-times">
|
||||
<t t-esc="att.check_in.strftime('%I:%M %p')"/>
|
||||
- <t t-esc="att.check_out.strftime('%I:%M %p') if att.check_out else '--'"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-recent-hours">
|
||||
<t t-esc="'%.1f' % (att.x_fclk_net_hours or 0)"/>h
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="not recent_attendances">
|
||||
<div class="fclk-recent-empty">
|
||||
No recent activity
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Bar -->
|
||||
<div class="fclk-nav-bar">
|
||||
<a href="/my/clock" class="fclk-nav-item fclk-nav-active">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>Clock</span>
|
||||
</a>
|
||||
<a href="/my/clock/timesheets" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<span>Reports</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Location Picker Modal -->
|
||||
<div class="fclk-modal" id="fclk-location-modal" style="display:none;">
|
||||
<div class="fclk-modal-backdrop" onclick="document.getElementById('fclk-location-modal').style.display='none'"></div>
|
||||
<div class="fclk-modal-content">
|
||||
<h3>Select Location</h3>
|
||||
<div class="fclk-modal-list">
|
||||
<t t-foreach="locations" t-as="loc">
|
||||
<div class="fclk-modal-item"
|
||||
t-att-data-id="loc.id"
|
||||
t-att-data-name="loc.name"
|
||||
t-att-data-address="loc.address or ''"
|
||||
t-att-data-lat="loc.latitude"
|
||||
t-att-data-lng="loc.longitude"
|
||||
t-att-data-radius="loc.radius">
|
||||
<div class="fclk-modal-item-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fclk-modal-item-name"><t t-esc="loc.name"/></div>
|
||||
<div class="fclk-modal-item-addr"><t t-esc="loc.address or 'No address'"/></div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Toast -->
|
||||
<div class="fclk-toast" id="fclk-toast" style="display:none;">
|
||||
<div class="fclk-toast-icon" id="fclk-toast-icon"></div>
|
||||
<div class="fclk-toast-msg" id="fclk-toast-msg"></div>
|
||||
</div>
|
||||
|
||||
<!-- GPS Loading Overlay -->
|
||||
<div class="fclk-gps-overlay" id="fclk-gps-overlay" style="display:none;">
|
||||
<div class="fclk-gps-spinner"></div>
|
||||
<div class="fclk-gps-text">Verifying your location...</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Hidden location data for JS -->
|
||||
<script type="application/json" id="fclk-locations-data">
|
||||
<t t-out="locations_json"/>
|
||||
</script>
|
||||
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
96
fusion_clock/fusion_clock/views/portal_report_templates.xml
Normal file
96
fusion_clock/fusion_clock/views/portal_report_templates.xml
Normal file
@@ -0,0 +1,96 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Reports Portal Page -->
|
||||
<template id="portal_report_page" name="Fusion Clock Reports">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="False"/>
|
||||
<t t-set="no_breadcrumbs" t-value="True"/>
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
|
||||
<div class="fclk-app">
|
||||
<div class="fclk-reports-container">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="fclk-ts-header" style="margin-bottom:24px;">
|
||||
<h2>Reports</h2>
|
||||
</div>
|
||||
|
||||
<!-- Reports List -->
|
||||
<t t-if="reports">
|
||||
<t t-foreach="reports" t-as="report">
|
||||
<div class="fclk-report-item">
|
||||
<div class="fclk-report-info">
|
||||
<h4>
|
||||
<t t-esc="report.date_start.strftime('%b %d')"/> -
|
||||
<t t-esc="report.date_end.strftime('%b %d, %Y')"/>
|
||||
</h4>
|
||||
<p>
|
||||
<t t-esc="'%.1f' % report.net_hours"/>h net |
|
||||
<t t-esc="report.days_worked"/> days |
|
||||
<t t-esc="dict([('weekly','Weekly'),('biweekly','Bi-Weekly'),('semi_monthly','Semi-Monthly'),('monthly','Monthly')]).get(report.schedule_type, '')"/>
|
||||
</p>
|
||||
</div>
|
||||
<t t-if="report.report_pdf">
|
||||
<a t-attf-href="/my/clock/reports/#{report.id}/download"
|
||||
class="fclk-report-download">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle; margin-right:4px;">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
PDF
|
||||
</a>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span style="color:#6b7280; font-size:12px;">Pending</span>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="fclk-empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
<p>No reports available yet.</p>
|
||||
<p style="font-size:12px; margin-top:4px;">Reports are generated automatically at the end of each pay period.</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Navigation Bar -->
|
||||
<div class="fclk-nav-bar">
|
||||
<a href="/my/clock" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>Clock</span>
|
||||
</a>
|
||||
<a href="/my/clock/timesheets" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" class="fclk-nav-item fclk-nav-active">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<span>Reports</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
146
fusion_clock/fusion_clock/views/portal_timesheet_templates.xml
Normal file
146
fusion_clock/fusion_clock/views/portal_timesheet_templates.xml
Normal file
@@ -0,0 +1,146 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Timesheet Portal Page -->
|
||||
<template id="portal_timesheet_page" name="Fusion Clock Timesheets">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="False"/>
|
||||
<t t-set="no_breadcrumbs" t-value="True"/>
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
|
||||
<div class="fclk-app">
|
||||
<div class="fclk-timesheet-container">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="fclk-ts-header">
|
||||
<h2>Timesheets</h2>
|
||||
<div class="fclk-ts-period-nav">
|
||||
<a t-attf-href="/my/clock/timesheets?period=last"
|
||||
t-attf-class="fclk-ts-period-btn {{ 'fclk-ts-period-btn-active' if period == 'last' else '' }}">
|
||||
Previous
|
||||
</a>
|
||||
<a t-attf-href="/my/clock/timesheets?period=current"
|
||||
t-attf-class="fclk-ts-period-btn {{ 'fclk-ts-period-btn-active' if period == 'current' else '' }}">
|
||||
Current
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Period Info -->
|
||||
<div style="margin-bottom:16px;">
|
||||
<span class="fclk-ts-period-btn" style="cursor:default; display:inline-block;">
|
||||
<t t-esc="period_start.strftime('%b %d')"/> -
|
||||
<t t-esc="period_end.strftime('%b %d, %Y')"/>
|
||||
(<t t-esc="dict([('weekly','Weekly'),('biweekly','Bi-Weekly'),('semi_monthly','Semi-Monthly'),('monthly','Monthly')]).get(schedule_type, schedule_type)"/>)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="fclk-ts-summary">
|
||||
<div class="fclk-ts-summary-card">
|
||||
<div class="fclk-ts-summary-value"><t t-esc="total_hours"/>h</div>
|
||||
<div class="fclk-ts-summary-label">Total Hours</div>
|
||||
</div>
|
||||
<div class="fclk-ts-summary-card">
|
||||
<div class="fclk-ts-summary-value" style="color:#10B981;"><t t-esc="net_hours"/>h</div>
|
||||
<div class="fclk-ts-summary-label">Net Hours</div>
|
||||
</div>
|
||||
<div class="fclk-ts-summary-card">
|
||||
<div class="fclk-ts-summary-value"><t t-esc="int(total_breaks)"/>m</div>
|
||||
<div class="fclk-ts-summary-label">Total Breaks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attendance Table -->
|
||||
<t t-if="attendances">
|
||||
<table class="fclk-ts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>In</th>
|
||||
<th>Out</th>
|
||||
<th>Break</th>
|
||||
<th>Net</th>
|
||||
<th>Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="attendances" t-as="att">
|
||||
<tr>
|
||||
<td>
|
||||
<strong><t t-esc="att.check_in.strftime('%a')"/></strong>
|
||||
<span style="color:#9ca3af; margin-left:4px;">
|
||||
<t t-esc="att.check_in.strftime('%b %d')"/>
|
||||
</span>
|
||||
</td>
|
||||
<td><t t-esc="att.check_in.strftime('%I:%M %p')"/></td>
|
||||
<td>
|
||||
<t t-if="att.check_out">
|
||||
<t t-esc="att.check_out.strftime('%I:%M %p')"/>
|
||||
<t t-if="att.x_fclk_auto_clocked_out">
|
||||
<span class="fclk-ts-badge-auto">AUTO</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span style="color:#f59e0b;">Active</span>
|
||||
</t>
|
||||
</td>
|
||||
<td><t t-esc="int(att.x_fclk_break_minutes or 0)"/>m</td>
|
||||
<td style="font-weight:600; color:#10B981;">
|
||||
<t t-esc="'%.1f' % (att.x_fclk_net_hours or 0)"/>h
|
||||
</td>
|
||||
<td style="color:#9ca3af; font-size:12px;">
|
||||
<t t-esc="att.x_fclk_location_id.name or ''"/>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="fclk-empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="1.5">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<p>No attendance records for this period.</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Navigation Bar -->
|
||||
<div class="fclk-nav-bar">
|
||||
<a href="/my/clock" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>Clock</span>
|
||||
</a>
|
||||
<a href="/my/clock/timesheets" class="fclk-nav-item fclk-nav-active">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<span>Reports</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
166
fusion_clock/fusion_clock/views/res_config_settings_views.xml
Normal file
166
fusion_clock/fusion_clock/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,166 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form_fusion_clock" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.fusion.clock</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="priority">95</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Fusion Clock" string="Fusion Clock" name="fusion_clock" groups="fusion_clock.group_fusion_clock_manager">
|
||||
|
||||
<!-- Work Schedule -->
|
||||
<block title="Work Schedule" name="fclk_work_schedule">
|
||||
<setting string="Default Clock-In Time" help="Scheduled start of shift. Used for penalty and auto clock-out calculations.">
|
||||
<span class="me-2">Clock-In</span>
|
||||
<field name="fclk_default_clock_in_time" widget="float_time"
|
||||
class="text-center" style="width: 6ch;"/>
|
||||
<span class="ms-2 text-muted">e.g. 09:00 = 9 AM</span>
|
||||
</setting>
|
||||
<setting string="Default Clock-Out Time" help="Scheduled end of shift. Used for penalty and auto clock-out calculations.">
|
||||
<span class="me-2">Clock-Out</span>
|
||||
<field name="fclk_default_clock_out_time" widget="float_time"
|
||||
class="text-center" style="width: 6ch;"/>
|
||||
<span class="ms-2 text-muted">e.g. 17:00 = 5 PM</span>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Break Settings -->
|
||||
<block title="Break Settings" name="fclk_break_settings">
|
||||
<setting string="Auto-Deduct Break" help="Automatically deduct unpaid break from total worked hours. Applies to portal/FAB clock-outs AND manually entered attendance.">
|
||||
<field name="fclk_auto_deduct_break"/>
|
||||
<div invisible="not fclk_auto_deduct_break" class="mt8">
|
||||
<span class="me-2">Break Duration</span>
|
||||
<field name="fclk_default_break_minutes" class="text-center" style="width: 6ch;"/>
|
||||
<span class="ms-2">minutes</span>
|
||||
<span class="ms-3 text-muted">e.g. 30 = half-hour unpaid break</span>
|
||||
<br/>
|
||||
<span class="me-2 mt8 d-inline-block">Threshold</span>
|
||||
<field name="fclk_break_threshold_hours" widget="float_time"
|
||||
class="text-center" style="width: 6ch;"/>
|
||||
<span class="ms-2">hours</span>
|
||||
<span class="ms-3 text-muted">break deducted only if shift exceeds this</span>
|
||||
<br/>
|
||||
<button name="action_backfill_breaks" type="object"
|
||||
string="Apply Break to Past Records"
|
||||
class="btn btn-secondary mt8" icon="fa-clock-o"
|
||||
confirm="This will apply break deduction to all past attendance records that are missing it. Continue?"/>
|
||||
<span class="ms-2 text-muted mt8 d-inline-block">
|
||||
Applies break to historical records that were entered manually or before break was enabled
|
||||
</span>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Auto Clock-Out & Grace Period -->
|
||||
<block title="Auto Clock-Out" name="fclk_auto_clockout">
|
||||
<setting string="Enable Auto Clock-Out" help="Automatically clock out employees who forget to clock out.">
|
||||
<field name="fclk_enable_auto_clockout"/>
|
||||
<div invisible="not fclk_enable_auto_clockout" class="mt8">
|
||||
<span class="me-2">Grace Period</span>
|
||||
<field name="fclk_grace_period_minutes" class="text-center" style="width: 6ch;"/>
|
||||
<span class="ms-2">minutes after scheduled end</span>
|
||||
<br/>
|
||||
<span class="me-2 mt8 d-inline-block">Max Shift</span>
|
||||
<field name="fclk_max_shift_hours" widget="float_time"
|
||||
class="text-center" style="width: 6ch;"/>
|
||||
<span class="ms-2">hours</span>
|
||||
<span class="ms-3 text-muted">safety net: force clock-out after this</span>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Penalties -->
|
||||
<block title="Penalty Tracking" name="fclk_penalties">
|
||||
<setting string="Enable Penalties" help="Track late clock-ins and early clock-outs for each employee.">
|
||||
<field name="fclk_enable_penalties"/>
|
||||
<div invisible="not fclk_enable_penalties" class="mt8">
|
||||
<span class="me-2">Grace</span>
|
||||
<field name="fclk_penalty_grace_minutes" class="text-center" style="width: 6ch;"/>
|
||||
<span class="ms-2">minutes before a penalty is logged</span>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Pay Period -->
|
||||
<block title="Pay Period" name="fclk_pay_period">
|
||||
<setting string="Pay Period Schedule" help="Defines how pay periods are calculated. Weekly and Bi-Weekly require an anchor date below. Semi-Monthly uses 1st-15th and 16th-end. Monthly uses 1st-end.">
|
||||
<div class="mt8">
|
||||
<span class="me-2">Schedule</span>
|
||||
<field name="fclk_pay_period_type" class="d-inline-block"/>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Anchor Date" help="The first day of any actual pay period. All periods are calculated forward and backward from this date in weekly or biweekly increments. Not used for Semi-Monthly or Monthly.">
|
||||
<div class="mt8">
|
||||
<field name="fclk_pay_period_start" class="d-inline-block"
|
||||
placeholder="Pick the start of a real pay period"/>
|
||||
<span class="ms-3 text-muted">
|
||||
Pick the first day of any real pay period
|
||||
</span>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Current Period Preview" help="Shows the current pay period based on today and the settings above. Use this to verify your anchor date is correct.">
|
||||
<div class="mt8">
|
||||
<field name="fclk_pay_period_preview" class="fw-bold text-primary"/>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Reports -->
|
||||
<block title="Reports & Email" name="fclk_reports">
|
||||
<setting string="Auto-Generate Reports" help="Automatically generate a summary report at the end of each pay period.">
|
||||
<field name="fclk_auto_generate_reports"/>
|
||||
</setting>
|
||||
<setting string="Send Employee Copies" help="Email each employee a copy of their individual timesheet at the end of every pay period.">
|
||||
<field name="fclk_send_employee_reports"/>
|
||||
</setting>
|
||||
<setting string="Manager Report Recipients" help="Comma-separated email addresses. A batch summary of all employees will be sent to these addresses at the end of every pay period.">
|
||||
<div class="mt8">
|
||||
<field name="fclk_report_recipient_emails" class="o_input w-100"
|
||||
placeholder="manager@company.com, hr@company.com"/>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Generate Historical Reports" help="Create reports for all past pay periods that have attendance data but no report yet. No emails will be sent for these. Use this after first install or when changing pay period settings.">
|
||||
<div class="mt8">
|
||||
<button name="action_backfill_reports" type="object"
|
||||
string="Generate Historical Reports"
|
||||
class="btn btn-secondary" icon="fa-history"
|
||||
confirm="This will create reports for all past pay periods. No emails will be sent. This may take a few minutes depending on the amount of data. Continue?"/>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Clock Locations -->
|
||||
<block title="Clock Locations" name="fclk_locations">
|
||||
<setting string="Manage Locations" help="Configure geofenced clock-in/out locations with GPS coordinates and allowed radius.">
|
||||
<div class="mt8">
|
||||
<button name="%(fusion_clock.action_fusion_clock_location)d" type="action"
|
||||
string="Manage Locations" class="btn btn-primary" icon="fa-map-marker"/>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Google Maps -->
|
||||
<block title="Google Maps" name="fclk_google_maps">
|
||||
<setting string="Google Maps API Key" help="Required for geocoding addresses and showing map previews on clock locations.">
|
||||
<div class="mt8">
|
||||
<field name="fclk_google_maps_api_key" class="o_input w-100"
|
||||
placeholder="AIza..." password="True"/>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Sounds -->
|
||||
<block title="Sounds" name="fclk_sounds">
|
||||
<setting string="Clock Sounds" help="Play an audio chime on clock-in and a different one on clock-out.">
|
||||
<field name="fclk_enable_sounds"/>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user