update
This commit is contained in:
BIN
fusion_clock/controllers/__pycache__/clock_api.cpython-312.pyc
Normal file
BIN
fusion_clock/controllers/__pycache__/clock_api.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,7 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today, get_local_day_boundaries
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -126,7 +127,7 @@ class FusionClockAPI(http.Controller):
|
||||
'scheduled_time': scheduled_dt,
|
||||
'actual_time': actual_dt,
|
||||
'penalty_minutes': deduction,
|
||||
'date': actual_dt.date() if isinstance(actual_dt, datetime) else fields.Date.today(),
|
||||
'date': actual_dt.date() if isinstance(actual_dt, datetime) else get_local_today(request.env, employee),
|
||||
})
|
||||
|
||||
# Deduct penalty minutes from attendance (adds to break deduction)
|
||||
@@ -266,7 +267,7 @@ class FusionClockAPI(http.Controller):
|
||||
)
|
||||
|
||||
now = fields.Datetime.now()
|
||||
today = now.date()
|
||||
today = get_local_today(request.env, employee)
|
||||
|
||||
geo_info = {
|
||||
'latitude': latitude,
|
||||
@@ -529,24 +530,23 @@ class FusionClockAPI(http.Controller):
|
||||
'location_id': att.x_fclk_location_id.id or False,
|
||||
})
|
||||
|
||||
today_start = fields.Datetime.to_string(
|
||||
datetime.combine(fields.Date.today(), datetime.min.time())
|
||||
)
|
||||
local_today = get_local_today(request.env, employee)
|
||||
today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
|
||||
today_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', today_start),
|
||||
('check_in', '>=', fields.Datetime.to_string(today_start_utc)),
|
||||
('check_in', '<', fields.Datetime.to_string(today_end_utc)),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
result['today_hours'] = round(sum(a.x_fclk_net_hours or 0 for a in today_atts), 2)
|
||||
|
||||
today = fields.Date.today()
|
||||
today = get_local_today(request.env, employee)
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_start_dt = fields.Datetime.to_string(
|
||||
datetime.combine(week_start, datetime.min.time())
|
||||
)
|
||||
week_start_utc, _ = get_local_day_boundaries(request.env, week_start, employee)
|
||||
week_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', week_start_dt),
|
||||
('check_in', '>=', fields.Datetime.to_string(week_start_utc)),
|
||||
('check_in', '<', fields.Datetime.to_string(today_end_utc)),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
result['week_hours'] = round(sum(a.x_fclk_net_hours or 0 for a in week_atts), 2)
|
||||
@@ -614,8 +614,8 @@ class FusionClockAPI(http.Controller):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
now = fields.Datetime.now()
|
||||
today = fields.Date.today()
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today = get_local_today(request.env)
|
||||
today_start, _ = get_local_day_boundaries(request.env, today)
|
||||
|
||||
Attendance = request.env['hr.attendance'].sudo()
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
|
||||
@@ -9,6 +9,7 @@ from datetime import datetime, timedelta
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today, get_local_day_boundaries
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,7 +49,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
if 'clock_count' in counters:
|
||||
employee = self._get_portal_employee()
|
||||
if employee:
|
||||
today_start = datetime.combine(fields.Date.today(), datetime.min.time())
|
||||
today_start, _ = get_local_day_boundaries(request.env, get_local_today(request.env, employee), employee)
|
||||
count = request.env['hr.attendance'].sudo().search_count([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', today_start),
|
||||
@@ -99,7 +100,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
], limit=1)
|
||||
|
||||
# Today stats
|
||||
today_start = datetime.combine(fields.Date.today(), datetime.min.time())
|
||||
today_start, _ = get_local_day_boundaries(request.env, get_local_today(request.env, employee), employee)
|
||||
today_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', today_start),
|
||||
@@ -108,9 +109,9 @@ class FusionClockPortal(CustomerPortal):
|
||||
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
|
||||
|
||||
# Week stats
|
||||
today = fields.Date.today()
|
||||
today = get_local_today(request.env, employee)
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_start_dt = datetime.combine(week_start, datetime.min.time())
|
||||
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
|
||||
week_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', week_start_dt),
|
||||
@@ -164,7 +165,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
period_start_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
|
||||
today = fields.Date.today()
|
||||
today = get_local_today(request.env, employee)
|
||||
|
||||
# Calculate period dates
|
||||
FusionReport = request.env['fusion.clock.report'].sudo()
|
||||
@@ -190,10 +191,12 @@ class FusionClockPortal(CustomerPortal):
|
||||
period_end -= timedelta(days=14)
|
||||
|
||||
# Get attendance records
|
||||
period_start_utc, _ = get_local_day_boundaries(request.env, period_start, employee)
|
||||
_, period_end_utc = get_local_day_boundaries(request.env, period_end, employee)
|
||||
attendances = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', datetime.combine(period_start, datetime.min.time())),
|
||||
('check_in', '<', datetime.combine(period_end + timedelta(days=1), datetime.min.time())),
|
||||
('check_in', '>=', period_start_utc),
|
||||
('check_in', '<', period_end_utc),
|
||||
], order='check_in desc')
|
||||
|
||||
total_hours = sum(a.worked_hours or 0 for a in attendances if a.check_out)
|
||||
|
||||
@@ -1,164 +1,190 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Email templates for Fusion Clock.
|
||||
Design: Matches the Fusion email design system (thin accent bar, system fonts,
|
||||
dark/light mode safe, no user signatures).
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- =============================================================== -->
|
||||
<!-- Employee Individual Report Email -->
|
||||
<!-- =============================================================== -->
|
||||
<record id="mail_template_clock_employee_report" model="mail.template">
|
||||
<field name="name">Fusion Clock: Employee Report</field>
|
||||
<field name="model_id" ref="fusion_clock.model_fusion_clock_report"/>
|
||||
<field name="subject">Your Attendance Report - {{ object.date_start }} to {{ object.date_end }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="email_to">{{ object.employee_id.work_email or '' }}</field>
|
||||
<field name="body_html"><![CDATA[
|
||||
<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>
|
||||
<p style="color:#9ca3af;margin:4px 0 0;">Attendance Report</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p>Hello <strong>{{ object.employee_id.name }}</strong>,</p>
|
||||
<p>Your attendance report for the period <strong>{{ object.date_start }}</strong> to <strong>{{ object.date_end }}</strong> is ready.</p>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="height:4px;background-color:#10B981;"></div>
|
||||
<div style="padding:32px 28px;">
|
||||
<p style="color:#10B981;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
|
||||
<t t-out="object.company_id.name"/>
|
||||
</p>
|
||||
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Attendance Report</h2>
|
||||
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
|
||||
Hello <strong><t t-out="object.employee_id.name"/></strong>,
|
||||
your attendance report for
|
||||
<strong><t t-out="object.date_start" t-options="{'widget': 'date'}"/></strong> to
|
||||
<strong><t t-out="object.date_end" t-options="{'widget': 'date'}"/></strong> is ready.
|
||||
</p>
|
||||
|
||||
<table width="100%" style="margin:16px 0;border-collapse:collapse;">
|
||||
<tr style="background:#f8f9fa;">
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Days Worked</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ object.days_worked }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Total Hours</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ '%.1f' % object.total_hours }}h</td>
|
||||
</tr>
|
||||
<tr style="background:#f8f9fa;">
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Net Hours</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ '%.1f' % object.net_hours }}h</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Total Breaks</strong></td>
|
||||
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ '%.0f' % object.total_breaks }} min</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
|
||||
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Report Summary</td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Days Worked</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.days_worked"/></td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Total Hours</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="'%.1f' % object.total_hours"/>h</td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Net Hours</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="'%.1f' % object.net_hours"/>h</td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Total Breaks</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="'%.0f' % object.total_breaks"/> min</td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Penalties</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.total_penalties"/></td></tr>
|
||||
</table>
|
||||
|
||||
<p>The full PDF report is attached. You can also download it from your portal at any time.</p>
|
||||
<p style="color:#6b7280;font-size:12px;">This is an automated message from Fusion Clock.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
|
||||
<p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> Attendance Report (PDF)</p>
|
||||
</div>
|
||||
|
||||
<div style="border-left:3px solid #10B981;padding:12px 16px;margin:0 0 24px 0;">
|
||||
<p style="margin:0;font-size:14px;line-height:1.5;">You can also download this report from your portal at any time.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 28px;text-align:center;">
|
||||
<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">
|
||||
<t t-out="object.company_id.name"/><br/>
|
||||
This is an automated notification from Fusion Clock.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
]]></field>
|
||||
<field name="report_template_ids" eval="[(4, ref('fusion_clock.action_report_clock_employee'))]"/>
|
||||
<field name="auto_delete" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- =============================================================== -->
|
||||
<!-- Batch Report Email (to managers) -->
|
||||
<!-- =============================================================== -->
|
||||
<record id="mail_template_clock_batch_report" model="mail.template">
|
||||
<field name="name">Fusion Clock: Batch Report</field>
|
||||
<field name="model_id" ref="fusion_clock.model_fusion_clock_report"/>
|
||||
<field name="subject">Employee Attendance Batch Report - {{ object.date_start }} to {{ object.date_end }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</field>
|
||||
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
|
||||
<field name="email_to">{{ (object.company_id.sudo().env['ir.config_parameter'].get_param('fusion_clock.report_recipient_emails') or '') }}</field>
|
||||
<field name="body_html"><![CDATA[
|
||||
<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;">
|
||||
<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>
|
||||
<p style="color:#9ca3af;margin:4px 0 0;">Batch Attendance Report</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p>The attendance batch report for <strong>{{ object.date_start }}</strong> to <strong>{{ object.date_end }}</strong> is attached.</p>
|
||||
<p>This report includes all employees' attendance summaries with daily breakdowns, total hours, and penalty information.</p>
|
||||
<p style="color:#6b7280;font-size:12px;">This is an automated message from Fusion Clock.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="height:4px;background-color:#10B981;"></div>
|
||||
<div style="padding:32px 28px;">
|
||||
<p style="color:#10B981;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
|
||||
<t t-out="object.company_id.name"/>
|
||||
</p>
|
||||
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Batch Attendance Report</h2>
|
||||
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
|
||||
The attendance batch report for
|
||||
<strong><t t-out="object.date_start" t-options="{'widget': 'date'}"/></strong> to
|
||||
<strong><t t-out="object.date_end" t-options="{'widget': 'date'}"/></strong> is attached.
|
||||
</p>
|
||||
|
||||
<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
|
||||
<p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> Batch Attendance Report (PDF)</p>
|
||||
</div>
|
||||
|
||||
<div style="border-left:3px solid #10B981;padding:12px 16px;margin:0 0 24px 0;">
|
||||
<p style="margin:0;font-size:14px;line-height:1.5;">This report includes all employees' attendance summaries with daily breakdowns, total hours, and penalty information.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 28px;text-align:center;">
|
||||
<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">
|
||||
<t t-out="object.company_id.name"/><br/>
|
||||
This is an automated notification from Fusion Clock.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
]]></field>
|
||||
<field name="report_template_ids" eval="[(4, ref('fusion_clock.action_report_clock_batch'))]"/>
|
||||
<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_from">{{ (object.company_id.email_formatted 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>
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="height:4px;background-color:#10B981;"></div>
|
||||
<div style="padding:32px 28px;">
|
||||
<p style="color:#10B981;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
|
||||
<t t-out="object.company_id.name"/>
|
||||
</p>
|
||||
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Weekly Summary</h2>
|
||||
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
|
||||
Hello <strong><t t-out="object.name"/></strong>,
|
||||
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>
|
||||
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
|
||||
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Summary</td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Total Hours</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="ctx.get('total_hours', 0)"/>h</td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Overtime</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="ctx.get('overtime_hours', 0)"/>h</td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Penalties</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="ctx.get('penalty_count', 0)"/></td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Absences</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="ctx.get('absence_count', 0)"/></td></tr>
|
||||
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">On-Time Streak</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="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 style="border-left:3px solid #10B981;padding:12px 16px;margin:0 0 24px 0;">
|
||||
<p style="margin:0;font-size:14px;line-height:1.5;">Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> to view full details.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 28px;text-align:center;">
|
||||
<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">
|
||||
<t t-out="object.company_id.name"/><br/>
|
||||
This is an automated notification from Fusion Clock.
|
||||
</p>
|
||||
</div>
|
||||
</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="email_from">{{ (object.company_id.email_formatted 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 style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="height:4px;background-color:#d69e2e;"></div>
|
||||
<div style="padding:32px 28px;">
|
||||
<p style="color:#d69e2e;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
|
||||
<t t-out="object.company_id.name"/>
|
||||
</p>
|
||||
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Correction Request</h2>
|
||||
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
|
||||
<strong><t t-out="object.employee_id.name"/></strong> has submitted a timesheet correction request.
|
||||
</p>
|
||||
|
||||
<div style="border-left:3px solid #d69e2e;padding:12px 16px;margin:0 0 24px 0;">
|
||||
<p style="margin:0;font-size:14px;line-height:1.5;"><strong>Reason:</strong> <t t-out="object.reason"/></p>
|
||||
</div>
|
||||
|
||||
<div style="border-left:3px solid #10B981;padding:12px 16px;margin:0 0 24px 0;">
|
||||
<p style="margin:0;font-size:14px;line-height:1.5;">Please review and approve or reject from the Fusion Clock backend.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:16px 28px;text-align:center;">
|
||||
<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">
|
||||
<t t-out="object.company_id.name"/><br/>
|
||||
This is an automated notification from Fusion Clock.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
]]></field>
|
||||
<field name="auto_delete" eval="False"/>
|
||||
|
||||
BIN
fusion_clock/models/__pycache__/clock_report.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_report.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock/models/__pycache__/hr_attendance.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/hr_attendance.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock/models/__pycache__/hr_employee.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/hr_employee.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock/models/__pycache__/tz_utils.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/tz_utils.cpython-312.pyc
Normal file
Binary file not shown.
@@ -4,9 +4,12 @@
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import pytz
|
||||
from datetime import timedelta
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
from .hr_attendance import _fclk_email_wrap, _fclk_utc_to_local_str
|
||||
from .tz_utils import get_local_today, get_local_day_boundaries, _resolve_tz
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,6 +70,7 @@ class FusionClockReport(models.Model):
|
||||
total_breaks = fields.Float(string='Total Breaks (min)', compute='_compute_totals', store=True)
|
||||
total_penalties = fields.Integer(string='Penalty Count', compute='_compute_totals', store=True)
|
||||
days_worked = fields.Integer(string='Days Worked', compute='_compute_totals', store=True)
|
||||
leave_days = fields.Integer(string='Leave Days', compute='_compute_totals', store=True)
|
||||
attendance_ids = fields.Many2many(
|
||||
'hr.attendance',
|
||||
'fusion_clock_report_attendance_rel',
|
||||
@@ -74,6 +78,13 @@ class FusionClockReport(models.Model):
|
||||
'attendance_id',
|
||||
string='Attendance Records',
|
||||
)
|
||||
leave_request_ids = fields.Many2many(
|
||||
'fusion.clock.leave.request',
|
||||
'fusion_clock_report_leave_rel',
|
||||
'report_id',
|
||||
'leave_request_id',
|
||||
string='Leave Requests',
|
||||
)
|
||||
|
||||
# PDF
|
||||
report_pdf = fields.Binary(string='Report PDF', attachment=True)
|
||||
@@ -93,7 +104,8 @@ class FusionClockReport(models.Model):
|
||||
rec.is_batch = not bool(rec.employee_id)
|
||||
|
||||
@api.depends('attendance_ids', 'attendance_ids.worked_hours',
|
||||
'attendance_ids.x_fclk_net_hours', 'attendance_ids.x_fclk_break_minutes')
|
||||
'attendance_ids.x_fclk_net_hours', 'attendance_ids.x_fclk_break_minutes',
|
||||
'leave_request_ids')
|
||||
def _compute_totals(self):
|
||||
for rec in self:
|
||||
atts = rec.attendance_ids
|
||||
@@ -105,12 +117,14 @@ class FusionClockReport(models.Model):
|
||||
('date', '>=', rec.date_start),
|
||||
('date', '<=', rec.date_end),
|
||||
]) if rec.employee_id else 0
|
||||
# Count unique dates
|
||||
tz = _resolve_tz(rec.env, rec.employee_id)
|
||||
dates = set()
|
||||
for a in atts:
|
||||
if a.check_in:
|
||||
dates.add(a.check_in.date())
|
||||
local_dt = pytz.UTC.localize(a.check_in).astimezone(tz)
|
||||
dates.add(local_dt.date())
|
||||
rec.days_worked = len(dates)
|
||||
rec.leave_days = len(rec.leave_request_ids)
|
||||
|
||||
def action_generate_report(self):
|
||||
"""Generate the PDF report for this record."""
|
||||
@@ -119,6 +133,11 @@ class FusionClockReport(models.Model):
|
||||
self._generate_pdf()
|
||||
self.state = 'generated'
|
||||
|
||||
def action_reset_draft(self):
|
||||
"""Reset the report back to draft so the user can make changes."""
|
||||
for rec in self:
|
||||
rec.state = 'draft'
|
||||
|
||||
def action_send_report(self):
|
||||
"""Send the report via email."""
|
||||
self.ensure_one()
|
||||
@@ -128,11 +147,14 @@ class FusionClockReport(models.Model):
|
||||
self.state = 'sent'
|
||||
|
||||
def _collect_attendance_records(self):
|
||||
"""Link attendance records for the period and employee."""
|
||||
"""Link attendance and leave records for the period and employee."""
|
||||
self.ensure_one()
|
||||
employee = self.employee_id or None
|
||||
start_utc, _ = get_local_day_boundaries(self.env, self.date_start, employee)
|
||||
_, end_utc = get_local_day_boundaries(self.env, self.date_end, employee)
|
||||
domain = [
|
||||
('check_in', '>=', fields.Datetime.to_datetime(self.date_start)),
|
||||
('check_in', '<', fields.Datetime.to_datetime(self.date_end + timedelta(days=1))),
|
||||
('check_in', '>=', start_utc),
|
||||
('check_in', '<', end_utc),
|
||||
('check_out', '!=', False),
|
||||
]
|
||||
if self.employee_id:
|
||||
@@ -143,6 +165,18 @@ class FusionClockReport(models.Model):
|
||||
attendances = self.env['hr.attendance'].search(domain)
|
||||
self.attendance_ids = [(6, 0, attendances.ids)]
|
||||
|
||||
leave_domain = [
|
||||
('leave_date', '>=', self.date_start),
|
||||
('leave_date', '<=', self.date_end),
|
||||
]
|
||||
if self.employee_id:
|
||||
leave_domain.append(('employee_id', '=', self.employee_id.id))
|
||||
else:
|
||||
leave_domain.append(('company_id', '=', self.company_id.id))
|
||||
|
||||
leaves = self.env['fusion.clock.leave.request'].search(leave_domain)
|
||||
self.leave_request_ids = [(6, 0, leaves.ids)]
|
||||
|
||||
def _generate_pdf(self):
|
||||
"""Render the QWeb report to PDF and store it."""
|
||||
self.ensure_one()
|
||||
@@ -172,52 +206,63 @@ class FusionClockReport(models.Model):
|
||||
def _send_report_email(self):
|
||||
"""Send the report with the PDF attached."""
|
||||
self.ensure_one()
|
||||
company_email = self.company_id.email or ''
|
||||
company = self.company_id or self.env.company
|
||||
company_email = company.email or ''
|
||||
company_name = company.name or ''
|
||||
ds_fmt = self.date_start.strftime('%b %d, %Y') if self.date_start else ''
|
||||
de_fmt = self.date_end.strftime('%b %d, %Y') if self.date_end else ''
|
||||
|
||||
if self.employee_id:
|
||||
email_to = self.employee_id.work_email or ''
|
||||
subject = f"Your Attendance Report - {self.date_start} to {self.date_end}"
|
||||
body = (
|
||||
'<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;overflow:hidden;">'
|
||||
'<tr><td style="padding:24px 32px;background:#1a1d23;">'
|
||||
'<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>'
|
||||
'<p style="color:#9ca3af;margin:4px 0 0;">Attendance Report</p>'
|
||||
'</td></tr><tr><td style="padding:24px 32px;">'
|
||||
f'<p>Hello <strong>{self.employee_id.name}</strong>,</p>'
|
||||
f'<p>Your attendance report for <strong>{self.date_start}</strong> to '
|
||||
f'<strong>{self.date_end}</strong> is ready.</p>'
|
||||
'<table width="100%" style="margin:16px 0;border-collapse:collapse;">'
|
||||
f'<tr style="background:#f8f9fa;"><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Days Worked</td>'
|
||||
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.days_worked}</td></tr>'
|
||||
f'<tr><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Total Hours</td>'
|
||||
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.total_hours:.1f}h</td></tr>'
|
||||
f'<tr style="background:#f8f9fa;"><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Net Hours</td>'
|
||||
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.net_hours:.1f}h</td></tr>'
|
||||
f'<tr><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Total Breaks</td>'
|
||||
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.total_breaks:.0f} min</td></tr>'
|
||||
'</table>'
|
||||
'<p>The full PDF report is attached.</p>'
|
||||
'<p style="color:#6b7280;font-size:12px;margin-top:16px;">This is an automated message from Fusion Clock.</p>'
|
||||
'</td></tr></table></div>'
|
||||
subject = f"Your Attendance Report - {ds_fmt} to {de_fmt}"
|
||||
body = _fclk_email_wrap(
|
||||
company_name=company_name,
|
||||
title='Attendance Report',
|
||||
summary=(
|
||||
f'Hello <strong>{self.employee_id.name}</strong>, your attendance '
|
||||
f'report for <strong>{ds_fmt}</strong> to '
|
||||
f'<strong>{de_fmt}</strong> is ready.'
|
||||
),
|
||||
sections=[('Report Summary', [
|
||||
('Pay Period', f'{ds_fmt} — {de_fmt}'),
|
||||
('Days Worked', str(self.days_worked)),
|
||||
('Leave Days', str(self.leave_days)),
|
||||
('Total Hours', f'{self.total_hours:.1f}h'),
|
||||
('Net Hours', f'{self.net_hours:.1f}h'),
|
||||
('Total Breaks', f'{self.total_breaks:.0f} min'),
|
||||
('Penalties', str(self.total_penalties)),
|
||||
])],
|
||||
note='The full PDF report is attached. You can also download '
|
||||
'it from <a href="/my/clock" style="color:#10B981;">'
|
||||
'your portal</a> at any time.',
|
||||
attachments_note='Attendance Report (PDF)',
|
||||
)
|
||||
else:
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
email_to = ICP.get_param('fusion_clock.report_recipient_emails', '')
|
||||
subject = f"Employee Attendance Batch Report - {self.date_start} to {self.date_end}"
|
||||
body = (
|
||||
'<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;overflow:hidden;">'
|
||||
'<tr><td style="padding:24px 32px;background:#1a1d23;">'
|
||||
'<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>'
|
||||
'<p style="color:#9ca3af;margin:4px 0 0;">Batch Attendance Report</p>'
|
||||
'</td></tr><tr><td style="padding:24px 32px;">'
|
||||
f'<p>The attendance batch report for <strong>{self.date_start}</strong> to '
|
||||
f'<strong>{self.date_end}</strong> is attached.</p>'
|
||||
'<p>This report includes all employees\' attendance summaries with daily breakdowns, '
|
||||
'total hours, and penalty information.</p>'
|
||||
'<p style="color:#6b7280;font-size:12px;margin-top:16px;">This is an automated message from Fusion Clock.</p>'
|
||||
'</td></tr></table></div>'
|
||||
user_ids_str = ICP.get_param('fusion_clock.report_recipient_user_ids', '')
|
||||
if user_ids_str:
|
||||
try:
|
||||
user_ids = [int(x) for x in user_ids_str.split(',') if x.strip()]
|
||||
users = self.env['res.users'].sudo().browse(user_ids).filtered('email')
|
||||
user_emails = ','.join(u.email for u in users if u.email)
|
||||
if email_to and user_emails:
|
||||
email_to = f"{email_to},{user_emails}"
|
||||
elif user_emails:
|
||||
email_to = user_emails
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
subject = f"Employee Attendance Batch Report - {ds_fmt} to {de_fmt}"
|
||||
body = _fclk_email_wrap(
|
||||
company_name=company_name,
|
||||
title='Batch Attendance Report',
|
||||
summary=(
|
||||
f'The attendance batch report for <strong>{ds_fmt}</strong> to '
|
||||
f'<strong>{de_fmt}</strong> is attached.'
|
||||
),
|
||||
note='This report includes all employees\' attendance summaries '
|
||||
'with daily breakdowns, total hours, and penalty information.',
|
||||
attachments_note='Batch Attendance Report (PDF)',
|
||||
)
|
||||
|
||||
if not email_to:
|
||||
@@ -283,8 +328,8 @@ class FusionClockReport(models.Model):
|
||||
|
||||
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 ''
|
||||
in_str = _fclk_utc_to_local_str(att.check_in, att.employee_id, '%H:%M') if att.check_in else ''
|
||||
out_str = _fclk_utc_to_local_str(att.check_out, att.employee_id, '%H:%M') if att.check_out else ''
|
||||
penalties = self.env['fusion.clock.penalty'].search_count([
|
||||
('attendance_id', '=', att.id),
|
||||
])
|
||||
@@ -301,6 +346,20 @@ class FusionClockReport(models.Model):
|
||||
att.x_fclk_location_id.name or '',
|
||||
])
|
||||
|
||||
for lv in self.leave_request_ids.sorted(key=lambda l: l.leave_date):
|
||||
writer.writerow([
|
||||
lv.employee_id.name or '',
|
||||
str(lv.leave_date),
|
||||
'LEAVE',
|
||||
'LEAVE',
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
lv.reason or '',
|
||||
])
|
||||
|
||||
csv_data = output.getvalue().encode('utf-8')
|
||||
output.close()
|
||||
|
||||
@@ -333,11 +392,10 @@ class FusionClockReport(models.Model):
|
||||
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
period_start_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
|
||||
today = fields.Date.today()
|
||||
period_start, period_end = self._calculate_current_period(schedule_type, period_start_str, today)
|
||||
|
||||
# Only generate if yesterday was the end of a period
|
||||
if period_end != today - timedelta(days=1):
|
||||
today = get_local_today(self.env)
|
||||
yesterday = today - timedelta(days=1)
|
||||
period_start, period_end = self._calculate_current_period(schedule_type, period_start_str, yesterday)
|
||||
if period_end != yesterday:
|
||||
return
|
||||
|
||||
_logger.info("Fusion Clock: Generating reports for period %s to %s", period_start, period_end)
|
||||
@@ -443,3 +501,86 @@ class FusionClockReport(models.Model):
|
||||
period_end = period_start + timedelta(days=13)
|
||||
|
||||
return period_start, period_end
|
||||
|
||||
@api.model
|
||||
def action_generate_historical_reports(self):
|
||||
"""Generate reports for all past pay periods from historical attendance data.
|
||||
|
||||
Iterates per-employee so each starts from their own earliest
|
||||
attendance record rather than the global earliest. Includes ALL
|
||||
employees who have completed attendance records, not only those
|
||||
with ``x_fclk_enable_clock``.
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
period_start_str = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
today = get_local_today(self.env)
|
||||
created_count = 0
|
||||
|
||||
emp_groups = self.env['hr.attendance'].sudo().read_group(
|
||||
[('check_out', '!=', False)],
|
||||
['employee_id'],
|
||||
['employee_id'],
|
||||
)
|
||||
|
||||
for emp_data in emp_groups:
|
||||
emp_id = emp_data['employee_id'][0]
|
||||
employee = self.env['hr.employee'].sudo().browse(emp_id)
|
||||
if not employee.exists():
|
||||
continue
|
||||
|
||||
earliest = self.env['hr.attendance'].sudo().search(
|
||||
[('employee_id', '=', emp_id), ('check_out', '!=', False)],
|
||||
order='check_in asc',
|
||||
limit=1,
|
||||
)
|
||||
if not earliest:
|
||||
continue
|
||||
|
||||
current = earliest.check_in.date()
|
||||
|
||||
while current <= today:
|
||||
period_start, period_end = self._calculate_current_period(
|
||||
schedule_type, period_start_str, current,
|
||||
)
|
||||
if period_end > today:
|
||||
break
|
||||
|
||||
existing = self.sudo().search([
|
||||
('employee_id', '=', emp_id),
|
||||
('date_start', '=', period_start),
|
||||
('date_end', '=', period_end),
|
||||
], limit=1)
|
||||
if not existing:
|
||||
att_count = self.env['hr.attendance'].sudo().search_count([
|
||||
('employee_id', '=', emp_id),
|
||||
('check_in', '>=', fields.Datetime.to_datetime(period_start)),
|
||||
('check_in', '<', fields.Datetime.to_datetime(
|
||||
period_end + timedelta(days=1),
|
||||
)),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
if att_count > 0:
|
||||
report = self.sudo().create({
|
||||
'date_start': period_start,
|
||||
'date_end': period_end,
|
||||
'schedule_type': schedule_type,
|
||||
'employee_id': emp_id,
|
||||
'company_id': employee.company_id.id,
|
||||
})
|
||||
report._collect_attendance_records()
|
||||
created_count += 1
|
||||
|
||||
current = period_end + timedelta(days=1)
|
||||
|
||||
_logger.info("Fusion Clock: Generated %d historical reports", created_count)
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Historical Reports',
|
||||
'message': f'{created_count} reports generated from historical data.',
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,13 +3,118 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
import pytz
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import models, fields, api
|
||||
from odoo.tools import float_round
|
||||
from .tz_utils import get_local_today, get_local_day_boundaries
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fclk_utc_to_local_str(dt, employee, fmt='%I:%M %p'):
|
||||
"""Convert a naive UTC datetime to a formatted string in the employee's timezone."""
|
||||
import pytz
|
||||
tz_name = (
|
||||
employee.resource_id.tz
|
||||
or (employee.user_id.partner_id.tz if employee.user_id else False)
|
||||
or employee.company_id.partner_id.tz
|
||||
or 'UTC'
|
||||
)
|
||||
utc_dt = pytz.UTC.localize(dt)
|
||||
local_dt = utc_dt.astimezone(pytz.timezone(tz_name))
|
||||
return local_dt.strftime(fmt)
|
||||
|
||||
|
||||
_FCLK_ACCENT = '#10B981'
|
||||
_FCLK_FONT = ("-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif")
|
||||
|
||||
|
||||
def _fclk_email_section(heading, rows):
|
||||
"""Build a details table matching the Fusion email design system."""
|
||||
if not rows:
|
||||
return ''
|
||||
html = (
|
||||
'<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">'
|
||||
f'<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;'
|
||||
f'opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;'
|
||||
f'border-bottom:2px solid rgba(128,128,128,0.25);">{heading}</td></tr>'
|
||||
)
|
||||
for label, value in rows:
|
||||
if value is None or value == '' or value is False:
|
||||
continue
|
||||
html += (
|
||||
f'<tr>'
|
||||
f'<td style="padding:10px 14px;opacity:0.6;font-size:14px;'
|
||||
f'border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">{label}</td>'
|
||||
f'<td style="padding:10px 14px;font-size:14px;'
|
||||
f'border-bottom:1px solid rgba(128,128,128,0.15);">{value}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
html += '</table>'
|
||||
return html
|
||||
|
||||
|
||||
def _fclk_email_wrap(
|
||||
company_name,
|
||||
title,
|
||||
summary,
|
||||
sections=None,
|
||||
note=None,
|
||||
attachments_note=None,
|
||||
extra_html='',
|
||||
):
|
||||
"""Build a complete Fusion Clock email matching the Fusion design system.
|
||||
|
||||
No user signatures are appended.
|
||||
"""
|
||||
parts = [
|
||||
f'<div style="font-family:{_FCLK_FONT};max-width:600px;margin:0 auto;">',
|
||||
f'<div style="height:4px;background-color:{_FCLK_ACCENT};"></div>',
|
||||
'<div style="padding:32px 28px;">',
|
||||
f'<p style="color:{_FCLK_ACCENT};font-size:13px;font-weight:600;'
|
||||
f'letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">'
|
||||
f'{company_name}</p>',
|
||||
f'<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;'
|
||||
f'line-height:1.3;">{title}</h2>',
|
||||
f'<p style="opacity:0.65;font-size:15px;line-height:1.5;'
|
||||
f'margin:0 0 24px 0;">{summary}</p>',
|
||||
]
|
||||
|
||||
if sections:
|
||||
for heading, rows in sections:
|
||||
parts.append(_fclk_email_section(heading, rows))
|
||||
|
||||
if note:
|
||||
parts.append(
|
||||
f'<div style="border-left:3px solid {_FCLK_ACCENT};padding:12px 16px;'
|
||||
f'margin:0 0 24px 0;">'
|
||||
f'<p style="margin:0;font-size:14px;line-height:1.5;">{note}</p></div>'
|
||||
)
|
||||
|
||||
if extra_html:
|
||||
parts.append(extra_html)
|
||||
|
||||
if attachments_note:
|
||||
parts.append(
|
||||
'<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);'
|
||||
'border-radius:6px;margin:0 0 24px 0;">'
|
||||
'<p style="margin:0;font-size:13px;opacity:0.65;">'
|
||||
f'<strong style="opacity:1;">Attached:</strong> {attachments_note}</p></div>'
|
||||
)
|
||||
|
||||
parts.append('</div>')
|
||||
parts.append(
|
||||
'<div style="padding:16px 28px;text-align:center;">'
|
||||
'<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">'
|
||||
f'{company_name}<br/>'
|
||||
'This is an automated notification from Fusion Clock.</p></div>'
|
||||
)
|
||||
parts.append('</div>')
|
||||
|
||||
return ''.join(parts)
|
||||
|
||||
|
||||
class HrAttendance(models.Model):
|
||||
_inherit = 'hr.attendance'
|
||||
|
||||
@@ -146,7 +251,9 @@ class HrAttendance(models.Model):
|
||||
continue
|
||||
|
||||
employee = att.employee_id
|
||||
_, scheduled_out = employee._get_fclk_scheduled_times(check_in.date())
|
||||
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
||||
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
|
||||
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
|
||||
|
||||
deadline = scheduled_out + timedelta(minutes=grace_min)
|
||||
max_deadline = check_in + timedelta(hours=max_shift)
|
||||
@@ -169,7 +276,7 @@ class HrAttendance(models.Model):
|
||||
att.sudo().write({'x_fclk_break_minutes': break_min})
|
||||
|
||||
att.sudo().message_post(
|
||||
body=f"Auto clocked out at {clock_out_time.strftime('%H:%M')} "
|
||||
body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
|
||||
f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
@@ -179,7 +286,7 @@ class HrAttendance(models.Model):
|
||||
ActivityLog.create({
|
||||
'employee_id': employee.id,
|
||||
'log_type': 'auto_clock_out',
|
||||
'description': f"Auto clocked out at {clock_out_time.strftime('%H:%M')}. "
|
||||
'description': f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%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,
|
||||
@@ -193,7 +300,7 @@ class HrAttendance(models.Model):
|
||||
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"{employee.name} was auto-clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')}. "
|
||||
f"Please review and correct if needed.",
|
||||
'hr.attendance',
|
||||
att.id,
|
||||
@@ -216,21 +323,6 @@ class HrAttendance(models.Model):
|
||||
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),
|
||||
])
|
||||
@@ -239,16 +331,29 @@ class HrAttendance(models.Model):
|
||||
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
|
||||
|
||||
for emp in employees:
|
||||
# Check for attendance yesterday
|
||||
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
|
||||
|
||||
if yesterday.weekday() >= 5:
|
||||
continue
|
||||
|
||||
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
|
||||
|
||||
holidays = self.env['resource.calendar.leaves'].sudo().search([
|
||||
('resource_id', '=', False),
|
||||
('date_from', '<=', day_end),
|
||||
('date_to', '>=', day_start),
|
||||
])
|
||||
if holidays:
|
||||
continue
|
||||
|
||||
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())),
|
||||
('check_in', '>=', day_start),
|
||||
('check_in', '<', day_end),
|
||||
])
|
||||
if att_count > 0:
|
||||
continue
|
||||
|
||||
# Check for approved leave
|
||||
leave = LeaveRequest.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('leave_date', '=', yesterday),
|
||||
@@ -256,23 +361,22 @@ class HrAttendance(models.Model):
|
||||
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)),
|
||||
'log_date': day_start,
|
||||
'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)
|
||||
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||
absence_count = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', datetime.combine(month_start, datetime.min.time())),
|
||||
('log_date', '>=', month_boundary_start),
|
||||
])
|
||||
|
||||
if absence_count >= max_absences:
|
||||
@@ -298,17 +402,17 @@ class HrAttendance(models.Model):
|
||||
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:
|
||||
today = get_local_today(self.env, emp)
|
||||
|
||||
if today.weekday() >= 5:
|
||||
continue
|
||||
|
||||
if emp.x_fclk_last_reminder_date == today:
|
||||
continue
|
||||
|
||||
@@ -318,16 +422,17 @@ class HrAttendance(models.Model):
|
||||
# Missed clock-in reminder
|
||||
reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min)
|
||||
if not is_checked_in and now > reminder_deadline:
|
||||
today_start, _ = get_local_day_boundaries(self.env, today, emp)
|
||||
has_attendance = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(today, datetime.min.time())),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
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')}.",
|
||||
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
|
||||
@@ -337,7 +442,7 @@ class HrAttendance(models.Model):
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-Out Reminder",
|
||||
f"Hi {emp.name}, your shift ends at {scheduled_out.strftime('%I:%M %p')}. "
|
||||
f"Hi {emp.name}, your shift ends at {_fclk_utc_to_local_str(scheduled_out, emp)}. "
|
||||
f"Don't forget to clock out.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
@@ -349,27 +454,34 @@ class HrAttendance(models.Model):
|
||||
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),
|
||||
])
|
||||
|
||||
company_email = self.env.company.email or ''
|
||||
company = self.env.company
|
||||
company_email = company.email or ''
|
||||
company_name = company.name or ''
|
||||
|
||||
for emp in employees:
|
||||
if not emp.work_email:
|
||||
continue
|
||||
|
||||
today = get_local_today(self.env, emp)
|
||||
if today.weekday() != 0:
|
||||
continue
|
||||
|
||||
week_start = today - timedelta(days=7)
|
||||
week_end = today - timedelta(days=1)
|
||||
ws_fmt = week_start.strftime('%b %d, %Y')
|
||||
we_fmt = week_end.strftime('%b %d, %Y')
|
||||
|
||||
ws_start, _ = get_local_day_boundaries(self.env, week_start, emp)
|
||||
_, we_end = get_local_day_boundaries(self.env, week_end, emp)
|
||||
|
||||
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_in', '>=', ws_start),
|
||||
('check_in', '<', we_end),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
|
||||
@@ -385,47 +497,36 @@ class HrAttendance(models.Model):
|
||||
absence_count = 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())),
|
||||
('log_date', '>=', ws_start),
|
||||
('log_date', '<', we_end),
|
||||
])
|
||||
|
||||
streak = emp.x_fclk_ontime_streak or 0
|
||||
emp_company = emp.company_id or company
|
||||
|
||||
def _row(label, value, bg=False):
|
||||
bg_style = 'background:#f8f9fa;' if bg else ''
|
||||
return (
|
||||
f'<tr style="{bg_style}">'
|
||||
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">{label}</td>'
|
||||
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{value}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
|
||||
body = (
|
||||
'<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;overflow:hidden;">'
|
||||
'<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;">'
|
||||
f'<p>Hello <strong>{emp.name}</strong>,</p>'
|
||||
f'<p>Here is your attendance summary for <strong>{week_start}</strong> to <strong>{week_end}</strong>:</p>'
|
||||
'<table width="100%" style="margin:16px 0;border-collapse:collapse;">'
|
||||
+ _row('Total Hours', f'{total_net}h', True)
|
||||
+ _row('Overtime', f'{total_ot}h', False)
|
||||
+ _row('Penalties', str(penalty_count), True)
|
||||
+ _row('Absences', str(absence_count), False)
|
||||
+ _row('On-Time Streak', f'{streak} days', True)
|
||||
+ '</table>'
|
||||
'<p>Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> to view details.</p>'
|
||||
'<p style="color:#6b7280;font-size:12px;margin-top:16px;">This is an automated message from Fusion Clock.</p>'
|
||||
'</td></tr></table></div>'
|
||||
body = _fclk_email_wrap(
|
||||
company_name=emp_company.name or company_name,
|
||||
title='Weekly Summary',
|
||||
summary=(
|
||||
f'Hello <strong>{emp.name}</strong>, here is your attendance '
|
||||
f'summary for <strong>{ws_fmt}</strong> to <strong>{we_fmt}</strong>.'
|
||||
),
|
||||
sections=[('Summary', [
|
||||
('Total Hours', f'{total_net}h'),
|
||||
('Overtime', f'{total_ot}h'),
|
||||
('Penalties', str(penalty_count)),
|
||||
('Absences', str(absence_count)),
|
||||
('On-Time Streak', f'{streak} days'),
|
||||
])],
|
||||
note='Log in to <a href="/my/clock" style="color:#10B981;">'
|
||||
'your portal</a> to view full details.',
|
||||
)
|
||||
|
||||
try:
|
||||
from_email = emp_company.email or company_email
|
||||
mail = self.env['mail.mail'].sudo().create({
|
||||
'subject': f'Your Weekly Attendance Summary ({week_start} - {week_end})',
|
||||
'email_from': company_email,
|
||||
'subject': f'Your Weekly Attendance Summary ({ws_fmt} - {we_fmt})',
|
||||
'email_from': from_email,
|
||||
'email_to': emp.work_email,
|
||||
'body_html': body,
|
||||
'auto_delete': True,
|
||||
@@ -450,7 +551,7 @@ class HrAttendance(models.Model):
|
||||
'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(),
|
||||
'date_deadline': get_local_today(self.env),
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to create office activity: %s", e)
|
||||
@@ -469,9 +570,20 @@ class HrAttendance(models.Model):
|
||||
pass
|
||||
try:
|
||||
if employee.work_email:
|
||||
company = employee.company_id or self.env.company
|
||||
company_email = company.email or ''
|
||||
company_name = company.name or ''
|
||||
html_body = _fclk_email_wrap(
|
||||
company_name=company_name,
|
||||
title=subject,
|
||||
summary=body,
|
||||
note='Log in to <a href="/my/clock" style="color:#10B981;">'
|
||||
'your portal</a> to view your attendance details.',
|
||||
)
|
||||
mail_values = {
|
||||
'subject': f"Fusion Clock: {subject}",
|
||||
'body_html': f"<p>{body}</p>",
|
||||
'email_from': company_email,
|
||||
'body_html': html_body,
|
||||
'email_to': employee.work_email,
|
||||
'auto_delete': True,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import models, fields, api
|
||||
from .tz_utils import get_local_today, get_local_day_boundaries
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
@@ -117,8 +118,14 @@ class HrEmployee(models.Model):
|
||||
|
||||
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.
|
||||
The configured hours are interpreted in the employee's local
|
||||
timezone and converted to naive-UTC datetimes so they can be
|
||||
compared with Odoo's UTC-based ``fields.Datetime.now()``.
|
||||
"""
|
||||
import pytz
|
||||
|
||||
self.ensure_one()
|
||||
if self.x_fclk_shift_id:
|
||||
in_hour = self.x_fclk_shift_id.start_time
|
||||
@@ -133,8 +140,24 @@ class HrEmployee(models.Model):
|
||||
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))
|
||||
tz_name = (
|
||||
self.resource_id.tz
|
||||
or (self.user_id.partner_id.tz if self.user_id else False)
|
||||
or self.company_id.partner_id.tz
|
||||
or 'UTC'
|
||||
)
|
||||
local_tz = pytz.timezone(tz_name)
|
||||
utc = pytz.UTC
|
||||
|
||||
local_in = local_tz.localize(
|
||||
datetime.combine(date, datetime.min.time().replace(hour=in_h, minute=in_m))
|
||||
)
|
||||
local_out = local_tz.localize(
|
||||
datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m))
|
||||
)
|
||||
|
||||
scheduled_in = local_in.astimezone(utc).replace(tzinfo=None)
|
||||
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
|
||||
return scheduled_in, scheduled_out
|
||||
|
||||
def _get_fclk_scheduled_hours(self):
|
||||
@@ -150,39 +173,44 @@ class HrEmployee(models.Model):
|
||||
|
||||
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:
|
||||
today = get_local_today(self.env, emp)
|
||||
month_start = today.replace(day=1)
|
||||
year_start = today.replace(month=1, day=1)
|
||||
month_start_utc, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||
year_start_utc, _ = get_local_day_boundaries(self.env, year_start, emp)
|
||||
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())),
|
||||
('log_date', '>=', month_start_utc),
|
||||
])
|
||||
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())),
|
||||
('log_date', '>=', year_start_utc),
|
||||
])
|
||||
|
||||
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:
|
||||
today = get_local_today(self.env, emp)
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
month_start = today.replace(day=1)
|
||||
week_start_utc, _ = get_local_day_boundaries(self.env, week_start, emp)
|
||||
month_start_utc, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||
|
||||
week_atts = Attendance.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', datetime.combine(week_start, datetime.min.time())),
|
||||
('check_in', '>=', week_start_utc),
|
||||
('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_in', '>=', month_start_utc),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
emp.x_fclk_overtime_this_month = sum(a.x_fclk_overtime_hours or 0 for a in month_atts)
|
||||
|
||||
@@ -8,7 +8,7 @@ from odoo import models, fields, api
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
# -- Work Schedule --
|
||||
# ── Work Schedule ──────────────────────────────────────────────────
|
||||
fclk_default_clock_in_time = fields.Float(
|
||||
string='Default Clock-In Time',
|
||||
config_parameter='fusion_clock.default_clock_in_time',
|
||||
@@ -21,20 +21,18 @@ class ResConfigSettings(models.TransientModel):
|
||||
default=17.0,
|
||||
help="Default scheduled clock-out time (24h format, e.g. 17.0 = 5:00 PM).",
|
||||
)
|
||||
|
||||
# -- Break --
|
||||
fclk_default_break_minutes = fields.Float(
|
||||
string='Default Break Duration (min)',
|
||||
config_parameter='fusion_clock.default_break_minutes',
|
||||
default=30.0,
|
||||
help="Default unpaid break duration in minutes.",
|
||||
)
|
||||
fclk_auto_deduct_break = fields.Boolean(
|
||||
string='Auto-Deduct Break',
|
||||
config_parameter='fusion_clock.auto_deduct_break',
|
||||
default=True,
|
||||
help="Automatically deduct break from worked hours on clock-out.",
|
||||
)
|
||||
fclk_default_break_minutes = fields.Float(
|
||||
string='Default Break Duration (min)',
|
||||
config_parameter='fusion_clock.default_break_minutes',
|
||||
default=30.0,
|
||||
help="Default unpaid break duration in minutes.",
|
||||
)
|
||||
fclk_break_threshold_hours = fields.Float(
|
||||
string='Break Threshold (hours)',
|
||||
config_parameter='fusion_clock.break_threshold_hours',
|
||||
@@ -42,30 +40,30 @@ class ResConfigSettings(models.TransientModel):
|
||||
help="Only deduct break if shift is longer than this many hours.",
|
||||
)
|
||||
|
||||
# -- Grace Period & Auto Clock-Out --
|
||||
# ── Attendance Rules ───────────────────────────────────────────────
|
||||
fclk_enable_auto_clockout = fields.Boolean(
|
||||
string='Enable Auto Clock-Out',
|
||||
config_parameter='fusion_clock.enable_auto_clockout',
|
||||
default=True,
|
||||
help="Automatically clock out employees who forget. Triggers after shift end time plus grace period, or after max shift hours.",
|
||||
)
|
||||
fclk_grace_period_minutes = fields.Float(
|
||||
string='Grace Period (min)',
|
||||
config_parameter='fusion_clock.grace_period_minutes',
|
||||
default=15.0,
|
||||
help="Minutes allowed after scheduled end before auto clock-out.",
|
||||
)
|
||||
fclk_enable_auto_clockout = fields.Boolean(
|
||||
string='Enable Auto Clock-Out',
|
||||
config_parameter='fusion_clock.enable_auto_clockout',
|
||||
default=True,
|
||||
)
|
||||
fclk_max_shift_hours = fields.Float(
|
||||
string='Max Shift Length (hours)',
|
||||
config_parameter='fusion_clock.max_shift_hours',
|
||||
default=12.0,
|
||||
help="Maximum shift length before auto clock-out (safety net).",
|
||||
)
|
||||
|
||||
# -- Penalties --
|
||||
fclk_enable_penalties = fields.Boolean(
|
||||
string='Enable Penalty Tracking',
|
||||
config_parameter='fusion_clock.enable_penalties',
|
||||
default=True,
|
||||
help="Deduct minutes from worked hours when employees clock in late or clock out early.",
|
||||
)
|
||||
fclk_penalty_grace_minutes = fields.Float(
|
||||
string='Penalty Grace (min)',
|
||||
@@ -79,8 +77,26 @@ class ResConfigSettings(models.TransientModel):
|
||||
default=15.0,
|
||||
help="Minutes deducted from worked hours per penalty occurrence.",
|
||||
)
|
||||
fclk_enable_overtime = fields.Boolean(
|
||||
string='Enable Overtime Tracking',
|
||||
config_parameter='fusion_clock.enable_overtime',
|
||||
default=True,
|
||||
help="Calculate and track overtime when net hours exceed the daily or weekly threshold.",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
|
||||
# -- Office User & Notifications --
|
||||
# ── Notifications ──────────────────────────────────────────────────
|
||||
fclk_office_user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Office User',
|
||||
@@ -123,26 +139,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
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 --
|
||||
# ── Location & Verification ────────────────────────────────────────
|
||||
fclk_enable_ip_fallback = fields.Boolean(
|
||||
string='Enable IP Fallback',
|
||||
config_parameter='fusion_clock.enable_ip_fallback',
|
||||
@@ -155,12 +152,17 @@ class ResConfigSettings(models.TransientModel):
|
||||
default=False,
|
||||
help="Global toggle for selfie verification on clock-in (per-location control).",
|
||||
)
|
||||
fclk_google_maps_api_key = fields.Char(
|
||||
string='Google Maps API Key',
|
||||
config_parameter='fusion_clock.google_maps_api_key',
|
||||
)
|
||||
|
||||
# -- Kiosk --
|
||||
# ── Kiosk & Portal ─────────────────────────────────────────────────
|
||||
fclk_enable_kiosk = fields.Boolean(
|
||||
string='Enable Kiosk Mode',
|
||||
config_parameter='fusion_clock.enable_kiosk',
|
||||
default=False,
|
||||
help="Allow employees to clock in/out from a shared device using their PIN code.",
|
||||
)
|
||||
fclk_kiosk_pin_required = fields.Boolean(
|
||||
string='Require PIN for Kiosk',
|
||||
@@ -168,23 +170,20 @@ class ResConfigSettings(models.TransientModel):
|
||||
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.",
|
||||
fclk_enable_sounds = fields.Boolean(
|
||||
string='Enable Clock Sounds',
|
||||
config_parameter='fusion_clock.enable_sounds',
|
||||
default=True,
|
||||
help="Play audio confirmation sounds when employees clock in or out.",
|
||||
)
|
||||
|
||||
# -- Pay Period --
|
||||
# ── Pay Period & Reports ───────────────────────────────────────────
|
||||
fclk_pay_period_type = fields.Selection(
|
||||
[
|
||||
('weekly', 'Weekly'),
|
||||
@@ -195,42 +194,42 @@ class ResConfigSettings(models.TransientModel):
|
||||
string='Pay Period',
|
||||
config_parameter='fusion_clock.pay_period_type',
|
||||
default='biweekly',
|
||||
help="How often attendance reports are generated.",
|
||||
)
|
||||
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).",
|
||||
)
|
||||
|
||||
# -- Reports --
|
||||
fclk_auto_generate_reports = fields.Boolean(
|
||||
string='Auto-Generate Reports',
|
||||
config_parameter='fusion_clock.auto_generate_reports',
|
||||
default=True,
|
||||
help="Automatically create attendance reports at the end of each pay period.",
|
||||
)
|
||||
fclk_send_employee_reports = fields.Boolean(
|
||||
string='Send Employee Copies',
|
||||
config_parameter='fusion_clock.send_employee_reports',
|
||||
default=True,
|
||||
help="Send each employee a copy of their individual attendance report.",
|
||||
)
|
||||
fclk_report_recipient_user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
'fclk_report_recipient_user_rel',
|
||||
'config_id',
|
||||
'user_id',
|
||||
string='Internal Report Recipients',
|
||||
help="Internal users who will receive batch reports.",
|
||||
)
|
||||
fclk_report_recipient_emails = fields.Char(
|
||||
string='Report Recipient Emails',
|
||||
config_parameter='fusion_clock.report_recipient_emails',
|
||||
help="Comma-separated email addresses for batch report delivery.",
|
||||
)
|
||||
fclk_send_employee_reports = fields.Boolean(
|
||||
string='Send Employee Copies',
|
||||
config_parameter='fusion_clock.send_employee_reports',
|
||||
default=True,
|
||||
help="Send individual report copies to each employee's work email.",
|
||||
)
|
||||
|
||||
# -- Google Maps --
|
||||
fclk_google_maps_api_key = fields.Char(
|
||||
string='Google Maps API Key',
|
||||
config_parameter='fusion_clock.google_maps_api_key',
|
||||
)
|
||||
|
||||
# -- Sounds --
|
||||
fclk_enable_sounds = fields.Boolean(
|
||||
string='Enable Clock Sounds',
|
||||
config_parameter='fusion_clock.enable_sounds',
|
||||
default=True,
|
||||
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.",
|
||||
)
|
||||
|
||||
def set_values(self):
|
||||
@@ -240,6 +239,11 @@ class ResConfigSettings(models.TransientModel):
|
||||
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')
|
||||
if self.fclk_report_recipient_user_ids:
|
||||
ICP.set_param('fusion_clock.report_recipient_user_ids',
|
||||
','.join(str(uid) for uid in self.fclk_report_recipient_user_ids.ids))
|
||||
else:
|
||||
ICP.set_param('fusion_clock.report_recipient_user_ids', '')
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
@@ -248,4 +252,11 @@ class ResConfigSettings(models.TransientModel):
|
||||
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
|
||||
user_ids_str = ICP.get_param('fusion_clock.report_recipient_user_ids', '')
|
||||
if user_ids_str:
|
||||
try:
|
||||
user_ids = [int(x) for x in user_ids_str.split(',') if x.strip()]
|
||||
res['fclk_report_recipient_user_ids'] = [(6, 0, user_ids)]
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return res
|
||||
|
||||
75
fusion_clock/models/tz_utils.py
Normal file
75
fusion_clock/models/tz_utils.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
"""
|
||||
Timezone helpers for Fusion Clock.
|
||||
|
||||
All Odoo datetimes are stored in UTC. These helpers derive "today",
|
||||
date boundaries, and display strings in the **user's local timezone**
|
||||
so that queries, penalties, and UI all reflect the real calendar day.
|
||||
|
||||
Timezone resolution order:
|
||||
1. Explicit employee.tz (if an employee record is available)
|
||||
2. env.user.tz (logged-in portal / backend user)
|
||||
3. env.company.tz (company-level default)
|
||||
4. 'UTC' (last resort — should rarely happen)
|
||||
"""
|
||||
|
||||
import pytz
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def _resolve_tz(env, employee=None):
|
||||
"""Return a pytz timezone from the best available source."""
|
||||
tz_name = (
|
||||
(employee.tz if employee else None)
|
||||
or env.user.tz
|
||||
or env.company.tz
|
||||
or 'UTC'
|
||||
)
|
||||
try:
|
||||
return pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
return pytz.UTC
|
||||
|
||||
|
||||
def get_local_now(env, employee=None):
|
||||
"""Return the current datetime in the resolved local timezone (aware)."""
|
||||
tz = _resolve_tz(env, employee)
|
||||
return datetime.now(pytz.UTC).astimezone(tz)
|
||||
|
||||
|
||||
def get_local_today(env, employee=None):
|
||||
"""Return today's date in the resolved local timezone."""
|
||||
return get_local_now(env, employee).date()
|
||||
|
||||
|
||||
def get_local_day_boundaries(env, date_val, employee=None):
|
||||
"""
|
||||
Return (start_utc, end_utc) as **naive** UTC datetimes representing
|
||||
midnight-to-midnight of *date_val* in the local timezone.
|
||||
|
||||
Suitable for Odoo domain filters like:
|
||||
('check_in', '>=', start_utc),
|
||||
('check_in', '<', end_utc),
|
||||
"""
|
||||
tz = _resolve_tz(env, employee)
|
||||
local_start = tz.localize(datetime.combine(date_val, datetime.min.time()))
|
||||
local_end = tz.localize(datetime.combine(date_val + timedelta(days=1), datetime.min.time()))
|
||||
return (
|
||||
local_start.astimezone(pytz.UTC).replace(tzinfo=None),
|
||||
local_end.astimezone(pytz.UTC).replace(tzinfo=None),
|
||||
)
|
||||
|
||||
|
||||
def utc_to_local_str(dt_utc, env, employee=None, fmt='%I:%M %p'):
|
||||
"""
|
||||
Convert a naive-UTC datetime to a formatted string in local timezone.
|
||||
Returns '' if dt_utc is falsy.
|
||||
"""
|
||||
if not dt_utc:
|
||||
return ''
|
||||
tz = _resolve_tz(env, employee)
|
||||
aware = pytz.UTC.localize(dt_utc)
|
||||
return aware.astimezone(tz).strftime(fmt)
|
||||
@@ -27,25 +27,37 @@
|
||||
</div>
|
||||
|
||||
<!-- Employee Info -->
|
||||
<table style="width:100%; margin-bottom:16px;">
|
||||
<table style="width:100%; margin-bottom:16px; border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="width:50%;">
|
||||
<td style="width:50%; padding:8px 12px; border:1px solid #e0e0e0;">
|
||||
<strong>Employee:</strong> <t t-esc="doc.employee_id.name"/>
|
||||
</td>
|
||||
<td style="width:50%; text-align:right;">
|
||||
<td style="width:50%; padding:8px 12px; border:1px solid #e0e0e0; text-align:right;">
|
||||
<strong>Company:</strong> <t t-esc="doc.company_id.name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<td style="padding:8px 12px; border:1px solid #e0e0e0;">
|
||||
<strong>Department:</strong> <t t-esc="doc.employee_id.department_id.name or 'N/A'"/>
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<td style="padding:8px 12px; border:1px solid #e0e0e0; text-align:right;">
|
||||
<strong>Schedule:</strong> <t t-esc="dict(doc._fields['schedule_type'].selection).get(doc.schedule_type, '')"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Pay Period -->
|
||||
<table style="width:100%; margin-bottom:16px; border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="padding:8px 12px; border:1px solid #e0e0e0; background:#f8f9fa;">
|
||||
<strong>Pay Period:</strong>
|
||||
<t t-esc="doc.date_start" t-options="{'widget': 'date', 'format': 'MMM d, yyyy'}"/>
|
||||
—
|
||||
<t t-esc="doc.date_end" t-options="{'widget': 'date', 'format': 'MMM d, yyyy'}"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Summary Box -->
|
||||
<table style="width:100%; border-collapse:collapse; margin-bottom:24px; background:#f8f9fa; border-radius:8px;">
|
||||
<tr>
|
||||
@@ -80,6 +92,13 @@
|
||||
</div>
|
||||
<div style="font-size:11px; color:#6b7280;">Penalties</div>
|
||||
</td>
|
||||
<td style="padding:12px; text-align:center; border:1px solid #e0e0e0;">
|
||||
<div style="font-size:20px; font-weight:bold;"
|
||||
t-attf-style="color: {{ '#3b82f6' if doc.leave_days > 0 else '#1a1d23' }};">
|
||||
<t t-esc="doc.leave_days"/>
|
||||
</div>
|
||||
<div style="font-size:11px; color:#6b7280;">Leave Days</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -129,6 +148,40 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Leave Days -->
|
||||
<t t-if="doc.leave_request_ids">
|
||||
<h4 style="color:#1a1d23; margin:24px 0 8px;">Leave Days</h4>
|
||||
<table style="width:100%; border-collapse:collapse; font-size:11px;">
|
||||
<thead>
|
||||
<tr style="background:#3b82f6; color:white;">
|
||||
<th style="padding:8px; text-align:left;">Date</th>
|
||||
<th style="padding:8px; text-align:left;">Reason</th>
|
||||
<th style="padding:8px; text-align:center;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="doc.leave_request_ids.sorted(key=lambda l: l.leave_date)" t-as="lv">
|
||||
<tr t-attf-style="background: {{ '#f0f7ff' if lv_index % 2 == 0 else '#ffffff' }};">
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0;">
|
||||
<t t-esc="lv.leave_date" t-options="{'widget': 'date'}"/>
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0;">
|
||||
<t t-esc="lv.reason"/>
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;">
|
||||
<t t-if="lv.state == 'reviewed'">
|
||||
<span style="color:#10B981; font-weight:bold;">Reviewed</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span style="color:#3b82f6;">Auto-Approved</span>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="margin-top:24px; padding-top:12px; border-top:1px solid #e0e0e0; text-align:center;">
|
||||
<p style="color:#9ca3af; font-size:10px;">
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<th style="padding:8px; text-align:center;">Breaks (min)</th>
|
||||
<th style="padding:8px; text-align:center;">Late In</th>
|
||||
<th style="padding:8px; text-align:center;">Early Out</th>
|
||||
<th style="padding:8px; text-align:center;">Leave</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -45,6 +46,7 @@
|
||||
<t t-set="emp_dates" t-value="set(a.check_in.date() for a in emp_atts if a.check_in)"/>
|
||||
<t t-set="late_count" t-value="len(doc.env['fusion.clock.penalty'].search([('employee_id', '=', emp.id), ('date', '>=', doc.date_start), ('date', '<=', doc.date_end), ('penalty_type', '=', 'late_in')]))"/>
|
||||
<t t-set="early_count" t-value="len(doc.env['fusion.clock.penalty'].search([('employee_id', '=', emp.id), ('date', '>=', doc.date_start), ('date', '<=', doc.date_end), ('penalty_type', '=', 'early_out')]))"/>
|
||||
<t t-set="emp_leaves" t-value="doc.leave_request_ids.filtered(lambda l: l.employee_id == emp)"/>
|
||||
<tr t-attf-style="background: {{ '#f8f9fa' if emp_index % 2 == 0 else '#ffffff' }};">
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; font-weight:bold;">
|
||||
<t t-esc="emp.name"/>
|
||||
@@ -69,6 +71,10 @@
|
||||
t-attf-style="color: {{ '#ef4444' if early_count else '#1a1d23' }};">
|
||||
<t t-esc="early_count"/>
|
||||
</td>
|
||||
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;"
|
||||
t-attf-style="color: {{ '#3b82f6' if emp_leaves else '#1a1d23' }};">
|
||||
<t t-esc="len(emp_leaves)"/>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
|
||||
@@ -294,13 +294,10 @@ body:has(.fclk-app) .o_footer {
|
||||
.fclk-btn-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fclk-btn-icon svg[id="fclk-btn-icon-play"] {
|
||||
transform: translateX(3px);
|
||||
#fclk-btn-icon-play {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.fclk-btn-ripple {
|
||||
@@ -605,22 +602,22 @@ body:has(.fclk-app) .o_footer {
|
||||
|
||||
/* 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);
|
||||
--fclk-wiz-card: #ffffff;
|
||||
--fclk-wiz-card-border: #e5e7eb;
|
||||
--fclk-wiz-bg: #f3f4f6;
|
||||
--fclk-wiz-text: #1f2937;
|
||||
--fclk-wiz-text-muted: #6b7280;
|
||||
--fclk-wiz-text-dim: #9ca3af;
|
||||
--fclk-wiz-green: #10B981;
|
||||
--fclk-wiz-green-glow: rgba(16, 185, 129, 0.25);
|
||||
--fclk-wiz-hover-bg: #f9fafb;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 300;
|
||||
z-index: 1055;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -629,26 +626,26 @@ body:has(.fclk-app) .o_footer {
|
||||
|
||||
@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;
|
||||
--fclk-wiz-card: #1a1d23;
|
||||
--fclk-wiz-card-border: #2a2d35;
|
||||
--fclk-wiz-bg: #0f1117;
|
||||
--fclk-wiz-text: #ffffff;
|
||||
--fclk-wiz-text-muted: #9ca3af;
|
||||
--fclk-wiz-text-dim: #6b7280;
|
||||
--fclk-wiz-green-glow: rgba(16, 185, 129, 0.3);
|
||||
--fclk-wiz-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-wiz-card: #1a1d23;
|
||||
--fclk-wiz-card-border: #2a2d35;
|
||||
--fclk-wiz-bg: #0f1117;
|
||||
--fclk-wiz-text: #ffffff;
|
||||
--fclk-wiz-text-muted: #9ca3af;
|
||||
--fclk-wiz-text-dim: #6b7280;
|
||||
--fclk-wiz-green-glow: rgba(16, 185, 129, 0.3);
|
||||
--fclk-wiz-hover-bg: #1e2128;
|
||||
}
|
||||
|
||||
.fclk-wizard-backdrop {
|
||||
@@ -664,8 +661,8 @@ html.o_dark .fclk-wizard-overlay {
|
||||
|
||||
.fclk-wizard-dialog {
|
||||
position: relative;
|
||||
background: var(--fclk-card);
|
||||
border: 1px solid var(--fclk-card-border);
|
||||
background: var(--fclk-wiz-card, #ffffff);
|
||||
border: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
@@ -693,7 +690,7 @@ html.o_dark .fclk-wizard-overlay {
|
||||
.fclk-wizard-header {
|
||||
padding: 28px 24px 20px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--fclk-card-border);
|
||||
border-bottom: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.fclk-wizard-header-icon {
|
||||
@@ -722,7 +719,7 @@ html.o_dark .fclk-wizard-overlay {
|
||||
}
|
||||
|
||||
.fclk-wizard-title {
|
||||
color: var(--fclk-text);
|
||||
color: var(--fclk-wiz-text, #1f2937);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 6px;
|
||||
@@ -730,7 +727,7 @@ html.o_dark .fclk-wizard-overlay {
|
||||
}
|
||||
|
||||
.fclk-wizard-subtitle {
|
||||
color: var(--fclk-text-muted);
|
||||
color: var(--fclk-wiz-text-muted, #6b7280);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
@@ -752,14 +749,14 @@ html.o_dark .fclk-wizard-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fclk-text);
|
||||
color: var(--fclk-wiz-text, #1f2937);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fclk-wizard-label svg {
|
||||
color: var(--fclk-text-muted);
|
||||
color: var(--fclk-wiz-text-muted, #6b7280);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -770,24 +767,24 @@ html.o_dark .fclk-wizard-overlay {
|
||||
|
||||
.fclk-wizard-input {
|
||||
width: 100%;
|
||||
background: var(--fclk-bg);
|
||||
border: 1.5px solid var(--fclk-card-border);
|
||||
background: var(--fclk-wiz-bg, #f3f4f6);
|
||||
border: 1.5px solid var(--fclk-wiz-card-border, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--fclk-text);
|
||||
color: var(--fclk-wiz-text, #1f2937);
|
||||
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);
|
||||
border-color: var(--fclk-wiz-green, #10B981);
|
||||
box-shadow: 0 0 0 3px var(--fclk-wiz-green-glow, rgba(16, 185, 129, 0.25));
|
||||
}
|
||||
|
||||
.fclk-wizard-input::placeholder {
|
||||
color: var(--fclk-text-dim);
|
||||
color: var(--fclk-wiz-text-dim, #9ca3af);
|
||||
}
|
||||
|
||||
.fclk-wizard-textarea {
|
||||
@@ -797,7 +794,7 @@ html.o_dark .fclk-wizard-overlay {
|
||||
|
||||
.fclk-wizard-hint {
|
||||
display: block;
|
||||
color: var(--fclk-text-dim);
|
||||
color: var(--fclk-wiz-text-dim, #9ca3af);
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
@@ -807,7 +804,7 @@ html.o_dark .fclk-wizard-overlay {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--fclk-card-border);
|
||||
border-top: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.fclk-wizard-btn {
|
||||
@@ -852,20 +849,20 @@ html.o_dark .fclk-wizard-overlay {
|
||||
}
|
||||
|
||||
.fclk-wizard-btn--secondary {
|
||||
background: var(--fclk-bg);
|
||||
color: var(--fclk-text-muted);
|
||||
border: 1px solid var(--fclk-card-border);
|
||||
background: var(--fclk-wiz-bg, #f3f4f6);
|
||||
color: var(--fclk-wiz-text-muted, #6b7280);
|
||||
border: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.fclk-wizard-btn--secondary:hover:not(:disabled) {
|
||||
background: var(--fclk-hover-bg);
|
||||
color: var(--fclk-text);
|
||||
background: var(--fclk-wiz-hover-bg, #f9fafb);
|
||||
color: var(--fclk-wiz-text, #1f2937);
|
||||
}
|
||||
|
||||
/* Clock-out confirmation summary card */
|
||||
.fclk-clockout-summary {
|
||||
background: var(--fclk-bg);
|
||||
border: 1px solid var(--fclk-card-border);
|
||||
background: var(--fclk-wiz-bg, #f3f4f6);
|
||||
border: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
@@ -878,16 +875,16 @@ html.o_dark .fclk-wizard-overlay {
|
||||
}
|
||||
|
||||
.fclk-clockout-summary-row + .fclk-clockout-summary-row {
|
||||
border-top: 1px solid var(--fclk-card-border);
|
||||
border-top: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.fclk-clockout-summary-label {
|
||||
color: var(--fclk-text-muted);
|
||||
color: var(--fclk-wiz-text-muted, #6b7280);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fclk-clockout-summary-value {
|
||||
color: var(--fclk-text);
|
||||
color: var(--fclk-wiz-text, #1f2937);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -917,13 +914,13 @@ html.o_dark .fclk-wizard-overlay {
|
||||
}
|
||||
|
||||
.fclk-modal-item-name {
|
||||
color: var(--fclk-text);
|
||||
color: var(--fclk-wiz-text, #1f2937);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fclk-modal-item-addr {
|
||||
color: var(--fclk-text-muted);
|
||||
color: var(--fclk-wiz-text-muted, #6b7280);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -535,10 +535,12 @@ export class FusionClockPortal extends Interaction {
|
||||
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() : "";
|
||||
const rawTime = timeEl ? timeEl.value.trim() : "";
|
||||
const depTime = rawTime ? new Date(rawTime).toISOString() : "";
|
||||
|
||||
if (!reason) {
|
||||
this._showToast("Please provide a reason.", "error");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -356,9 +356,10 @@ export class FusionClockPortalFAB extends Interaction {
|
||||
const submitBtn = document.getElementById("fclk-pfab-reason-submit-btn");
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
try {
|
||||
const rawTime = timeEl ? timeEl.value.trim() : "";
|
||||
await rpc("/fusion_clock/submit_reason", {
|
||||
reason: reason,
|
||||
departure_time: timeEl ? timeEl.value : "",
|
||||
departure_time: rawTime ? new Date(rawTime).toISOString() : "",
|
||||
});
|
||||
modal.style.display = "none";
|
||||
if (reasonEl) reasonEl.value = "";
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class FusionClockFAB extends Component {
|
||||
static props = {};
|
||||
static template = "fusion_clock.ClockFAB";
|
||||
static template = "fusion_clock.ClockSystray";
|
||||
static components = { Dropdown, DropdownItem };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.dropdown = useDropdownState();
|
||||
|
||||
this.state = useState({
|
||||
isCheckedIn: false,
|
||||
isDisplayed: false,
|
||||
expanded: false,
|
||||
checkInTime: null,
|
||||
locationName: "",
|
||||
timerDisplay: "00:00:00",
|
||||
@@ -40,38 +44,22 @@ export class FusionClockFAB extends Component {
|
||||
if (this.state.isCheckedIn) {
|
||||
this._startTimer();
|
||||
}
|
||||
// Poll every 15s to stay in sync with portal clock-outs
|
||||
this._pollInterval = setInterval(() => this._fetchStatus(), 15000);
|
||||
|
||||
// Re-sync immediately when browser tab regains focus
|
||||
this._onFocus = () => this._fetchStatus();
|
||||
window.addEventListener("focus", this._onFocus);
|
||||
|
||||
// Close panel when clicking outside
|
||||
this._onDocClick = (ev) => {
|
||||
if (!this.state.expanded) return;
|
||||
const el = ev.target.closest(".fclk-fab-wrapper");
|
||||
if (!el) this.state.expanded = false;
|
||||
};
|
||||
document.addEventListener("click", this._onDocClick, true);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
this._stopTimer();
|
||||
if (this._pollInterval) clearInterval(this._pollInterval);
|
||||
if (this._onDocClick) document.removeEventListener("click", this._onDocClick, true);
|
||||
if (this._onFocus) window.removeEventListener("focus", this._onFocus);
|
||||
});
|
||||
}
|
||||
|
||||
togglePanel() {
|
||||
this.state.expanded = !this.state.expanded;
|
||||
this.state.error = "";
|
||||
// Always re-fetch when opening the panel
|
||||
if (this.state.expanded) {
|
||||
this._fetchStatus();
|
||||
}
|
||||
}
|
||||
// =================================================================
|
||||
// Server sync
|
||||
// =================================================================
|
||||
|
||||
async _fetchStatus() {
|
||||
try {
|
||||
@@ -83,6 +71,10 @@ export class FusionClockFAB extends Component {
|
||||
this.state.todayHours = (result.today_hours || 0).toFixed(1);
|
||||
this.state.weekHours = (result.week_hours || 0).toFixed(1);
|
||||
|
||||
if (result.pending_reason) {
|
||||
this.state.showReasonDialog = true;
|
||||
}
|
||||
|
||||
if (result.is_checked_in && result.check_in) {
|
||||
const serverTime = new Date(result.check_in + "Z");
|
||||
const wasRunning = this.state.isCheckedIn;
|
||||
@@ -117,8 +109,13 @@ export class FusionClockFAB extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Clock actions
|
||||
// =================================================================
|
||||
|
||||
async onClockAction() {
|
||||
if (this.state.isCheckedIn) {
|
||||
this.dropdown.close();
|
||||
this.state.showClockoutConfirm = true;
|
||||
return;
|
||||
}
|
||||
@@ -181,6 +178,7 @@ export class FusionClockFAB extends Component {
|
||||
|
||||
if (result.requires_reason) {
|
||||
this.state.loading = false;
|
||||
this.dropdown.close();
|
||||
this.state.showReasonDialog = true;
|
||||
this.state.reasonText = "";
|
||||
this.state.reasonTime = "";
|
||||
@@ -214,6 +212,10 @@ export class FusionClockFAB extends Component {
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Reason dialog
|
||||
// =================================================================
|
||||
|
||||
onReasonTextInput(ev) {
|
||||
this.state.reasonText = ev.target.value;
|
||||
}
|
||||
@@ -243,13 +245,17 @@ export class FusionClockFAB extends Component {
|
||||
this.state.reasonText = "";
|
||||
this.state.reasonTime = "";
|
||||
this.state.reasonSubmitting = false;
|
||||
await this._executeClockAction();
|
||||
this.notification.add("Reason submitted. You can now clock in.", { type: "success" });
|
||||
} catch (e) {
|
||||
this.state.error = "Failed to submit reason.";
|
||||
this.state.reasonSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Timer
|
||||
// =================================================================
|
||||
|
||||
get confirmCheckinDisplay() {
|
||||
if (!this.state.checkInTime) return "--";
|
||||
const d = this.state.checkInTime;
|
||||
@@ -293,6 +299,11 @@ export class FusionClockFAB extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("main_components").add("FusionClockFAB", {
|
||||
const systrayRegistry = registry.category("systray");
|
||||
if (systrayRegistry.contains("hr_attendance.attendance_menu")) {
|
||||
systrayRegistry.remove("hr_attendance.attendance_menu");
|
||||
}
|
||||
|
||||
registry.category("systray").add("fusion_clock.ClockSystray", {
|
||||
Component: FusionClockFAB,
|
||||
});
|
||||
}, { sequence: 101 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* ============================================================
|
||||
Fusion Clock - Floating Action Button (FAB)
|
||||
Bottom-left corner clock widget with ripple animation
|
||||
Fusion Clock - Systray Icon & Dropdown Panel
|
||||
Top-right navbar clock widget with pulse animation
|
||||
Theme-aware: adapts to Odoo light / dark mode
|
||||
============================================================ */
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
--fclk-fab-divider: #e5e7eb;
|
||||
--fclk-fab-location-bg: rgba(16, 185, 129, 0.08);
|
||||
--fclk-fab-error-bg: rgba(239, 68, 68, 0.06);
|
||||
--fclk-fab-arrow-bg: #ffffff;
|
||||
}
|
||||
|
||||
// ---- Dark-mode tokens ----
|
||||
@@ -27,7 +26,6 @@ html.o_dark {
|
||||
--fclk-fab-divider: #3a3d48;
|
||||
--fclk-fab-location-bg: rgba(16, 185, 129, 0.1);
|
||||
--fclk-fab-error-bg: rgba(239, 68, 68, 0.1);
|
||||
--fclk-fab-arrow-bg: #1e2028;
|
||||
}
|
||||
|
||||
// Static color palette
|
||||
@@ -36,349 +34,214 @@ $fclk-blue: #3b82f6;
|
||||
$fclk-green: #10B981;
|
||||
$fclk-red: #ef4444;
|
||||
|
||||
// Gradient used on the FAB (teal-to-blue like the portal header)
|
||||
$fclk-gradient: linear-gradient(135deg, $fclk-teal 0%, #2563eb 100%);
|
||||
$fclk-gradient-active: linear-gradient(135deg, $fclk-green 0%, $fclk-teal 100%);
|
||||
|
||||
// ===========================================================
|
||||
// Wrapper - anchored bottom-LEFT
|
||||
// Systray Icon & Dropdown
|
||||
// ===========================================================
|
||||
.fclk-fab-wrapper {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
|
||||
> * { pointer-events: auto; }
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// FAB Button
|
||||
// ===========================================================
|
||||
.fclk-fab-btn {
|
||||
.fclk-systray-btn {
|
||||
position: relative;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
border: none;
|
||||
background: $fclk-gradient;
|
||||
color: #fff;
|
||||
font-size: 21px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 20px rgba($fclk-teal, 0.35);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
outline: none;
|
||||
overflow: visible;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 28px rgba($fclk-teal, 0.45);
|
||||
.fclk-systray-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
.fa-clock-o {
|
||||
font-size: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.93);
|
||||
}
|
||||
&--in {
|
||||
.fa-clock-o {
|
||||
color: var(--bs-success, var(--o-success, #198754));
|
||||
}
|
||||
|
||||
// Clocked-in: green-teal gradient
|
||||
&.fclk-fab-btn--active {
|
||||
background: $fclk-gradient-active;
|
||||
box-shadow: 0 4px 20px rgba($fclk-green, 0.4);
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--bs-success, var(--o-success, #198754));
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
animation: fclk-wave 2.4s infinite ease-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 28px rgba($fclk-green, 0.5);
|
||||
&::after {
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
}
|
||||
|
||||
// Panel-open: muted
|
||||
&.fclk-fab-btn--open {
|
||||
background: #374151;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.fclk-fab-icon {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition: transform 0.3s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&.fclk-fab-btn--open .fclk-fab-icon {
|
||||
transform: rotate(90deg);
|
||||
&--out {
|
||||
.fa-clock-o {
|
||||
color: var(--bs-danger, var(--o-danger, #dc3545));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Ripple rings radiating outward from the FAB ----
|
||||
.fclk-fab-ripple-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba($fclk-green, 0.5);
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
|
||||
&.fclk-fab-ripple-ring--1 {
|
||||
animation: fclk-ripple-out 2.4s ease-out infinite;
|
||||
}
|
||||
&.fclk-fab-ripple-ring--2 {
|
||||
animation: fclk-ripple-out 2.4s ease-out 0.8s infinite;
|
||||
}
|
||||
&.fclk-fab-ripple-ring--3 {
|
||||
animation: fclk-ripple-out 2.4s ease-out 1.6s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fclk-ripple-out {
|
||||
@keyframes fclk-wave {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 0.55;
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(2.6);
|
||||
transform: translate(-50%, -50%) scale(2.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mini timer badge ----
|
||||
.fclk-fab-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #111827;
|
||||
color: $fclk-green;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid rgba($fclk-green, 0.35);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
animation: fclk-badge-in 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fclk-badge-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(4px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// Expanded Panel
|
||||
// ===========================================================
|
||||
.fclk-fab-panel {
|
||||
.fclk-systray-dropdown {
|
||||
width: 280px;
|
||||
background: var(--fclk-fab-panel-bg);
|
||||
border: 1px solid var(--fclk-fab-panel-border);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
box-shadow: var(--fclk-fab-panel-shadow);
|
||||
animation: fclk-panel-slide-up 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
border-radius: 12px !important;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--fclk-fab-panel-shadow) !important;
|
||||
}
|
||||
|
||||
@keyframes fclk-panel-slide-up {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
// Arrow pointing down toward the FAB
|
||||
.fclk-fab-panel-arrow {
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 22px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--fclk-fab-arrow-bg);
|
||||
border-right: 1px solid var(--fclk-fab-panel-border);
|
||||
border-bottom: 1px solid var(--fclk-fab-panel-border);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
// ---- Header row ----
|
||||
.fclk-fab-panel-header {
|
||||
.fclk-systray-panel {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--fclk-fab-panel-bg);
|
||||
}
|
||||
|
||||
.fclk-fab-panel-title {
|
||||
.fclk-systray-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.fclk-fab-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #9ca3af;
|
||||
.fclk-systray-header-dot {
|
||||
font-size: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.active {
|
||||
background: $fclk-green;
|
||||
box-shadow: 0 0 6px rgba($fclk-green, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.fclk-fab-open-link {
|
||||
color: var(--fclk-fab-muted);
|
||||
.fclk-systray-header-text {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover { color: $fclk-blue; }
|
||||
}
|
||||
|
||||
// ---- Location chip ----
|
||||
.fclk-fab-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: $fclk-green;
|
||||
background: var(--fclk-fab-location-bg);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.fa { font-size: 12px; }
|
||||
}
|
||||
|
||||
// ---- Timer ----
|
||||
.fclk-fab-timer {
|
||||
text-align: center;
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 28px;
|
||||
font-weight: 300;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
letter-spacing: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-bottom: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
// ---- Stats row ----
|
||||
.fclk-fab-stats {
|
||||
.fclk-systray-link {
|
||||
margin-left: auto;
|
||||
color: var(--fclk-fab-muted);
|
||||
font-size: 12px;
|
||||
&:hover { color: var(--fclk-fab-text); }
|
||||
}
|
||||
|
||||
.fclk-systray-location {
|
||||
font-size: 12px;
|
||||
color: var(--fclk-fab-muted);
|
||||
.fa { margin-right: 4px; }
|
||||
}
|
||||
|
||||
.fclk-systray-timer {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
letter-spacing: 2px;
|
||||
color: var(--fclk-fab-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.fclk-systray-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
padding: 8px;
|
||||
background: var(--fclk-fab-location-bg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.fclk-fab-stat {
|
||||
text-align: center;
|
||||
|
||||
.fclk-fab-stat-val {
|
||||
display: block;
|
||||
color: var(--fclk-fab-text);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fclk-fab-stat-lbl {
|
||||
display: block;
|
||||
color: var(--fclk-fab-muted);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.fclk-systray-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fclk-fab-stat-divider {
|
||||
.fclk-systray-stat-val {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--fclk-fab-text);
|
||||
}
|
||||
|
||||
.fclk-systray-stat-lbl {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--fclk-fab-muted);
|
||||
}
|
||||
|
||||
.fclk-systray-stat-sep {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
height: 24px;
|
||||
background: var(--fclk-fab-divider);
|
||||
}
|
||||
|
||||
// ---- Action button ----
|
||||
.fclk-fab-action {
|
||||
.fclk-systray-action {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 11px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
letter-spacing: 0.3px;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
color: #fff;
|
||||
|
||||
.fa { font-size: 15px; }
|
||||
|
||||
&.fclk-fab-action--in {
|
||||
background: $fclk-gradient;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba($fclk-teal, 0.4);
|
||||
}
|
||||
&--in {
|
||||
background: var(--bs-success, var(--o-success, #198754));
|
||||
&:hover { opacity: 0.9; }
|
||||
}
|
||||
|
||||
&.fclk-fab-action--out {
|
||||
background: $fclk-red;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 16px rgba($fclk-red, 0.35);
|
||||
}
|
||||
&--out {
|
||||
background: var(--bs-danger, var(--o-danger, #dc3545));
|
||||
&:hover { opacity: 0.9; }
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&:active { transform: scale(0.98); }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
// ---- Error ----
|
||||
.fclk-fab-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
color: $fclk-red;
|
||||
font-size: 11px;
|
||||
.fclk-systray-error {
|
||||
padding: 8px 12px;
|
||||
background: var(--fclk-fab-error-bg);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-top: 10px;
|
||||
animation: fclk-shake 0.35s ease;
|
||||
line-height: 1.4;
|
||||
|
||||
.fa { font-size: 12px; margin-top: 1px; flex-shrink: 0; }
|
||||
}
|
||||
|
||||
@keyframes fclk-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--bs-warning, var(--o-warning, #856404));
|
||||
.fa { margin-right: 4px; }
|
||||
}
|
||||
|
||||
// ===========================================================
|
||||
// FAB Dialog Overlays (reason, clock-out confirmation)
|
||||
// Dialog Overlays (reason, clock-out confirmation)
|
||||
// ===========================================================
|
||||
.fclk-fab-dialog-overlay {
|
||||
position: fixed;
|
||||
@@ -593,7 +456,6 @@ $fclk-gradient-active: linear-gradient(135deg, $fclk-green 0%, $fclk-teal 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -678,7 +540,6 @@ html.o_dark {
|
||||
.fa { margin-right: 4px; }
|
||||
}
|
||||
|
||||
// Google Places dropdown z-index fix
|
||||
.pac-container {
|
||||
z-index: 2100 !important;
|
||||
border-radius: 8px;
|
||||
@@ -732,7 +593,6 @@ html.o_dark {
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
// -- Total (blue/slate) --
|
||||
.fclk-dash-card--total {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #e0e7ff 100%);
|
||||
border: 1px solid #bfdbfe;
|
||||
@@ -742,7 +602,6 @@ html.o_dark {
|
||||
.fclk-dash-card-label { color: #3b82f6; }
|
||||
}
|
||||
|
||||
// -- Present (green) --
|
||||
.fclk-dash-card--present {
|
||||
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
|
||||
border: 1px solid #a7f3d0;
|
||||
@@ -752,7 +611,6 @@ html.o_dark {
|
||||
.fclk-dash-card-label { color: #10b981; }
|
||||
}
|
||||
|
||||
// -- Absent (red) --
|
||||
.fclk-dash-card--absent {
|
||||
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
|
||||
border: 1px solid #fecaca;
|
||||
@@ -762,7 +620,6 @@ html.o_dark {
|
||||
.fclk-dash-card-label { color: #ef4444; }
|
||||
}
|
||||
|
||||
// -- Late (amber) --
|
||||
.fclk-dash-card--late {
|
||||
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||
border: 1px solid #fde68a;
|
||||
@@ -772,7 +629,6 @@ html.o_dark {
|
||||
.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%);
|
||||
|
||||
@@ -1,120 +1,96 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_clock.ClockFAB">
|
||||
<div t-if="state.isDisplayed" class="fclk-fab-wrapper">
|
||||
|
||||
<!-- Expanded Panel (above the button) -->
|
||||
<div t-if="state.expanded" class="fclk-fab-panel">
|
||||
<!-- Header -->
|
||||
<div class="fclk-fab-panel-header">
|
||||
<div class="fclk-fab-panel-title">
|
||||
<span t-attf-class="fclk-fab-status-dot {{ state.isCheckedIn ? 'active' : '' }}"/>
|
||||
<span t-if="state.isCheckedIn">Clocked In</span>
|
||||
<span t-else="">Ready</span>
|
||||
</div>
|
||||
<a href="/my/clock" class="fclk-fab-open-link" target="_blank" title="Open Full Clock">
|
||||
<i class="fa fa-external-link"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div t-if="state.isCheckedIn and state.locationName" class="fclk-fab-location">
|
||||
<i class="fa fa-map-marker"/>
|
||||
<span t-esc="state.locationName"/>
|
||||
</div>
|
||||
|
||||
<!-- Timer -->
|
||||
<div class="fclk-fab-timer" t-esc="state.timerDisplay"/>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="fclk-fab-stats">
|
||||
<div class="fclk-fab-stat">
|
||||
<span class="fclk-fab-stat-val"><t t-esc="state.todayHours"/>h</span>
|
||||
<span class="fclk-fab-stat-lbl">Today</span>
|
||||
</div>
|
||||
<div class="fclk-fab-stat-divider"/>
|
||||
<div class="fclk-fab-stat">
|
||||
<span class="fclk-fab-stat-val"><t t-esc="state.weekHours"/>h</span>
|
||||
<span class="fclk-fab-stat-lbl">Week</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clock Action Button -->
|
||||
<button t-attf-class="fclk-fab-action {{ state.isCheckedIn ? 'fclk-fab-action--out' : 'fclk-fab-action--in' }}"
|
||||
t-on-click="onClockAction"
|
||||
t-att-disabled="state.loading">
|
||||
<t t-if="state.loading">
|
||||
<i class="fa fa-circle-o-notch fa-spin"/> Working...
|
||||
</t>
|
||||
<t t-elif="state.isCheckedIn">
|
||||
<i class="fa fa-stop-circle-o"/> Clock Out
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-play-circle-o"/> Clock In
|
||||
</t>
|
||||
<t t-name="fusion_clock.ClockSystray">
|
||||
<t t-if="state.isDisplayed">
|
||||
<!-- Systray Dropdown -->
|
||||
<Dropdown position="'bottom-end'" state="dropdown"
|
||||
beforeOpen.bind="_fetchStatus"
|
||||
menuClass="'fclk-systray-dropdown p-0'">
|
||||
<button class="fclk-systray-btn">
|
||||
<span t-attf-class="fclk-systray-icon {{ state.isCheckedIn ? 'fclk-systray-icon--in' : 'fclk-systray-icon--out' }}">
|
||||
<i class="fa fa-clock-o"/>
|
||||
</span>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<div class="fclk-systray-panel">
|
||||
<!-- Header -->
|
||||
<div class="fclk-systray-header">
|
||||
<i t-attf-class="fa fa-circle fclk-systray-header-dot {{ state.isCheckedIn ? 'text-success' : 'text-danger' }}"/>
|
||||
<span t-if="state.isCheckedIn" class="fclk-systray-header-text">Clocked In</span>
|
||||
<span t-else="" class="fclk-systray-header-text">Ready to Clock In</span>
|
||||
<a href="/my/clock" class="fclk-systray-link" target="_blank" title="Open Full Clock">
|
||||
<i class="fa fa-external-link"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div t-if="state.error" class="fclk-fab-error">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<t t-esc="state.error"/>
|
||||
</div>
|
||||
<!-- Location -->
|
||||
<div t-if="state.isCheckedIn and state.locationName" class="fclk-systray-location">
|
||||
<i class="fa fa-map-marker"/> <t t-esc="state.locationName"/>
|
||||
</div>
|
||||
|
||||
<!-- Arrow pointing to button -->
|
||||
<div class="fclk-fab-panel-arrow"/>
|
||||
</div>
|
||||
<!-- Timer -->
|
||||
<div class="fclk-systray-timer" t-esc="state.timerDisplay"/>
|
||||
|
||||
<!-- 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">
|
||||
<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"/>
|
||||
<span class="fclk-fab-icon">
|
||||
<i t-if="!state.expanded" class="fa fa-clock-o"/>
|
||||
<i t-else="" class="fa fa-times"/>
|
||||
</span>
|
||||
<span t-if="state.isCheckedIn and !state.expanded" class="fclk-fab-badge">
|
||||
<t t-esc="state.timerDisplay"/>
|
||||
</span>
|
||||
</button>
|
||||
<!-- Stats -->
|
||||
<div class="fclk-systray-stats">
|
||||
<div class="fclk-systray-stat">
|
||||
<span class="fclk-systray-stat-val"><t t-esc="state.todayHours"/>h</span>
|
||||
<span class="fclk-systray-stat-lbl">Today</span>
|
||||
</div>
|
||||
<div class="fclk-systray-stat-sep"/>
|
||||
<div class="fclk-systray-stat">
|
||||
<span class="fclk-systray-stat-val"><t t-esc="state.weekHours"/>h</span>
|
||||
<span class="fclk-systray-stat-lbl">This Week</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Missed Clock-Out Reason Dialog -->
|
||||
<!-- Clock Action -->
|
||||
<button t-attf-class="fclk-systray-action {{ state.isCheckedIn ? 'fclk-systray-action--out' : 'fclk-systray-action--in' }}"
|
||||
t-on-click="onClockAction"
|
||||
t-att-disabled="state.loading">
|
||||
<t t-if="state.loading">
|
||||
<i class="fa fa-circle-o-notch fa-spin"/> Working...
|
||||
</t>
|
||||
<t t-elif="state.isCheckedIn">
|
||||
<i class="fa fa-stop-circle-o"/> Clock Out
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-play-circle-o"/> Clock In
|
||||
</t>
|
||||
</button>
|
||||
|
||||
<!-- Error -->
|
||||
<div t-if="state.error" class="fclk-systray-error">
|
||||
<i class="fa fa-exclamation-triangle"/> <t t-esc="state.error"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</Dropdown>
|
||||
|
||||
<!-- Missed Clock-Out Reason Dialog (outside dropdown) -->
|
||||
<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>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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">
|
||||
<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>
|
||||
@@ -122,14 +98,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clock-Out Confirmation Dialog -->
|
||||
<!-- Clock-Out Confirmation Dialog (outside dropdown) -->
|
||||
<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>
|
||||
<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>
|
||||
@@ -153,7 +127,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
||||
@@ -69,13 +69,34 @@
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<!-- Reports Sub-Menu -->
|
||||
<menuitem id="menu_fusion_clock_reports"
|
||||
<menuitem id="menu_fusion_clock_reports_parent"
|
||||
name="Reports"
|
||||
parent="menu_fusion_clock_root"
|
||||
action="action_fusion_clock_report"
|
||||
sequence="40"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_reports"
|
||||
name="All Reports"
|
||||
parent="menu_fusion_clock_reports_parent"
|
||||
action="action_fusion_clock_report"
|
||||
sequence="10"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<menuitem id="menu_fusion_clock_generate_historical"
|
||||
name="Generate Historical Reports"
|
||||
parent="menu_fusion_clock_reports_parent"
|
||||
action="action_server_generate_historical"
|
||||
sequence="20"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
|
||||
<!-- Employees -->
|
||||
<menuitem id="menu_fusion_clock_employees"
|
||||
name="Employees"
|
||||
parent="menu_fusion_clock_root"
|
||||
action="hr.open_view_employee_list_my"
|
||||
sequence="50"
|
||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||
|
||||
<!-- Configuration Sub-Menu -->
|
||||
<menuitem id="menu_fusion_clock_config"
|
||||
name="Configuration"
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Server Action: Generate Historical Reports -->
|
||||
<record id="action_server_generate_historical" model="ir.actions.server">
|
||||
<field name="name">Generate Historical Reports</field>
|
||||
<field name="model_id" ref="model_fusion_clock_report"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = model.action_generate_historical_reports()</field>
|
||||
</record>
|
||||
|
||||
<!-- Report List View -->
|
||||
<record id="view_fusion_clock_report_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.report.list</field>
|
||||
<field name="model">fusion.clock.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Clock Reports" default_order="date_end desc">
|
||||
<header>
|
||||
<button name="%(action_server_generate_historical)d"
|
||||
type="action"
|
||||
string="Generate Historical Reports"
|
||||
class="btn-secondary"
|
||||
icon="fa-history"/>
|
||||
</header>
|
||||
<field name="name"/>
|
||||
<field name="date_start"/>
|
||||
<field name="date_end"/>
|
||||
@@ -44,6 +59,11 @@
|
||||
string="Export CSV" class="btn-secondary"
|
||||
invisible="state == 'draft'"
|
||||
icon="fa-download"/>
|
||||
<button name="action_reset_draft" type="object"
|
||||
string="Reset to Draft" class="btn-secondary"
|
||||
invisible="state == 'draft'"
|
||||
icon="fa-undo"
|
||||
confirm="This will reset the report to draft. Continue?"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,generated,sent"/>
|
||||
</header>
|
||||
<sheet>
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<field name="model">hr.attendance</field>
|
||||
<field name="inherit_id" ref="hr_attendance.view_attendance_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//list" position="attributes">
|
||||
<attribute name="default_order">check_in desc</attribute>
|
||||
</xpath>
|
||||
<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"/>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<page string="Fusion Clock" name="fusion_clock_tab"
|
||||
groups="fusion_clock.group_fusion_clock_manager,fusion_clock.group_fusion_clock_team_lead">
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<!-- Configuration & Status -->
|
||||
<group>
|
||||
<group string="Configuration">
|
||||
<field name="x_fclk_enable_clock"/>
|
||||
@@ -31,133 +31,124 @@
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<separator string="Activity Logs"/>
|
||||
<!-- Activity Log Sub-Tabs -->
|
||||
<notebook>
|
||||
<page string="Clock Events" name="fclk_sub_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" default_order="log_date desc">
|
||||
<field name="log_date"/>
|
||||
<field name="log_type"/>
|
||||
<field name="description"/>
|
||||
<field name="location_id"/>
|
||||
<field name="source"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- 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>
|
||||
<page string="Penalties" name="fclk_sub_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" default_order="log_date desc">
|
||||
<field name="log_date"/>
|
||||
<field name="log_type"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- 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>
|
||||
<page string="Geofence" name="fclk_sub_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" default_order="log_date desc">
|
||||
<field name="log_date"/>
|
||||
<field name="log_type"/>
|
||||
<field name="description"/>
|
||||
<field name="latitude"/>
|
||||
<field name="longitude"/>
|
||||
<field name="distance"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- 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>
|
||||
<page string="System" name="fclk_sub_system">
|
||||
<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" default_order="log_date desc">
|
||||
<field name="log_date"/>
|
||||
<field name="log_type"/>
|
||||
<field name="description"/>
|
||||
<field name="attendance_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- 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>
|
||||
<page string="Absences" name="fclk_sub_absences">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', '=', 'absent')]">
|
||||
<list create="false" delete="false" limit="20" default_order="log_date desc">
|
||||
<field name="log_date"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- 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>
|
||||
<page string="Leave Requests" name="fclk_sub_leaves">
|
||||
<field name="x_fclk_leave_request_ids" nolabel="1" colspan="2">
|
||||
<list create="false" delete="false" limit="20" default_order="leave_date desc">
|
||||
<field name="leave_date"/>
|
||||
<field name="reason"/>
|
||||
<field name="state"/>
|
||||
<field name="created_from"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- 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>
|
||||
<page string="Reasons" name="fclk_sub_reasons">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', '=', 'reason_provided')]">
|
||||
<list create="false" delete="false" limit="20" default_order="log_date desc">
|
||||
<field name="log_date"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- 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>
|
||||
<page string="Overtime" name="fclk_sub_overtime">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', '=', 'overtime')]">
|
||||
<list create="false" delete="false" limit="20" default_order="log_date desc">
|
||||
<field name="log_date"/>
|
||||
<field name="description"/>
|
||||
<field name="attendance_id"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- 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>
|
||||
<page string="Corrections" name="fclk_sub_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>
|
||||
</page>
|
||||
|
||||
<!-- 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 string="Streaks" name="fclk_sub_streaks">
|
||||
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
|
||||
domain="[('log_type', '=', 'streak_milestone')]">
|
||||
<list create="false" delete="false" limit="20" default_order="log_date desc">
|
||||
<field name="log_date"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
@@ -226,10 +226,10 @@
|
||||
<div class="fclk-recent-item">
|
||||
<div class="fclk-recent-date">
|
||||
<div class="fclk-recent-day-name">
|
||||
<t t-esc="att.check_in.strftime('%a')"/>
|
||||
<t t-esc="context_timestamp(att.check_in).strftime('%a')"/>
|
||||
</div>
|
||||
<div class="fclk-recent-day-num">
|
||||
<t t-esc="att.check_in.strftime('%d')"/>
|
||||
<t t-esc="context_timestamp(att.check_in).strftime('%d')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-recent-info">
|
||||
@@ -237,8 +237,8 @@
|
||||
<t t-esc="att.x_fclk_location_id.name or 'Unknown'"/>
|
||||
</div>
|
||||
<div class="fclk-recent-times">
|
||||
<t t-esc="att.check_in.strftime('%I:%M %p')"/>
|
||||
- <t t-esc="att.check_out.strftime('%I:%M %p') if att.check_out else '--'"/>
|
||||
<t t-esc="context_timestamp(att.check_in).strftime('%I:%M %p')"/>
|
||||
- <t t-esc="context_timestamp(att.check_out).strftime('%I:%M %p') if att.check_out else '--'"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-recent-hours">
|
||||
|
||||
@@ -69,15 +69,15 @@
|
||||
<t t-foreach="attendances" t-as="att">
|
||||
<tr>
|
||||
<td>
|
||||
<strong><t t-esc="att.check_in.strftime('%a')"/></strong>
|
||||
<strong><t t-esc="context_timestamp(att.check_in).strftime('%a')"/></strong>
|
||||
<span style="color:#9ca3af; margin-left:4px;">
|
||||
<t t-esc="att.check_in.strftime('%b %d')"/>
|
||||
<t t-esc="context_timestamp(att.check_in).strftime('%b %d')"/>
|
||||
</span>
|
||||
</td>
|
||||
<td><t t-esc="att.check_in.strftime('%I:%M %p')"/></td>
|
||||
<td><t t-esc="context_timestamp(att.check_in).strftime('%I:%M %p')"/></td>
|
||||
<td>
|
||||
<t t-if="att.check_out">
|
||||
<t t-esc="att.check_out.strftime('%I:%M %p')"/>
|
||||
<t t-esc="context_timestamp(att.check_out).strftime('%I:%M %p')"/>
|
||||
<t t-if="att.x_fclk_auto_clocked_out">
|
||||
<span class="fclk-ts-badge-auto">AUTO</span>
|
||||
</t>
|
||||
|
||||
@@ -10,155 +10,166 @@
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Fusion Clock" string="Fusion Clock" name="fusion_clock" groups="fusion_clock.group_fusion_clock_manager">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Work Schedule -->
|
||||
<!-- ============================================================ -->
|
||||
<block title="Work Schedule" name="fclk_work_schedule">
|
||||
<setting string="Default Clock-In Time" help="The scheduled start time for employees (used when no shift is assigned).">
|
||||
<setting id="fclk_default_schedule" string="Default Schedule"
|
||||
help="Scheduled start and end times used when no shift is assigned to an employee.">
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_default_clock_in_time" class="col-lg-3"/>
|
||||
<label for="fclk_default_clock_in_time" string="Clock-In" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_default_clock_in_time" widget="float_time"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Default Clock-Out Time" help="The scheduled end time for employees.">
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_default_clock_out_time" class="col-lg-3"/>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_default_clock_out_time" string="Clock-Out" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_default_clock_out_time" widget="float_time"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Break Settings -->
|
||||
<block title="Break Settings" name="fclk_break_settings">
|
||||
<setting string="Auto-Deduct Break" help="Automatically deduct unpaid break from worked hours.">
|
||||
<setting id="fclk_auto_break" string="Auto-Deduct Break"
|
||||
help="Automatically deduct unpaid break from worked hours on clock-out.">
|
||||
<field name="fclk_auto_deduct_break"/>
|
||||
<div class="content-group" invisible="not fclk_auto_deduct_break">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_default_break_minutes" class="col-lg-3"/>
|
||||
<label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_default_break_minutes"/>
|
||||
<span class="ms-1">minutes</span>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_break_threshold_hours" class="col-lg-3"/>
|
||||
<label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_break_threshold_hours" widget="float_time"/>
|
||||
<span class="ms-1">(only deduct if shift exceeds this)</span>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Auto Clock-Out & Grace Period -->
|
||||
<block title="Auto Clock-Out" name="fclk_auto_clockout">
|
||||
<setting string="Enable Auto Clock-Out" help="Automatically clock out employees after shift + grace period.">
|
||||
<!-- ============================================================ -->
|
||||
<!-- Attendance Rules -->
|
||||
<!-- ============================================================ -->
|
||||
<block title="Attendance Rules" name="fclk_attendance_rules">
|
||||
<setting id="fclk_auto_clockout" string="Auto Clock-Out"
|
||||
help="Automatically clock out employees after their shift end time plus a grace period.">
|
||||
<field name="fclk_enable_auto_clockout"/>
|
||||
<div class="content-group" invisible="not fclk_enable_auto_clockout">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_grace_period_minutes" class="col-lg-3"/>
|
||||
<label for="fclk_grace_period_minutes" string="Grace (min)" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_grace_period_minutes"/>
|
||||
<span class="ms-1">minutes grace after scheduled end</span>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_max_shift_hours" class="col-lg-3"/>
|
||||
<label for="fclk_max_shift_hours" string="Max Shift" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_max_shift_hours" widget="float_time"/>
|
||||
<span class="ms-1">max shift safety net</span>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Penalties -->
|
||||
<block title="Penalty Tracking" name="fclk_penalties">
|
||||
<setting string="Enable Penalties" help="Track late clock-in and early clock-out with automatic deductions.">
|
||||
<setting id="fclk_penalties" string="Penalty Tracking"
|
||||
help="Deduct minutes from worked hours when employees clock in late or clock out early.">
|
||||
<field name="fclk_enable_penalties"/>
|
||||
<div class="content-group" invisible="not fclk_enable_penalties">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_penalty_grace_minutes" class="col-lg-3"/>
|
||||
<label for="fclk_penalty_grace_minutes" string="Grace (min)" class="col-lg-5 o_light_label"/>
|
||||
<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"/>
|
||||
<label for="fclk_penalty_deduction_minutes" string="Deduction (min)" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_penalty_deduction_minutes"/>
|
||||
<span class="ms-1">minutes deducted per penalty</span>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting id="fclk_overtime" string="Overtime Tracking"
|
||||
help="Calculate and track overtime when net hours exceed the daily or weekly threshold.">
|
||||
<field name="fclk_enable_overtime"/>
|
||||
<div class="content-group" invisible="not fclk_enable_overtime">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_daily_overtime_threshold" string="Daily Limit" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_daily_overtime_threshold" widget="float_time"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_weekly_overtime_threshold" string="Weekly Limit" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_weekly_overtime_threshold" widget="float_time"/>
|
||||
</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.">
|
||||
<!-- ============================================================ -->
|
||||
<!-- Notifications -->
|
||||
<!-- ============================================================ -->
|
||||
<block title="Notifications" name="fclk_notifications">
|
||||
<setting id="fclk_office_user" string="Office User"
|
||||
help="User who receives activity notifications for attendance issues (late arrivals, excessive absences).">
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_office_user_id" class="col-lg-3"/>
|
||||
<label for="fclk_office_user_id" string="User" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_office_user_id"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_very_late_threshold_minutes" class="col-lg-3"/>
|
||||
<label for="fclk_very_late_threshold_minutes" string="Late Alert (min)" class="col-lg-5 o_light_label"/>
|
||||
<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"/>
|
||||
<label for="fclk_max_monthly_absences" string="Max Absences" class="col-lg-5 o_light_label"/>
|
||||
<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.">
|
||||
<setting id="fclk_employee_notifications" string="Employee Reminders"
|
||||
help="Send clock-in and clock-out reminders to employees based on their shift schedule.">
|
||||
<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"/>
|
||||
<label for="fclk_reminder_before_shift_minutes" string="Late Reminder (min)" class="col-lg-5 o_light_label"/>
|
||||
<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"/>
|
||||
<label for="fclk_reminder_before_end_minutes" string="End Reminder (min)" class="col-lg-5 o_light_label"/>
|
||||
<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.">
|
||||
<setting id="fclk_weekly_summary" string="Weekly Summary"
|
||||
help="Send a weekly attendance summary email to each employee 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">
|
||||
<!-- ============================================================ -->
|
||||
<!-- Location & Verification -->
|
||||
<!-- ============================================================ -->
|
||||
<block title="Location & Verification" name="fclk_location_settings">
|
||||
<setting id="fclk_ip_fallback" string="IP Fallback"
|
||||
help="Allow IP-based location verification when GPS is unavailable.">
|
||||
<field name="fclk_enable_ip_fallback"/>
|
||||
</setting>
|
||||
<setting id="fclk_photo_verification" string="Photo Verification"
|
||||
help="Require selfie on clock-in. Per-location control is available in each location's settings.">
|
||||
<field name="fclk_enable_photo_verification"/>
|
||||
</setting>
|
||||
<setting id="fclk_google_maps" string="Google Maps API Key"
|
||||
help="Required for location geocoding and map previews in clock location setup.">
|
||||
<div class="content-group">
|
||||
<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>
|
||||
<label for="fclk_google_maps_api_key" string="API Key" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_google_maps_api_key" class="o_input" placeholder="AIza..." password="True"/>
|
||||
</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>
|
||||
</setting>
|
||||
<setting id="fclk_manage_locations" string="Clock Locations"
|
||||
help="Configure geofenced clock-in/out locations with GPS radius, IP ranges, and verification rules.">
|
||||
<div class="content-group">
|
||||
<div class="mt16">
|
||||
<button name="%(fusion_clock.action_fusion_clock_location)d" type="action"
|
||||
string="Manage Locations" icon="oi-arrow-right" class="btn-link"/>
|
||||
</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.">
|
||||
<!-- ============================================================ -->
|
||||
<!-- Kiosk & Portal -->
|
||||
<!-- ============================================================ -->
|
||||
<block title="Kiosk & Portal" name="fclk_kiosk_portal">
|
||||
<setting id="fclk_kiosk" string="Kiosk Mode"
|
||||
help="Allow employees to clock in/out from a shared device (tablet or computer).">
|
||||
<field name="fclk_enable_kiosk"/>
|
||||
<div class="content-group" invisible="not fclk_enable_kiosk">
|
||||
<div class="row mt16">
|
||||
@@ -167,88 +178,70 @@
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Corrections -->
|
||||
<block title="Corrections" name="fclk_corrections">
|
||||
<setting string="Enable Correction Requests" help="Allow employees to request timesheet corrections.">
|
||||
<setting id="fclk_corrections" string="Correction Requests"
|
||||
help="Allow employees to request timesheet corrections from the portal.">
|
||||
<field name="fclk_enable_correction_requests"/>
|
||||
</setting>
|
||||
<setting id="fclk_sounds" string="Clock Sounds"
|
||||
help="Play audio confirmation sounds when employees clock in or out.">
|
||||
<field name="fclk_enable_sounds"/>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Pay Period -->
|
||||
<block title="Pay Period" name="fclk_pay_period">
|
||||
<setting string="Pay Period Schedule" help="Defines how often reports are generated.">
|
||||
<!-- ============================================================ -->
|
||||
<!-- Pay Period & Reports -->
|
||||
<!-- ============================================================ -->
|
||||
<block title="Pay Period & Reports" name="fclk_pay_period_reports">
|
||||
<setting id="fclk_pay_period" string="Pay Period Schedule"
|
||||
help="Defines how often attendance reports are generated and the start/end dates of each reporting period.">
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_pay_period_type" class="col-lg-3"/>
|
||||
<label for="fclk_pay_period_type" string="Frequency" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_pay_period_type"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_pay_period_start" class="col-lg-3"/>
|
||||
<label for="fclk_pay_period_start" string="Anchor Date" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_pay_period_start"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Reports -->
|
||||
<block title="Reports & Email" name="fclk_reports">
|
||||
<setting string="Auto-Generate Reports" help="Automatically generate reports at the end of each pay period.">
|
||||
<setting id="fclk_auto_reports" string="Auto-Generate Reports"
|
||||
help="Automatically create attendance reports at the end of each pay period.">
|
||||
<field name="fclk_auto_generate_reports"/>
|
||||
</setting>
|
||||
<setting string="Send Employee Copies" help="Email individual reports to each employee.">
|
||||
<setting id="fclk_employee_copies" string="Send Employee Copies"
|
||||
help="Email individual attendance reports to each employee at the end of each pay period.">
|
||||
<field name="fclk_send_employee_reports"/>
|
||||
</setting>
|
||||
<setting string="Manager Report Recipients" help="Comma-separated emails for batch report delivery.">
|
||||
<setting id="fclk_internal_recipients" string="Internal Recipients"
|
||||
help="Select internal users who should receive batch attendance reports.">
|
||||
<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"/>
|
||||
<label for="fclk_report_recipient_user_ids" string="Users" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_report_recipient_user_ids" widget="many2many_tags"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="CSV Column Mapping" help="Custom column names for CSV export (JSON format).">
|
||||
<setting id="fclk_external_recipients" string="External Recipients"
|
||||
help="Additional email addresses for batch report delivery (e.g., external payroll agency).">
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_csv_column_mapping" class="col-lg-3"/>
|
||||
<label for="fclk_report_recipient_emails" string="Emails" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_report_recipient_emails" class="o_input" placeholder="payroll@agency.com, manager@company.com"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
<setting id="fclk_csv_mapping" string="CSV Column Mapping"
|
||||
help="Custom column names for CSV export (JSON format). Leave blank for defaults.">
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_csv_column_mapping" string="Mapping" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_csv_column_mapping" class="o_input"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Clock Locations -->
|
||||
<block title="Clock Locations" name="fclk_locations">
|
||||
<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"
|
||||
string="Manage Locations" class="btn btn-primary" icon="fa-map-marker"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Google Maps -->
|
||||
<block title="Google Maps" name="fclk_google_maps">
|
||||
<setting string="Google Maps API Key" help="Required for location geocoding and map previews.">
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_google_maps_api_key" class="col-lg-3"/>
|
||||
<field name="fclk_google_maps_api_key" class="o_input" placeholder="AIza..." password="True"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- Sounds -->
|
||||
<block title="Sounds" name="fclk_sounds">
|
||||
<setting string="Clock Sounds" help="Play audio feedback on clock-in and clock-out.">
|
||||
<field name="fclk_enable_sounds"/>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
Reference in New Issue
Block a user