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',
|
||||
'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': """
|
||||
|
||||
@@ -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):
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user