diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py
index 5e027958..7d92de95 100644
--- a/fusion_clock/__manifest__.py
+++ b/fusion_clock/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
- 'version': '19.0.4.2.0',
+ 'version': '19.0.5.0.0',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """
@@ -54,6 +54,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'data/ir_config_parameter_data.xml',
'data/clock_break_rule_data.xml',
'data/ir_cron_data.xml',
+ 'data/clock_recurrence_cron.xml',
# Reports (must load before mail templates that reference them)
'report/clock_report_template.xml',
'report/clock_employee_report.xml',
@@ -72,6 +73,8 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/clock_dashboard_views.xml',
'views/hr_employee_views.xml',
'views/clock_schedule_views.xml',
+ 'views/clock_role_views.xml',
+ 'views/clock_recurrence_views.xml',
'views/clock_break_rule_views.xml',
# Wizards (must load before clock_menus.xml since menu references wizard action)
'wizard/clock_nfc_enrollment_views.xml',
@@ -82,12 +85,14 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/portal_timesheet_templates.xml',
'views/portal_report_templates.xml',
'views/portal_payslip_templates.xml',
+ 'views/portal_schedule_templates.xml',
'views/kiosk_templates.xml',
'views/kiosk_nfc_templates.xml',
],
'assets': {
'web.assets_frontend': [
'fusion_clock/static/src/css/portal_clock.css',
+ 'fusion_clock/static/src/css/portal_schedule.css',
'fusion_clock/static/src/scss/nfc_kiosk.scss',
'fusion_clock/static/src/scss/pin_kiosk.scss',
'fusion_clock/static/src/js/fusion_clock_portal.js',
diff --git a/fusion_clock/models/__init__.py b/fusion_clock/models/__init__.py
index c51939ad..b0797190 100644
--- a/fusion_clock/models/__init__.py
+++ b/fusion_clock/models/__init__.py
@@ -10,6 +10,7 @@ from . import clock_report
from . import res_config_settings
from . import clock_activity_log
from . import clock_leave_request
+from . import clock_role
from . import clock_shift
from . import clock_schedule
from . import clock_correction
diff --git a/fusion_clock/models/clock_role.py b/fusion_clock/models/clock_role.py
new file mode 100644
index 00000000..b68a4bfb
--- /dev/null
+++ b/fusion_clock/models/clock_role.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+#
+# Native shift role. Re-implements the small, useful subset of Odoo
+# Enterprise ``planning.role`` (name + colour) so Fusion Clock can colour and
+# label shifts on the portal without depending on the Enterprise Planning app.
+
+from random import randint
+
+from odoo import fields, models
+
+
+class FusionClockRole(models.Model):
+ _name = 'fusion.clock.role'
+ _description = 'Clock Shift Role'
+ _order = 'sequence, name'
+ _rec_name = 'name'
+
+ def _get_default_color(self):
+ return randint(1, 11)
+
+ name = fields.Char(required=True, translate=True)
+ color = fields.Integer(default=_get_default_color)
+ active = fields.Boolean(default=True)
+ sequence = fields.Integer(default=10)
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ default=lambda self: self.env.company,
+ )
+
+ # Kanban colour code (1-11) -> hex, mirroring planning.role._get_color_from_code
+ # so the portal Schedule tab shows the same palette Planning used.
+ _COLOR_HEX = {
+ 0: '#008784', 1: '#EE4B39', 2: '#F29648', 3: '#F4C609',
+ 4: '#55B7EA', 5: '#71405B', 6: '#E86869', 7: '#008784',
+ 8: '#267283', 9: '#BF1255', 10: '#2BAF73', 11: '#8754B0',
+ }
+
+ def _get_color_from_code(self, is_open_shift=False):
+ """Return a hex colour for this role. Open shifts get an '80' alpha
+ suffix (matching Planning's open-shift transparency convention)."""
+ self.ensure_one()
+ hex_value = self._COLOR_HEX.get(self.color, '#008784')
+ return hex_value + ('80' if is_open_shift else '')
diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py
index e734cf6f..3756fb0f 100644
--- a/fusion_clock/models/clock_schedule.py
+++ b/fusion_clock/models/clock_schedule.py
@@ -58,6 +58,20 @@ class FusionClockSchedule(models.Model):
store=True,
)
note = fields.Char(string='Note')
+ role_id = fields.Many2one(
+ 'fusion.clock.role',
+ string='Role',
+ help="Shift role — drives the colour/label shown on the employee's "
+ "portal schedule. Defaults from the shift template or the "
+ "employee's Default Shift Role.",
+ )
+ recurrence_id = fields.Many2one(
+ 'fusion.clock.schedule.recurrence',
+ string='Recurrence',
+ ondelete='set null',
+ index=True,
+ help="Set when this entry was generated by a recurring rule.",
+ )
company_id = fields.Many2one(
'res.company',
string='Company',
@@ -292,10 +306,21 @@ class FusionClockSchedule(models.Model):
new_schedule = self.browse()
new_value = ''
else:
+ # Resolve the role: explicit payload role wins, then the shift
+ # template's role, then the employee's default role.
+ role_id = payload.get('role_id')
+ if not role_id:
+ shift_id = parsed.get('shift_id')
+ shift = self.env['fusion.clock.shift'].browse(shift_id) if shift_id else None
+ if shift and shift.role_id:
+ role_id = shift.role_id.id
+ elif employee.x_fclk_default_role_id:
+ role_id = employee.x_fclk_default_role_id.id
vals = {
'employee_id': employee.id,
'schedule_date': date_obj,
'shift_id': parsed.get('shift_id') or False,
+ 'role_id': int(role_id) if role_id else False,
'is_off': bool(parsed.get('is_off')),
'start_time': parsed.get('start_time') or 0.0,
'end_time': parsed.get('end_time') or 0.0,
@@ -349,6 +374,9 @@ class FusionClockSchedule(models.Model):
'hours': schedule.planned_hours,
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
'note': schedule.note or '',
+ '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 '',
}
plan = employee._get_fclk_day_plan(date_obj)
@@ -366,6 +394,9 @@ class FusionClockSchedule(models.Model):
'hours': plan.get('hours') or 0.0,
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
'note': '',
+ 'role_id': False,
+ 'role_name': '',
+ 'role_color': '',
}
@api.model
diff --git a/fusion_clock/models/clock_shift.py b/fusion_clock/models/clock_shift.py
index c86ad164..b5bb2dd7 100644
--- a/fusion_clock/models/clock_shift.py
+++ b/fusion_clock/models/clock_shift.py
@@ -42,6 +42,12 @@ class FusionClockShift(models.Model):
)
active = fields.Boolean(default=True)
color = fields.Char(string='Color', default='#3B82F6')
+ role_id = fields.Many2one(
+ 'fusion.clock.role',
+ string='Default Role',
+ help="Role assigned to shifts created from this template "
+ "(drives the colour/label on the employee's portal schedule).",
+ )
# Weekday pattern — which days this recurring shift applies as the baseline
# when there is no posted planner entry for the day. Default Mon-Fri.
diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py
index 1b840320..1c67fe4b 100644
--- a/fusion_clock/models/hr_employee.py
+++ b/fusion_clock/models/hr_employee.py
@@ -33,6 +33,21 @@ class HrEmployee(models.Model):
help="Assigned shift schedule. Leave empty to use global defaults.",
)
+ # Shift roles (native replacement for Odoo Planning's employee role fields)
+ x_fclk_default_role_id = fields.Many2one(
+ 'fusion.clock.role',
+ string='Default Shift Role',
+ help="Pre-fills the role on every new shift created for this employee.",
+ )
+ x_fclk_role_ids = fields.Many2many(
+ 'fusion.clock.role',
+ 'fclk_employee_role_rel',
+ 'employee_id',
+ 'role_id',
+ string='Allowed Shift Roles',
+ help="Roles this employee is allowed to be scheduled for.",
+ )
+
# Pending reason enforcement
x_fclk_pending_reason = fields.Boolean(
string='Pending Reason Required',
diff --git a/fusion_clock/security/ir.model.access.csv b/fusion_clock/security/ir.model.access.csv
index 644cecff..240c705d 100644
--- a/fusion_clock/security/ir.model.access.csv
+++ b/fusion_clock/security/ir.model.access.csv
@@ -28,3 +28,8 @@ access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_sh
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
+access_fusion_clock_role_user,fusion.clock.role.user,model_fusion_clock_role,group_fusion_clock_user,1,0,0,0
+access_fusion_clock_role_manager,fusion.clock.role.manager,model_fusion_clock_role,group_fusion_clock_manager,1,1,1,1
+access_fusion_clock_role_portal,fusion.clock.role.portal,model_fusion_clock_role,base.group_portal,1,0,0,0
+access_fusion_clock_recurrence_user,fusion.clock.schedule.recurrence.user,model_fusion_clock_schedule_recurrence,group_fusion_clock_user,1,0,0,0
+access_fusion_clock_recurrence_manager,fusion.clock.schedule.recurrence.manager,model_fusion_clock_schedule_recurrence,group_fusion_clock_manager,1,1,1,1
diff --git a/fusion_clock/tests/__init__.py b/fusion_clock/tests/__init__.py
index 49f71366..2af2e0a8 100644
--- a/fusion_clock/tests/__init__.py
+++ b/fusion_clock/tests/__init__.py
@@ -11,3 +11,10 @@ from . import test_settings
from . import test_clock_kiosk
from . import test_break_rules
from . import test_pending_reason_exempt
+from . import test_role
+from . import test_recurrence
+from . import test_publish_range
+from . import test_open_shift
+from . import test_overnight
+from . import test_multishift_window
+from . import test_planning_migration
diff --git a/fusion_clock/tests/test_role.py b/fusion_clock/tests/test_role.py
new file mode 100644
index 00000000..781fcffd
--- /dev/null
+++ b/fusion_clock/tests/test_role.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+from odoo import fields
+from odoo.tests import tagged
+from odoo.tests.common import TransactionCase
+
+
+@tagged('-at_install', 'post_install', 'fusion_clock')
+class TestFusionClockRole(TransactionCase):
+
+ def test_default_color_in_range(self):
+ role = self.env['fusion.clock.role'].create({'name': 'Cashier'})
+ self.assertTrue(1 <= role.color <= 11, "Default colour should be 1..11")
+
+ def test_color_hex_and_open_alpha(self):
+ role = self.env['fusion.clock.role'].create({'name': 'Red', 'color': 1})
+ self.assertEqual(role._get_color_from_code(), '#EE4B39')
+ self.assertEqual(role._get_color_from_code(True), '#EE4B3980')
+
+ def test_employee_default_and_allowed_roles(self):
+ lead = self.env['fusion.clock.role'].create({'name': 'Lead', 'color': 3})
+ cashier = self.env['fusion.clock.role'].create({'name': 'Cashier', 'color': 4})
+ emp = self.env['hr.employee'].create({
+ 'name': 'Bob',
+ 'x_fclk_default_role_id': lead.id,
+ 'x_fclk_role_ids': [(6, 0, [lead.id, cashier.id])],
+ })
+ self.assertEqual(emp.x_fclk_default_role_id, lead)
+ self.assertIn(cashier, emp.x_fclk_role_ids)
+
+ def test_schedule_inherits_employee_default_role(self):
+ lead = self.env['fusion.clock.role'].create({'name': 'Lead', 'color': 3})
+ emp = self.env['hr.employee'].create({'name': 'Cara', 'x_fclk_default_role_id': lead.id})
+ sch = self.env['fusion.clock.schedule'].fclk_apply_planner_cell(
+ emp, fields.Date.today(), {'input': '9-5'})
+ self.assertEqual(sch.role_id, lead,
+ "A new shift should inherit the employee's default role")
+
+ def test_schedule_role_from_shift_template(self):
+ stock = self.env['fusion.clock.role'].create({'name': 'Stock', 'color': 5})
+ shift = self.env['fusion.clock.shift'].create({
+ 'name': 'Morning', 'start_time': 8.0, 'end_time': 16.0, 'role_id': stock.id})
+ emp = self.env['hr.employee'].create({'name': 'Dan'})
+ sch = self.env['fusion.clock.schedule'].fclk_apply_planner_cell(
+ emp, fields.Date.today(), {'shift_id': shift.id})
+ self.assertEqual(sch.role_id, stock,
+ "Shift-template role should win when employee has no default")
diff --git a/fusion_clock/views/clock_menus.xml b/fusion_clock/views/clock_menus.xml
index 672ed6b7..3ddfcfa8 100644
--- a/fusion_clock/views/clock_menus.xml
+++ b/fusion_clock/views/clock_menus.xml
@@ -121,6 +121,20 @@
sequence="15"
groups="group_fusion_clock_manager"/>
+
+
+
+
+
+