diff --git a/fusion_clock/controllers/shift_planner.py b/fusion_clock/controllers/shift_planner.py index 6f3f6a3b..1ffcb1b5 100644 --- a/fusion_clock/controllers/shift_planner.py +++ b/fusion_clock/controllers/shift_planner.py @@ -237,6 +237,38 @@ class FusionClockShiftPlanner(http.Controller): 'data': self._load_week_data(start), } + @http.route('/fusion_clock/shift_planner/set_recurrence', type='jsonrpc', auth='user', methods=['POST']) + def set_recurrence(self, employee_id=None, date=None, repeat=None, week_start=None, **kw): + """Make the shift at (employee, date) recurring and generate it forward.""" + if not self._check_manager(): + return {'error': 'Access denied.'} + Schedule = request.env['fusion.clock.schedule'].sudo() + schedule = Schedule.search([ + ('employee_id', '=', int(employee_id or 0)), + ('schedule_date', '=', date), + ], limit=1) + if not schedule: + return {'success': False, 'message': 'Save this shift before repeating it.'} + try: + Schedule.fclk_attach_recurrence(schedule, repeat or {}) + except ValidationError as exc: + return {'success': False, 'message': str(exc.args[0] if exc.args else exc)} + return {'success': True, 'data': self._load_week_data(week_start)} + + @http.route('/fusion_clock/shift_planner/clear_recurrence', type='jsonrpc', auth='user', methods=['POST']) + def clear_recurrence(self, employee_id=None, date=None, week_start=None, **kw): + """Stop the recurrence seeded at (employee, date); keep posted rows.""" + if not self._check_manager(): + return {'error': 'Access denied.'} + Schedule = request.env['fusion.clock.schedule'].sudo() + schedule = Schedule.search([ + ('employee_id', '=', int(employee_id or 0)), + ('schedule_date', '=', date), + ], limit=1) + if schedule: + Schedule.fclk_clear_recurrence(schedule) + return {'success': True, 'data': self._load_week_data(week_start)} + @http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST']) def export_xlsx(self, week_start=None, **kw): if not self._check_manager(): diff --git a/fusion_clock/data/clock_recurrence_cron.xml b/fusion_clock/data/clock_recurrence_cron.xml new file mode 100644 index 00000000..8cf8b892 --- /dev/null +++ b/fusion_clock/data/clock_recurrence_cron.xml @@ -0,0 +1,17 @@ + + + + + + Fusion Clock: Generate Recurring Shifts + + code + model._cron_generate() + 1 + days + True + 75 + + + diff --git a/fusion_clock/models/__init__.py b/fusion_clock/models/__init__.py index b0797190..aeb5efe8 100644 --- a/fusion_clock/models/__init__.py +++ b/fusion_clock/models/__init__.py @@ -12,6 +12,7 @@ from . import clock_activity_log from . import clock_leave_request from . import clock_role from . import clock_shift +from . import clock_recurrence from . import clock_schedule from . import clock_correction from . import res_company diff --git a/fusion_clock/models/clock_recurrence.py b/fusion_clock/models/clock_recurrence.py new file mode 100644 index 00000000..caf8767a --- /dev/null +++ b/fusion_clock/models/clock_recurrence.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# Native recurring-shift engine. The field design and repeat semantics are +# adapted from Odoo Enterprise ``planning.recurrency`` (repeat every N +# days/weeks/months/years; forever / until / N-times), but the generation loop +# targets Fusion Clock's per-day ``fusion.clock.schedule`` rows instead of +# datetime ``planning.slot`` records — so there is no resource-calendar / DST +# machinery to carry. Generated rows are born ``draft`` and must be posted +# (published) before any attendance automation acts on them. + +import logging + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + +# Hard safety cap on iterations when projecting a recurrence forward, so a +# misconfigured rule can never loop unbounded (5 years of daily shifts). +_MAX_OCCURRENCES = 365 * 5 + + +class FusionClockScheduleRecurrence(models.Model): + _name = 'fusion.clock.schedule.recurrence' + _description = 'Clock Schedule Recurrence' + _rec_name = 'display_name' + + schedule_ids = fields.One2many( + 'fusion.clock.schedule', 'recurrence_id', string='Generated Shifts') + repeat_interval = fields.Integer('Repeat Every', default=1, required=True) + repeat_unit = fields.Selection( + [('day', 'Days'), ('week', 'Weeks'), ('month', 'Months'), ('year', 'Years')], + string='Repeat Unit', default='week', required=True) + repeat_type = fields.Selection( + [('forever', 'Forever'), ('until', 'Until'), ('x_times', 'Number of Repetitions')], + string='Until', default='forever', required=True) + repeat_until = fields.Date('Repeat Until') + repeat_number = fields.Integer('Repetitions', default=1) + last_generated_date = fields.Date(readonly=True) + company_id = fields.Many2one( + 'res.company', string='Company', required=True, + default=lambda self: self.env.company) + display_name = fields.Char(compute='_compute_display_name') + + _check_interval_positive = models.Constraint( + 'CHECK(repeat_interval >= 1)', 'The repeat interval must be at least 1.') + + @api.constrains('repeat_type', 'repeat_until') + def _check_until(self): + for rec in self: + if rec.repeat_type == 'until' and not rec.repeat_until: + raise ValidationError(_('Set an end date for an "Until" recurrence.')) + + @api.constrains('repeat_type', 'repeat_number') + def _check_number(self): + for rec in self: + if rec.repeat_type == 'x_times' and rec.repeat_number < 1: + raise ValidationError(_('The number of repetitions must be at least 1.')) + + @api.depends('repeat_type', 'repeat_interval', 'repeat_unit', 'repeat_until', 'repeat_number') + def _compute_display_name(self): + units = dict(self._fields['repeat_unit'].selection) + for rec in self: + unit = units.get(rec.repeat_unit, rec.repeat_unit) + if rec.repeat_type == 'forever': + rec.display_name = _('Every %(n)s %(u)s, forever', n=rec.repeat_interval, u=unit) + elif rec.repeat_type == 'until': + rec.display_name = _('Every %(n)s %(u)s until %(d)s', + n=rec.repeat_interval, u=unit, d=rec.repeat_until) + else: + rec.display_name = _('Every %(n)s %(u)s, %(c)s times', + n=rec.repeat_interval, u=unit, c=rec.repeat_number) + + def _delta(self, n): + """relativedelta for the n-th occurrence after the seed.""" + self.ensure_one() + key = {'day': 'days', 'week': 'weeks', 'month': 'months', 'year': 'years'}[self.repeat_unit] + return relativedelta(**{key: self.repeat_interval * n}) + + def _horizon(self): + """Furthest date we pre-generate to when the recurrence has no end.""" + self.ensure_one() + months = self.company_id.fclk_planning_generation_months or 6 + return fields.Date.today() + relativedelta(months=months) + + def _generate(self, stop_date=False): + """Materialise per-day schedule rows for each recurrence up to its + horizon. Idempotent: dates already covered for the rule are skipped and + ``last_generated_date`` advances.""" + Schedule = self.env['fusion.clock.schedule'].sudo() + for rec in self: + seed = Schedule.search( + [('recurrence_id', '=', rec.id)], order='schedule_date desc', limit=1) + if not seed: + # No anchor row -> nothing to repeat; drop the empty rule. + rec.unlink() + continue + anchor = Schedule.search( + [('recurrence_id', '=', rec.id)], order='schedule_date asc', limit=1) + bounds = [stop_date or rec._horizon()] + if rec.repeat_until: + bounds.append(rec.repeat_until) + limit = min(bounds) + + existing = Schedule.search_count([('recurrence_id', '=', rec.id)]) + vals_list, last = [], rec.last_generated_date + for i in range(1, _MAX_OCCURRENCES + 1): + nxt = anchor.schedule_date + rec._delta(i) + if nxt > limit: + break + if rec.repeat_type == 'x_times' and existing + len(vals_list) >= rec.repeat_number: + break + if Schedule.search_count( + [('recurrence_id', '=', rec.id), ('schedule_date', '=', nxt)]): + continue + if anchor.employee_id and anchor.employee_id._fclk_on_leave(nxt): + continue + vals_list.append({ + 'employee_id': anchor.employee_id.id or False, + 'schedule_date': nxt, + 'shift_id': anchor.shift_id.id or False, + 'role_id': anchor.role_id.id or False, + 'is_off': anchor.is_off, + # is_open is added in the Phase B schedule extension; guard so + # the engine works whether or not that field exists yet. + 'is_open': bool(getattr(anchor, 'is_open', False)), + 'start_time': anchor.start_time, + 'end_time': anchor.end_time, + 'break_minutes': anchor.break_minutes, + 'note': anchor.note or False, + 'recurrence_id': rec.id, + 'state': 'draft', + }) + last = nxt + if vals_list: + Schedule.create(vals_list) + rec.last_generated_date = last + + def _stop(self, from_date): + """Delete future DRAFT rows of these rules (posted rows are kept).""" + self.env['fusion.clock.schedule'].sudo().search([ + ('recurrence_id', 'in', self.ids), + ('schedule_date', '>=', from_date), + ('state', '=', 'draft'), + ]).unlink() + + @api.model + def _cron_generate(self): + """Roll every recurrence's horizon forward (called daily).""" + self.search([])._generate() diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index 3756fb0f..ac183f7e 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -377,6 +377,7 @@ class FusionClockSchedule(models.Model): 'role_id': schedule.role_id.id or False, 'role_name': schedule.role_id.name or '', 'role_color': schedule.role_id._get_color_from_code() if schedule.role_id else '', + 'recurring': bool(schedule.recurrence_id), } plan = employee._get_fclk_day_plan(date_obj) @@ -399,6 +400,37 @@ class FusionClockSchedule(models.Model): 'role_color': '', } + @api.model + def fclk_attach_recurrence(self, schedule, repeat_vals): + """Attach a recurrence rule to a seed schedule cell and generate it + forward. ``repeat_vals`` mirrors the recurrence fields.""" + schedule = schedule.sudo() + if not schedule: + raise ValidationError(_("Pick a shift to repeat first.")) + rule = self.env['fusion.clock.schedule.recurrence'].sudo().create({ + 'repeat_interval': int(repeat_vals.get('repeat_interval') or 1), + 'repeat_unit': repeat_vals.get('repeat_unit') or 'week', + 'repeat_type': repeat_vals.get('repeat_type') or 'forever', + 'repeat_until': repeat_vals.get('repeat_until') or False, + 'repeat_number': int(repeat_vals.get('repeat_number') or 1), + 'company_id': schedule.company_id.id or self.env.company.id, + }) + schedule.recurrence_id = rule.id + rule._generate() + return rule + + @api.model + def fclk_clear_recurrence(self, schedule): + """Detach + stop the recurrence on a seed cell (keeps posted rows).""" + schedule = schedule.sudo() + rule = schedule.recurrence_id + if rule: + rule._stop(fields.Date.today()) + schedule.recurrence_id = False + if not rule.schedule_ids: + rule.unlink() + return True + @api.model def fclk_email_posted_week(self, employee, week_start, week_end): """Email one employee a summary of their POSTED shifts for the week.""" diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py index 1c67fe4b..51c7b833 100644 --- a/fusion_clock/models/hr_employee.py +++ b/fusion_clock/models/hr_employee.py @@ -173,6 +173,19 @@ class HrEmployee(models.Model): ('schedule_date', '=', date_obj), ], limit=1) + def _fclk_on_leave(self, date): + """True if an approved leave request covers ``date`` for this employee. + Used by the recurrence engine to skip generating shifts on days off.""" + self.ensure_one() + date_obj = fields.Date.to_date(date) + if not date_obj: + return False + return bool(self.env['fusion.clock.leave.request'].sudo().search_count([ + ('employee_id', '=', self.id), + ('leave_date', '<=', date_obj), + ('date_to', '>=', date_obj), + ])) + def _get_fclk_day_plan(self, date): """Return the effective plan for a local date, with an explicit ``scheduled`` flag that ALL attendance automation keys off. diff --git a/fusion_clock/models/res_company.py b/fusion_clock/models/res_company.py index 7daa8453..c6914193 100644 --- a/fusion_clock/models/res_company.py +++ b/fusion_clock/models/res_company.py @@ -14,3 +14,14 @@ class ResCompany(models.Model): domain="[('company_id', '=', id)]", help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.", ) + fclk_planning_generation_months = fields.Integer( + string='Schedule Generation Horizon (months)', + default=6, + help="How many months ahead recurring shifts are pre-generated.", + ) + fclk_self_unassign_days_before = fields.Integer( + string='Self-Unassign Cutoff (days before shift)', + default=1, + help="Employees may release an open shift they claimed up to this many " + "days before it starts.", + ) diff --git a/fusion_clock/static/src/js/fusion_clock_shift_planner.js b/fusion_clock/static/src/js/fusion_clock_shift_planner.js index f725dbe6..ea739541 100644 --- a/fusion_clock/static/src/js/fusion_clock_shift_planner.js +++ b/fusion_clock/static/src/js/fusion_clock_shift_planner.js @@ -45,6 +45,9 @@ export class FusionClockShiftPlanner extends Component { error: "", top: 0, left: 0, + recurring: false, + showRepeat: false, + repeat: { interval: 1, unit: "week", type: "forever", until: "", number: 4 }, }, }); @@ -258,9 +261,72 @@ export class FusionClockShiftPlanner extends Component { this.state.editor.breakMinutes = breakMinutes; this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours); this.state.editor.error = cell.error || ""; + this.state.editor.recurring = !!cell.recurring; + this.state.editor.showRepeat = false; this._positionActiveEditor(anchor); } + toggleRepeatPanel() { + this.state.editor.showRepeat = !this.state.editor.showRepeat; + } + + onRepeatField(field, ev) { + const value = ev.target.value; + this.state.editor.repeat[field] = + field === "interval" || field === "number" ? Number(value) : value; + } + + async setRecurrence() { + const editor = this.state.editor; + this.state.saving = true; + try { + const result = await rpc("/fusion_clock/shift_planner/set_recurrence", { + employee_id: editor.employeeId, + date: editor.date, + week_start: this.state.weekStart, + repeat: { + repeat_interval: editor.repeat.interval, + repeat_unit: editor.repeat.unit, + repeat_type: editor.repeat.type, + repeat_until: editor.repeat.until || false, + repeat_number: editor.repeat.number, + }, + }); + if (result.error || result.success === false) { + this.notification.add(result.error || result.message || "Could not repeat shift.", { + type: "danger", + }); + } else { + this._applyData(result.data); + this.notification.add("Recurring shift created.", { type: "success" }); + } + } catch (error) { + this.notification.add(error.message || "Could not repeat shift.", { type: "danger" }); + } + this.state.saving = false; + } + + async clearRecurrence() { + const editor = this.state.editor; + this.state.saving = true; + try { + const result = await rpc("/fusion_clock/shift_planner/clear_recurrence", { + employee_id: editor.employeeId, + date: editor.date, + week_start: this.state.weekStart, + }); + if (result.error) { + this.notification.add(result.error, { type: "danger" }); + } else { + this._applyData(result.data); + this.notification.add("Recurrence stopped.", { type: "success" }); + } + } catch (error) { + this.notification.add(error.message || "Could not stop recurrence.", { type: "danger" }); + } + this.state.saving = false; + } + closeCellEditor() { this.state.editor.open = false; this.activeCellAnchor = null; diff --git a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss index 735c45c0..3732a3da 100644 --- a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss +++ b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss @@ -217,6 +217,52 @@ padding: 4px; vertical-align: top; background: var(--fclk-planner-card, #ffffff); + position: relative; +} + +.fclk-planner__cell-recur { + position: absolute; + top: 2px; + right: 4px; + font-size: 9px; + opacity: 0.6; + pointer-events: none; +} + +.fclk-planner__cell-role { + position: absolute; + bottom: 3px; + right: 4px; + width: 8px; + height: 8px; + border-radius: 50%; + pointer-events: none; +} + +.fclk-planner__repeat-panel { + border-top: 1px solid var(--fclk-planner-border, #d8dadd); + margin-top: 6px; + padding-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; + + .fclk-planner__repeat-row { + display: flex; + align-items: center; + gap: 6px; + + select, + input { + flex: 1; + min-width: 0; + } + } + + .fclk-planner__repeat-int { + max-width: 64px; + flex: 0 0 auto; + } } .fclk-planner__shift-cell--fallback { diff --git a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml index e3a96537..fc26481d 100644 --- a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml +++ b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml @@ -115,6 +115,13 @@
+ + + + @@ -182,12 +189,57 @@ +
+
+ Every + + +
+
+ + + +
+ +
+
+ +