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 @@
+
+
+
+
|