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:
gsinghpal
2026-05-30 23:01:19 -04:00
parent 87639a12b5
commit 6a9c7c74ea
10 changed files with 128 additions and 27 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.3.12.4',
'version': '19.0.3.13.0',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """

View File

@@ -482,35 +482,47 @@ class FusionClockAPI(http.Controller):
return {'success': True, 'message': 'Reason submitted. You may now clock in.'}
@http.route('/fusion_clock/request_leave', type='jsonrpc', auth='user', methods=['POST'])
def request_leave(self, leave_date='', reason='', **kw):
"""Submit a leave request from the portal."""
def request_leave(self, date_from='', date_to='', reason='', leave_date='', **kw):
"""Submit a (possibly multi-day) leave request from the portal."""
employee = self._get_employee()
if not employee:
return {'error': 'No employee record found for current user.'}
if not leave_date or not reason:
return {'error': 'Please provide both a date and a reason.'}
date_from = date_from or leave_date # back-compat with the old single-date payload
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:
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:
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([
('employee_id', '=', employee.id),
('leave_date', '=', date_obj),
('leave_date', '<=', to_obj),
('date_to', '>=', from_obj),
], limit=1)
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({
'employee_id': employee.id,
'leave_date': date_obj,
'leave_date': from_obj,
'date_to': to_obj,
'reason': reason,
'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'])
def request_correction(self, attendance_id=0, check_in='', check_out='', reason='', **kw):

View 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"
)

View File

@@ -3,7 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from odoo import models, fields, api
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
@@ -23,10 +24,16 @@ class FusionClockLeaveRequest(models.Model):
ondelete='cascade',
)
leave_date = fields.Date(
string='Leave Date',
string='From Date',
required=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(
string='Reason',
required=True,
@@ -59,15 +66,32 @@ class FusionClockLeaveRequest(models.Model):
store=True,
)
@api.depends('employee_id', 'leave_date')
@api.depends('employee_id', 'leave_date', 'date_to')
def _compute_display_name(self):
for rec in self:
emp = rec.employee_id.name or ''
date_str = str(rec.leave_date) if rec.leave_date else ''
rec.display_name = f"{emp} - Leave ({date_str})"
rec.display_name = f"{emp} - Leave ({rec._fclk_date_label()})"
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
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)
for rec in records:
rec._notify_office_user()
@@ -86,7 +110,7 @@ class FusionClockLeaveRequest(models.Model):
try:
self.env['mail.activity'].sudo().create({
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'summary': f"Leave Request: {self.employee_id.name} on {self.leave_date}",
'summary': f"Leave Request: {self.employee_id.name} ({self._fclk_date_label()})",
'note': f"Reason: {self.reason}",
'user_id': office_user.id,
'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({
'employee_id': self.employee_id.id,
'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',
})
except Exception as e:

View File

@@ -166,8 +166,9 @@ class FusionClockReport(models.Model):
self.attendance_ids = [(6, 0, attendances.ids)]
leave_domain = [
('leave_date', '>=', self.date_start),
# Any leave whose range overlaps the report period.
('leave_date', '<=', self.date_end),
('date_to', '>=', self.date_start),
]
if self.employee_id:
leave_domain.append(('employee_id', '=', self.employee_id.id))

View File

@@ -423,7 +423,8 @@ class HrAttendance(models.Model):
leave = LeaveRequest.search([
('employee_id', '=', emp.id),
('leave_date', '=', yesterday),
('leave_date', '<=', yesterday),
('date_to', '>=', yesterday),
], limit=1)
if leave:
continue

View File

@@ -1319,6 +1319,33 @@ html.o_dark .fclk-wizard-overlay {
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 ---- */
.fclk-reports-container {
max-width: 600px;

View File

@@ -655,26 +655,34 @@ export class FusionClockPortal extends Interaction {
}
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 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() : "";
if (!leaveDate || !reason) {
this._showToast("Please provide both a date and reason.", "error");
if (!dateFrom || !reason) {
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;
}
try {
const result = await rpc("/fusion_clock/request_leave", {
leave_date: leaveDate,
date_from: dateFrom,
date_to: dateTo,
reason: reason,
});
if (result.success) {
const modal = document.getElementById("fclk-leave-modal");
if (modal) modal.style.display = "none";
this._showToast(result.message, "success");
if (dateEl) dateEl.value = "";
if (fromEl) fromEl.value = "";
if (toEl) toEl.value = "";
if (reasonEl) reasonEl.value = "";
} else {
this._showToast(result.error || "Failed to submit.", "error");

View File

@@ -8,6 +8,7 @@
<field name="arch" type="xml">
<list>
<field name="leave_date"/>
<field name="date_to"/>
<field name="employee_id"/>
<field name="reason"/>
<field name="state" decoration-success="state == 'reviewed'"
@@ -34,6 +35,7 @@
<group>
<field name="employee_id"/>
<field name="leave_date"/>
<field name="date_to"/>
<field name="created_from"/>
</group>
<group>

View File

@@ -423,9 +423,18 @@
<div class="fclk-wizard-field">
<label class="fclk-wizard-label" for="fclk-leave-date">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
Leave Date <span class="fclk-wizard-required">*</span>
Leave Dates <span class="fclk-wizard-required">*</span>
</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 class="fclk-wizard-field">
<label class="fclk-wizard-label" for="fclk-leave-reason">