changes
This commit is contained in:
@@ -5,24 +5,33 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Clock',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.2.0.0',
|
||||
'category': 'Human Resources/Attendances',
|
||||
'summary': 'Geofenced Clock-In/Out with Portal UI, Auto Clock-Out, Penalties, and Pay Period Reporting',
|
||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||
'description': """
|
||||
Fusion Clock - Geofenced Employee Attendance
|
||||
=============================================
|
||||
Fusion Clock - Complete Employee Time & 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
|
||||
* **Geofenced Clock-In/Out** - GPS and IP whitelist verification
|
||||
* **Shift Scheduling** - Per-employee shift assignment with break management
|
||||
* **Auto Clock-Out** - Automatic clock-out after shift + grace period
|
||||
* **Penalty Tracking** - Auto-deduction for late clock-in and early clock-out
|
||||
* **Overtime Tracking** - Daily and weekly overtime with configurable thresholds
|
||||
* **Activity Logging** - Comprehensive audit trail of every attendance event
|
||||
* **Absence Tracking** - Automatic detection with monthly/yearly counters
|
||||
* **Leave Requests** - Portal-based leave requests with auto-approval
|
||||
* **Timesheet Corrections** - Employee correction requests with approval workflow
|
||||
* **Manager Dashboard** - Live status, alerts, and team overview
|
||||
* **Kiosk Mode** - Shared-device clock-in/out with PIN verification
|
||||
* **Photo Verification** - Optional selfie capture at clock-in
|
||||
* **On-Time Streak** - Gamification with milestone tracking
|
||||
* **CSV Export** - Configurable payroll-compatible export
|
||||
* **Team Lead Views** - Filtered read-only access for direct reports
|
||||
* **Pay Period Reports** - PDF reports with email automation
|
||||
* **Employee Notifications** - Clock-in/out reminders and weekly summaries
|
||||
* **Portal UI** - Dark-themed mobile-first portal with FAB and modals
|
||||
* **Systray Widget** - Backend users can clock in/out from any Odoo page
|
||||
|
||||
Integrates natively with Odoo's hr.attendance module for full payroll compatibility.
|
||||
@@ -35,6 +44,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'hr',
|
||||
'portal',
|
||||
'mail',
|
||||
'resource',
|
||||
],
|
||||
'data': [
|
||||
# Security
|
||||
@@ -54,22 +64,35 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'views/clock_report_views.xml',
|
||||
'views/clock_penalty_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/clock_activity_log_views.xml',
|
||||
'views/clock_leave_request_views.xml',
|
||||
'views/clock_shift_views.xml',
|
||||
'views/clock_correction_views.xml',
|
||||
'views/clock_dashboard_views.xml',
|
||||
'views/hr_employee_views.xml',
|
||||
'views/clock_menus.xml',
|
||||
# Views - Portal
|
||||
'views/portal_clock_templates.xml',
|
||||
'views/portal_timesheet_templates.xml',
|
||||
'views/portal_report_templates.xml',
|
||||
'views/kiosk_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',
|
||||
'fusion_clock/static/src/js/fusion_clock_kiosk.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/fusion_clock_dashboard.js',
|
||||
'fusion_clock/static/src/xml/fusion_clock_dashboard.xml',
|
||||
'fusion_clock/static/src/js/fusion_clock_location_map.js',
|
||||
'fusion_clock/static/src/js/fusion_clock_location_places.js',
|
||||
'fusion_clock/static/src/xml/fusion_clock_location.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
from . import portal_clock
|
||||
from . import clock_api
|
||||
from . import clock_kiosk
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
import math
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
@@ -10,10 +11,12 @@ from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
STREAK_MILESTONES = [5, 10, 20, 50, 100]
|
||||
|
||||
|
||||
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
|
||||
R = 6371000
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
delta_phi = math.radians(lat2 - lat1)
|
||||
@@ -29,40 +32,35 @@ 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([
|
||||
return 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(
|
||||
return 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.
|
||||
def _verify_location(self, latitude, longitude, employee, client_ip=None):
|
||||
"""Verify GPS coordinates or IP against allowed geofences.
|
||||
Returns (location_record, distance, error_detail, method) tuple.
|
||||
method is 'gps' or 'ip'.
|
||||
"""
|
||||
locations = self._get_locations_for_employee(employee)
|
||||
|
||||
if not locations:
|
||||
return False, 0, 'no_locations'
|
||||
return False, 0, 'no_locations', 'gps'
|
||||
|
||||
geocoded = locations.filtered(lambda l: l.latitude is not None and l.longitude is not None
|
||||
geocoded = locations.filtered(lambda l: l.latitude and l.longitude
|
||||
and not (l.latitude == 0.0 and l.longitude == 0.0))
|
||||
if not geocoded:
|
||||
return False, 0, 'no_geocoded'
|
||||
return False, 0, 'no_geocoded', 'gps'
|
||||
|
||||
nearest_location = False
|
||||
nearest_distance = float('inf')
|
||||
@@ -70,47 +68,41 @@ class FusionClockAPI(http.Controller):
|
||||
for loc in geocoded:
|
||||
dist = haversine_distance(latitude, longitude, loc.latitude, loc.longitude)
|
||||
if dist <= loc.radius:
|
||||
return loc, dist, None
|
||||
return loc, dist, None, 'gps'
|
||||
if dist < nearest_distance:
|
||||
nearest_distance = dist
|
||||
nearest_location = loc
|
||||
|
||||
return False, nearest_distance, 'outside'
|
||||
# IP fallback check
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_ip_fallback', 'False') == 'True' and client_ip:
|
||||
for loc in locations:
|
||||
if loc.check_ip_whitelist(client_ip):
|
||||
return loc, 0, None, 'ip'
|
||||
|
||||
return False, nearest_distance, 'outside', 'gps'
|
||||
|
||||
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.'
|
||||
return f'You are {dist_str} away from the nearest clock location. Please clock in/out within the allowed area.'
|
||||
|
||||
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
|
||||
"""Get scheduled clock-in and clock-out datetime using employee shift."""
|
||||
return employee._get_fclk_scheduled_times(date)
|
||||
|
||||
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."""
|
||||
"""Check if a penalty should be created and deduct minutes."""
|
||||
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'))
|
||||
deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15'))
|
||||
diff_minutes = abs((actual_dt - scheduled_dt).total_seconds()) / 60.0
|
||||
|
||||
should_penalize = False
|
||||
@@ -126,22 +118,65 @@ class FusionClockAPI(http.Controller):
|
||||
'penalty_type': penalty_type,
|
||||
'scheduled_time': scheduled_dt,
|
||||
'actual_time': actual_dt,
|
||||
'penalty_minutes': deduction,
|
||||
'date': actual_dt.date() if isinstance(actual_dt, datetime) else fields.Date.today(),
|
||||
})
|
||||
|
||||
# Deduct penalty minutes from attendance (adds to break deduction)
|
||||
current_break = attendance.x_fclk_break_minutes or 0.0
|
||||
attendance.sudo().write({
|
||||
'x_fclk_break_minutes': current_break + deduction,
|
||||
})
|
||||
|
||||
# Log penalty
|
||||
log_type = 'late_clock_in' if penalty_type == 'late_in' else 'early_clock_out'
|
||||
request.env['fusion.clock.activity.log'].sudo().create({
|
||||
'employee_id': employee.id,
|
||||
'log_type': log_type,
|
||||
'description': f"{'Late' if penalty_type == 'late_in' else 'Early'} by "
|
||||
f"{diff_minutes:.0f} min. {deduction:.0f} min deducted.",
|
||||
'attendance_id': attendance.id,
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
# Reset streak on late clock-in
|
||||
if penalty_type == 'late_in':
|
||||
employee.sudo().write({'x_fclk_ontime_streak': 0})
|
||||
|
||||
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':
|
||||
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
|
||||
return
|
||||
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0'))
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.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})
|
||||
current = attendance.x_fclk_break_minutes or 0.0
|
||||
# Set to whichever is higher: configured break or existing (penalty-inflated) value
|
||||
new_val = max(break_min, current)
|
||||
if new_val != current:
|
||||
attendance.sudo().write({'x_fclk_break_minutes': new_val})
|
||||
|
||||
def _log_activity(self, employee, log_type, description, attendance=None,
|
||||
location=None, latitude=0, longitude=0, distance=0, source='portal'):
|
||||
"""Create an activity log entry."""
|
||||
try:
|
||||
request.env['fusion.clock.activity.log'].sudo().create({
|
||||
'employee_id': employee.id,
|
||||
'log_type': log_type,
|
||||
'description': description,
|
||||
'attendance_id': attendance.id if attendance else False,
|
||||
'location_id': location.id if location else False,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'distance': distance,
|
||||
'source': source,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create activity log: %s", e)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# API Endpoints
|
||||
@@ -149,12 +184,15 @@ class FusionClockAPI(http.Controller):
|
||||
|
||||
@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)
|
||||
client_ip = request.httprequest.environ.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip()
|
||||
if not client_ip:
|
||||
client_ip = request.httprequest.remote_addr
|
||||
|
||||
location, distance, err, method = self._verify_location(latitude, longitude, employee, client_ip)
|
||||
|
||||
if location:
|
||||
return {
|
||||
@@ -164,6 +202,7 @@ class FusionClockAPI(http.Controller):
|
||||
'location_address': location.address or '',
|
||||
'distance': round(distance, 1),
|
||||
'radius': location.radius,
|
||||
'method': method,
|
||||
}
|
||||
else:
|
||||
msg = self._location_error_message(err, distance)
|
||||
@@ -175,8 +214,7 @@ class FusionClockAPI(http.Controller):
|
||||
}
|
||||
|
||||
@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."""
|
||||
def clock_action(self, latitude=0, longitude=0, accuracy=0, source='portal', photo=None, **kw):
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found for current user.'}
|
||||
@@ -184,17 +222,41 @@ class FusionClockAPI(http.Controller):
|
||||
if not employee.x_fclk_enable_clock:
|
||||
return {'error': 'Fusion Clock is not enabled for your account.'}
|
||||
|
||||
# Check pending reason before clock-in
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
if not is_checked_in and employee.x_fclk_pending_reason:
|
||||
return {
|
||||
'requires_reason': True,
|
||||
'message': 'Please provide a reason for your missed clock-out before clocking in.',
|
||||
}
|
||||
|
||||
# Server-side location verification
|
||||
location, distance, err = self._verify_location(latitude, longitude, employee)
|
||||
client_ip = request.httprequest.environ.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip()
|
||||
if not client_ip:
|
||||
client_ip = request.httprequest.remote_addr
|
||||
|
||||
location, distance, err, method = self._verify_location(latitude, longitude, employee, client_ip)
|
||||
if not location:
|
||||
# Log geofence violation
|
||||
self._log_activity(
|
||||
employee, 'outside_geofence',
|
||||
self._location_error_message(err, distance),
|
||||
latitude=latitude, longitude=longitude, distance=distance, source=source,
|
||||
)
|
||||
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'
|
||||
# Log IP fallback usage
|
||||
if method == 'ip':
|
||||
self._log_activity(
|
||||
employee, 'ip_fallback',
|
||||
f"IP-based verification used (IP: {client_ip}) at {location.name}",
|
||||
location=location, source=source,
|
||||
)
|
||||
|
||||
now = fields.Datetime.now()
|
||||
today = now.date()
|
||||
|
||||
@@ -202,23 +264,70 @@ class FusionClockAPI(http.Controller):
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'browser': kw.get('browser', ''),
|
||||
'ip_address': kw.get('ip_address', ''),
|
||||
'ip_address': client_ip,
|
||||
}
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
|
||||
try:
|
||||
if not is_checked_in:
|
||||
# CLOCK IN
|
||||
attendance = employee.sudo()._attendance_action_change(geo_info)
|
||||
attendance.sudo().write({
|
||||
|
||||
write_vals = {
|
||||
'x_fclk_location_id': location.id,
|
||||
'x_fclk_in_distance': round(distance, 1),
|
||||
'x_fclk_clock_source': source,
|
||||
})
|
||||
}
|
||||
|
||||
# Photo verification
|
||||
if photo and location.require_photo:
|
||||
try:
|
||||
write_vals['x_fclk_checkin_photo'] = photo
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
attendance.sudo().write(write_vals)
|
||||
|
||||
# Log clock-in
|
||||
self._log_activity(
|
||||
employee, 'clock_in',
|
||||
f"Clocked in at {location.name} ({method.upper()})",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
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)
|
||||
|
||||
# Update on-time streak
|
||||
diff_min = (now - scheduled_in).total_seconds() / 60.0 if now > scheduled_in else 0
|
||||
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
|
||||
if diff_min <= grace:
|
||||
new_streak = (employee.x_fclk_ontime_streak or 0) + 1
|
||||
employee.sudo().write({'x_fclk_ontime_streak': new_streak})
|
||||
if new_streak in STREAK_MILESTONES:
|
||||
self._log_activity(
|
||||
employee, 'streak_milestone',
|
||||
f"On-time streak reached {new_streak} days!",
|
||||
attendance=attendance, source='system',
|
||||
)
|
||||
|
||||
# Very late notification
|
||||
very_late = float(ICP.get_param('fusion_clock.very_late_threshold_minutes', '15'))
|
||||
if diff_min > very_late:
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
if office_user_id:
|
||||
request.env['hr.attendance'].sudo()._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Very Late Clock-In: {employee.name}",
|
||||
f"{employee.name} clocked in {int(diff_min)} minutes late.",
|
||||
'hr.attendance',
|
||||
attendance.id,
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
@@ -226,6 +335,7 @@ class FusionClockAPI(http.Controller):
|
||||
'check_in': fields.Datetime.to_string(attendance.check_in),
|
||||
'location_name': location.name,
|
||||
'message': f'Clocked in at {location.name}',
|
||||
'streak': employee.x_fclk_ontime_streak,
|
||||
}
|
||||
|
||||
else:
|
||||
@@ -242,6 +352,23 @@ class FusionClockAPI(http.Controller):
|
||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
|
||||
# Log clock-out
|
||||
self._log_activity(
|
||||
employee, 'clock_out',
|
||||
f"Clocked out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source=source,
|
||||
)
|
||||
|
||||
# Log overtime
|
||||
if attendance.x_fclk_is_overtime and attendance.x_fclk_overtime_hours > 0:
|
||||
self._log_activity(
|
||||
employee, 'overtime',
|
||||
f"Overtime: {attendance.x_fclk_overtime_hours:.1f}h beyond scheduled shift",
|
||||
attendance=attendance, source='system',
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_out',
|
||||
@@ -251,6 +378,7 @@ class FusionClockAPI(http.Controller):
|
||||
'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,
|
||||
'overtime_hours': round(attendance.x_fclk_overtime_hours or 0, 2),
|
||||
'location_name': location.name,
|
||||
'message': f'Clocked out from {location.name}',
|
||||
}
|
||||
@@ -259,9 +387,113 @@ class FusionClockAPI(http.Controller):
|
||||
_logger.error("Fusion Clock error: %s", str(e))
|
||||
return {'error': str(e)}
|
||||
|
||||
@http.route('/fusion_clock/submit_reason', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def submit_reason(self, reason='', departure_time='', **kw):
|
||||
"""Submit a reason for missed clock-out."""
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found for current user.'}
|
||||
|
||||
if not reason:
|
||||
return {'error': 'Please provide a reason.'}
|
||||
|
||||
# Find the last unclosed attendance or last auto-closed
|
||||
last_att = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '!=', False),
|
||||
], order='check_out desc', limit=1)
|
||||
|
||||
if last_att and departure_time:
|
||||
try:
|
||||
dep_dt = fields.Datetime.from_string(departure_time)
|
||||
if dep_dt and dep_dt > last_att.check_in:
|
||||
last_att.sudo().write({'check_out': dep_dt})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log the reason
|
||||
self._log_activity(
|
||||
employee, 'reason_provided',
|
||||
f"Reason for missed clock-out: {reason}. "
|
||||
f"Reported departure: {departure_time or 'not specified'}",
|
||||
attendance=last_att if last_att else None,
|
||||
source='portal',
|
||||
)
|
||||
|
||||
employee.sudo().write({'x_fclk_pending_reason': False})
|
||||
|
||||
return {'success': True, 'message': 'Reason submitted. You may now clock in.'}
|
||||
|
||||
@http.route('/fusion_clock/request_leave', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def request_leave(self, leave_date='', reason='', **kw):
|
||||
"""Submit a leave request from the portal."""
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found for current user.'}
|
||||
|
||||
if not leave_date or not reason:
|
||||
return {'error': 'Please provide both a date and a reason.'}
|
||||
|
||||
try:
|
||||
date_obj = fields.Date.from_string(leave_date)
|
||||
except Exception:
|
||||
return {'error': 'Invalid date format. Use YYYY-MM-DD.'}
|
||||
|
||||
existing = request.env['fusion.clock.leave.request'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('leave_date', '=', date_obj),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {'error': 'A leave request already exists for this date.'}
|
||||
|
||||
request.env['fusion.clock.leave.request'].sudo().create({
|
||||
'employee_id': employee.id,
|
||||
'leave_date': date_obj,
|
||||
'reason': reason,
|
||||
'created_from': 'portal',
|
||||
})
|
||||
|
||||
return {'success': True, 'message': f'Leave request for {leave_date} submitted.'}
|
||||
|
||||
@http.route('/fusion_clock/request_correction', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def request_correction(self, attendance_id=0, check_in='', check_out='', reason='', **kw):
|
||||
"""Submit a timesheet correction request from the portal."""
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found for current user.'}
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_correction_requests', 'True') != 'True':
|
||||
return {'error': 'Correction requests are not enabled.'}
|
||||
|
||||
if not attendance_id or not reason:
|
||||
return {'error': 'Please provide the attendance record and a reason.'}
|
||||
|
||||
attendance = request.env['hr.attendance'].sudo().browse(attendance_id)
|
||||
if not attendance.exists() or attendance.employee_id.id != employee.id:
|
||||
return {'error': 'Attendance record not found.'}
|
||||
|
||||
vals = {
|
||||
'employee_id': employee.id,
|
||||
'attendance_id': attendance_id,
|
||||
'reason': reason,
|
||||
}
|
||||
if check_in:
|
||||
try:
|
||||
vals['requested_check_in'] = fields.Datetime.from_string(check_in)
|
||||
except Exception:
|
||||
pass
|
||||
if check_out:
|
||||
try:
|
||||
vals['requested_check_out'] = fields.Datetime.from_string(check_out)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
request.env['fusion.clock.correction'].sudo().create(vals)
|
||||
return {'success': True, 'message': 'Correction request submitted for review.'}
|
||||
|
||||
@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.'}
|
||||
@@ -272,10 +504,11 @@ class FusionClockAPI(http.Controller):
|
||||
'is_checked_in': is_checked_in,
|
||||
'employee_name': employee.name,
|
||||
'enable_clock': employee.x_fclk_enable_clock,
|
||||
'pending_reason': employee.x_fclk_pending_reason,
|
||||
'ontime_streak': employee.x_fclk_ontime_streak,
|
||||
}
|
||||
|
||||
if is_checked_in:
|
||||
# Find the open attendance
|
||||
att = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '=', False),
|
||||
@@ -288,7 +521,6 @@ class FusionClockAPI(http.Controller):
|
||||
'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())
|
||||
)
|
||||
@@ -299,7 +531,6 @@ class FusionClockAPI(http.Controller):
|
||||
])
|
||||
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(
|
||||
@@ -312,7 +543,6 @@ class FusionClockAPI(http.Controller):
|
||||
])
|
||||
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),
|
||||
@@ -330,7 +560,6 @@ class FusionClockAPI(http.Controller):
|
||||
|
||||
@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.'}
|
||||
@@ -347,13 +576,13 @@ class FusionClockAPI(http.Controller):
|
||||
'longitude': loc.longitude,
|
||||
'radius': loc.radius,
|
||||
'is_default': loc.id == default_id,
|
||||
'require_photo': loc.require_photo,
|
||||
} 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',
|
||||
@@ -362,4 +591,82 @@ class FusionClockAPI(http.Controller):
|
||||
'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,
|
||||
'enable_photo': ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True',
|
||||
'enable_corrections': ICP.get_param('fusion_clock.enable_correction_requests', 'True') == 'True',
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/dashboard_data', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def dashboard_data(self, **kw):
|
||||
"""Return dashboard data for managers."""
|
||||
user = request.env.user
|
||||
is_manager = user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
is_team_lead = user.has_group('fusion_clock.group_fusion_clock_team_lead')
|
||||
|
||||
if not is_manager and not is_team_lead:
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
now = fields.Datetime.now()
|
||||
today = fields.Date.today()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
|
||||
Attendance = request.env['hr.attendance'].sudo()
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
|
||||
# Filter employees by access
|
||||
if is_manager:
|
||||
employees = Employee.search([('x_fclk_enable_clock', '=', True)])
|
||||
else:
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found.'}
|
||||
employees = Employee.search([
|
||||
('parent_id', '=', employee.id),
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
emp_ids = employees.ids
|
||||
|
||||
# Currently clocked in
|
||||
open_atts = Attendance.search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('check_out', '=', False),
|
||||
])
|
||||
clocked_in = [{
|
||||
'employee': a.employee_id.name,
|
||||
'check_in': fields.Datetime.to_string(a.check_in),
|
||||
'location': a.x_fclk_location_id.name or '',
|
||||
} for a in open_atts]
|
||||
|
||||
# Today stats
|
||||
today_atts = Attendance.search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
present_ids = set(a.employee_id.id for a in today_atts)
|
||||
|
||||
ActivityLog = request.env['fusion.clock.activity.log'].sudo()
|
||||
late_count = ActivityLog.search_count([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('log_type', '=', 'late_clock_in'),
|
||||
('log_date', '>=', today_start),
|
||||
])
|
||||
|
||||
# Pending alerts
|
||||
pending_reasons = Employee.search_count([
|
||||
('id', 'in', emp_ids),
|
||||
('x_fclk_pending_reason', '=', True),
|
||||
])
|
||||
pending_corrections = request.env['fusion.clock.correction'].sudo().search_count([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('state', '=', 'pending'),
|
||||
])
|
||||
|
||||
return {
|
||||
'clocked_in': clocked_in,
|
||||
'total_employees': len(emp_ids),
|
||||
'present_count': len(present_ids),
|
||||
'absent_count': len(emp_ids) - len(present_ids),
|
||||
'late_count': late_count,
|
||||
'pending_reasons': pending_reasons,
|
||||
'pending_corrections': pending_corrections,
|
||||
}
|
||||
|
||||
159
fusion_clock/controllers/clock_kiosk.py
Normal file
159
fusion_clock/controllers/clock_kiosk.py
Normal file
@@ -0,0 +1,159 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockKiosk(http.Controller):
|
||||
"""Kiosk mode controller for shared-device clock-in/out."""
|
||||
|
||||
@http.route('/fusion_clock/kiosk', type='http', auth='user', website=True)
|
||||
def kiosk_page(self, **kw):
|
||||
"""Kiosk clock-in/out page for shared tablets."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
return request.redirect('/my')
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_kiosk', 'False') != 'True':
|
||||
return request.redirect('/my')
|
||||
|
||||
values = {
|
||||
'pin_required': ICP.get_param('fusion_clock.kiosk_pin_required', 'True') == 'True',
|
||||
'page_name': 'kiosk',
|
||||
}
|
||||
return request.render('fusion_clock.kiosk_page', values)
|
||||
|
||||
@http.route('/fusion_clock/kiosk/search', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def kiosk_search(self, query='', **kw):
|
||||
"""Search employees for kiosk identification."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employees = request.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
('name', 'ilike', query),
|
||||
], limit=20)
|
||||
|
||||
return {
|
||||
'employees': [{
|
||||
'id': emp.id,
|
||||
'name': emp.name,
|
||||
'department': emp.department_id.name or '',
|
||||
'is_checked_in': emp.attendance_state == 'checked_in',
|
||||
} for emp in employees],
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/verify_pin', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def kiosk_verify_pin(self, employee_id=0, pin='', **kw):
|
||||
"""Verify employee PIN for kiosk mode."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||
if not employee.exists():
|
||||
return {'error': 'Employee not found.'}
|
||||
|
||||
if employee.x_fclk_kiosk_pin and employee.x_fclk_kiosk_pin != pin:
|
||||
return {'error': 'Invalid PIN.'}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'employee_name': employee.name,
|
||||
'is_checked_in': employee.attendance_state == 'checked_in',
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/clock', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def kiosk_clock(self, employee_id=0, latitude=0, longitude=0, **kw):
|
||||
"""Perform clock action from kiosk on behalf of an employee."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||
if not employee.exists() or not employee.x_fclk_enable_clock:
|
||||
return {'error': 'Employee not found or clock not enabled.'}
|
||||
|
||||
from .clock_api import FusionClockAPI, haversine_distance
|
||||
api = FusionClockAPI()
|
||||
|
||||
location, distance, err, method = api._verify_location(latitude, longitude, employee)
|
||||
if not location:
|
||||
return {
|
||||
'error': api._location_error_message(err, distance),
|
||||
'allowed': False,
|
||||
}
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
now = fields.Datetime.now()
|
||||
today = now.date()
|
||||
|
||||
geo_info = {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'browser': 'kiosk',
|
||||
'ip_address': request.httprequest.remote_addr or '',
|
||||
}
|
||||
|
||||
try:
|
||||
attendance = employee.sudo()._attendance_action_change(geo_info)
|
||||
|
||||
if not is_checked_in:
|
||||
attendance.sudo().write({
|
||||
'x_fclk_location_id': location.id,
|
||||
'x_fclk_in_distance': round(distance, 1),
|
||||
'x_fclk_clock_source': 'kiosk',
|
||||
})
|
||||
|
||||
api._log_activity(
|
||||
employee, 'clock_in',
|
||||
f"Kiosk clock-in at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source='kiosk',
|
||||
)
|
||||
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
'employee_name': employee.name,
|
||||
'message': f'{employee.name} clocked in at {location.name}',
|
||||
}
|
||||
else:
|
||||
attendance.sudo().write({
|
||||
'x_fclk_out_distance': round(distance, 1),
|
||||
})
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
|
||||
api._log_activity(
|
||||
employee, 'clock_out',
|
||||
f"Kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source='kiosk',
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_out',
|
||||
'employee_name': employee.name,
|
||||
'message': f'{employee.name} clocked out from {location.name}',
|
||||
'net_hours': round(attendance.x_fclk_net_hours or 0, 2),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock kiosk error: %s", str(e))
|
||||
return {'error': str(e)}
|
||||
@@ -22,7 +22,7 @@
|
||||
</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>
|
||||
<field name="value">4.0</field>
|
||||
</record>
|
||||
|
||||
<!-- Grace Period & Auto Clock-Out -->
|
||||
@@ -48,6 +48,80 @@
|
||||
<field name="key">fusion_clock.penalty_grace_minutes</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
<record id="config_penalty_deduction_minutes" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.penalty_deduction_minutes</field>
|
||||
<field name="value">15</field>
|
||||
</record>
|
||||
|
||||
<!-- Office User & Notifications -->
|
||||
<record id="config_office_user_id" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.office_user_id</field>
|
||||
<field name="value">0</field>
|
||||
</record>
|
||||
<record id="config_very_late_threshold" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.very_late_threshold_minutes</field>
|
||||
<field name="value">15</field>
|
||||
</record>
|
||||
<record id="config_max_monthly_absences" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.max_monthly_absences</field>
|
||||
<field name="value">3</field>
|
||||
</record>
|
||||
<record id="config_enable_employee_notifications" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_employee_notifications</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
<record id="config_reminder_before_shift" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.reminder_before_shift_minutes</field>
|
||||
<field name="value">30</field>
|
||||
</record>
|
||||
<record id="config_reminder_before_end" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.reminder_before_end_minutes</field>
|
||||
<field name="value">15</field>
|
||||
</record>
|
||||
<record id="config_send_weekly_summary" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.send_weekly_summary</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Overtime -->
|
||||
<record id="config_enable_overtime" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_overtime</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
<record id="config_daily_overtime_threshold" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.daily_overtime_threshold</field>
|
||||
<field name="value">8.0</field>
|
||||
</record>
|
||||
<record id="config_weekly_overtime_threshold" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.weekly_overtime_threshold</field>
|
||||
<field name="value">40.0</field>
|
||||
</record>
|
||||
|
||||
<!-- Location & Verification -->
|
||||
<record id="config_enable_ip_fallback" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_ip_fallback</field>
|
||||
<field name="value">False</field>
|
||||
</record>
|
||||
<record id="config_enable_photo_verification" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_photo_verification</field>
|
||||
<field name="value">False</field>
|
||||
</record>
|
||||
|
||||
<!-- Kiosk -->
|
||||
<record id="config_enable_kiosk" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_kiosk</field>
|
||||
<field name="value">False</field>
|
||||
</record>
|
||||
<record id="config_kiosk_pin_required" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.kiosk_pin_required</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Corrections -->
|
||||
<record id="config_enable_corrections" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_correction_requests</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Pay Period -->
|
||||
<record id="config_pay_period_type" model="ir.config_parameter">
|
||||
|
||||
@@ -25,4 +25,40 @@
|
||||
<field name="priority">60</field>
|
||||
</record>
|
||||
|
||||
<!-- Absence Check Cron: runs daily at midnight -->
|
||||
<record id="cron_check_absences" model="ir.cron">
|
||||
<field name="name">Fusion Clock: Daily Absence Check</field>
|
||||
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_fusion_check_absences()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
<field name="priority">55</field>
|
||||
</record>
|
||||
|
||||
<!-- Employee Reminder Cron: runs every 15 minutes -->
|
||||
<record id="cron_employee_reminders" model="ir.cron">
|
||||
<field name="name">Fusion Clock: Employee Reminders</field>
|
||||
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_fusion_employee_reminders()</field>
|
||||
<field name="interval_number">15</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active">True</field>
|
||||
<field name="priority">70</field>
|
||||
</record>
|
||||
|
||||
<!-- Weekly Summary Cron: runs daily (checks if Monday internally) -->
|
||||
<record id="cron_weekly_summary" model="ir.cron">
|
||||
<field name="name">Fusion Clock: Weekly Summary</field>
|
||||
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_fusion_weekly_summary()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
<field name="priority">80</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -82,4 +82,86 @@
|
||||
<field name="auto_delete" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Weekly Summary Email -->
|
||||
<record id="mail_template_weekly_summary" model="mail.template">
|
||||
<field name="name">Fusion Clock: Weekly Summary</field>
|
||||
<field name="model_id" ref="hr.model_hr_employee"/>
|
||||
<field name="subject">Your Weekly Attendance Summary</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</field>
|
||||
<field name="email_to">{{ object.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;">Weekly Summary</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p>Hello <strong>{{ object.name }}</strong>,</p>
|
||||
<p>Here is your attendance summary for the past week:</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>Total Hours</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('total_hours', 0) }}h</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Overtime</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('overtime_hours', 0) }}h</td>
|
||||
</tr>
|
||||
<tr style="background:#f8f9fa;">
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Penalties</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('penalty_count', 0) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Absences</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('absence_count', 0) }}</td>
|
||||
</tr>
|
||||
<tr style="background:#f8f9fa;">
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>On-Time Streak</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('streak', 0) }} days</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>Log in to <a href="/my/clock">your portal</a> to view details.</p>
|
||||
<p style="color:#6b7280;font-size:12px;">This is an automated message from Fusion Clock.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
]]></field>
|
||||
<field name="auto_delete" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Correction Request Notification -->
|
||||
<record id="mail_template_correction_request" model="mail.template">
|
||||
<field name="name">Fusion Clock: Correction Request</field>
|
||||
<field name="model_id" ref="fusion_clock.model_fusion_clock_correction"/>
|
||||
<field name="subject">Timesheet Correction Request: {{ object.employee_id.name }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</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;">Correction Request</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p><strong>{{ object.employee_id.name }}</strong> has submitted a timesheet correction request.</p>
|
||||
<p><strong>Reason:</strong> {{ object.reason }}</p>
|
||||
<p>Please review and approve/reject from the Fusion Clock backend.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
]]></field>
|
||||
<field name="auto_delete" eval="False"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -6,3 +6,7 @@ from . import hr_employee
|
||||
from . import clock_penalty
|
||||
from . import clock_report
|
||||
from . import res_config_settings
|
||||
from . import clock_activity_log
|
||||
from . import clock_leave_request
|
||||
from . import clock_shift
|
||||
from . import clock_correction
|
||||
|
||||
133
fusion_clock/models/clock_activity_log.py
Normal file
133
fusion_clock/models/clock_activity_log.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class FusionClockActivityLog(models.Model):
|
||||
_name = 'fusion.clock.activity.log'
|
||||
_description = 'Clock Activity Log'
|
||||
_order = 'log_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
log_type = fields.Selection(
|
||||
[
|
||||
('clock_in', 'Clock In'),
|
||||
('clock_out', 'Clock Out'),
|
||||
('late_clock_in', 'Late Clock-In'),
|
||||
('early_clock_out', 'Early Clock-Out'),
|
||||
('outside_geofence', 'Outside Geofence'),
|
||||
('auto_clock_out', 'Auto Clock-Out'),
|
||||
('missed_clock_out', 'Missed Clock-Out'),
|
||||
('absent', 'Absent'),
|
||||
('leave_request', 'Leave Request'),
|
||||
('reason_provided', 'Reason Provided'),
|
||||
('overtime', 'Overtime'),
|
||||
('correction_request', 'Correction Request'),
|
||||
('ip_fallback', 'IP Fallback Used'),
|
||||
('streak_milestone', 'Streak Milestone'),
|
||||
],
|
||||
string='Log Type',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
log_date = fields.Datetime(
|
||||
string='Date/Time',
|
||||
required=True,
|
||||
default=fields.Datetime.now,
|
||||
index=True,
|
||||
)
|
||||
description = fields.Text(string='Description')
|
||||
attendance_id = fields.Many2one(
|
||||
'hr.attendance',
|
||||
string='Attendance',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
)
|
||||
location_id = fields.Many2one(
|
||||
'fusion.clock.location',
|
||||
string='Location',
|
||||
ondelete='set null',
|
||||
)
|
||||
latitude = fields.Float(string='Latitude', digits=(10, 7))
|
||||
longitude = fields.Float(string='Longitude', digits=(10, 7))
|
||||
distance = fields.Float(
|
||||
string='Distance (m)',
|
||||
digits=(10, 2),
|
||||
help="Distance from location center in meters.",
|
||||
)
|
||||
source = fields.Selection(
|
||||
[
|
||||
('portal', 'Portal'),
|
||||
('portal_fab', 'Portal FAB'),
|
||||
('systray', 'Systray'),
|
||||
('backend_fab', 'Backend FAB'),
|
||||
('kiosk', 'Kiosk'),
|
||||
('system', 'System (Cron)'),
|
||||
],
|
||||
string='Source',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
)
|
||||
attempt_map_url = fields.Char(
|
||||
string='Map Preview',
|
||||
compute='_compute_attempt_map_url',
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
LOG_TYPE_LABELS = {
|
||||
'clock_in': 'Clock In',
|
||||
'clock_out': 'Clock Out',
|
||||
'late_clock_in': 'Late Clock-In',
|
||||
'early_clock_out': 'Early Clock-Out',
|
||||
'outside_geofence': 'Outside Geofence',
|
||||
'auto_clock_out': 'Auto Clock-Out',
|
||||
'missed_clock_out': 'Missed Clock-Out',
|
||||
'absent': 'Absent',
|
||||
'leave_request': 'Leave Request',
|
||||
'reason_provided': 'Reason Provided',
|
||||
'overtime': 'Overtime',
|
||||
'correction_request': 'Correction Request',
|
||||
'ip_fallback': 'IP Fallback Used',
|
||||
'streak_milestone': 'Streak Milestone',
|
||||
}
|
||||
|
||||
@api.depends('latitude', 'longitude')
|
||||
def _compute_attempt_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:
|
||||
lat, lng = rec.latitude, rec.longitude
|
||||
rec.attempt_map_url = (
|
||||
f"https://maps.googleapis.com/maps/api/staticmap?"
|
||||
f"center={lat},{lng}&zoom=16&size=600x250&maptype=roadmap"
|
||||
f"&markers=color:red%7C{lat},{lng}"
|
||||
f"&key={api_key}"
|
||||
)
|
||||
else:
|
||||
rec.attempt_map_url = False
|
||||
|
||||
@api.depends('employee_id', 'log_type', 'log_date')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
emp = rec.employee_id.name or ''
|
||||
ltype = self.LOG_TYPE_LABELS.get(rec.log_type, rec.log_type or '')
|
||||
date_str = rec.log_date.strftime('%Y-%m-%d %H:%M') if rec.log_date else ''
|
||||
rec.display_name = f"{emp} - {ltype} ({date_str})"
|
||||
165
fusion_clock/models/clock_correction.py
Normal file
165
fusion_clock/models/clock_correction.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockCorrection(models.Model):
|
||||
_name = 'fusion.clock.correction'
|
||||
_description = 'Timesheet Correction Request'
|
||||
_order = 'create_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
attendance_id = fields.Many2one(
|
||||
'hr.attendance',
|
||||
string='Attendance Record',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
original_check_in = fields.Datetime(
|
||||
string='Original Clock-In',
|
||||
related='attendance_id.check_in',
|
||||
)
|
||||
original_check_out = fields.Datetime(
|
||||
string='Original Clock-Out',
|
||||
related='attendance_id.check_out',
|
||||
)
|
||||
requested_check_in = fields.Datetime(
|
||||
string='Corrected Clock-In',
|
||||
)
|
||||
requested_check_out = fields.Datetime(
|
||||
string='Corrected Clock-Out',
|
||||
)
|
||||
reason = fields.Text(
|
||||
string='Reason for Correction',
|
||||
required=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('pending', 'Pending'),
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
],
|
||||
string='Status',
|
||||
default='pending',
|
||||
tracking=True,
|
||||
index=True,
|
||||
)
|
||||
reviewed_by = fields.Many2one(
|
||||
'res.users',
|
||||
string='Reviewed By',
|
||||
)
|
||||
reviewed_date = fields.Datetime(string='Reviewed Date')
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'attendance_id', 'state')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
emp = rec.employee_id.name or ''
|
||||
date_str = rec.attendance_id.check_in.strftime('%Y-%m-%d') if rec.attendance_id and rec.attendance_id.check_in else ''
|
||||
rec.display_name = f"{emp} - Correction ({date_str}) [{rec.state}]"
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
rec._notify_office_user()
|
||||
rec._create_activity_log('pending')
|
||||
return records
|
||||
|
||||
def action_approve(self):
|
||||
"""Approve the correction and update the attendance record."""
|
||||
self.ensure_one()
|
||||
if self.state != 'pending':
|
||||
raise UserError(_("Only pending corrections can be approved."))
|
||||
|
||||
vals = {}
|
||||
if self.requested_check_in:
|
||||
vals['check_in'] = self.requested_check_in
|
||||
if self.requested_check_out:
|
||||
vals['check_out'] = self.requested_check_out
|
||||
|
||||
if vals:
|
||||
self.attendance_id.sudo().write(vals)
|
||||
|
||||
self.write({
|
||||
'state': 'approved',
|
||||
'reviewed_by': self.env.uid,
|
||||
'reviewed_date': fields.Datetime.now(),
|
||||
})
|
||||
self._create_activity_log('approved')
|
||||
|
||||
def action_reject(self):
|
||||
"""Reject the correction request."""
|
||||
self.ensure_one()
|
||||
if self.state != 'pending':
|
||||
raise UserError(_("Only pending corrections can be rejected."))
|
||||
|
||||
self.write({
|
||||
'state': 'rejected',
|
||||
'reviewed_by': self.env.uid,
|
||||
'reviewed_date': fields.Datetime.now(),
|
||||
})
|
||||
self._create_activity_log('rejected')
|
||||
|
||||
def _notify_office_user(self):
|
||||
"""Schedule a mail.activity for the office user."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
if not office_user_id:
|
||||
return
|
||||
office_user = self.env['res.users'].sudo().browse(office_user_id)
|
||||
if not office_user.exists():
|
||||
return
|
||||
try:
|
||||
date_str = self.attendance_id.check_in.strftime('%Y-%m-%d') if self.attendance_id.check_in else 'unknown'
|
||||
self.env['mail.activity'].sudo().create({
|
||||
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||
'summary': f"Timesheet Correction: {self.employee_id.name} ({date_str})",
|
||||
'note': f"Reason: {self.reason}",
|
||||
'user_id': office_user.id,
|
||||
'res_model_id': self.env['ir.model']._get_id('fusion.clock.correction'),
|
||||
'res_id': self.id,
|
||||
'date_deadline': fields.Date.today(),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create correction activity: %s", e)
|
||||
|
||||
def _create_activity_log(self, action):
|
||||
"""Log the correction event."""
|
||||
try:
|
||||
desc = f"Correction {action} for attendance on "
|
||||
if self.attendance_id.check_in:
|
||||
desc += self.attendance_id.check_in.strftime('%Y-%m-%d')
|
||||
desc += f": {self.reason}"
|
||||
self.env['fusion.clock.activity.log'].sudo().create({
|
||||
'employee_id': self.employee_id.id,
|
||||
'log_type': 'correction_request',
|
||||
'description': desc,
|
||||
'attendance_id': self.attendance_id.id,
|
||||
'source': 'system',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create correction log: %s", e)
|
||||
113
fusion_clock/models/clock_leave_request.py
Normal file
113
fusion_clock/models/clock_leave_request.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockLeaveRequest(models.Model):
|
||||
_name = 'fusion.clock.leave.request'
|
||||
_description = 'Clock Leave Request'
|
||||
_order = 'leave_date desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
leave_date = fields.Date(
|
||||
string='Leave Date',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
reason = fields.Text(
|
||||
string='Reason',
|
||||
required=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('auto_approved', 'Auto-Approved'),
|
||||
('reviewed', 'Reviewed'),
|
||||
],
|
||||
string='Status',
|
||||
default='auto_approved',
|
||||
tracking=True,
|
||||
)
|
||||
created_from = fields.Selection(
|
||||
[
|
||||
('portal', 'Portal'),
|
||||
('backend', 'Backend'),
|
||||
],
|
||||
string='Created From',
|
||||
default='portal',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'leave_date')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
emp = rec.employee_id.name or ''
|
||||
date_str = str(rec.leave_date) if rec.leave_date else ''
|
||||
rec.display_name = f"{emp} - Leave ({date_str})"
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
rec._notify_office_user()
|
||||
rec._create_activity_log()
|
||||
return records
|
||||
|
||||
def _notify_office_user(self):
|
||||
"""Schedule a mail.activity for the office user."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
if not office_user_id:
|
||||
return
|
||||
office_user = self.env['res.users'].sudo().browse(office_user_id)
|
||||
if not office_user.exists():
|
||||
return
|
||||
try:
|
||||
self.env['mail.activity'].sudo().create({
|
||||
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||
'summary': f"Leave Request: {self.employee_id.name} on {self.leave_date}",
|
||||
'note': f"Reason: {self.reason}",
|
||||
'user_id': office_user.id,
|
||||
'res_model_id': self.env['ir.model']._get_id('fusion.clock.leave.request'),
|
||||
'res_id': self.id,
|
||||
'date_deadline': self.leave_date,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create leave activity: %s", e)
|
||||
|
||||
def _create_activity_log(self):
|
||||
"""Log the leave request in the activity log."""
|
||||
try:
|
||||
self.env['fusion.clock.activity.log'].sudo().create({
|
||||
'employee_id': self.employee_id.id,
|
||||
'log_type': 'leave_request',
|
||||
'description': f"Leave requested for {self.leave_date}: {self.reason}",
|
||||
'source': 'portal' if self.created_from == 'portal' else 'system',
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create leave activity log: %s", e)
|
||||
|
||||
def action_mark_reviewed(self):
|
||||
"""Mark the leave request as reviewed by the office user."""
|
||||
self.write({'state': 'reviewed'})
|
||||
@@ -2,6 +2,7 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
@@ -54,6 +55,19 @@ class FusionClockLocation(models.Model):
|
||||
default=lambda self: self.env.user.tz or 'UTC',
|
||||
)
|
||||
|
||||
# IP whitelist fallback
|
||||
ip_whitelist = fields.Text(
|
||||
string='IP Whitelist',
|
||||
help="One IP address or CIDR per line. Used as fallback when GPS is unavailable.",
|
||||
)
|
||||
|
||||
# Photo verification
|
||||
require_photo = fields.Boolean(
|
||||
string='Require Photo on Clock-In',
|
||||
default=False,
|
||||
help="If enabled, employees must take a selfie when clocking in at this location.",
|
||||
)
|
||||
|
||||
# Computed
|
||||
attendance_count = fields.Integer(
|
||||
string='Total Attendances',
|
||||
@@ -89,6 +103,28 @@ class FusionClockLocation(models.Model):
|
||||
('x_fclk_location_id', '=', rec.id),
|
||||
])
|
||||
|
||||
def check_ip_whitelist(self, client_ip):
|
||||
"""Check if a client IP matches this location's whitelist.
|
||||
Returns True if matched, False otherwise.
|
||||
"""
|
||||
if not self.ip_whitelist or not client_ip:
|
||||
return False
|
||||
try:
|
||||
client = ipaddress.ip_address(client_ip)
|
||||
for line in self.ip_whitelist.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
try:
|
||||
network = ipaddress.ip_network(line, strict=False)
|
||||
if client in network:
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
except ValueError:
|
||||
return False
|
||||
return False
|
||||
|
||||
def action_geocode_address(self):
|
||||
"""Geocode the address to get lat/lng using Google Geocoding API.
|
||||
Falls back to Nominatim (OpenStreetMap) if Google fails.
|
||||
@@ -97,7 +133,6 @@ class FusionClockLocation(models.Model):
|
||||
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:
|
||||
@@ -126,13 +161,12 @@ class FusionClockLocation(models.Model):
|
||||
},
|
||||
}
|
||||
elif data.get('status') == 'REQUEST_DENIED':
|
||||
_logger.warning("Google Geocoding API denied. Enable the Geocoding API in Google Cloud Console. Falling back to Nominatim.")
|
||||
_logger.warning("Google Geocoding API denied. Falling back to Nominatim.")
|
||||
else:
|
||||
_logger.warning("Google geocoding returned: %s. Trying Nominatim fallback.", data.get('status'))
|
||||
_logger.warning("Google geocoding returned: %s. Trying Nominatim.", data.get('status'))
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.warning("Google geocoding network error: %s. Trying Nominatim fallback.", e)
|
||||
_logger.warning("Google geocoding network error: %s. Trying Nominatim.", e)
|
||||
|
||||
# Fallback: Nominatim (OpenStreetMap) - free, no API key needed
|
||||
try:
|
||||
url = 'https://nominatim.openstreetmap.org/search'
|
||||
params = {
|
||||
@@ -165,7 +199,7 @@ class FusionClockLocation(models.Model):
|
||||
},
|
||||
}
|
||||
else:
|
||||
raise UserError(_("Could not geocode address. No results found. Try a more specific address."))
|
||||
raise UserError(_("Could not geocode address. No results found."))
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Network error during geocoding: %s") % str(e))
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ class FusionClockPenalty(models.Model):
|
||||
compute='_compute_difference',
|
||||
store=True,
|
||||
)
|
||||
penalty_minutes = fields.Float(
|
||||
string='Deducted (min)',
|
||||
default=0.0,
|
||||
help="Minutes deducted from worked hours as penalty.",
|
||||
)
|
||||
date = fields.Date(string='Date', required=True, index=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
|
||||
@@ -182,6 +182,84 @@ class FusionClockReport(models.Model):
|
||||
else:
|
||||
_logger.warning("Fusion Clock: Mail template not found for report %s", self.id)
|
||||
|
||||
def action_export_csv(self):
|
||||
"""Export the report data as a CSV file for payroll."""
|
||||
import csv
|
||||
import io
|
||||
|
||||
self.ensure_one()
|
||||
if not self.attendance_ids:
|
||||
self._collect_attendance_records()
|
||||
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
mapping_raw = ICP.get_param('fusion_clock.csv_column_mapping', '')
|
||||
import json as json_mod
|
||||
try:
|
||||
col_map = json_mod.loads(mapping_raw) if mapping_raw else {}
|
||||
except Exception:
|
||||
col_map = {}
|
||||
|
||||
default_cols = {
|
||||
'employee': 'Employee',
|
||||
'date': 'Date',
|
||||
'clock_in': 'Clock In',
|
||||
'clock_out': 'Clock Out',
|
||||
'worked_hours': 'Worked Hours',
|
||||
'net_hours': 'Net Hours',
|
||||
'break_min': 'Break (min)',
|
||||
'overtime': 'Overtime (h)',
|
||||
'penalties': 'Penalties',
|
||||
'location': 'Location',
|
||||
}
|
||||
for k in default_cols:
|
||||
if k in col_map:
|
||||
default_cols[k] = col_map[k]
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(list(default_cols.values()))
|
||||
|
||||
for att in self.attendance_ids.sorted(key=lambda a: a.check_in):
|
||||
date_str = att.check_in.strftime('%Y-%m-%d') if att.check_in else ''
|
||||
in_str = att.check_in.strftime('%H:%M') if att.check_in else ''
|
||||
out_str = att.check_out.strftime('%H:%M') if att.check_out else ''
|
||||
penalties = self.env['fusion.clock.penalty'].search_count([
|
||||
('attendance_id', '=', att.id),
|
||||
])
|
||||
writer.writerow([
|
||||
att.employee_id.name or '',
|
||||
date_str,
|
||||
in_str,
|
||||
out_str,
|
||||
round(att.worked_hours or 0, 2),
|
||||
round(att.x_fclk_net_hours or 0, 2),
|
||||
round(att.x_fclk_break_minutes or 0, 0),
|
||||
round(att.x_fclk_overtime_hours or 0, 2),
|
||||
penalties,
|
||||
att.x_fclk_location_id.name or '',
|
||||
])
|
||||
|
||||
csv_data = output.getvalue().encode('utf-8')
|
||||
output.close()
|
||||
|
||||
filename = f"clock_export_{self.date_start}_{self.date_end}"
|
||||
if self.employee_id:
|
||||
filename += f"_{self.employee_id.name.replace(' ', '_')}"
|
||||
filename += ".csv"
|
||||
|
||||
attachment = self.env['ir.attachment'].create({
|
||||
'name': filename,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(csv_data),
|
||||
'mimetype': 'text/csv',
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': f'/web/content/{attachment.id}/{filename}?download=true',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _cron_generate_period_reports(self):
|
||||
"""Cron: Generate reports when a pay period ends."""
|
||||
|
||||
63
fusion_clock/models/clock_shift.py
Normal file
63
fusion_clock/models/clock_shift.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class FusionClockShift(models.Model):
|
||||
_name = 'fusion.clock.shift'
|
||||
_description = 'Clock Shift Schedule'
|
||||
_order = 'sequence, name'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Shift Name',
|
||||
required=True,
|
||||
help="E.g. 'Morning Shift', 'Evening Shift'.",
|
||||
)
|
||||
start_time = fields.Float(
|
||||
string='Start Time',
|
||||
required=True,
|
||||
default=9.0,
|
||||
help="Shift start in 24h float (e.g. 7.0 = 7:00 AM).",
|
||||
)
|
||||
end_time = fields.Float(
|
||||
string='End Time',
|
||||
required=True,
|
||||
default=17.0,
|
||||
help="Shift end in 24h float (e.g. 15.0 = 3:00 PM).",
|
||||
)
|
||||
break_minutes = fields.Float(
|
||||
string='Break Duration (min)',
|
||||
default=30.0,
|
||||
help="Unpaid break duration in minutes for this shift.",
|
||||
)
|
||||
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)
|
||||
color = fields.Char(string='Color', default='#3B82F6')
|
||||
employee_ids = fields.One2many(
|
||||
'hr.employee',
|
||||
'x_fclk_shift_id',
|
||||
string='Assigned Employees',
|
||||
)
|
||||
employee_count = fields.Integer(
|
||||
string='Employees',
|
||||
compute='_compute_employee_count',
|
||||
)
|
||||
|
||||
def _compute_employee_count(self):
|
||||
for rec in self:
|
||||
rec.employee_count = len(rec.employee_ids)
|
||||
|
||||
@property
|
||||
def scheduled_hours(self):
|
||||
"""Return the scheduled work hours for this shift (excluding break)."""
|
||||
raw = self.end_time - self.start_time
|
||||
return max(raw - (self.break_minutes / 60.0), 0.0)
|
||||
@@ -21,12 +21,15 @@ class HrAttendance(models.Model):
|
||||
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',
|
||||
tracking=True,
|
||||
help="How this attendance was recorded.",
|
||||
)
|
||||
x_fclk_in_distance = fields.Float(
|
||||
@@ -42,12 +45,14 @@ class HrAttendance(models.Model):
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
default=0.0,
|
||||
tracking=True,
|
||||
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,
|
||||
tracking=True,
|
||||
help="Worked hours minus break deduction.",
|
||||
)
|
||||
x_fclk_penalty_ids = fields.One2many(
|
||||
@@ -66,6 +71,26 @@ class HrAttendance(models.Model):
|
||||
help="Whether the grace period was consumed before auto clock-out.",
|
||||
)
|
||||
|
||||
# Overtime
|
||||
x_fclk_overtime_hours = fields.Float(
|
||||
string='Overtime (h)',
|
||||
compute='_compute_overtime_hours',
|
||||
store=True,
|
||||
help="Hours beyond the scheduled shift for this day.",
|
||||
)
|
||||
x_fclk_is_overtime = fields.Boolean(
|
||||
string='Has Overtime',
|
||||
compute='_compute_overtime_hours',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# Photo verification
|
||||
x_fclk_checkin_photo = fields.Binary(
|
||||
string='Check-In Photo',
|
||||
attachment=True,
|
||||
help="Selfie captured at clock-in for verification.",
|
||||
)
|
||||
|
||||
@api.depends('worked_hours', 'x_fclk_break_minutes')
|
||||
def _compute_net_hours(self):
|
||||
for att in self:
|
||||
@@ -73,51 +98,61 @@ class HrAttendance(models.Model):
|
||||
raw = att.worked_hours or 0.0
|
||||
att.x_fclk_net_hours = max(raw - break_hours, 0.0)
|
||||
|
||||
@api.depends('x_fclk_net_hours')
|
||||
def _compute_overtime_hours(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
enable_ot = ICP.get_param('fusion_clock.enable_overtime', 'True') == 'True'
|
||||
daily_threshold = float(ICP.get_param('fusion_clock.daily_overtime_threshold', '8.0'))
|
||||
|
||||
for att in self:
|
||||
if not enable_ot or not att.check_out:
|
||||
att.x_fclk_overtime_hours = 0.0
|
||||
att.x_fclk_is_overtime = False
|
||||
continue
|
||||
|
||||
employee = att.employee_id
|
||||
scheduled_hours = employee._get_fclk_scheduled_hours() if employee else daily_threshold
|
||||
net = att.x_fclk_net_hours or 0.0
|
||||
|
||||
if net > scheduled_hours:
|
||||
att.x_fclk_overtime_hours = round(net - scheduled_hours, 2)
|
||||
att.x_fclk_is_overtime = True
|
||||
else:
|
||||
att.x_fclk_overtime_hours = 0.0
|
||||
att.x_fclk_is_overtime = False
|
||||
|
||||
@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.
|
||||
"""
|
||||
"""Cron job: auto clock-out employees after shift + grace period."""
|
||||
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'))
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
|
||||
now = fields.Datetime.now()
|
||||
|
||||
# Find all open attendances (no check_out)
|
||||
open_attendances = self.sudo().search([
|
||||
('check_out', '=', False),
|
||||
])
|
||||
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
|
||||
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)
|
||||
employee = att.employee_id
|
||||
_, scheduled_out = employee._get_fclk_scheduled_times(check_in.date())
|
||||
|
||||
# Also check max shift safety net
|
||||
deadline = scheduled_out + timedelta(minutes=grace_min)
|
||||
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({
|
||||
@@ -128,19 +163,42 @@ class HrAttendance(models.Model):
|
||||
})
|
||||
|
||||
# Apply break deduction
|
||||
employee = att.employee_id
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0'))
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.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',
|
||||
)
|
||||
|
||||
# Log to activity log
|
||||
ActivityLog.create({
|
||||
'employee_id': employee.id,
|
||||
'log_type': 'auto_clock_out',
|
||||
'description': f"Auto clocked out at {clock_out_time.strftime('%H:%M')}. "
|
||||
f"Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||
'attendance_id': att.id,
|
||||
'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False,
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
# Set pending reason
|
||||
employee.sudo().write({'x_fclk_pending_reason': True})
|
||||
|
||||
# Notify office user
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Auto Clock-Out: {employee.name}",
|
||||
f"{employee.name} was auto-clocked out at {clock_out_time.strftime('%H:%M')}. "
|
||||
f"Please review and correct if needed.",
|
||||
'hr.attendance',
|
||||
att.id,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Fusion Clock: Auto clocked out %s (attendance %s)",
|
||||
employee.name, att.id,
|
||||
@@ -150,3 +208,242 @@ class HrAttendance(models.Model):
|
||||
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
|
||||
att.id, str(e),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_check_absences(self):
|
||||
"""Cron job: check for absent employees (no attendance on workday)."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
max_absences = int(ICP.get_param('fusion_clock.max_monthly_absences', '3'))
|
||||
|
||||
yesterday = fields.Date.today() - timedelta(days=1)
|
||||
|
||||
# Skip weekends
|
||||
if yesterday.weekday() >= 5:
|
||||
return
|
||||
|
||||
# Skip public holidays
|
||||
holidays = self.env['resource.calendar.leaves'].sudo().search([
|
||||
('resource_id', '=', False),
|
||||
('date_from', '<=', datetime.combine(yesterday, datetime.max.time())),
|
||||
('date_to', '>=', datetime.combine(yesterday, datetime.min.time())),
|
||||
])
|
||||
if holidays:
|
||||
return
|
||||
|
||||
employees = self.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
|
||||
|
||||
for emp in employees:
|
||||
# Check for attendance yesterday
|
||||
att_count = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(yesterday, datetime.min.time())),
|
||||
('check_in', '<', datetime.combine(yesterday + timedelta(days=1), datetime.min.time())),
|
||||
])
|
||||
if att_count > 0:
|
||||
continue
|
||||
|
||||
# Check for approved leave
|
||||
leave = LeaveRequest.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('leave_date', '=', yesterday),
|
||||
], limit=1)
|
||||
if leave:
|
||||
continue
|
||||
|
||||
# Mark absent
|
||||
ActivityLog.create({
|
||||
'employee_id': emp.id,
|
||||
'log_type': 'absent',
|
||||
'log_date': datetime.combine(yesterday, datetime.min.time().replace(hour=9)),
|
||||
'description': f"No attendance recorded for {yesterday}",
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
emp.sudo().write({'x_fclk_pending_reason': True})
|
||||
|
||||
# Check monthly threshold
|
||||
month_start = yesterday.replace(day=1)
|
||||
absence_count = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', datetime.combine(month_start, datetime.min.time())),
|
||||
])
|
||||
|
||||
if absence_count >= max_absences:
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Excessive Absences: {emp.name}",
|
||||
f"{emp.name} has {absence_count} absences this month "
|
||||
f"(threshold: {max_absences}). Please review.",
|
||||
'hr.employee',
|
||||
emp.id,
|
||||
)
|
||||
|
||||
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_employee_reminders(self):
|
||||
"""Cron job: send clock-in/out reminders to employees."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_employee_notifications', 'True') != 'True':
|
||||
return
|
||||
|
||||
reminder_in_min = float(ICP.get_param('fusion_clock.reminder_before_shift_minutes', '30'))
|
||||
reminder_out_min = float(ICP.get_param('fusion_clock.reminder_before_end_minutes', '15'))
|
||||
|
||||
now = fields.Datetime.now()
|
||||
today = fields.Date.today()
|
||||
|
||||
# Skip weekends
|
||||
if today.weekday() >= 5:
|
||||
return
|
||||
|
||||
employees = self.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
for emp in employees:
|
||||
if emp.x_fclk_last_reminder_date == today:
|
||||
continue
|
||||
|
||||
scheduled_in, scheduled_out = emp._get_fclk_scheduled_times(today)
|
||||
is_checked_in = emp.attendance_state == 'checked_in'
|
||||
|
||||
# Missed clock-in reminder
|
||||
reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min)
|
||||
if not is_checked_in and now > reminder_deadline:
|
||||
has_attendance = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(today, datetime.min.time())),
|
||||
])
|
||||
if has_attendance == 0:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-In Reminder",
|
||||
f"Hi {emp.name}, you haven't clocked in yet today. "
|
||||
f"Your shift started at {scheduled_in.strftime('%I:%M %p')}.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
|
||||
# Clock-out reminder
|
||||
reminder_before_end = scheduled_out - timedelta(minutes=reminder_out_min)
|
||||
if is_checked_in and now > reminder_before_end and now < scheduled_out:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-Out Reminder",
|
||||
f"Hi {emp.name}, your shift ends at {scheduled_out.strftime('%I:%M %p')}. "
|
||||
f"Don't forget to clock out.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_weekly_summary(self):
|
||||
"""Cron job: send weekly summary email to employees (Monday 8 AM)."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.send_weekly_summary', 'True') != 'True':
|
||||
return
|
||||
|
||||
today = fields.Date.today()
|
||||
if today.weekday() != 0:
|
||||
return
|
||||
|
||||
week_start = today - timedelta(days=7)
|
||||
week_end = today - timedelta(days=1)
|
||||
|
||||
employees = self.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
template = self.env.ref('fusion_clock.mail_template_weekly_summary', raise_if_not_found=False)
|
||||
|
||||
for emp in employees:
|
||||
if not emp.work_email:
|
||||
continue
|
||||
|
||||
atts = self.sudo().search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(week_start, datetime.min.time())),
|
||||
('check_in', '<', datetime.combine(week_end + timedelta(days=1), datetime.min.time())),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
|
||||
total_net = sum(a.x_fclk_net_hours or 0 for a in atts)
|
||||
total_ot = sum(a.x_fclk_overtime_hours or 0 for a in atts)
|
||||
penalties = self.env['fusion.clock.penalty'].sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('date', '>=', week_start),
|
||||
('date', '<=', week_end),
|
||||
])
|
||||
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
absences = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', datetime.combine(week_start, datetime.min.time())),
|
||||
('log_date', '<', datetime.combine(week_end + timedelta(days=1), datetime.min.time())),
|
||||
])
|
||||
|
||||
if template:
|
||||
try:
|
||||
template.with_context(
|
||||
week_start=week_start,
|
||||
week_end=week_end,
|
||||
total_hours=round(total_net, 1),
|
||||
overtime_hours=round(total_ot, 1),
|
||||
penalty_count=penalties,
|
||||
absence_count=absences,
|
||||
streak=emp.x_fclk_ontime_streak,
|
||||
).send_mail(emp.id, force_send=False)
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to send weekly summary to %s: %s", emp.name, e)
|
||||
|
||||
@api.model
|
||||
def _fclk_notify_office(self, office_user_id, summary, note, res_model, res_id):
|
||||
"""Create a mail.activity for the office user."""
|
||||
if not office_user_id:
|
||||
return
|
||||
office_user = self.env['res.users'].sudo().browse(office_user_id)
|
||||
if not office_user.exists():
|
||||
return
|
||||
try:
|
||||
self.env['mail.activity'].sudo().create({
|
||||
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||
'summary': summary,
|
||||
'note': note,
|
||||
'user_id': office_user_id,
|
||||
'res_model_id': self.env['ir.model']._get_id(res_model),
|
||||
'res_id': res_id,
|
||||
'date_deadline': fields.Date.today(),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create office activity: %s", e)
|
||||
|
||||
@api.model
|
||||
def _fclk_send_employee_reminder(self, employee, subject, body):
|
||||
"""Send a notification to an employee via internal note."""
|
||||
try:
|
||||
if employee.user_id:
|
||||
employee.user_id.sudo().notify_info(
|
||||
message=body,
|
||||
title=subject,
|
||||
sticky=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if employee.work_email:
|
||||
mail_values = {
|
||||
'subject': f"Fusion Clock: {subject}",
|
||||
'body_html': f"<p>{body}</p>",
|
||||
'email_to': employee.work_email,
|
||||
'auto_delete': True,
|
||||
}
|
||||
self.env['mail.mail'].sudo().create(mail_values).send()
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to send reminder to %s: %s", employee.name, e)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
@@ -21,16 +22,167 @@ class HrEmployee(models.Model):
|
||||
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.",
|
||||
help="Override default break duration for this employee. 0 = use shift or company default.",
|
||||
)
|
||||
|
||||
# Shift scheduling
|
||||
x_fclk_shift_id = fields.Many2one(
|
||||
'fusion.clock.shift',
|
||||
string='Work Shift',
|
||||
help="Assigned shift schedule. Leave empty to use global defaults.",
|
||||
)
|
||||
|
||||
# Pending reason enforcement
|
||||
x_fclk_pending_reason = fields.Boolean(
|
||||
string='Pending Reason Required',
|
||||
default=False,
|
||||
help="If set, employee must explain a missed clock-out before clocking in again.",
|
||||
)
|
||||
|
||||
# Kiosk PIN
|
||||
x_fclk_kiosk_pin = fields.Char(
|
||||
string='Kiosk PIN',
|
||||
help="PIN code for kiosk clock-in/out identification.",
|
||||
groups="fusion_clock.group_fusion_clock_manager",
|
||||
)
|
||||
|
||||
# On-time streak
|
||||
x_fclk_ontime_streak = fields.Integer(
|
||||
string='On-Time Streak',
|
||||
default=0,
|
||||
help="Consecutive workdays clocked in on time.",
|
||||
)
|
||||
|
||||
# Absence tracking (computed)
|
||||
x_fclk_absences_this_month = fields.Integer(
|
||||
string='Absences This Month',
|
||||
compute='_compute_absence_counts',
|
||||
)
|
||||
x_fclk_absences_this_year = fields.Integer(
|
||||
string='Absences This Year',
|
||||
compute='_compute_absence_counts',
|
||||
)
|
||||
|
||||
# Overtime tracking (computed)
|
||||
x_fclk_overtime_this_week = fields.Float(
|
||||
string='Overtime This Week (h)',
|
||||
compute='_compute_overtime',
|
||||
)
|
||||
x_fclk_overtime_this_month = fields.Float(
|
||||
string='Overtime This Month (h)',
|
||||
compute='_compute_overtime',
|
||||
)
|
||||
|
||||
# Activity log relation
|
||||
x_fclk_activity_log_ids = fields.One2many(
|
||||
'fusion.clock.activity.log',
|
||||
'employee_id',
|
||||
string='Activity Logs',
|
||||
)
|
||||
|
||||
# Leave request relation
|
||||
x_fclk_leave_request_ids = fields.One2many(
|
||||
'fusion.clock.leave.request',
|
||||
'employee_id',
|
||||
string='Leave Requests',
|
||||
)
|
||||
|
||||
# Correction request relation
|
||||
x_fclk_correction_ids = fields.One2many(
|
||||
'fusion.clock.correction',
|
||||
'employee_id',
|
||||
string='Correction Requests',
|
||||
)
|
||||
|
||||
# Reminder tracking
|
||||
x_fclk_last_reminder_date = fields.Date(
|
||||
string='Last Reminder Date',
|
||||
help="Tracks the last date a reminder was sent to avoid duplicates.",
|
||||
)
|
||||
|
||||
def _get_fclk_break_minutes(self):
|
||||
"""Return effective break minutes for this employee."""
|
||||
"""Return effective break minutes for this employee.
|
||||
Priority: employee override > shift > global setting.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_break_minutes > 0:
|
||||
return self.x_fclk_break_minutes
|
||||
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
|
||||
return self.x_fclk_shift_id.break_minutes
|
||||
return float(
|
||||
self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_clock.default_break_minutes', '30'
|
||||
)
|
||||
)
|
||||
|
||||
def _get_fclk_scheduled_times(self, date):
|
||||
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
|
||||
Uses employee shift if assigned, otherwise global settings.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_shift_id:
|
||||
in_hour = self.x_fclk_shift_id.start_time
|
||||
out_hour = self.x_fclk_shift_id.end_time
|
||||
else:
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
|
||||
in_h = int(in_hour)
|
||||
in_m = int((in_hour - in_h) * 60)
|
||||
out_h = int(out_hour)
|
||||
out_m = int((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 _get_fclk_scheduled_hours(self):
|
||||
"""Return the expected work hours for this employee's shift."""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_shift_id:
|
||||
return self.x_fclk_shift_id.scheduled_hours
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
break_hrs = self._get_fclk_break_minutes() / 60.0
|
||||
return max((out_hour - in_hour) - break_hrs, 0.0)
|
||||
|
||||
def _compute_absence_counts(self):
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
today = fields.Date.today()
|
||||
month_start = today.replace(day=1)
|
||||
year_start = today.replace(month=1, day=1)
|
||||
|
||||
for emp in self:
|
||||
emp.x_fclk_absences_this_month = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', datetime.combine(month_start, datetime.min.time())),
|
||||
])
|
||||
emp.x_fclk_absences_this_year = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', datetime.combine(year_start, datetime.min.time())),
|
||||
])
|
||||
|
||||
def _compute_overtime(self):
|
||||
Attendance = self.env['hr.attendance'].sudo()
|
||||
today = fields.Date.today()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
for emp in self:
|
||||
week_atts = Attendance.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(week_start, datetime.min.time())),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
emp.x_fclk_overtime_this_week = sum(a.x_fclk_overtime_hours or 0 for a in week_atts)
|
||||
|
||||
month_atts = Attendance.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(month_start, datetime.min.time())),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
emp.x_fclk_overtime_this_month = sum(a.x_fclk_overtime_hours or 0 for a in month_atts)
|
||||
|
||||
@@ -38,7 +38,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
fclk_break_threshold_hours = fields.Float(
|
||||
string='Break Threshold (hours)',
|
||||
config_parameter='fusion_clock.break_threshold_hours',
|
||||
default=5.0,
|
||||
default=4.0,
|
||||
help="Only deduct break if shift is longer than this many hours.",
|
||||
)
|
||||
|
||||
@@ -73,6 +73,116 @@ class ResConfigSettings(models.TransientModel):
|
||||
default=5.0,
|
||||
help="Minutes of grace before a late/early penalty is recorded.",
|
||||
)
|
||||
fclk_penalty_deduction_minutes = fields.Float(
|
||||
string='Penalty Deduction (min)',
|
||||
config_parameter='fusion_clock.penalty_deduction_minutes',
|
||||
default=15.0,
|
||||
help="Minutes deducted from worked hours per penalty occurrence.",
|
||||
)
|
||||
|
||||
# -- Office User & Notifications --
|
||||
fclk_office_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Office User',
|
||||
help="User who receives activity notifications for attendance issues.",
|
||||
)
|
||||
fclk_very_late_threshold_minutes = fields.Float(
|
||||
string='Very Late Threshold (min)',
|
||||
config_parameter='fusion_clock.very_late_threshold_minutes',
|
||||
default=15.0,
|
||||
help="Minutes late before an activity is scheduled for the office user.",
|
||||
)
|
||||
fclk_max_monthly_absences = fields.Integer(
|
||||
string='Max Monthly Absences',
|
||||
config_parameter='fusion_clock.max_monthly_absences',
|
||||
default=3,
|
||||
help="Alert office user when an employee reaches this many absences in a month.",
|
||||
)
|
||||
fclk_enable_employee_notifications = fields.Boolean(
|
||||
string='Enable Employee Notifications',
|
||||
config_parameter='fusion_clock.enable_employee_notifications',
|
||||
default=True,
|
||||
help="Send clock-in/out reminders to employees.",
|
||||
)
|
||||
fclk_reminder_before_shift_minutes = fields.Float(
|
||||
string='Remind After Shift Start (min)',
|
||||
config_parameter='fusion_clock.reminder_before_shift_minutes',
|
||||
default=30.0,
|
||||
help="Send reminder if employee hasn't clocked in this many minutes after shift start.",
|
||||
)
|
||||
fclk_reminder_before_end_minutes = fields.Float(
|
||||
string='Remind Before Shift End (min)',
|
||||
config_parameter='fusion_clock.reminder_before_end_minutes',
|
||||
default=15.0,
|
||||
help="Send clock-out reminder this many minutes before shift end.",
|
||||
)
|
||||
fclk_send_weekly_summary = fields.Boolean(
|
||||
string='Send Weekly Summary',
|
||||
config_parameter='fusion_clock.send_weekly_summary',
|
||||
default=True,
|
||||
help="Send weekly attendance summary to each employee on Monday.",
|
||||
)
|
||||
|
||||
# -- Overtime --
|
||||
fclk_enable_overtime = fields.Boolean(
|
||||
string='Enable Overtime Tracking',
|
||||
config_parameter='fusion_clock.enable_overtime',
|
||||
default=True,
|
||||
)
|
||||
fclk_daily_overtime_threshold = fields.Float(
|
||||
string='Daily OT Threshold (hours)',
|
||||
config_parameter='fusion_clock.daily_overtime_threshold',
|
||||
default=8.0,
|
||||
help="Net hours beyond this threshold count as daily overtime.",
|
||||
)
|
||||
fclk_weekly_overtime_threshold = fields.Float(
|
||||
string='Weekly OT Threshold (hours)',
|
||||
config_parameter='fusion_clock.weekly_overtime_threshold',
|
||||
default=40.0,
|
||||
help="Net hours beyond this threshold count as weekly overtime.",
|
||||
)
|
||||
|
||||
# -- Location --
|
||||
fclk_enable_ip_fallback = fields.Boolean(
|
||||
string='Enable IP Fallback',
|
||||
config_parameter='fusion_clock.enable_ip_fallback',
|
||||
default=False,
|
||||
help="Allow IP-based location verification when GPS is unavailable.",
|
||||
)
|
||||
fclk_enable_photo_verification = fields.Boolean(
|
||||
string='Enable Photo Verification',
|
||||
config_parameter='fusion_clock.enable_photo_verification',
|
||||
default=False,
|
||||
help="Global toggle for selfie verification on clock-in (per-location control).",
|
||||
)
|
||||
|
||||
# -- Kiosk --
|
||||
fclk_enable_kiosk = fields.Boolean(
|
||||
string='Enable Kiosk Mode',
|
||||
config_parameter='fusion_clock.enable_kiosk',
|
||||
default=False,
|
||||
)
|
||||
fclk_kiosk_pin_required = fields.Boolean(
|
||||
string='Require PIN for Kiosk',
|
||||
config_parameter='fusion_clock.kiosk_pin_required',
|
||||
default=True,
|
||||
help="Require employees to enter a PIN when using kiosk mode.",
|
||||
)
|
||||
|
||||
# -- Corrections --
|
||||
fclk_enable_correction_requests = fields.Boolean(
|
||||
string='Enable Correction Requests',
|
||||
config_parameter='fusion_clock.enable_correction_requests',
|
||||
default=True,
|
||||
help="Allow employees to request timesheet corrections from the portal.",
|
||||
)
|
||||
|
||||
# -- CSV Export --
|
||||
fclk_csv_column_mapping = fields.Char(
|
||||
string='CSV Column Mapping',
|
||||
config_parameter='fusion_clock.csv_column_mapping',
|
||||
help="Custom column names for CSV export (JSON format). Leave blank for defaults.",
|
||||
)
|
||||
|
||||
# -- Pay Period --
|
||||
fclk_pay_period_type = fields.Selection(
|
||||
@@ -89,7 +199,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
fclk_pay_period_start = fields.Char(
|
||||
string='Pay Period Anchor Date',
|
||||
config_parameter='fusion_clock.pay_period_start',
|
||||
help="Start date for pay period calculations (YYYY-MM-DD format, anchor for weekly/biweekly).",
|
||||
help="Start date for pay period calculations (YYYY-MM-DD format).",
|
||||
)
|
||||
|
||||
# -- Reports --
|
||||
@@ -122,3 +232,20 @@ class ResConfigSettings(models.TransientModel):
|
||||
config_parameter='fusion_clock.enable_sounds',
|
||||
default=True,
|
||||
)
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if self.fclk_office_user_id:
|
||||
ICP.set_param('fusion_clock.office_user_id', str(self.fclk_office_user_id.id))
|
||||
else:
|
||||
ICP.set_param('fusion_clock.office_user_id', '0')
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
res = super().get_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
if office_user_id:
|
||||
res['fclk_office_user_id'] = office_user_id
|
||||
return res
|
||||
|
||||
@@ -5,8 +5,20 @@ access_fusion_clock_penalty_user,fusion.clock.penalty.user,model_fusion_clock_pe
|
||||
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_activity_log_user,fusion.clock.activity.log.user,model_fusion_clock_activity_log,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_activity_log_manager,fusion.clock.activity.log.manager,model_fusion_clock_activity_log,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_leave_request_user,fusion.clock.leave.request.user,model_fusion_clock_leave_request,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_leave_request_manager,fusion.clock.leave.request.manager,model_fusion_clock_leave_request,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_shift_user,fusion.clock.shift.user,model_fusion_clock_shift,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_shift_manager,fusion.clock.shift.manager,model_fusion_clock_shift,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_correction_user,fusion.clock.correction.user,model_fusion_clock_correction,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,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_fusion_clock_activity_log_portal,fusion.clock.activity.log.portal,model_fusion_clock_activity_log,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_leave_request_portal,fusion.clock.leave.request.portal,model_fusion_clock_leave_request,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusion_clock_correction,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
|
||||
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
|
||||
|
||||
|
@@ -8,20 +8,27 @@
|
||||
<field name="comment">Can clock in/out and view own attendance</field>
|
||||
</record>
|
||||
|
||||
<record id="group_fusion_clock_team_lead" model="res.groups">
|
||||
<field name="name">Fusion Clock / Team Lead</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
||||
<field name="comment">Can view direct reports attendance (read-only)</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="implied_ids" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
|
||||
<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>
|
||||
<function model="res.users" name="write">
|
||||
<value eval="[ref('base.user_admin')]"/>
|
||||
<value eval="{'group_ids': [(4, ref('group_fusion_clock_manager'))]}"/>
|
||||
</function>
|
||||
|
||||
<!-- Record Rules -->
|
||||
|
||||
<!-- Clock Location: Managers see all, Users see active ones for their company -->
|
||||
<!-- ================================================================
|
||||
Record Rules - Clock Location
|
||||
================================================================ -->
|
||||
<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"/>
|
||||
@@ -40,7 +47,9 @@
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Clock Penalty: Users see own, Managers see all -->
|
||||
<!-- ================================================================
|
||||
Record Rules - Clock Penalty
|
||||
================================================================ -->
|
||||
<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"/>
|
||||
@@ -52,6 +61,17 @@
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_clock_penalty_team_lead" model="ir.rule">
|
||||
<field name="name">Clock Penalty: Team Lead sees direct reports</field>
|
||||
<field name="model_id" ref="model_fusion_clock_penalty"/>
|
||||
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
|
||||
<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"/>
|
||||
@@ -59,7 +79,9 @@
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Clock Report: Users see own, Managers see all -->
|
||||
<!-- ================================================================
|
||||
Record Rules - Clock Report
|
||||
================================================================ -->
|
||||
<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"/>
|
||||
@@ -78,7 +100,115 @@
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Portal access for attendance records -->
|
||||
<!-- ================================================================
|
||||
Record Rules - Activity Log
|
||||
================================================================ -->
|
||||
<record id="rule_activity_log_user" model="ir.rule">
|
||||
<field name="name">Activity Log: User sees own logs</field>
|
||||
<field name="model_id" ref="model_fusion_clock_activity_log"/>
|
||||
<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_activity_log_team_lead" model="ir.rule">
|
||||
<field name="name">Activity Log: Team Lead sees direct reports</field>
|
||||
<field name="model_id" ref="model_fusion_clock_activity_log"/>
|
||||
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
|
||||
<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_activity_log_manager" model="ir.rule">
|
||||
<field name="name">Activity Log: Manager full access</field>
|
||||
<field name="model_id" ref="model_fusion_clock_activity_log"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Record Rules - Leave Request
|
||||
================================================================ -->
|
||||
<record id="rule_leave_request_user" model="ir.rule">
|
||||
<field name="name">Leave Request: User sees own</field>
|
||||
<field name="model_id" ref="model_fusion_clock_leave_request"/>
|
||||
<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_leave_request_manager" model="ir.rule">
|
||||
<field name="name">Leave Request: Manager full access</field>
|
||||
<field name="model_id" ref="model_fusion_clock_leave_request"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Record Rules - Shift
|
||||
================================================================ -->
|
||||
<record id="rule_shift_user" model="ir.rule">
|
||||
<field name="name">Shift: User reads active</field>
|
||||
<field name="model_id" ref="model_fusion_clock_shift"/>
|
||||
<field name="domain_force">[('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_shift_manager" model="ir.rule">
|
||||
<field name="name">Shift: Manager full access</field>
|
||||
<field name="model_id" ref="model_fusion_clock_shift"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Record Rules - Correction Request
|
||||
================================================================ -->
|
||||
<record id="rule_correction_user" model="ir.rule">
|
||||
<field name="name">Correction: User sees own</field>
|
||||
<field name="model_id" ref="model_fusion_clock_correction"/>
|
||||
<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_correction_team_lead" model="ir.rule">
|
||||
<field name="name">Correction: Team Lead sees direct reports</field>
|
||||
<field name="model_id" ref="model_fusion_clock_correction"/>
|
||||
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
|
||||
<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_correction_manager" model="ir.rule">
|
||||
<field name="name">Correction: Manager full access</field>
|
||||
<field name="model_id" ref="model_fusion_clock_correction"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Portal Access
|
||||
================================================================ -->
|
||||
<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"/>
|
||||
@@ -90,7 +220,6 @@
|
||||
<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"/>
|
||||
@@ -102,7 +231,6 @@
|
||||
<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"/>
|
||||
@@ -114,7 +242,6 @@
|
||||
<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"/>
|
||||
@@ -126,4 +253,37 @@
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_activity_log_portal" model="ir.rule">
|
||||
<field name="name">Activity Log: Portal user sees own</field>
|
||||
<field name="model_id" ref="model_fusion_clock_activity_log"/>
|
||||
<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>
|
||||
|
||||
<record id="rule_leave_request_portal" model="ir.rule">
|
||||
<field name="name">Leave Request: Portal user sees own</field>
|
||||
<field name="model_id" ref="model_fusion_clock_leave_request"/>
|
||||
<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>
|
||||
|
||||
<record id="rule_correction_portal" model="ir.rule">
|
||||
<field name="name">Correction: Portal user sees own</field>
|
||||
<field name="model_id" ref="model_fusion_clock_correction"/>
|
||||
<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>
|
||||
|
||||
@@ -357,6 +357,52 @@ html.o_dark .fclk-app,
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---- Request Leave Button ---- */
|
||||
.fclk-leave-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
background: var(--fclk-card);
|
||||
border: 1px solid var(--fclk-card-border);
|
||||
border-radius: 14px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 28px;
|
||||
color: var(--fclk-text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--fclk-shadow);
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.fclk-leave-btn svg:first-child {
|
||||
color: var(--fclk-green);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fclk-leave-btn-arrow {
|
||||
margin-left: auto;
|
||||
color: var(--fclk-text-dim);
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fclk-leave-btn:hover {
|
||||
background: var(--fclk-hover-bg);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.fclk-leave-btn:hover .fclk-leave-btn-arrow {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.fclk-leave-btn:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
/* ---- Recent Activity ---- */
|
||||
.fclk-recent-section {
|
||||
margin-bottom: 24px;
|
||||
@@ -486,7 +532,7 @@ html.o_dark .fclk-app,
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ---- Modal ---- */
|
||||
/* ---- Legacy Modal (location picker still uses this) ---- */
|
||||
.fclk-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -533,6 +579,300 @@ html.o_dark .fclk-app,
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Wizard Dialogs - Professional modals for reasons, confirmations
|
||||
Theme-aware, works in both light and dark mode
|
||||
============================================================ */
|
||||
|
||||
/* Standalone fallbacks for wizard modals rendered outside .fclk-app */
|
||||
.fclk-wizard-overlay {
|
||||
--fclk-card: var(--fclk-card, #ffffff);
|
||||
--fclk-card-border: var(--fclk-card-border, #e5e7eb);
|
||||
--fclk-bg: var(--fclk-bg, #f3f4f6);
|
||||
--fclk-text: var(--fclk-text, #1f2937);
|
||||
--fclk-text-muted: var(--fclk-text-muted, #6b7280);
|
||||
--fclk-text-dim: var(--fclk-text-dim, #9ca3af);
|
||||
--fclk-green: var(--fclk-green, #10B981);
|
||||
--fclk-green-glow: var(--fclk-green-glow, rgba(16, 185, 129, 0.25));
|
||||
--fclk-hover-bg: var(--fclk-hover-bg, #f9fafb);
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fclk-wizard-overlay {
|
||||
--fclk-card: #1a1d23;
|
||||
--fclk-card-border: #2a2d35;
|
||||
--fclk-bg: #0f1117;
|
||||
--fclk-text: #ffffff;
|
||||
--fclk-text-muted: #9ca3af;
|
||||
--fclk-text-dim: #6b7280;
|
||||
--fclk-green-glow: rgba(16, 185, 129, 0.3);
|
||||
--fclk-hover-bg: #1e2128;
|
||||
}
|
||||
}
|
||||
|
||||
html.o_dark .fclk-wizard-overlay {
|
||||
--fclk-card: #1a1d23;
|
||||
--fclk-card-border: #2a2d35;
|
||||
--fclk-bg: #0f1117;
|
||||
--fclk-text: #ffffff;
|
||||
--fclk-text-muted: #9ca3af;
|
||||
--fclk-text-dim: #6b7280;
|
||||
--fclk-green-glow: rgba(16, 185, 129, 0.3);
|
||||
--fclk-hover-bg: #1e2128;
|
||||
}
|
||||
|
||||
.fclk-wizard-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.fclk-wizard-dialog {
|
||||
position: relative;
|
||||
background: var(--fclk-card);
|
||||
border: 1px solid var(--fclk-card-border);
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
animation: fclk-wizard-enter 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.fclk-wizard-dialog--compact {
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
@keyframes fclk-wizard-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-wizard-header {
|
||||
padding: 28px 24px 20px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--fclk-card-border);
|
||||
}
|
||||
|
||||
.fclk-wizard-header-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.fclk-wizard-header--warning .fclk-wizard-header-icon {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.fclk-wizard-header--danger .fclk-wizard-header-icon {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.fclk-wizard-header--info .fclk-wizard-header-icon {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.fclk-wizard-title {
|
||||
color: var(--fclk-text);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 6px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.fclk-wizard-subtitle {
|
||||
color: var(--fclk-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fclk-wizard-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.fclk-wizard-field {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.fclk-wizard-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fclk-wizard-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fclk-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fclk-wizard-label svg {
|
||||
color: var(--fclk-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fclk-wizard-required {
|
||||
color: #ef4444;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fclk-wizard-input {
|
||||
width: 100%;
|
||||
background: var(--fclk-bg);
|
||||
border: 1.5px solid var(--fclk-card-border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--fclk-text);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.fclk-wizard-input:focus {
|
||||
border-color: var(--fclk-green);
|
||||
box-shadow: 0 0 0 3px var(--fclk-green-glow);
|
||||
}
|
||||
|
||||
.fclk-wizard-input::placeholder {
|
||||
color: var(--fclk-text-dim);
|
||||
}
|
||||
|
||||
.fclk-wizard-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.fclk-wizard-hint {
|
||||
display: block;
|
||||
color: var(--fclk-text-dim);
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.fclk-wizard-footer {
|
||||
padding: 16px 24px 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--fclk-card-border);
|
||||
}
|
||||
|
||||
.fclk-wizard-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.fclk-wizard-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fclk-wizard-btn--primary {
|
||||
background: linear-gradient(135deg, #10B981, #059669);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.fclk-wizard-btn--primary:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.fclk-wizard-btn--danger {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.fclk-wizard-btn--danger:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.fclk-wizard-btn--secondary {
|
||||
background: var(--fclk-bg);
|
||||
color: var(--fclk-text-muted);
|
||||
border: 1px solid var(--fclk-card-border);
|
||||
}
|
||||
|
||||
.fclk-wizard-btn--secondary:hover:not(:disabled) {
|
||||
background: var(--fclk-hover-bg);
|
||||
color: var(--fclk-text);
|
||||
}
|
||||
|
||||
/* Clock-out confirmation summary card */
|
||||
.fclk-clockout-summary {
|
||||
background: var(--fclk-bg);
|
||||
border: 1px solid var(--fclk-card-border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.fclk-clockout-summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.fclk-clockout-summary-row + .fclk-clockout-summary-row {
|
||||
border-top: 1px solid var(--fclk-card-border);
|
||||
}
|
||||
|
||||
.fclk-clockout-summary-label {
|
||||
color: var(--fclk-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fclk-clockout-summary-value {
|
||||
color: var(--fclk-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fclk-modal-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
67
fusion_clock/static/src/js/fusion_clock_dashboard.js
Normal file
67
fusion_clock/static/src/js/fusion_clock_dashboard.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, onWillStart } 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 FusionClockDashboard extends Component {
|
||||
static template = "fusion_clock.Dashboard";
|
||||
static props = { "*": true };
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
clocked_in: [],
|
||||
total_employees: 0,
|
||||
present_count: 0,
|
||||
absent_count: 0,
|
||||
late_count: 0,
|
||||
pending_reasons: 0,
|
||||
pending_corrections: 0,
|
||||
error: "",
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
await this._fetchData();
|
||||
});
|
||||
}
|
||||
|
||||
async _fetchData() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const data = await rpc("/fusion_clock/dashboard_data", {});
|
||||
if (data.error) {
|
||||
this.state.error = data.error;
|
||||
} else {
|
||||
Object.assign(this.state, data);
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.error = "Failed to load dashboard data.";
|
||||
}
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
async onRefresh() {
|
||||
await this._fetchData();
|
||||
}
|
||||
|
||||
onViewAttendances() {
|
||||
this.action.doAction("hr_attendance.hr_attendance_action");
|
||||
}
|
||||
|
||||
onViewCorrections() {
|
||||
this.action.doAction("fusion_clock.action_fusion_clock_correction");
|
||||
}
|
||||
|
||||
onViewActivityLogs() {
|
||||
this.action.doAction("fusion_clock.action_fusion_clock_activity_log");
|
||||
}
|
||||
|
||||
onViewPenalties() {
|
||||
this.action.doAction("fusion_clock.action_fusion_clock_penalty");
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fusion_clock.Dashboard", FusionClockDashboard);
|
||||
228
fusion_clock/static/src/js/fusion_clock_kiosk.js
Normal file
228
fusion_clock/static/src/js/fusion_clock_kiosk.js
Normal file
@@ -0,0 +1,228 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class FusionClockKiosk extends Interaction {
|
||||
static selector = "#fclk-kiosk";
|
||||
|
||||
setup() {
|
||||
this.selectedEmployeeId = 0;
|
||||
this.resetTimer = null;
|
||||
this.searchTimeout = null;
|
||||
|
||||
const pinAttr = this.el.dataset.pinRequired;
|
||||
this.pinRequired = pinAttr === "true" || pinAttr === "True";
|
||||
|
||||
this._startClock();
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
_startClock() {
|
||||
const el = document.getElementById("fclk-kiosk-time");
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
el.textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
};
|
||||
update();
|
||||
setInterval(update, 1000);
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
const queryInput = document.getElementById("fclk-kiosk-query");
|
||||
if (queryInput) {
|
||||
queryInput.addEventListener("input", (e) => this._onSearch(e.target.value));
|
||||
}
|
||||
|
||||
const backBtn = document.getElementById("fclk-kiosk-back-btn");
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener("click", () => this._resetKiosk());
|
||||
}
|
||||
|
||||
const clockBtn = document.getElementById("fclk-kiosk-clock-btn");
|
||||
if (clockBtn) {
|
||||
clockBtn.addEventListener("click", () => this._onClock());
|
||||
}
|
||||
}
|
||||
|
||||
_resetKiosk() {
|
||||
const search = document.getElementById("fclk-kiosk-search");
|
||||
const pin = document.getElementById("fclk-kiosk-pin");
|
||||
const result = document.getElementById("fclk-kiosk-result");
|
||||
const error = document.getElementById("fclk-kiosk-error");
|
||||
const query = document.getElementById("fclk-kiosk-query");
|
||||
const results = document.getElementById("fclk-kiosk-results");
|
||||
const pinInput = document.getElementById("fclk-kiosk-pin-input");
|
||||
|
||||
if (search) search.style.display = "";
|
||||
if (pin) pin.style.display = "none";
|
||||
if (result) result.style.display = "none";
|
||||
if (error) error.style.display = "none";
|
||||
if (query) query.value = "";
|
||||
if (results) results.innerHTML = "";
|
||||
if (pinInput) pinInput.value = "";
|
||||
|
||||
this.selectedEmployeeId = 0;
|
||||
if (this.resetTimer) clearTimeout(this.resetTimer);
|
||||
}
|
||||
|
||||
_showError(msg) {
|
||||
const el = document.getElementById("fclk-kiosk-error");
|
||||
if (el) {
|
||||
el.textContent = msg;
|
||||
el.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
_onSearch(value) {
|
||||
if (this.searchTimeout) clearTimeout(this.searchTimeout);
|
||||
const q = value.trim();
|
||||
if (q.length < 2) {
|
||||
const container = document.getElementById("fclk-kiosk-results");
|
||||
if (container) container.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
this.searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const resp = await fetch("/fusion_clock/kiosk/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { query: q } }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
const employees = (data.result || {}).employees || [];
|
||||
const container = document.getElementById("fclk-kiosk-results");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
for (const emp of employees) {
|
||||
const item = document.createElement("a");
|
||||
item.href = "#";
|
||||
item.className = "list-group-item list-group-item-action d-flex justify-content-between";
|
||||
const statusBadge = emp.is_checked_in ? "bg-success" : "bg-secondary";
|
||||
const statusText = emp.is_checked_in ? "In" : "Out";
|
||||
item.innerHTML =
|
||||
`<span>${emp.name} <small class="text-muted">${emp.department}</small></span>` +
|
||||
`<span class="badge ${statusBadge}">${statusText}</span>`;
|
||||
item.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
this._selectEmployee(emp);
|
||||
});
|
||||
container.appendChild(item);
|
||||
}
|
||||
} catch {
|
||||
this._showError("Search failed.");
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
_selectEmployee(emp) {
|
||||
this.selectedEmployeeId = emp.id;
|
||||
const nameEl = document.getElementById("fclk-kiosk-emp-name");
|
||||
if (nameEl) nameEl.textContent = emp.name;
|
||||
|
||||
const searchEl = document.getElementById("fclk-kiosk-search");
|
||||
const pinEl = document.getElementById("fclk-kiosk-pin");
|
||||
const errorEl = document.getElementById("fclk-kiosk-error");
|
||||
if (searchEl) searchEl.style.display = "none";
|
||||
if (pinEl) pinEl.style.display = "";
|
||||
if (errorEl) errorEl.style.display = "none";
|
||||
|
||||
const clockBtn = document.getElementById("fclk-kiosk-clock-btn");
|
||||
if (clockBtn) {
|
||||
clockBtn.textContent = emp.is_checked_in ? "Clock Out" : "Clock In";
|
||||
clockBtn.className = "btn btn-lg " + (emp.is_checked_in ? "btn-danger" : "btn-success");
|
||||
}
|
||||
}
|
||||
|
||||
async _onClock() {
|
||||
if (!this.selectedEmployeeId) return;
|
||||
|
||||
const btn = document.getElementById("fclk-kiosk-clock-btn");
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
const pinInput = document.getElementById("fclk-kiosk-pin-input");
|
||||
const pin = pinInput ? pinInput.value : "";
|
||||
|
||||
if (this.pinRequired && pin.length === 0) {
|
||||
this._showError("Please enter your PIN.");
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.pinRequired) {
|
||||
const vResp = await fetch("/fusion_clock/kiosk/verify_pin", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "call",
|
||||
params: { employee_id: this.selectedEmployeeId, pin },
|
||||
}),
|
||||
});
|
||||
const vData = await vResp.json();
|
||||
if (vData.result && vData.result.error) {
|
||||
this._showError(vData.result.error);
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let lat = 0;
|
||||
let lng = 0;
|
||||
try {
|
||||
const pos = await new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
timeout: 10000,
|
||||
enableHighAccuracy: true,
|
||||
});
|
||||
});
|
||||
lat = pos.coords.latitude;
|
||||
lng = pos.coords.longitude;
|
||||
} catch {
|
||||
// GPS unavailable on kiosk device
|
||||
}
|
||||
|
||||
const resp = await fetch("/fusion_clock/kiosk/clock", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "call",
|
||||
params: { employee_id: this.selectedEmployeeId, latitude: lat, longitude: lng },
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
const result = data.result || {};
|
||||
|
||||
if (result.error) {
|
||||
this._showError(result.error);
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const pinEl = document.getElementById("fclk-kiosk-pin");
|
||||
const resultEl = document.getElementById("fclk-kiosk-result");
|
||||
if (pinEl) pinEl.style.display = "none";
|
||||
if (resultEl) resultEl.style.display = "";
|
||||
|
||||
const msgEl = document.getElementById("fclk-kiosk-result-msg");
|
||||
if (msgEl) {
|
||||
const icon = result.action === "clock_in" ? "fa-check-circle text-success" : "fa-hand-paper-o text-warning";
|
||||
let html = `<div style="font-size:3rem"><i class="fa ${icon}"></i></div>`;
|
||||
html += `<div class="mt-2">${result.message || "Done"}</div>`;
|
||||
if (result.net_hours !== undefined) {
|
||||
html += `<div class="text-muted mt-1">Net hours: ${result.net_hours}h</div>`;
|
||||
}
|
||||
msgEl.innerHTML = html;
|
||||
}
|
||||
|
||||
this.resetTimer = setTimeout(() => this._resetKiosk(), 10000);
|
||||
} catch {
|
||||
this._showError("Operation failed.");
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("fusion_clock.kiosk", FusionClockKiosk);
|
||||
247
fusion_clock/static/src/js/fusion_clock_location_map.js
Normal file
247
fusion_clock/static/src/js/fusion_clock_location_map.js
Normal file
@@ -0,0 +1,247 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
export class FusionClockLocationMap extends Component {
|
||||
static template = "fusion_clock.LocationMap";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.mapRef = useRef("mapContainer");
|
||||
this.map = null;
|
||||
this.marker = null;
|
||||
this.circle = null;
|
||||
this._suppress = false;
|
||||
this._interval = null;
|
||||
this._AdvancedMarkerElement = null;
|
||||
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
error: "",
|
||||
mapVisible: false,
|
||||
});
|
||||
|
||||
onMounted(() => this._init());
|
||||
onWillUnmount(() => this._cleanup());
|
||||
}
|
||||
|
||||
get lat() { return this.props.record.data.latitude || 0; }
|
||||
get lng() { return this.props.record.data.longitude || 0; }
|
||||
get radius() { return this.props.record.data.radius || 100; }
|
||||
get color() { return this.props.record.data.color || "#10B981"; }
|
||||
get hasCoords() { return this.lat !== 0 || this.lng !== 0; }
|
||||
|
||||
async _init() {
|
||||
const apiKey = await this._getApiKey();
|
||||
if (!apiKey) {
|
||||
this.state.loading = false;
|
||||
this.state.error = "Google Maps API key not configured. Set it in Fusion Clock Settings.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this._loadScript(apiKey);
|
||||
} catch {
|
||||
this.state.loading = false;
|
||||
this.state.error = "Failed to load Google Maps API.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
|
||||
this._AdvancedMarkerElement = AdvancedMarkerElement;
|
||||
} catch {
|
||||
this.state.loading = false;
|
||||
this.state.error = "Failed to load marker library.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.loading = false;
|
||||
|
||||
if (!this.hasCoords) {
|
||||
this._startWatcher();
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.mapVisible = true;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
this._buildMap();
|
||||
}
|
||||
|
||||
_buildMap() {
|
||||
const el = this.mapRef.el;
|
||||
if (!el || !window.google || !this._AdvancedMarkerElement) return;
|
||||
|
||||
const center = { lat: this.lat, lng: this.lng };
|
||||
|
||||
this.map = new google.maps.Map(el, {
|
||||
center,
|
||||
zoom: 17,
|
||||
mapId: "DEMO_MAP_ID",
|
||||
mapTypeControl: true,
|
||||
mapTypeControlOptions: {
|
||||
style: google.maps.MapTypeControlStyle.DROPDOWN_MENU,
|
||||
position: google.maps.ControlPosition.TOP_RIGHT,
|
||||
mapTypeIds: ["roadmap", "satellite", "hybrid"],
|
||||
},
|
||||
streetViewControl: false,
|
||||
fullscreenControl: true,
|
||||
zoomControl: true,
|
||||
gestureHandling: "greedy",
|
||||
});
|
||||
|
||||
this._placeMarker(center);
|
||||
this._drawCircle(center);
|
||||
|
||||
if (!this.props.readonly) {
|
||||
this.map.addListener("click", (e) => {
|
||||
const pos = { lat: e.latLng.lat(), lng: e.latLng.lng() };
|
||||
this._placeMarker(pos);
|
||||
this._drawCircle(pos);
|
||||
this._suppress = true;
|
||||
this._saveCoords(pos.lat, pos.lng);
|
||||
});
|
||||
}
|
||||
|
||||
this._startWatcher();
|
||||
}
|
||||
|
||||
_placeMarker(pos) {
|
||||
if (this.marker) {
|
||||
this.marker.position = pos;
|
||||
return;
|
||||
}
|
||||
|
||||
this.marker = new this._AdvancedMarkerElement({
|
||||
map: this.map,
|
||||
position: pos,
|
||||
gmpDraggable: !this.props.readonly,
|
||||
title: "Drag to fine-tune location",
|
||||
});
|
||||
|
||||
if (!this.props.readonly) {
|
||||
this.marker.addListener("dragend", () => {
|
||||
const p = this.marker.position;
|
||||
const newPos = { lat: p.lat, lng: p.lng };
|
||||
this._drawCircle(newPos);
|
||||
this._suppress = true;
|
||||
this._saveCoords(newPos.lat, newPos.lng);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_drawCircle(center) {
|
||||
if (this.circle) {
|
||||
this.circle.setCenter(center);
|
||||
this.circle.setRadius(this.radius);
|
||||
this.circle.setOptions({ fillColor: this.color, strokeColor: this.color });
|
||||
} else {
|
||||
this.circle = new google.maps.Circle({
|
||||
map: this.map,
|
||||
center,
|
||||
radius: this.radius,
|
||||
fillColor: this.color,
|
||||
fillOpacity: 0.15,
|
||||
strokeColor: this.color,
|
||||
strokeOpacity: 0.6,
|
||||
strokeWeight: 2,
|
||||
clickable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async _saveCoords(lat, lng) {
|
||||
if (this.props.readonly) return;
|
||||
await this.props.record.update({
|
||||
latitude: Math.round(lat * 10000000) / 10000000,
|
||||
longitude: Math.round(lng * 10000000) / 10000000,
|
||||
});
|
||||
}
|
||||
|
||||
_startWatcher() {
|
||||
if (this._interval) return;
|
||||
this._lastLat = this.lat;
|
||||
this._lastLng = this.lng;
|
||||
this._lastRadius = this.radius;
|
||||
|
||||
this._interval = setInterval(() => {
|
||||
const lat = this.lat;
|
||||
const lng = this.lng;
|
||||
const r = this.radius;
|
||||
|
||||
const moved = Math.abs(this._lastLat - lat) > 0.0000001
|
||||
|| Math.abs(this._lastLng - lng) > 0.0000001;
|
||||
const resized = Math.abs(this._lastRadius - r) > 0.5;
|
||||
|
||||
if (moved && this.map) {
|
||||
this._lastLat = lat;
|
||||
this._lastLng = lng;
|
||||
if (this._suppress) { this._suppress = false; return; }
|
||||
const pos = { lat, lng };
|
||||
this._placeMarker(pos);
|
||||
this._drawCircle(pos);
|
||||
this.map.panTo(pos);
|
||||
}
|
||||
|
||||
if (resized && this.circle) {
|
||||
this._lastRadius = r;
|
||||
this.circle.setRadius(r);
|
||||
}
|
||||
|
||||
if (!this.map && this.hasCoords && !this.state.error && this._AdvancedMarkerElement) {
|
||||
this.state.mapVisible = true;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this._buildMap();
|
||||
});
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async _getApiKey() {
|
||||
try {
|
||||
return await rpc("/web/dataset/call_kw", {
|
||||
model: "ir.config_parameter",
|
||||
method: "get_param",
|
||||
args: ["fusion_clock.google_maps_api_key", ""],
|
||||
kwargs: {},
|
||||
}) || "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
async _loadScript(apiKey) {
|
||||
if (window.google && window.google.maps) return;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector('script[src*="maps.googleapis.com"]')) {
|
||||
const t = setInterval(() => {
|
||||
if (window.google && window.google.maps) { clearInterval(t); resolve(); }
|
||||
}, 100);
|
||||
setTimeout(() => { clearInterval(t); resolve(); }, 5000);
|
||||
return;
|
||||
}
|
||||
const s = document.createElement("script");
|
||||
s.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=__fclkMapCb`;
|
||||
s.async = true;
|
||||
s.defer = true;
|
||||
window.__fclkMapCb = () => { delete window.__fclkMapCb; resolve(); };
|
||||
s.onerror = () => reject(new Error("script load failed"));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
if (this._interval) clearInterval(this._interval);
|
||||
if (this.marker) { this.marker.map = null; this.marker = null; }
|
||||
if (this.circle) { this.circle.setMap(null); this.circle = null; }
|
||||
this.map = null;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("fclk_location_map", {
|
||||
component: FusionClockLocationMap,
|
||||
supportedTypes: ["char"],
|
||||
});
|
||||
150
fusion_clock/static/src/js/fusion_clock_location_places.js
Normal file
150
fusion_clock/static/src/js/fusion_clock_location_places.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
/**
|
||||
* Google Places Autocomplete widget for the address field.
|
||||
* Automatically geocodes the selected place and updates lat/lng on the record.
|
||||
*/
|
||||
export class FusionClockPlacesAutocomplete extends Component {
|
||||
static template = "fusion_clock.PlacesAutocomplete";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.inputRef = useRef("addressInput");
|
||||
this.autocomplete = null;
|
||||
this._apiReady = false;
|
||||
|
||||
this.state = useState({
|
||||
value: this.props.record.data[this.props.name] || "",
|
||||
});
|
||||
|
||||
onMounted(() => this._init());
|
||||
onWillUnmount(() => this._cleanup());
|
||||
}
|
||||
|
||||
get isReadonly() {
|
||||
return this.props.readonly;
|
||||
}
|
||||
|
||||
async _getApiKey() {
|
||||
try {
|
||||
return await rpc("/web/dataset/call_kw", {
|
||||
model: "ir.config_parameter",
|
||||
method: "get_param",
|
||||
args: ["fusion_clock.google_maps_api_key", ""],
|
||||
kwargs: {},
|
||||
}) || "";
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async _waitForGoogleMaps() {
|
||||
if (window.google && window.google.maps && window.google.maps.places) {
|
||||
return true;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let attempts = 0;
|
||||
const check = setInterval(() => {
|
||||
attempts++;
|
||||
if (window.google && window.google.maps && window.google.maps.places) {
|
||||
clearInterval(check);
|
||||
resolve(true);
|
||||
}
|
||||
if (attempts > 50) {
|
||||
clearInterval(check);
|
||||
resolve(false);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
async _loadGoogleMaps(apiKey) {
|
||||
if (window.google && window.google.maps) return;
|
||||
|
||||
if (document.querySelector('script[src*="maps.googleapis.com"]')) {
|
||||
await this._waitForGoogleMaps();
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=__fclkPlacesInit`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
window.__fclkPlacesInit = () => {
|
||||
delete window.__fclkPlacesInit;
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => reject(new Error("Failed to load Google Maps"));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
async _init() {
|
||||
if (this.isReadonly) return;
|
||||
|
||||
const apiKey = await this._getApiKey();
|
||||
if (!apiKey) return;
|
||||
|
||||
try {
|
||||
await this._loadGoogleMaps(apiKey);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._waitForGoogleMaps();
|
||||
|
||||
if (!this.inputRef.el || !window.google || !window.google.maps.places) return;
|
||||
|
||||
this.autocomplete = new google.maps.places.Autocomplete(this.inputRef.el, {
|
||||
types: ["geocode", "establishment"],
|
||||
fields: ["formatted_address", "geometry", "name"],
|
||||
});
|
||||
|
||||
this.autocomplete.addListener("place_changed", () => {
|
||||
const place = this.autocomplete.getPlace();
|
||||
if (!place || !place.geometry) return;
|
||||
|
||||
const lat = place.geometry.location.lat();
|
||||
const lng = place.geometry.location.lng();
|
||||
const address = place.formatted_address || place.name || "";
|
||||
|
||||
this.state.value = address;
|
||||
this.props.record.update({
|
||||
[this.props.name]: address,
|
||||
latitude: Math.round(lat * 10000000) / 10000000,
|
||||
longitude: Math.round(lng * 10000000) / 10000000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onInput(ev) {
|
||||
this.state.value = ev.target.value;
|
||||
}
|
||||
|
||||
onChange(ev) {
|
||||
this.props.record.update({ [this.props.name]: ev.target.value });
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
if (this.autocomplete) {
|
||||
google.maps.event.clearInstanceListeners(this.autocomplete);
|
||||
this.autocomplete = null;
|
||||
}
|
||||
|
||||
const containers = document.querySelectorAll(".pac-container");
|
||||
containers.forEach((c) => c.remove());
|
||||
}
|
||||
}
|
||||
|
||||
FusionClockPlacesAutocomplete.template = "fusion_clock.PlacesAutocomplete";
|
||||
|
||||
registry.category("fields").add("fclk_places_autocomplete", {
|
||||
component: FusionClockPlacesAutocomplete,
|
||||
supportedTypes: ["char"],
|
||||
});
|
||||
@@ -79,6 +79,37 @@ export class FusionClockPortal extends Interaction {
|
||||
});
|
||||
}
|
||||
|
||||
const reasonSubmitBtn = document.getElementById("fclk-reason-submit");
|
||||
if (reasonSubmitBtn) {
|
||||
reasonSubmitBtn.addEventListener("click", () => this._submitReason());
|
||||
}
|
||||
|
||||
const leaveBtn = document.getElementById("fclk-leave-btn");
|
||||
if (leaveBtn) {
|
||||
leaveBtn.addEventListener("click", () => {
|
||||
const modal = document.getElementById("fclk-leave-modal");
|
||||
if (modal) modal.style.display = "flex";
|
||||
});
|
||||
}
|
||||
|
||||
const leaveSubmitBtn = document.getElementById("fclk-leave-submit");
|
||||
if (leaveSubmitBtn) {
|
||||
leaveSubmitBtn.addEventListener("click", () => this._submitLeave());
|
||||
}
|
||||
|
||||
const clockoutConfirmBtn = document.getElementById("fclk-clockout-confirm-btn");
|
||||
if (clockoutConfirmBtn) {
|
||||
clockoutConfirmBtn.addEventListener("click", () => this._confirmClockOut());
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-dismiss]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const targetId = btn.dataset.dismiss;
|
||||
const modal = document.getElementById(targetId);
|
||||
if (modal) modal.style.display = "none";
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".fclk-modal-item").forEach((item) => {
|
||||
item.addEventListener("click", () => {
|
||||
this.selectedLocationId = parseInt(item.dataset.id);
|
||||
@@ -100,9 +131,54 @@ export class FusionClockPortal extends Interaction {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("fclk-clock-btn");
|
||||
if (!btn || btn.disabled) return;
|
||||
|
||||
if (this.isCheckedIn) {
|
||||
this._showClockOutConfirmation();
|
||||
return;
|
||||
}
|
||||
|
||||
this._beginClockAction();
|
||||
}
|
||||
|
||||
_showClockOutConfirmation() {
|
||||
const modal = document.getElementById("fclk-clockout-confirm-modal");
|
||||
if (!modal) {
|
||||
this._beginClockAction();
|
||||
return;
|
||||
}
|
||||
|
||||
const checkinEl = document.getElementById("fclk-confirm-checkin-time");
|
||||
const durationEl = document.getElementById("fclk-confirm-duration");
|
||||
|
||||
if (checkinEl && this.checkInTime) {
|
||||
const h = this.checkInTime.getHours();
|
||||
const m = this.checkInTime.getMinutes();
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
const hour12 = h % 12 || 12;
|
||||
checkinEl.textContent = hour12 + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
|
||||
}
|
||||
|
||||
if (durationEl && this.checkInTime) {
|
||||
const diff = Math.max(0, Math.floor((new Date() - this.checkInTime) / 1000));
|
||||
const dh = Math.floor(diff / 3600);
|
||||
const dm = Math.floor((diff % 3600) / 60);
|
||||
durationEl.textContent = dh + "h " + dm + "m";
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
_confirmClockOut() {
|
||||
const modal = document.getElementById("fclk-clockout-confirm-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
this._beginClockAction();
|
||||
}
|
||||
|
||||
_beginClockAction() {
|
||||
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");
|
||||
@@ -150,6 +226,11 @@ export class FusionClockPortal extends Interaction {
|
||||
this._hideGPSOverlay();
|
||||
if (btn) btn.disabled = false;
|
||||
|
||||
if (result.requires_reason) {
|
||||
this._showReasonModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
this._showToast(result.error, "error");
|
||||
this._shakeButton();
|
||||
@@ -413,6 +494,75 @@ export class FusionClockPortal extends Interaction {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Reason Modal & Leave Request
|
||||
// =========================================================================
|
||||
|
||||
_showReasonModal() {
|
||||
const modal = document.getElementById("fclk-reason-modal");
|
||||
if (modal) modal.style.display = "flex";
|
||||
}
|
||||
|
||||
async _submitReason() {
|
||||
const reasonEl = document.getElementById("fclk-reason-text");
|
||||
const timeEl = document.getElementById("fclk-reason-time");
|
||||
const reason = reasonEl ? reasonEl.value.trim() : "";
|
||||
const depTime = timeEl ? timeEl.value.trim() : "";
|
||||
|
||||
if (!reason) {
|
||||
this._showToast("Please provide a reason.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/submit_reason", {
|
||||
reason: reason,
|
||||
departure_time: depTime,
|
||||
});
|
||||
if (result.success) {
|
||||
const modal = document.getElementById("fclk-reason-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
this._showToast(result.message, "success");
|
||||
if (reasonEl) reasonEl.value = "";
|
||||
if (timeEl) timeEl.value = "";
|
||||
} else {
|
||||
this._showToast(result.error || "Failed to submit.", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
this._showToast("Network error.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async _submitLeave() {
|
||||
const dateEl = document.getElementById("fclk-leave-date");
|
||||
const reasonEl = document.getElementById("fclk-leave-reason");
|
||||
const leaveDate = dateEl ? dateEl.value : "";
|
||||
const reason = reasonEl ? reasonEl.value.trim() : "";
|
||||
|
||||
if (!leaveDate || !reason) {
|
||||
this._showToast("Please provide both a date and reason.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/request_leave", {
|
||||
leave_date: leaveDate,
|
||||
reason: reason,
|
||||
});
|
||||
if (result.success) {
|
||||
const modal = document.getElementById("fclk-leave-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
this._showToast(result.message, "success");
|
||||
if (dateEl) dateEl.value = "";
|
||||
if (reasonEl) reasonEl.value = "";
|
||||
} else {
|
||||
this._showToast(result.error || "Failed to submit.", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
this._showToast("Network error.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Sync on visibility change
|
||||
// =========================================================================
|
||||
|
||||
@@ -223,6 +223,163 @@ export class FusionClockPortalFAB extends Interaction {
|
||||
// =========================================================================
|
||||
|
||||
async _onClockAction() {
|
||||
if (this.isCheckedIn) {
|
||||
this._showClockOutConfirm();
|
||||
return;
|
||||
}
|
||||
await this._executeClockAction();
|
||||
}
|
||||
|
||||
_showClockOutConfirm() {
|
||||
let modal = document.getElementById("fclk-pfab-clockout-modal");
|
||||
if (!modal) {
|
||||
modal = document.createElement("div");
|
||||
modal.id = "fclk-pfab-clockout-modal";
|
||||
modal.className = "fclk-wizard-overlay";
|
||||
modal.innerHTML = `
|
||||
<div class="fclk-wizard-backdrop" data-pfab-dismiss="fclk-pfab-clockout-modal"></div>
|
||||
<div class="fclk-wizard-dialog fclk-wizard-dialog--compact">
|
||||
<div class="fclk-wizard-header fclk-wizard-header--danger">
|
||||
<div class="fclk-wizard-header-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
|
||||
</div>
|
||||
<h3 class="fclk-wizard-title">Clock Out?</h3>
|
||||
<p class="fclk-wizard-subtitle">Are you sure you want to end your current shift?</p>
|
||||
</div>
|
||||
<div class="fclk-wizard-body">
|
||||
<div class="fclk-clockout-summary">
|
||||
<div class="fclk-clockout-summary-row">
|
||||
<span class="fclk-clockout-summary-label">Clocked in at</span>
|
||||
<span class="fclk-clockout-summary-value" id="fclk-pfab-confirm-time">--</span>
|
||||
</div>
|
||||
<div class="fclk-clockout-summary-row">
|
||||
<span class="fclk-clockout-summary-label">Duration</span>
|
||||
<span class="fclk-clockout-summary-value" id="fclk-pfab-confirm-dur">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-wizard-footer">
|
||||
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-pfab-dismiss="fclk-pfab-clockout-modal">Cancel</button>
|
||||
<button class="fclk-wizard-btn fclk-wizard-btn--danger" id="fclk-pfab-confirm-clockout-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
|
||||
Confirm Clock Out
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.querySelectorAll("[data-pfab-dismiss]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => { modal.style.display = "none"; });
|
||||
});
|
||||
document.getElementById("fclk-pfab-confirm-clockout-btn").addEventListener("click", () => {
|
||||
modal.style.display = "none";
|
||||
this._executeClockAction();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.checkInTime) {
|
||||
const h = this.checkInTime.getHours();
|
||||
const m = this.checkInTime.getMinutes();
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
const hour12 = h % 12 || 12;
|
||||
const timeEl = document.getElementById("fclk-pfab-confirm-time");
|
||||
if (timeEl) timeEl.textContent = hour12 + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
|
||||
|
||||
const diff = Math.max(0, Math.floor((new Date() - this.checkInTime) / 1000));
|
||||
const dh = Math.floor(diff / 3600);
|
||||
const dm = Math.floor((diff % 3600) / 60);
|
||||
const durEl = document.getElementById("fclk-pfab-confirm-dur");
|
||||
if (durEl) durEl.textContent = dh + "h " + dm + "m";
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
_showReasonDialog() {
|
||||
let modal = document.getElementById("fclk-pfab-reason-modal");
|
||||
if (!modal) {
|
||||
modal = document.createElement("div");
|
||||
modal.id = "fclk-pfab-reason-modal";
|
||||
modal.className = "fclk-wizard-overlay";
|
||||
modal.innerHTML = `
|
||||
<div class="fclk-wizard-backdrop" data-pfab-dismiss="fclk-pfab-reason-modal"></div>
|
||||
<div class="fclk-wizard-dialog">
|
||||
<div class="fclk-wizard-header fclk-wizard-header--warning">
|
||||
<div class="fclk-wizard-header-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="fclk-wizard-title">Missed Clock-Out</h3>
|
||||
<p class="fclk-wizard-subtitle">You didn't clock out on your last shift. Please provide details before continuing.</p>
|
||||
</div>
|
||||
<div class="fclk-wizard-body">
|
||||
<div class="fclk-wizard-field">
|
||||
<label class="fclk-wizard-label">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
Reason <span class="fclk-wizard-required">*</span>
|
||||
</label>
|
||||
<textarea id="fclk-pfab-reason-text" class="fclk-wizard-input fclk-wizard-textarea" rows="3"
|
||||
placeholder="Please explain why you didn't clock out..."></textarea>
|
||||
</div>
|
||||
<div class="fclk-wizard-field">
|
||||
<label class="fclk-wizard-label">
|
||||
<svg width="14" height="14" 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>
|
||||
Departure Time
|
||||
</label>
|
||||
<input type="datetime-local" id="fclk-pfab-reason-time" class="fclk-wizard-input"/>
|
||||
<span class="fclk-wizard-hint">When did you actually leave? (optional)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-wizard-footer">
|
||||
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-pfab-dismiss="fclk-pfab-reason-modal">Cancel</button>
|
||||
<button class="fclk-wizard-btn fclk-wizard-btn--primary" id="fclk-pfab-reason-submit-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Submit Reason
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.querySelectorAll("[data-pfab-dismiss]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => { modal.style.display = "none"; });
|
||||
});
|
||||
document.getElementById("fclk-pfab-reason-submit-btn").addEventListener("click", async () => {
|
||||
const reasonEl = document.getElementById("fclk-pfab-reason-text");
|
||||
const timeEl = document.getElementById("fclk-pfab-reason-time");
|
||||
const reason = reasonEl ? reasonEl.value.trim() : "";
|
||||
if (!reason) {
|
||||
this._showError("Please provide a reason.");
|
||||
return;
|
||||
}
|
||||
const submitBtn = document.getElementById("fclk-pfab-reason-submit-btn");
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
try {
|
||||
await rpc("/fusion_clock/submit_reason", {
|
||||
reason: reason,
|
||||
departure_time: timeEl ? timeEl.value : "",
|
||||
});
|
||||
modal.style.display = "none";
|
||||
if (reasonEl) reasonEl.value = "";
|
||||
if (timeEl) timeEl.value = "";
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
await this._executeClockAction();
|
||||
} catch (e) {
|
||||
this._showError("Failed to submit reason.");
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const reasonEl = document.getElementById("fclk-pfab-reason-text");
|
||||
const timeEl = document.getElementById("fclk-pfab-reason-time");
|
||||
if (reasonEl) reasonEl.value = "";
|
||||
if (timeEl) timeEl.value = "";
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
async _executeClockAction() {
|
||||
if (this.actionBtn) this.actionBtn.disabled = true;
|
||||
this._clearError();
|
||||
|
||||
@@ -255,6 +412,12 @@ export class FusionClockPortalFAB extends Interaction {
|
||||
source: "portal_fab",
|
||||
});
|
||||
|
||||
if (result.requires_reason) {
|
||||
if (this.actionBtn) this.actionBtn.disabled = false;
|
||||
this._showReasonDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
this._showError(result.error);
|
||||
if (this.actionBtn) this.actionBtn.disabled = false;
|
||||
|
||||
@@ -23,6 +23,11 @@ export class FusionClockFAB extends Component {
|
||||
weekHours: "0.0",
|
||||
loading: false,
|
||||
error: "",
|
||||
showReasonDialog: false,
|
||||
showClockoutConfirm: false,
|
||||
reasonText: "",
|
||||
reasonTime: "",
|
||||
reasonSubmitting: false,
|
||||
});
|
||||
|
||||
this._timerInterval = null;
|
||||
@@ -95,6 +100,23 @@ export class FusionClockFAB extends Component {
|
||||
}
|
||||
|
||||
async onClockAction() {
|
||||
if (this.state.isCheckedIn) {
|
||||
this.state.showClockoutConfirm = true;
|
||||
return;
|
||||
}
|
||||
await this._executeClockAction();
|
||||
}
|
||||
|
||||
async confirmClockOut() {
|
||||
this.state.showClockoutConfirm = false;
|
||||
await this._executeClockAction();
|
||||
}
|
||||
|
||||
cancelClockOut() {
|
||||
this.state.showClockoutConfirm = false;
|
||||
}
|
||||
|
||||
async _executeClockAction() {
|
||||
this.state.loading = true;
|
||||
this.state.error = "";
|
||||
|
||||
@@ -126,6 +148,14 @@ export class FusionClockFAB extends Component {
|
||||
source: "backend_fab",
|
||||
});
|
||||
|
||||
if (result.requires_reason) {
|
||||
this.state.loading = false;
|
||||
this.state.showReasonDialog = true;
|
||||
this.state.reasonText = "";
|
||||
this.state.reasonTime = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
this.state.error = result.error;
|
||||
this.state.loading = false;
|
||||
@@ -153,6 +183,60 @@ export class FusionClockFAB extends Component {
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
onReasonTextInput(ev) {
|
||||
this.state.reasonText = ev.target.value;
|
||||
}
|
||||
|
||||
onReasonTimeInput(ev) {
|
||||
this.state.reasonTime = ev.target.value;
|
||||
}
|
||||
|
||||
cancelReason() {
|
||||
this.state.showReasonDialog = false;
|
||||
this.state.reasonText = "";
|
||||
this.state.reasonTime = "";
|
||||
}
|
||||
|
||||
async submitReason() {
|
||||
if (!this.state.reasonText.trim()) {
|
||||
this.state.error = "Please provide a reason.";
|
||||
return;
|
||||
}
|
||||
this.state.reasonSubmitting = true;
|
||||
try {
|
||||
await rpc("/fusion_clock/submit_reason", {
|
||||
reason: this.state.reasonText.trim(),
|
||||
departure_time: this.state.reasonTime || "",
|
||||
});
|
||||
this.state.showReasonDialog = false;
|
||||
this.state.reasonText = "";
|
||||
this.state.reasonTime = "";
|
||||
this.state.reasonSubmitting = false;
|
||||
await this._executeClockAction();
|
||||
} catch (e) {
|
||||
this.state.error = "Failed to submit reason.";
|
||||
this.state.reasonSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
get confirmCheckinDisplay() {
|
||||
if (!this.state.checkInTime) return "--";
|
||||
const d = this.state.checkInTime;
|
||||
let h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
const ampm = h >= 12 ? "PM" : "AM";
|
||||
h = h % 12 || 12;
|
||||
return h + ":" + (m < 10 ? "0" : "") + m + " " + ampm;
|
||||
}
|
||||
|
||||
get confirmDurationDisplay() {
|
||||
if (!this.state.checkInTime) return "--";
|
||||
const diff = Math.max(0, Math.floor((new Date() - this.state.checkInTime) / 1000));
|
||||
const dh = Math.floor(diff / 3600);
|
||||
const dm = Math.floor((diff % 3600) / 60);
|
||||
return dh + "h " + dm + "m";
|
||||
}
|
||||
|
||||
_startTimer() {
|
||||
this._stopTimer();
|
||||
this._updateTimer();
|
||||
|
||||
@@ -376,3 +376,437 @@ $fclk-gradient-active: linear-gradient(135deg, $fclk-green 0%, $fclk-teal 100%);
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// FAB Dialog Overlays (reason, clock-out confirmation)
|
||||
// ===========================================================
|
||||
.fclk-fab-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.fclk-fab-dialog {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: var(--fclk-fab-panel-bg);
|
||||
border: 1px solid var(--fclk-fab-panel-border);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
animation: fclk-dialog-enter 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
|
||||
&.fclk-fab-dialog--compact {
|
||||
max-width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fclk-dialog-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-header {
|
||||
padding: 28px 24px 20px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--fclk-fab-panel-border);
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 14px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-header--warning .fclk-fab-dialog-icon {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-header--danger .fclk-fab-dialog-icon {
|
||||
background: rgba($fclk-red, 0.12);
|
||||
color: $fclk-red;
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-title {
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 6px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-subtitle {
|
||||
color: var(--fclk-fab-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-body {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-field {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.fa { color: var(--fclk-fab-muted); font-size: 13px; }
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-required {
|
||||
color: $fclk-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-input {
|
||||
width: 100%;
|
||||
background: var(--fclk-fab-location-bg, rgba(0, 0, 0, 0.04));
|
||||
border: 1.5px solid var(--fclk-fab-panel-border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--fclk-fab-text);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
border-color: $fclk-green;
|
||||
box-shadow: 0 0 0 3px rgba($fclk-green, 0.15);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--fclk-fab-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-hint {
|
||||
display: block;
|
||||
color: var(--fclk-fab-muted);
|
||||
font-size: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-footer {
|
||||
padding: 14px 24px 18px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--fclk-fab-panel-border);
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fa { font-size: 13px; }
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-btn--cancel {
|
||||
background: transparent;
|
||||
color: var(--fclk-fab-muted);
|
||||
border: 1px solid var(--fclk-fab-panel-border);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--fclk-fab-location-bg);
|
||||
color: var(--fclk-fab-text);
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-btn--submit {
|
||||
background: $fclk-gradient-active;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba($fclk-green, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba($fclk-green, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-btn--danger {
|
||||
background: $fclk-red;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba($fclk-red, 0.3);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba($fclk-red, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary card (used in clock-out confirmation)
|
||||
.fclk-fab-dialog-summary {
|
||||
background: var(--fclk-fab-location-bg, rgba(0, 0, 0, 0.04));
|
||||
border: 1px solid var(--fclk-fab-panel-border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
|
||||
+ .fclk-fab-dialog-summary-row {
|
||||
border-top: 1px solid var(--fclk-fab-panel-border);
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-summary-label {
|
||||
color: var(--fclk-fab-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fclk-fab-dialog-summary-value {
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// Location Map Widget
|
||||
// ===========================================================
|
||||
.fclk-map-widget {
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.fclk-map-container {
|
||||
display: block;
|
||||
border: 1px solid var(--fclk-fab-panel-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.fclk-map-loading,
|
||||
.fclk-map-error,
|
||||
.fclk-map-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 16px;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--fclk-fab-panel-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.fclk-map-loading {
|
||||
color: var(--fclk-fab-muted, #6b7280);
|
||||
background: rgba(59, 130, 246, 0.04);
|
||||
}
|
||||
|
||||
.fclk-map-error {
|
||||
color: $fclk-red;
|
||||
background: rgba($fclk-red, 0.04);
|
||||
}
|
||||
|
||||
.fclk-map-placeholder {
|
||||
color: var(--fclk-fab-muted, #6b7280);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
html.o_dark {
|
||||
.fclk-map-loading { background: rgba(59, 130, 246, 0.08); }
|
||||
.fclk-map-error { background: rgba($fclk-red, 0.08); }
|
||||
.fclk-map-placeholder { background: rgba(255, 255, 255, 0.03); }
|
||||
}
|
||||
|
||||
.fclk-map-hint {
|
||||
text-align: center;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--fclk-fab-muted, #6b7280);
|
||||
|
||||
.fa { margin-right: 4px; }
|
||||
}
|
||||
|
||||
// Google Places dropdown z-index fix
|
||||
.pac-container {
|
||||
z-index: 2100 !important;
|
||||
border-radius: 8px;
|
||||
margin-top: 4px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.fclk-places-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// Dashboard Summary Cards
|
||||
// ===========================================================
|
||||
.fclk-dash-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-dash-card-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fclk-dash-card-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fclk-dash-card-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
// -- Total (blue/slate) --
|
||||
.fclk-dash-card--total {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #e0e7ff 100%);
|
||||
border: 1px solid #bfdbfe;
|
||||
|
||||
.fclk-dash-card-icon { background: rgba(59, 130, 246, 0.15); color: #2563eb; }
|
||||
.fclk-dash-card-value { color: #1e3a5f; }
|
||||
.fclk-dash-card-label { color: #3b82f6; }
|
||||
}
|
||||
|
||||
// -- Present (green) --
|
||||
.fclk-dash-card--present {
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
|
||||
border: 1px solid #a7f3d0;
|
||||
|
||||
.fclk-dash-card-icon { background: rgba(16, 185, 129, 0.15); color: #059669; }
|
||||
.fclk-dash-card-value { color: #064e3b; }
|
||||
.fclk-dash-card-label { color: #10b981; }
|
||||
}
|
||||
|
||||
// -- Absent (red) --
|
||||
.fclk-dash-card--absent {
|
||||
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
|
||||
border: 1px solid #fecaca;
|
||||
|
||||
.fclk-dash-card-icon { background: rgba(239, 68, 68, 0.12); color: #dc2626; }
|
||||
.fclk-dash-card-value { color: #7f1d1d; }
|
||||
.fclk-dash-card-label { color: #ef4444; }
|
||||
}
|
||||
|
||||
// -- Late (amber) --
|
||||
.fclk-dash-card--late {
|
||||
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||
border: 1px solid #fde68a;
|
||||
|
||||
.fclk-dash-card-icon { background: rgba(245, 158, 11, 0.15); color: #d97706; }
|
||||
.fclk-dash-card-value { color: #78350f; }
|
||||
.fclk-dash-card-label { color: #f59e0b; }
|
||||
}
|
||||
|
||||
// -- Dark mode overrides --
|
||||
html.o_dark {
|
||||
.fclk-dash-card--total {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.1) 100%);
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
.fclk-dash-card-value { color: #93c5fd; }
|
||||
.fclk-dash-card-label { color: #60a5fa; }
|
||||
.fclk-dash-card-icon { background: rgba(59, 130, 246, 0.2); color: #60a5fa; }
|
||||
}
|
||||
|
||||
.fclk-dash-card--present {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(52, 211, 153, 0.08) 100%);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
.fclk-dash-card-value { color: #6ee7b7; }
|
||||
.fclk-dash-card-label { color: #34d399; }
|
||||
.fclk-dash-card-icon { background: rgba(16, 185, 129, 0.2); color: #34d399; }
|
||||
}
|
||||
|
||||
.fclk-dash-card--absent {
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(248, 113, 113, 0.08) 100%);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
.fclk-dash-card-value { color: #fca5a5; }
|
||||
.fclk-dash-card-label { color: #f87171; }
|
||||
.fclk-dash-card-icon { background: rgba(239, 68, 68, 0.18); color: #f87171; }
|
||||
}
|
||||
|
||||
.fclk-dash-card--late {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(251, 191, 36, 0.08) 100%);
|
||||
border-color: rgba(245, 158, 11, 0.25);
|
||||
.fclk-dash-card-value { color: #fcd34d; }
|
||||
.fclk-dash-card-label { color: #fbbf24; }
|
||||
.fclk-dash-card-icon { background: rgba(245, 158, 11, 0.2); color: #fbbf24; }
|
||||
}
|
||||
|
||||
.fclk-dash-card:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
155
fusion_clock/static/src/xml/fusion_clock_dashboard.xml
Normal file
155
fusion_clock/static/src/xml/fusion_clock_dashboard.xml
Normal file
@@ -0,0 +1,155 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_clock.Dashboard">
|
||||
<div class="o_action">
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">Fusion Clock Dashboard</h2>
|
||||
<button class="btn btn-outline-primary" t-on-click="onRefresh">
|
||||
<i class="fa fa-refresh"/> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center py-5">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<p class="mt-2">Loading dashboard...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="state.error">
|
||||
<div class="alert alert-danger">
|
||||
<t t-esc="state.error"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="!state.loading and !state.error">
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="fclk-dash-card fclk-dash-card--total">
|
||||
<div class="fclk-dash-card-icon">
|
||||
<i class="fa fa-users"/>
|
||||
</div>
|
||||
<div class="fclk-dash-card-value"><t t-esc="state.total_employees"/></div>
|
||||
<div class="fclk-dash-card-label">Total Employees</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="fclk-dash-card fclk-dash-card--present">
|
||||
<div class="fclk-dash-card-icon">
|
||||
<i class="fa fa-check-circle"/>
|
||||
</div>
|
||||
<div class="fclk-dash-card-value"><t t-esc="state.present_count"/></div>
|
||||
<div class="fclk-dash-card-label">Present Today</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="fclk-dash-card fclk-dash-card--absent">
|
||||
<div class="fclk-dash-card-icon">
|
||||
<i class="fa fa-times-circle"/>
|
||||
</div>
|
||||
<div class="fclk-dash-card-value"><t t-esc="state.absent_count"/></div>
|
||||
<div class="fclk-dash-card-label">Absent Today</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="fclk-dash-card fclk-dash-card--late">
|
||||
<div class="fclk-dash-card-icon">
|
||||
<i class="fa fa-clock-o"/>
|
||||
</div>
|
||||
<div class="fclk-dash-card-value"><t t-esc="state.late_count"/></div>
|
||||
<div class="fclk-dash-card-label">Late Today</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Currently Clocked In -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Currently Clocked In</h5>
|
||||
<span class="badge bg-success"><t t-esc="state.clocked_in.length"/> active</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<t t-if="state.clocked_in.length === 0">
|
||||
<div class="text-center py-4 text-muted">
|
||||
No employees currently clocked in
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Employee</th>
|
||||
<th>Clock-In</th>
|
||||
<th>Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.clocked_in" t-as="emp" t-key="emp_index">
|
||||
<tr>
|
||||
<td><t t-esc="emp.employee"/></td>
|
||||
<td><t t-esc="emp.check_in"/></td>
|
||||
<td><t t-esc="emp.location"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerts Panel -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Alerts</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 cursor-pointer"
|
||||
t-on-click="onViewActivityLogs">
|
||||
<span><i class="fa fa-exclamation-circle text-warning me-2"/>Pending Reasons</span>
|
||||
<span class="badge bg-warning"><t t-esc="state.pending_reasons"/></span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 cursor-pointer"
|
||||
t-on-click="onViewCorrections">
|
||||
<span><i class="fa fa-edit text-info me-2"/>Pending Corrections</span>
|
||||
<span class="badge bg-info"><t t-esc="state.pending_corrections"/></span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center cursor-pointer"
|
||||
t-on-click="onViewPenalties">
|
||||
<span><i class="fa fa-clock-o text-danger me-2"/>Late Today</span>
|
||||
<span class="badge bg-danger"><t t-esc="state.late_count"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Quick Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button class="btn btn-outline-primary w-100 mb-2" t-on-click="onViewAttendances">
|
||||
<i class="fa fa-list me-1"/> View All Attendances
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary w-100" t-on-click="onViewActivityLogs">
|
||||
<i class="fa fa-history me-1"/> Activity Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
40
fusion_clock/static/src/xml/fusion_clock_location.xml
Normal file
40
fusion_clock/static/src/xml/fusion_clock_location.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- Interactive Map Widget -->
|
||||
<t t-name="fusion_clock.LocationMap">
|
||||
<div class="fclk-map-widget">
|
||||
<div t-if="state.loading" class="fclk-map-loading">
|
||||
<i class="fa fa-circle-o-notch fa-spin"/> Loading map...
|
||||
</div>
|
||||
<div t-if="state.error" class="fclk-map-error">
|
||||
<i class="fa fa-exclamation-triangle"/> <t t-esc="state.error"/>
|
||||
</div>
|
||||
<!-- ALWAYS in the DOM so the ref is available at mount time.
|
||||
Hidden via inline display:none until the map is ready. -->
|
||||
<div t-ref="mapContainer"
|
||||
class="fclk-map-container"
|
||||
t-att-style="state.mapVisible ? 'width:100%;height:400px;border-radius:8px;' : 'display:none;'"/>
|
||||
<div t-if="state.mapVisible and !props.readonly" class="fclk-map-hint">
|
||||
<i class="fa fa-hand-pointer-o"/> Click the map or drag the marker to fine-tune the location
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Places Autocomplete Widget -->
|
||||
<t t-name="fusion_clock.PlacesAutocomplete">
|
||||
<t t-if="isReadonly">
|
||||
<span t-esc="props.record.data[props.name] || ''"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<input t-ref="addressInput"
|
||||
type="text"
|
||||
class="o_input fclk-places-input"
|
||||
t-att-value="state.value"
|
||||
t-on-input="onInput"
|
||||
t-on-change="onChange"
|
||||
placeholder="Start typing an address..."/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -68,20 +68,91 @@
|
||||
<!-- 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>
|
||||
|
||||
<!-- Missed Clock-Out Reason Dialog -->
|
||||
<div t-if="state.showReasonDialog" class="fclk-fab-dialog-overlay">
|
||||
<div class="fclk-fab-dialog-backdrop" t-on-click="cancelReason"/>
|
||||
<div class="fclk-fab-dialog">
|
||||
<div class="fclk-fab-dialog-header fclk-fab-dialog-header--warning">
|
||||
<div class="fclk-fab-dialog-icon">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
</div>
|
||||
<h4 class="fclk-fab-dialog-title">Missed Clock-Out</h4>
|
||||
<p class="fclk-fab-dialog-subtitle">You didn't clock out on your last shift. Please provide details before continuing.</p>
|
||||
</div>
|
||||
<div class="fclk-fab-dialog-body">
|
||||
<div class="fclk-fab-dialog-field">
|
||||
<label class="fclk-fab-dialog-label">
|
||||
<i class="fa fa-comment-o"/> Reason <span class="fclk-fab-dialog-required">*</span>
|
||||
</label>
|
||||
<textarea class="fclk-fab-dialog-input" rows="3"
|
||||
placeholder="Please explain why you didn't clock out..."
|
||||
t-on-input="onReasonTextInput"
|
||||
t-att-value="state.reasonText"/>
|
||||
</div>
|
||||
<div class="fclk-fab-dialog-field">
|
||||
<label class="fclk-fab-dialog-label">
|
||||
<i class="fa fa-clock-o"/> Departure Time
|
||||
</label>
|
||||
<input type="datetime-local" class="fclk-fab-dialog-input"
|
||||
t-on-input="onReasonTimeInput"
|
||||
t-att-value="state.reasonTime"/>
|
||||
<span class="fclk-fab-dialog-hint">When did you actually leave? (optional)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-fab-dialog-footer">
|
||||
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--cancel" t-on-click="cancelReason">Cancel</button>
|
||||
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--submit" t-on-click="submitReason"
|
||||
t-att-disabled="state.reasonSubmitting">
|
||||
<t t-if="state.reasonSubmitting"><i class="fa fa-circle-o-notch fa-spin"/> Submitting...</t>
|
||||
<t t-else=""><i class="fa fa-check"/> Submit Reason</t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clock-Out Confirmation Dialog -->
|
||||
<div t-if="state.showClockoutConfirm" class="fclk-fab-dialog-overlay">
|
||||
<div class="fclk-fab-dialog-backdrop" t-on-click="cancelClockOut"/>
|
||||
<div class="fclk-fab-dialog fclk-fab-dialog--compact">
|
||||
<div class="fclk-fab-dialog-header fclk-fab-dialog-header--danger">
|
||||
<div class="fclk-fab-dialog-icon">
|
||||
<i class="fa fa-stop-circle"/>
|
||||
</div>
|
||||
<h4 class="fclk-fab-dialog-title">Clock Out?</h4>
|
||||
<p class="fclk-fab-dialog-subtitle">Are you sure you want to end your current shift?</p>
|
||||
</div>
|
||||
<div class="fclk-fab-dialog-body">
|
||||
<div class="fclk-fab-dialog-summary">
|
||||
<div class="fclk-fab-dialog-summary-row">
|
||||
<span class="fclk-fab-dialog-summary-label">Clocked in at</span>
|
||||
<span class="fclk-fab-dialog-summary-value" t-esc="confirmCheckinDisplay"/>
|
||||
</div>
|
||||
<div class="fclk-fab-dialog-summary-row">
|
||||
<span class="fclk-fab-dialog-summary-label">Duration</span>
|
||||
<span class="fclk-fab-dialog-summary-value" t-esc="confirmDurationDisplay"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-fab-dialog-footer">
|
||||
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--cancel" t-on-click="cancelClockOut">Cancel</button>
|
||||
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--danger" t-on-click="confirmClockOut">
|
||||
<i class="fa fa-stop-circle-o"/> Confirm Clock Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
|
||||
88
fusion_clock/views/clock_activity_log_views.xml
Normal file
88
fusion_clock/views/clock_activity_log_views.xml
Normal file
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Activity Log List View -->
|
||||
<record id="view_fusion_clock_activity_log_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.activity.log.list</field>
|
||||
<field name="model">fusion.clock.activity.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="log_date"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="log_type"/>
|
||||
<field name="description"/>
|
||||
<field name="location_id" optional="show"/>
|
||||
<field name="source" optional="show"/>
|
||||
<field name="distance" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Activity Log Form View -->
|
||||
<record id="view_fusion_clock_activity_log_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.activity.log.form</field>
|
||||
<field name="model">fusion.clock.activity.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form create="false" edit="false">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="employee_id"/>
|
||||
<field name="log_type"/>
|
||||
<field name="log_date"/>
|
||||
<field name="source"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="location_id"/>
|
||||
<field name="attendance_id"/>
|
||||
<field name="latitude"/>
|
||||
<field name="longitude"/>
|
||||
<field name="distance"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description"/>
|
||||
</group>
|
||||
<!-- Clock-in attempt location map -->
|
||||
<group string="Clock Attempt Location"
|
||||
invisible="not latitude or not longitude">
|
||||
<field name="attempt_map_url" widget="image_url" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Activity Log Search View -->
|
||||
<record id="view_fusion_clock_activity_log_search" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.activity.log.search</field>
|
||||
<field name="model">fusion.clock.activity.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="employee_id"/>
|
||||
<field name="log_type"/>
|
||||
<separator/>
|
||||
<filter name="clock_events" string="Clock Events" domain="[('log_type', 'in', ['clock_in', 'clock_out'])]"/>
|
||||
<filter name="penalties" string="Penalties" domain="[('log_type', 'in', ['late_clock_in', 'early_clock_out'])]"/>
|
||||
<filter name="geofence" string="Geofence" domain="[('log_type', 'in', ['outside_geofence', 'ip_fallback'])]"/>
|
||||
<filter name="system" string="System" domain="[('log_type', 'in', ['auto_clock_out', 'missed_clock_out'])]"/>
|
||||
<filter name="absences" string="Absences" domain="[('log_type', '=', 'absent')]"/>
|
||||
<filter name="leaves" string="Leaves" domain="[('log_type', '=', 'leave_request')]"/>
|
||||
<filter name="overtime" string="Overtime" domain="[('log_type', '=', 'overtime')]"/>
|
||||
<separator/>
|
||||
<filter name="group_employee" string="Employee" context="{'group_by': 'employee_id'}"/>
|
||||
<filter name="group_type" string="Type" context="{'group_by': 'log_type'}"/>
|
||||
<filter name="group_date" string="Date" context="{'group_by': 'log_date:day'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Activity Log Action -->
|
||||
<record id="action_fusion_clock_activity_log" model="ir.actions.act_window">
|
||||
<field name="name">Activity Logs</field>
|
||||
<field name="res_model">fusion.clock.activity.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_clock_activity_log_search"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
76
fusion_clock/views/clock_correction_views.xml
Normal file
76
fusion_clock/views/clock_correction_views.xml
Normal file
@@ -0,0 +1,76 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Correction Request List View -->
|
||||
<record id="view_fusion_clock_correction_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.correction.list</field>
|
||||
<field name="model">fusion.clock.correction</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="create_date"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="original_check_in"/>
|
||||
<field name="original_check_out"/>
|
||||
<field name="requested_check_in"/>
|
||||
<field name="requested_check_out"/>
|
||||
<field name="reason"/>
|
||||
<field name="state" decoration-success="state == 'approved'"
|
||||
decoration-danger="state == 'rejected'"
|
||||
decoration-warning="state == 'pending'" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Correction Request Form View -->
|
||||
<record id="view_fusion_clock_correction_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.correction.form</field>
|
||||
<field name="model">fusion.clock.correction</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_approve" string="Approve" type="object"
|
||||
class="btn-primary" invisible="state != 'pending'"
|
||||
groups="fusion_clock.group_fusion_clock_manager"/>
|
||||
<button name="action_reject" string="Reject" type="object"
|
||||
class="btn-danger" invisible="state != 'pending'"
|
||||
groups="fusion_clock.group_fusion_clock_manager"/>
|
||||
<field name="state" widget="statusbar"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Employee">
|
||||
<field name="employee_id"/>
|
||||
<field name="attendance_id"/>
|
||||
</group>
|
||||
<group string="Review">
|
||||
<field name="reviewed_by" readonly="1"/>
|
||||
<field name="reviewed_date" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Original Times">
|
||||
<field name="original_check_in"/>
|
||||
<field name="original_check_out"/>
|
||||
</group>
|
||||
<group string="Requested Correction">
|
||||
<field name="requested_check_in"/>
|
||||
<field name="requested_check_out"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="reason"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Correction Request Action -->
|
||||
<record id="action_fusion_clock_correction" model="ir.actions.act_window">
|
||||
<field name="name">Correction Requests</field>
|
||||
<field name="res_model">fusion.clock.correction</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
10
fusion_clock/views/clock_dashboard_views.xml
Normal file
10
fusion_clock/views/clock_dashboard_views.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Dashboard Client Action -->
|
||||
<record id="action_fusion_clock_dashboard" model="ir.actions.client">
|
||||
<field name="name">Dashboard</field>
|
||||
<field name="tag">fusion_clock.Dashboard</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
56
fusion_clock/views/clock_leave_request_views.xml
Normal file
56
fusion_clock/views/clock_leave_request_views.xml
Normal file
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Leave Request List View -->
|
||||
<record id="view_fusion_clock_leave_request_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.leave.request.list</field>
|
||||
<field name="model">fusion.clock.leave.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="leave_date"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="reason"/>
|
||||
<field name="state" decoration-success="state == 'reviewed'"
|
||||
decoration-info="state == 'auto_approved'" widget="badge"/>
|
||||
<field name="created_from"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Leave Request Form View -->
|
||||
<record id="view_fusion_clock_leave_request_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.leave.request.form</field>
|
||||
<field name="model">fusion.clock.leave.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_mark_reviewed" string="Mark Reviewed" type="object"
|
||||
class="btn-primary" invisible="state == 'reviewed'"
|
||||
groups="fusion_clock.group_fusion_clock_manager"/>
|
||||
<field name="state" widget="statusbar"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="employee_id"/>
|
||||
<field name="leave_date"/>
|
||||
<field name="created_from"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="reason"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Leave Request Action -->
|
||||
<record id="action_fusion_clock_leave_request" model="ir.actions.act_window">
|
||||
<field name="name">Leave Requests</field>
|
||||
<field name="res_model">fusion.clock.leave.request</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -29,7 +29,8 @@
|
||||
</div>
|
||||
<group>
|
||||
<group string="Address">
|
||||
<field name="address" placeholder="123 Business Ave, San Francisco, CA"/>
|
||||
<field name="address" widget="fclk_places_autocomplete"
|
||||
placeholder="Start typing an address..."/>
|
||||
<field name="timezone"/>
|
||||
</group>
|
||||
<group string="Geofence">
|
||||
@@ -45,6 +46,15 @@
|
||||
<field name="employee_ids" widget="many2many_tags"
|
||||
invisible="all_employees"/>
|
||||
</group>
|
||||
<group string="Verification">
|
||||
<field name="require_photo"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="IP Whitelist">
|
||||
<field name="ip_whitelist" nolabel="1" colspan="2"
|
||||
placeholder="One IP or CIDR per line, e.g. 192.168.1.0/24 10.0.0.100"/>
|
||||
</group>
|
||||
<group string="Other">
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="active" invisible="1"/>
|
||||
@@ -52,9 +62,9 @@
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Google Maps Static Preview -->
|
||||
<group string="Map Preview" invisible="not latitude or not longitude">
|
||||
<field name="map_url" widget="image_url" string="Map" invisible="not map_url"/>
|
||||
<!-- Interactive Map Preview -->
|
||||
<group string="Map Preview">
|
||||
<field name="map_url" widget="fclk_location_map" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
|
||||
<group string="Notes">
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
sequence="45"
|
||||
groups="group_fusion_clock_user"/>
|
||||
|
||||
<!-- Dashboard / Attendance Sub-Menu -->
|
||||
<!-- Dashboard -->
|
||||
<menuitem id="menu_fusion_clock_dashboard"
|
||||
name="Dashboard"
|
||||
parent="menu_fusion_clock_root"
|
||||
action="action_fusion_clock_dashboard"
|
||||
sequence="5"
|
||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||
|
||||
<!-- Attendance Sub-Menu -->
|
||||
<menuitem id="menu_fusion_clock_attendance"
|
||||
name="Attendance"
|
||||
parent="menu_fusion_clock_root"
|
||||
@@ -19,8 +27,23 @@
|
||||
parent="menu_fusion_clock_attendance"
|
||||
action="hr_attendance.hr_attendance_action"
|
||||
sequence="10"
|
||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_corrections"
|
||||
name="Correction Requests"
|
||||
parent="menu_fusion_clock_attendance"
|
||||
action="action_fusion_clock_correction"
|
||||
sequence="20"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<!-- Activity Logs -->
|
||||
<menuitem id="menu_fusion_clock_activity_logs"
|
||||
name="Activity Logs"
|
||||
parent="menu_fusion_clock_root"
|
||||
action="action_fusion_clock_activity_log"
|
||||
sequence="15"
|
||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||
|
||||
<!-- Locations Sub-Menu -->
|
||||
<menuitem id="menu_fusion_clock_locations"
|
||||
name="Locations"
|
||||
@@ -35,6 +58,14 @@
|
||||
parent="menu_fusion_clock_root"
|
||||
action="action_fusion_clock_penalty"
|
||||
sequence="30"
|
||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||
|
||||
<!-- Leave Requests -->
|
||||
<menuitem id="menu_fusion_clock_leaves"
|
||||
name="Leave Requests"
|
||||
parent="menu_fusion_clock_root"
|
||||
action="action_fusion_clock_leave_request"
|
||||
sequence="35"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<!-- Reports Sub-Menu -->
|
||||
@@ -52,13 +83,28 @@
|
||||
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', 'bin_size': False}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_clock_settings"
|
||||
name="Settings"
|
||||
parent="menu_fusion_clock_config"
|
||||
action="base.res_config_setting_act_window"
|
||||
action="action_fusion_clock_config_settings"
|
||||
sequence="10"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_shifts"
|
||||
name="Shifts"
|
||||
parent="menu_fusion_clock_config"
|
||||
action="action_fusion_clock_shift"
|
||||
sequence="15"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_locations_config"
|
||||
name="Locations"
|
||||
parent="menu_fusion_clock_config"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<field name="scheduled_time" widget="datetime"/>
|
||||
<field name="actual_time" widget="datetime"/>
|
||||
<field name="difference_minutes" string="Diff (min)"/>
|
||||
<field name="penalty_minutes" string="Deducted (min)"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
@@ -36,6 +37,7 @@
|
||||
<field name="scheduled_time"/>
|
||||
<field name="actual_time"/>
|
||||
<field name="difference_minutes"/>
|
||||
<field name="penalty_minutes"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
string="Send Report" class="btn-secondary"
|
||||
invisible="state != 'generated'"
|
||||
icon="fa-envelope"/>
|
||||
<button name="action_export_csv" type="object"
|
||||
string="Export CSV" class="btn-secondary"
|
||||
invisible="state == 'draft'"
|
||||
icon="fa-download"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,generated,sent"/>
|
||||
</header>
|
||||
<sheet>
|
||||
|
||||
63
fusion_clock/views/clock_shift_views.xml
Normal file
63
fusion_clock/views/clock_shift_views.xml
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Shift List View -->
|
||||
<record id="view_fusion_clock_shift_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.shift.list</field>
|
||||
<field name="model">fusion.clock.shift</field>
|
||||
<field name="arch" type="xml">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="start_time" widget="float_time"/>
|
||||
<field name="end_time" widget="float_time"/>
|
||||
<field name="break_minutes"/>
|
||||
<field name="employee_count"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Shift Form View -->
|
||||
<record id="view_fusion_clock_shift_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.shift.form</field>
|
||||
<field name="model">fusion.clock.shift</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="start_time" widget="float_time"/>
|
||||
<field name="end_time" widget="float_time"/>
|
||||
<field name="break_minutes"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
<field name="active"/>
|
||||
<field name="color" widget="color"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Assigned Employees">
|
||||
<field name="employee_ids" nolabel="1" colspan="2">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="department_id"/>
|
||||
<field name="job_title"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Shift Action -->
|
||||
<record id="action_fusion_clock_shift" model="ir.actions.act_window">
|
||||
<field name="name">Shifts</field>
|
||||
<field name="res_model">fusion.clock.shift</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -10,9 +10,12 @@
|
||||
<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_overtime_hours" string="Overtime (h)" widget="float_time" optional="show"
|
||||
decoration-danger="x_fclk_is_overtime"/>
|
||||
<field name="x_fclk_location_id" string="Location" optional="show"/>
|
||||
<field name="x_fclk_clock_source" string="Source" optional="hide"/>
|
||||
<field name="x_fclk_auto_clocked_out" string="Auto Out" optional="hide"/>
|
||||
<field name="x_fclk_is_overtime" column_invisible="True"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
@@ -30,14 +33,20 @@
|
||||
<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_overtime_hours" widget="float_time"/>
|
||||
</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"/>
|
||||
<field name="x_fclk_is_overtime"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Check-In Photo" name="fusion_clock_photo"
|
||||
invisible="not x_fclk_checkin_photo">
|
||||
<field name="x_fclk_checkin_photo" widget="image" class="oe_avatar"/>
|
||||
</group>
|
||||
<group string="Penalties" name="fusion_clock_penalties"
|
||||
invisible="not x_fclk_penalty_ids">
|
||||
<field name="x_fclk_penalty_ids" nolabel="1" colspan="2">
|
||||
@@ -46,6 +55,7 @@
|
||||
<field name="scheduled_time"/>
|
||||
<field name="actual_time"/>
|
||||
<field name="difference_minutes"/>
|
||||
<field name="penalty_minutes"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
@@ -62,11 +72,13 @@
|
||||
<xpath expr="//search" position="inside">
|
||||
<field name="x_fclk_location_id"/>
|
||||
<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_portal" string="Portal" domain="[('x_fclk_clock_source', 'in', ['portal', 'portal_fab'])]"/>
|
||||
<filter name="fclk_systray" string="Systray/Backend" domain="[('x_fclk_clock_source', 'in', ['systray', 'backend_fab'])]"/>
|
||||
<filter name="fclk_kiosk" string="Kiosk" domain="[('x_fclk_clock_source', '=', 'kiosk')]"/>
|
||||
<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)]"/>
|
||||
<filter name="fclk_has_overtime" string="Has Overtime" domain="[('x_fclk_is_overtime', '=', True)]"/>
|
||||
<separator/>
|
||||
<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'}"/>
|
||||
|
||||
167
fusion_clock/views/hr_employee_views.xml
Normal file
167
fusion_clock/views/hr_employee_views.xml
Normal file
@@ -0,0 +1,167 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Employee Form: Fusion Clock Tab -->
|
||||
<record id="view_employee_form_fusion_clock" model="ir.ui.view">
|
||||
<field name="name">hr.employee.form.fusion.clock</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Fusion Clock" name="fusion_clock_tab"
|
||||
groups="fusion_clock.group_fusion_clock_manager,fusion_clock.group_fusion_clock_team_lead">
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<group>
|
||||
<group string="Configuration">
|
||||
<field name="x_fclk_enable_clock"/>
|
||||
<field name="x_fclk_shift_id"/>
|
||||
<field name="x_fclk_default_location_id"/>
|
||||
<field name="x_fclk_break_minutes"/>
|
||||
<field name="x_fclk_kiosk_pin" password="True"
|
||||
groups="fusion_clock.group_fusion_clock_manager"/>
|
||||
</group>
|
||||
<group string="Status">
|
||||
<field name="x_fclk_ontime_streak"/>
|
||||
<field name="x_fclk_absences_this_month"/>
|
||||
<field name="x_fclk_absences_this_year"/>
|
||||
<field name="x_fclk_overtime_this_week" widget="float_time"/>
|
||||
<field name="x_fclk_overtime_this_month" widget="float_time"/>
|
||||
<field name="x_fclk_pending_reason"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<separator string="Activity Logs"/>
|
||||
|
||||
<!-- Clock Events -->
|
||||
<group string="Clock Events" name="fclk_clock_events">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', 'in', ['clock_in', 'clock_out'])]">
|
||||
<list create="false" delete="false" limit="20">
|
||||
<field name="log_date"/>
|
||||
<field name="log_type"/>
|
||||
<field name="description"/>
|
||||
<field name="location_id"/>
|
||||
<field name="source"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
||||
<!-- Penalties -->
|
||||
<group string="Penalties" name="fclk_penalties">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', 'in', ['late_clock_in', 'early_clock_out'])]">
|
||||
<list create="false" delete="false" limit="20">
|
||||
<field name="log_date"/>
|
||||
<field name="log_type"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
||||
<!-- Geofence Violations -->
|
||||
<group string="Geofence Violations" name="fclk_geofence">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', 'in', ['outside_geofence', 'ip_fallback'])]">
|
||||
<list create="false" delete="false" limit="20">
|
||||
<field name="log_date"/>
|
||||
<field name="log_type"/>
|
||||
<field name="description"/>
|
||||
<field name="latitude"/>
|
||||
<field name="longitude"/>
|
||||
<field name="distance"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
||||
<!-- System Actions -->
|
||||
<group string="System Actions" name="fclk_system_actions">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', 'in', ['auto_clock_out', 'missed_clock_out'])]">
|
||||
<list create="false" delete="false" limit="20">
|
||||
<field name="log_date"/>
|
||||
<field name="log_type"/>
|
||||
<field name="description"/>
|
||||
<field name="attendance_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
||||
<!-- Absences -->
|
||||
<group string="Absences" name="fclk_absences">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', '=', 'absent')]">
|
||||
<list create="false" delete="false" limit="20">
|
||||
<field name="log_date"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
||||
<!-- Leave Requests -->
|
||||
<group string="Leave Requests" name="fclk_leaves">
|
||||
<field name="x_fclk_leave_request_ids" nolabel="1" colspan="2">
|
||||
<list create="false" delete="false" limit="20">
|
||||
<field name="leave_date"/>
|
||||
<field name="reason"/>
|
||||
<field name="state"/>
|
||||
<field name="created_from"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
||||
<!-- Reason Submissions -->
|
||||
<group string="Reason Submissions" name="fclk_reasons">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', '=', 'reason_provided')]">
|
||||
<list create="false" delete="false" limit="20">
|
||||
<field name="log_date"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
||||
<!-- Overtime -->
|
||||
<group string="Overtime" name="fclk_overtime">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', '=', 'overtime')]">
|
||||
<list create="false" delete="false" limit="20">
|
||||
<field name="log_date"/>
|
||||
<field name="description"/>
|
||||
<field name="attendance_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
||||
<!-- Correction Requests -->
|
||||
<group string="Correction Requests" name="fclk_corrections">
|
||||
<field name="x_fclk_correction_ids" nolabel="1" colspan="2">
|
||||
<list create="false" delete="false" limit="20">
|
||||
<field name="attendance_id"/>
|
||||
<field name="requested_check_in"/>
|
||||
<field name="requested_check_out"/>
|
||||
<field name="reason"/>
|
||||
<field name="state" decoration-success="state == 'approved'"
|
||||
decoration-danger="state == 'rejected'"
|
||||
decoration-warning="state == 'pending'"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
||||
<!-- Streak Milestones -->
|
||||
<group string="Streak Milestones" name="fclk_streaks">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', '=', 'streak_milestone')]">
|
||||
<list create="false" delete="false" limit="20">
|
||||
<field name="log_date"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
63
fusion_clock/views/kiosk_templates.xml
Normal file
63
fusion_clock/views/kiosk_templates.xml
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Kiosk Page Template -->
|
||||
<template id="kiosk_page" name="Fusion Clock Kiosk">
|
||||
<t t-call="web.frontend_layout">
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
<t t-set="no_footer" t-value="True"/>
|
||||
<div id="fclk-kiosk" class="container-fluid vh-100 d-flex flex-column align-items-center justify-content-center"
|
||||
style="background: var(--o-main-bg-color, #f8f9fa);"
|
||||
t-att-data-pin-required="'true' if pin_required else 'false'">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4">
|
||||
<h1 style="font-size: 2.5rem;">Fusion Clock</h1>
|
||||
<p class="text-muted" style="font-size: 1.2rem;">Kiosk Mode</p>
|
||||
<div id="fclk-kiosk-time" style="font-size: 3rem; font-weight: 300;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Search / PIN Entry -->
|
||||
<div class="card shadow-sm" style="width: 100%; max-width: 500px;">
|
||||
<div class="card-body p-4">
|
||||
|
||||
<!-- Step 1: Search Employee -->
|
||||
<div id="fclk-kiosk-search">
|
||||
<label class="form-label fw-bold">Employee Name</label>
|
||||
<input type="text" id="fclk-kiosk-query" class="form-control form-control-lg mb-3"
|
||||
placeholder="Type your name..." autocomplete="off"/>
|
||||
<div id="fclk-kiosk-results" class="list-group mb-3" style="max-height: 300px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: PIN (if required) -->
|
||||
<div id="fclk-kiosk-pin" style="display: none;">
|
||||
<h4 id="fclk-kiosk-emp-name" class="text-center mb-3"></h4>
|
||||
<t t-if="pin_required">
|
||||
<label class="form-label fw-bold">Enter PIN</label>
|
||||
<input type="password" id="fclk-kiosk-pin-input" class="form-control form-control-lg text-center mb-3"
|
||||
maxlength="6" placeholder="------"/>
|
||||
</t>
|
||||
<div class="d-grid gap-2">
|
||||
<button id="fclk-kiosk-clock-btn" class="btn btn-lg btn-primary">
|
||||
Clock In / Out
|
||||
</button>
|
||||
<button id="fclk-kiosk-back-btn" class="btn btn-outline-secondary">
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Result -->
|
||||
<div id="fclk-kiosk-result" style="display: none;">
|
||||
<div id="fclk-kiosk-result-msg" class="text-center py-4" style="font-size: 1.3rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div id="fclk-kiosk-error" class="alert alert-danger mt-3" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -1,17 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Add Clock link to portal home -->
|
||||
<!-- Clock link removed from portal home - now handled by fusion_authorizer_portal -->
|
||||
<template id="portal_my_home_clock" name="Portal My Home: Clock"
|
||||
inherit_id="portal.portal_my_home" priority="60">
|
||||
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
|
||||
<t t-call="portal.portal_docs_entry">
|
||||
<t t-set="icon" t-value="'/fusion_clock/static/description/icon.png'"/>
|
||||
<t t-set="title">Clock In / Out</t>
|
||||
<t t-set="url" t-value="'/my/clock'"/>
|
||||
<t t-set="text">Punch in, view timesheets, and download attendance reports</t>
|
||||
<t t-set="placeholder_count" t-value="'clock_count'"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
@@ -22,8 +15,8 @@
|
||||
<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="__fclk_emp" t-value="fclk_employee if fclk_employee is defined else False"/>
|
||||
<t t-if="__fclk_emp and __fclk_emp.x_fclk_enable_clock">
|
||||
<t t-set="fclk_emp" t-value="fclk_employee if fclk_employee is defined else False"/>
|
||||
<t t-if="fclk_emp and fclk_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 ''"
|
||||
@@ -208,6 +201,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request Leave -->
|
||||
<button id="fclk-leave-btn" class="fclk-leave-btn">
|
||||
<svg width="18" height="18" 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>
|
||||
Request Leave
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="fclk-leave-btn-arrow">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="fclk-recent-section">
|
||||
<div class="fclk-recent-header">
|
||||
@@ -278,6 +285,127 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Reason Modal (missed clock-out) -->
|
||||
<div class="fclk-wizard-overlay" id="fclk-reason-modal" style="display:none;">
|
||||
<div class="fclk-wizard-backdrop" data-dismiss="fclk-reason-modal"></div>
|
||||
<div class="fclk-wizard-dialog">
|
||||
<div class="fclk-wizard-header fclk-wizard-header--warning">
|
||||
<div class="fclk-wizard-header-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="fclk-wizard-title">Missed Clock-Out</h3>
|
||||
<p class="fclk-wizard-subtitle">You didn't clock out on your last shift. Please provide details before continuing.</p>
|
||||
</div>
|
||||
<div class="fclk-wizard-body">
|
||||
<div class="fclk-wizard-field">
|
||||
<label class="fclk-wizard-label" for="fclk-reason-text">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
Reason <span class="fclk-wizard-required">*</span>
|
||||
</label>
|
||||
<textarea id="fclk-reason-text" class="fclk-wizard-input fclk-wizard-textarea" rows="3"
|
||||
placeholder="Please explain why you didn't clock out..."></textarea>
|
||||
</div>
|
||||
<div class="fclk-wizard-field">
|
||||
<label class="fclk-wizard-label" for="fclk-reason-time">
|
||||
<svg width="14" height="14" 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>
|
||||
Departure Time
|
||||
</label>
|
||||
<input type="datetime-local" id="fclk-reason-time" class="fclk-wizard-input"/>
|
||||
<span class="fclk-wizard-hint">When did you actually leave? (optional)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-wizard-footer">
|
||||
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-dismiss="fclk-reason-modal">Cancel</button>
|
||||
<button id="fclk-reason-submit" class="fclk-wizard-btn fclk-wizard-btn--primary">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Submit Reason
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clock-Out Confirmation Modal -->
|
||||
<div class="fclk-wizard-overlay" id="fclk-clockout-confirm-modal" style="display:none;">
|
||||
<div class="fclk-wizard-backdrop" data-dismiss="fclk-clockout-confirm-modal"></div>
|
||||
<div class="fclk-wizard-dialog fclk-wizard-dialog--compact">
|
||||
<div class="fclk-wizard-header fclk-wizard-header--danger">
|
||||
<div class="fclk-wizard-header-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="fclk-wizard-title">Clock Out?</h3>
|
||||
<p class="fclk-wizard-subtitle">Are you sure you want to end your current shift?</p>
|
||||
</div>
|
||||
<div class="fclk-wizard-body">
|
||||
<div class="fclk-clockout-summary">
|
||||
<div class="fclk-clockout-summary-row">
|
||||
<span class="fclk-clockout-summary-label">Clocked in at</span>
|
||||
<span class="fclk-clockout-summary-value" id="fclk-confirm-checkin-time">--</span>
|
||||
</div>
|
||||
<div class="fclk-clockout-summary-row">
|
||||
<span class="fclk-clockout-summary-label">Duration</span>
|
||||
<span class="fclk-clockout-summary-value" id="fclk-confirm-duration">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-wizard-footer">
|
||||
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-dismiss="fclk-clockout-confirm-modal">Cancel</button>
|
||||
<button id="fclk-clockout-confirm-btn" class="fclk-wizard-btn fclk-wizard-btn--danger">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
|
||||
Confirm Clock Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leave Request Modal -->
|
||||
<div class="fclk-wizard-overlay" id="fclk-leave-modal" style="display:none;">
|
||||
<div class="fclk-wizard-backdrop" data-dismiss="fclk-leave-modal"></div>
|
||||
<div class="fclk-wizard-dialog">
|
||||
<div class="fclk-wizard-header fclk-wizard-header--info">
|
||||
<div class="fclk-wizard-header-icon">
|
||||
<svg width="28" height="28" 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>
|
||||
</div>
|
||||
<h3 class="fclk-wizard-title">Request Leave</h3>
|
||||
<p class="fclk-wizard-subtitle">Submit a leave request for your manager to review.</p>
|
||||
</div>
|
||||
<div class="fclk-wizard-body">
|
||||
<div class="fclk-wizard-field">
|
||||
<label class="fclk-wizard-label" for="fclk-leave-date">
|
||||
<svg width="14" height="14" 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>
|
||||
Leave Date <span class="fclk-wizard-required">*</span>
|
||||
</label>
|
||||
<input type="date" id="fclk-leave-date" class="fclk-wizard-input"/>
|
||||
</div>
|
||||
<div class="fclk-wizard-field">
|
||||
<label class="fclk-wizard-label" for="fclk-leave-reason">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
Reason <span class="fclk-wizard-required">*</span>
|
||||
</label>
|
||||
<textarea id="fclk-leave-reason" class="fclk-wizard-input fclk-wizard-textarea" rows="3"
|
||||
placeholder="Please provide a reason for your leave request..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-wizard-footer">
|
||||
<button class="fclk-wizard-btn fclk-wizard-btn--secondary" data-dismiss="fclk-leave-modal">Cancel</button>
|
||||
<button id="fclk-leave-submit" class="fclk-wizard-btn fclk-wizard-btn--primary">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Submit Leave Request
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<th>Break</th>
|
||||
<th>Net</th>
|
||||
<th>Location</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -92,6 +93,15 @@
|
||||
<td style="color:#9ca3af; font-size:12px;">
|
||||
<t t-esc="att.x_fclk_location_id.name or ''"/>
|
||||
</td>
|
||||
<td>
|
||||
<a href="#" class="fclk-correction-link"
|
||||
t-att-data-att-id="att.id"
|
||||
t-att-data-check-in="att.check_in.strftime('%Y-%m-%d %H:%M:%S') if att.check_in else ''"
|
||||
t-att-data-check-out="att.check_out.strftime('%Y-%m-%d %H:%M:%S') if att.check_out else ''"
|
||||
style="font-size:11px; color:#6b7280;">
|
||||
Correct
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<!-- Work Schedule -->
|
||||
<block title="Work Schedule" name="fclk_work_schedule">
|
||||
<setting string="Default Clock-In Time" help="The scheduled start time for employees.">
|
||||
<setting string="Default Clock-In Time" help="The scheduled start time for employees (used when no shift is assigned).">
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_default_clock_in_time" class="col-lg-3"/>
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
<!-- Penalties -->
|
||||
<block title="Penalty Tracking" name="fclk_penalties">
|
||||
<setting string="Enable Penalties" help="Track late clock-in and early clock-out.">
|
||||
<setting string="Enable Penalties" help="Track late clock-in and early clock-out with automatic deductions.">
|
||||
<field name="fclk_enable_penalties"/>
|
||||
<div class="content-group" invisible="not fclk_enable_penalties">
|
||||
<div class="row mt16">
|
||||
@@ -78,10 +78,104 @@
|
||||
<field name="fclk_penalty_grace_minutes"/>
|
||||
<span class="ms-1">minutes grace before penalty</span>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_penalty_deduction_minutes" class="col-lg-3"/>
|
||||
<field name="fclk_penalty_deduction_minutes"/>
|
||||
<span class="ms-1">minutes deducted per penalty</span>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Office User & Notifications -->
|
||||
<block title="Office User & Notifications" name="fclk_notifications">
|
||||
<setting string="Office User" help="User who receives all attendance-related activity notifications.">
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_office_user_id" class="col-lg-3"/>
|
||||
<field name="fclk_office_user_id"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_very_late_threshold_minutes" class="col-lg-3"/>
|
||||
<field name="fclk_very_late_threshold_minutes"/>
|
||||
<span class="ms-1">minutes late before office user is notified</span>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_max_monthly_absences" class="col-lg-3"/>
|
||||
<field name="fclk_max_monthly_absences"/>
|
||||
<span class="ms-1">absences before office user is alerted</span>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Employee Notifications" help="Send clock-in/out reminders to employees.">
|
||||
<field name="fclk_enable_employee_notifications"/>
|
||||
<div class="content-group" invisible="not fclk_enable_employee_notifications">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_reminder_before_shift_minutes" class="col-lg-3"/>
|
||||
<field name="fclk_reminder_before_shift_minutes"/>
|
||||
<span class="ms-1">minutes after shift start to remind</span>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_reminder_before_end_minutes" class="col-lg-3"/>
|
||||
<field name="fclk_reminder_before_end_minutes"/>
|
||||
<span class="ms-1">minutes before shift end to remind</span>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Weekly Summary" help="Send weekly attendance summary to employees on Monday.">
|
||||
<field name="fclk_send_weekly_summary"/>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Overtime -->
|
||||
<block title="Overtime" name="fclk_overtime">
|
||||
<setting string="Enable Overtime Tracking" help="Track hours beyond scheduled shift.">
|
||||
<field name="fclk_enable_overtime"/>
|
||||
<div class="content-group" invisible="not fclk_enable_overtime">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_daily_overtime_threshold" class="col-lg-3"/>
|
||||
<field name="fclk_daily_overtime_threshold" widget="float_time"/>
|
||||
<span class="ms-1">daily net hours threshold</span>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_weekly_overtime_threshold" class="col-lg-3"/>
|
||||
<field name="fclk_weekly_overtime_threshold" widget="float_time"/>
|
||||
<span class="ms-1">weekly net hours threshold</span>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Location & Verification -->
|
||||
<block title="Location & Verification" name="fclk_location_verification">
|
||||
<setting string="IP Fallback" help="Allow IP-based verification when GPS is unavailable.">
|
||||
<field name="fclk_enable_ip_fallback"/>
|
||||
</setting>
|
||||
<setting string="Photo Verification" help="Require selfie on clock-in (controlled per location).">
|
||||
<field name="fclk_enable_photo_verification"/>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Kiosk -->
|
||||
<block title="Kiosk Mode" name="fclk_kiosk">
|
||||
<setting string="Enable Kiosk" help="Allow shared-device clock-in/out.">
|
||||
<field name="fclk_enable_kiosk"/>
|
||||
<div class="content-group" invisible="not fclk_enable_kiosk">
|
||||
<div class="row mt16">
|
||||
<field name="fclk_kiosk_pin_required"/>
|
||||
<label for="fclk_kiosk_pin_required" class="ms-1"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Corrections -->
|
||||
<block title="Corrections" name="fclk_corrections">
|
||||
<setting string="Enable Correction Requests" help="Allow employees to request timesheet corrections.">
|
||||
<field name="fclk_enable_correction_requests"/>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Pay Period -->
|
||||
<block title="Pay Period" name="fclk_pay_period">
|
||||
<setting string="Pay Period Schedule" help="Defines how often reports are generated.">
|
||||
@@ -110,7 +204,15 @@
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_report_recipient_emails" class="col-lg-3"/>
|
||||
<field name="fclk_report_recipient_emails" class="o_input" placeholder="manager@company.com, hr@company.com"/>
|
||||
<field name="fclk_report_recipient_emails" class="o_input" placeholder="manager@company.com"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="CSV Column Mapping" help="Custom column names for CSV export (JSON format).">
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_csv_column_mapping" class="col-lg-3"/>
|
||||
<field name="fclk_csv_column_mapping" class="o_input"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
@@ -118,7 +220,7 @@
|
||||
|
||||
<!-- Clock Locations -->
|
||||
<block title="Clock Locations" name="fclk_locations">
|
||||
<setting string="Manage Locations" help="Configure geofenced clock-in/out locations with GPS coordinates and radius.">
|
||||
<setting string="Manage Locations" help="Configure geofenced clock-in/out locations.">
|
||||
<div class="content-group">
|
||||
<div class="mt16">
|
||||
<button name="%(fusion_clock.action_fusion_clock_location)d" type="action"
|
||||
|
||||
Reference in New Issue
Block a user