feat(fusion_clock): multi-day leave requests (date range)
Request Leave now takes a From/To date range instead of a single day (the To field is optional -> single-day). Added date_to to fusion.clock.leave.request (start kept as leave_date), with overlap detection on submit and a date_to >= leave_date constraint. The absence check and reports now treat a leave as covering its whole span. The form shows two date inputs; the controller accepts date_from/date_to (the old single leave_date payload is still honoured). A migration backfills date_to = leave_date for existing rows. Live and verified on entech 19.0.3.13.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.3.12.4',
|
'version': '19.0.3.13.0',
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -482,35 +482,47 @@ class FusionClockAPI(http.Controller):
|
|||||||
return {'success': True, 'message': 'Reason submitted. You may now clock in.'}
|
return {'success': True, 'message': 'Reason submitted. You may now clock in.'}
|
||||||
|
|
||||||
@http.route('/fusion_clock/request_leave', type='jsonrpc', auth='user', methods=['POST'])
|
@http.route('/fusion_clock/request_leave', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
def request_leave(self, leave_date='', reason='', **kw):
|
def request_leave(self, date_from='', date_to='', reason='', leave_date='', **kw):
|
||||||
"""Submit a leave request from the portal."""
|
"""Submit a (possibly multi-day) leave request from the portal."""
|
||||||
employee = self._get_employee()
|
employee = self._get_employee()
|
||||||
if not employee:
|
if not employee:
|
||||||
return {'error': 'No employee record found for current user.'}
|
return {'error': 'No employee record found for current user.'}
|
||||||
|
|
||||||
if not leave_date or not reason:
|
date_from = date_from or leave_date # back-compat with the old single-date payload
|
||||||
return {'error': 'Please provide both a date and a reason.'}
|
date_to = date_to or date_from
|
||||||
|
if not date_from or not reason:
|
||||||
|
return {'error': 'Please provide a start date and a reason.'}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
date_obj = fields.Date.from_string(leave_date)
|
from_obj = fields.Date.from_string(date_from)
|
||||||
|
to_obj = fields.Date.from_string(date_to)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {'error': 'Invalid date format. Use YYYY-MM-DD.'}
|
return {'error': 'Invalid date format. Use YYYY-MM-DD.'}
|
||||||
|
if to_obj < from_obj:
|
||||||
|
return {'error': 'The end date cannot be before the start date.'}
|
||||||
|
|
||||||
|
# Reject if an existing request overlaps the requested range.
|
||||||
existing = request.env['fusion.clock.leave.request'].sudo().search([
|
existing = request.env['fusion.clock.leave.request'].sudo().search([
|
||||||
('employee_id', '=', employee.id),
|
('employee_id', '=', employee.id),
|
||||||
('leave_date', '=', date_obj),
|
('leave_date', '<=', to_obj),
|
||||||
|
('date_to', '>=', from_obj),
|
||||||
], limit=1)
|
], limit=1)
|
||||||
if existing:
|
if existing:
|
||||||
return {'error': 'A leave request already exists for this date.'}
|
return {'error': 'A leave request already overlaps these dates.'}
|
||||||
|
|
||||||
request.env['fusion.clock.leave.request'].sudo().create({
|
request.env['fusion.clock.leave.request'].sudo().create({
|
||||||
'employee_id': employee.id,
|
'employee_id': employee.id,
|
||||||
'leave_date': date_obj,
|
'leave_date': from_obj,
|
||||||
|
'date_to': to_obj,
|
||||||
'reason': reason,
|
'reason': reason,
|
||||||
'created_from': 'portal',
|
'created_from': 'portal',
|
||||||
})
|
})
|
||||||
|
|
||||||
return {'success': True, 'message': f'Leave request for {leave_date} submitted.'}
|
if from_obj == to_obj:
|
||||||
|
msg = f'Leave request for {date_from} submitted.'
|
||||||
|
else:
|
||||||
|
msg = f'Leave request for {date_from} to {date_to} submitted.'
|
||||||
|
return {'success': True, 'message': msg}
|
||||||
|
|
||||||
@http.route('/fusion_clock/request_correction', type='jsonrpc', auth='user', methods=['POST'])
|
@http.route('/fusion_clock/request_correction', type='jsonrpc', auth='user', methods=['POST'])
|
||||||
def request_correction(self, attendance_id=0, check_in='', check_out='', reason='', **kw):
|
def request_correction(self, attendance_id=0, check_in='', check_out='', reason='', **kw):
|
||||||
|
|||||||
17
fusion_clock/migrations/19.0.3.13.0/post-migrate.py
Normal file
17
fusion_clock/migrations/19.0.3.13.0/post-migrate.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""Backfill leave-request end dates on upgrade to 19.0.3.13.0.
|
||||||
|
|
||||||
|
Leave requests gained a `date_to` (end of a multi-day range). Existing
|
||||||
|
single-day requests have no end date; set it to the start date so they keep
|
||||||
|
being treated as one-day leaves by the absence check and reports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
cr.execute(
|
||||||
|
"UPDATE fusion_clock_leave_request SET date_to = leave_date WHERE date_to IS NULL"
|
||||||
|
)
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from odoo import models, fields, api
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -23,10 +24,16 @@ class FusionClockLeaveRequest(models.Model):
|
|||||||
ondelete='cascade',
|
ondelete='cascade',
|
||||||
)
|
)
|
||||||
leave_date = fields.Date(
|
leave_date = fields.Date(
|
||||||
string='Leave Date',
|
string='From Date',
|
||||||
required=True,
|
required=True,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
date_to = fields.Date(
|
||||||
|
string='To Date',
|
||||||
|
index=True,
|
||||||
|
help="Last day of the leave (inclusive); equals the start date for a "
|
||||||
|
"single-day request.",
|
||||||
|
)
|
||||||
reason = fields.Text(
|
reason = fields.Text(
|
||||||
string='Reason',
|
string='Reason',
|
||||||
required=True,
|
required=True,
|
||||||
@@ -59,15 +66,32 @@ class FusionClockLeaveRequest(models.Model):
|
|||||||
store=True,
|
store=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.depends('employee_id', 'leave_date')
|
@api.depends('employee_id', 'leave_date', 'date_to')
|
||||||
def _compute_display_name(self):
|
def _compute_display_name(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
emp = rec.employee_id.name or ''
|
emp = rec.employee_id.name or ''
|
||||||
date_str = str(rec.leave_date) if rec.leave_date else ''
|
rec.display_name = f"{emp} - Leave ({rec._fclk_date_label()})"
|
||||||
rec.display_name = f"{emp} - Leave ({date_str})"
|
|
||||||
|
def _fclk_date_label(self):
|
||||||
|
"""Human label for the leave period: a single date, or 'from to to'."""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.leave_date:
|
||||||
|
return ''
|
||||||
|
if self.date_to and self.date_to != self.leave_date:
|
||||||
|
return f"{self.leave_date} to {self.date_to}"
|
||||||
|
return str(self.leave_date)
|
||||||
|
|
||||||
|
@api.constrains('leave_date', 'date_to')
|
||||||
|
def _check_leave_dates(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.date_to and rec.leave_date and rec.date_to < rec.leave_date:
|
||||||
|
raise ValidationError(_("The end date cannot be before the start date."))
|
||||||
|
|
||||||
@api.model_create_multi
|
@api.model_create_multi
|
||||||
def create(self, vals_list):
|
def create(self, vals_list):
|
||||||
|
for vals in vals_list:
|
||||||
|
if not vals.get('date_to') and vals.get('leave_date'):
|
||||||
|
vals['date_to'] = vals['leave_date']
|
||||||
records = super().create(vals_list)
|
records = super().create(vals_list)
|
||||||
for rec in records:
|
for rec in records:
|
||||||
rec._notify_office_user()
|
rec._notify_office_user()
|
||||||
@@ -86,7 +110,7 @@ class FusionClockLeaveRequest(models.Model):
|
|||||||
try:
|
try:
|
||||||
self.env['mail.activity'].sudo().create({
|
self.env['mail.activity'].sudo().create({
|
||||||
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||||
'summary': f"Leave Request: {self.employee_id.name} on {self.leave_date}",
|
'summary': f"Leave Request: {self.employee_id.name} ({self._fclk_date_label()})",
|
||||||
'note': f"Reason: {self.reason}",
|
'note': f"Reason: {self.reason}",
|
||||||
'user_id': office_user.id,
|
'user_id': office_user.id,
|
||||||
'res_model_id': self.env['ir.model']._get_id('fusion.clock.leave.request'),
|
'res_model_id': self.env['ir.model']._get_id('fusion.clock.leave.request'),
|
||||||
@@ -102,7 +126,7 @@ class FusionClockLeaveRequest(models.Model):
|
|||||||
self.env['fusion.clock.activity.log'].sudo().create({
|
self.env['fusion.clock.activity.log'].sudo().create({
|
||||||
'employee_id': self.employee_id.id,
|
'employee_id': self.employee_id.id,
|
||||||
'log_type': 'leave_request',
|
'log_type': 'leave_request',
|
||||||
'description': f"Leave requested for {self.leave_date}: {self.reason}",
|
'description': f"Leave requested for {self._fclk_date_label()}: {self.reason}",
|
||||||
'source': 'portal' if self.created_from == 'portal' else 'system',
|
'source': 'portal' if self.created_from == 'portal' else 'system',
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -166,8 +166,9 @@ class FusionClockReport(models.Model):
|
|||||||
self.attendance_ids = [(6, 0, attendances.ids)]
|
self.attendance_ids = [(6, 0, attendances.ids)]
|
||||||
|
|
||||||
leave_domain = [
|
leave_domain = [
|
||||||
('leave_date', '>=', self.date_start),
|
# Any leave whose range overlaps the report period.
|
||||||
('leave_date', '<=', self.date_end),
|
('leave_date', '<=', self.date_end),
|
||||||
|
('date_to', '>=', self.date_start),
|
||||||
]
|
]
|
||||||
if self.employee_id:
|
if self.employee_id:
|
||||||
leave_domain.append(('employee_id', '=', self.employee_id.id))
|
leave_domain.append(('employee_id', '=', self.employee_id.id))
|
||||||
|
|||||||
@@ -423,7 +423,8 @@ class HrAttendance(models.Model):
|
|||||||
|
|
||||||
leave = LeaveRequest.search([
|
leave = LeaveRequest.search([
|
||||||
('employee_id', '=', emp.id),
|
('employee_id', '=', emp.id),
|
||||||
('leave_date', '=', yesterday),
|
('leave_date', '<=', yesterday),
|
||||||
|
('date_to', '>=', yesterday),
|
||||||
], limit=1)
|
], limit=1)
|
||||||
if leave:
|
if leave:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1319,6 +1319,33 @@ html.o_dark .fclk-wizard-overlay {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Leave request: From / To date-range row */
|
||||||
|
.fclk-leave-daterange {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.fclk-leave-daterange-col {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.fclk-leave-daterange-col .fclk-wizard-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.fclk-leave-daterange-cap {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fclk-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.fclk-leave-daterange-cap small {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--fclk-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Reports Page ---- */
|
/* ---- Reports Page ---- */
|
||||||
.fclk-reports-container {
|
.fclk-reports-container {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
|
|||||||
@@ -655,26 +655,34 @@ export class FusionClockPortal extends Interaction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _submitLeave() {
|
async _submitLeave() {
|
||||||
const dateEl = document.getElementById("fclk-leave-date");
|
const fromEl = document.getElementById("fclk-leave-date");
|
||||||
|
const toEl = document.getElementById("fclk-leave-date-to");
|
||||||
const reasonEl = document.getElementById("fclk-leave-reason");
|
const reasonEl = document.getElementById("fclk-leave-reason");
|
||||||
const leaveDate = dateEl ? dateEl.value : "";
|
const dateFrom = fromEl ? fromEl.value : "";
|
||||||
|
let dateTo = toEl && toEl.value ? toEl.value : dateFrom; // single day if "To" left blank
|
||||||
const reason = reasonEl ? reasonEl.value.trim() : "";
|
const reason = reasonEl ? reasonEl.value.trim() : "";
|
||||||
|
|
||||||
if (!leaveDate || !reason) {
|
if (!dateFrom || !reason) {
|
||||||
this._showToast("Please provide both a date and reason.", "error");
|
this._showToast("Please provide a start date and a reason.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dateTo < dateFrom) {
|
||||||
|
this._showToast("End date can't be before the start date.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await rpc("/fusion_clock/request_leave", {
|
const result = await rpc("/fusion_clock/request_leave", {
|
||||||
leave_date: leaveDate,
|
date_from: dateFrom,
|
||||||
|
date_to: dateTo,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const modal = document.getElementById("fclk-leave-modal");
|
const modal = document.getElementById("fclk-leave-modal");
|
||||||
if (modal) modal.style.display = "none";
|
if (modal) modal.style.display = "none";
|
||||||
this._showToast(result.message, "success");
|
this._showToast(result.message, "success");
|
||||||
if (dateEl) dateEl.value = "";
|
if (fromEl) fromEl.value = "";
|
||||||
|
if (toEl) toEl.value = "";
|
||||||
if (reasonEl) reasonEl.value = "";
|
if (reasonEl) reasonEl.value = "";
|
||||||
} else {
|
} else {
|
||||||
this._showToast(result.error || "Failed to submit.", "error");
|
this._showToast(result.error || "Failed to submit.", "error");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list>
|
<list>
|
||||||
<field name="leave_date"/>
|
<field name="leave_date"/>
|
||||||
|
<field name="date_to"/>
|
||||||
<field name="employee_id"/>
|
<field name="employee_id"/>
|
||||||
<field name="reason"/>
|
<field name="reason"/>
|
||||||
<field name="state" decoration-success="state == 'reviewed'"
|
<field name="state" decoration-success="state == 'reviewed'"
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
<group>
|
<group>
|
||||||
<field name="employee_id"/>
|
<field name="employee_id"/>
|
||||||
<field name="leave_date"/>
|
<field name="leave_date"/>
|
||||||
|
<field name="date_to"/>
|
||||||
<field name="created_from"/>
|
<field name="created_from"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
|
|||||||
@@ -423,9 +423,18 @@
|
|||||||
<div class="fclk-wizard-field">
|
<div class="fclk-wizard-field">
|
||||||
<label class="fclk-wizard-label" for="fclk-leave-date">
|
<label class="fclk-wizard-label" for="fclk-leave-date">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
Leave Date <span class="fclk-wizard-required">*</span>
|
Leave Dates <span class="fclk-wizard-required">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="date" id="fclk-leave-date" class="fclk-wizard-input"/>
|
<div class="fclk-leave-daterange">
|
||||||
|
<div class="fclk-leave-daterange-col">
|
||||||
|
<span class="fclk-leave-daterange-cap">From</span>
|
||||||
|
<input type="date" id="fclk-leave-date" class="fclk-wizard-input"/>
|
||||||
|
</div>
|
||||||
|
<div class="fclk-leave-daterange-col">
|
||||||
|
<span class="fclk-leave-daterange-cap">To <small>(optional)</small></span>
|
||||||
|
<input type="date" id="fclk-leave-date-to" class="fclk-wizard-input"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fclk-wizard-field">
|
<div class="fclk-wizard-field">
|
||||||
<label class="fclk-wizard-label" for="fclk-leave-reason">
|
<label class="fclk-wizard-label" for="fclk-leave-reason">
|
||||||
|
|||||||
Reference in New Issue
Block a user