diff --git a/fusion_clock/controllers/__init__.py b/fusion_clock/controllers/__init__.py
index e657e0f7..eb4eb355 100644
--- a/fusion_clock/controllers/__init__.py
+++ b/fusion_clock/controllers/__init__.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from . import portal_clock
+from . import portal_schedule
from . import clock_api
from . import clock_kiosk
from . import clock_nfc_kiosk
diff --git a/fusion_clock/controllers/portal_schedule.py b/fusion_clock/controllers/portal_schedule.py
new file mode 100644
index 00000000..8a11529f
--- /dev/null
+++ b/fusion_clock/controllers/portal_schedule.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+#
+# Portal "My Schedule" tab. Folded in from the retired fusion_planning bridge —
+# now reads ONLY the native fusion.clock.schedule (no planning.slot), so it
+# works on Community Odoo.
+
+import logging
+from collections import OrderedDict
+from datetime import timedelta
+
+from odoo import http, fields
+from odoo.http import request
+
+_logger = logging.getLogger(__name__)
+
+
+class FusionClockSchedulePortal(http.Controller):
+ """Exposes the employee's published shifts on the portal Schedule tab."""
+
+ @http.route('/my/clock/schedule', type='http', auth='user', website=True)
+ def portal_schedule(self, **kw):
+ employee = request.env.user.employee_id
+ if not employee:
+ return request.redirect('/my')
+
+ now_utc = fields.Datetime.now()
+ today_local = fields.Datetime.context_timestamp(request.env.user, now_utc).date()
+ horizon_local = today_local + timedelta(days=60)
+
+ Schedule = request.env['fusion.clock.schedule'].sudo()
+ entries = []
+ for sch in Schedule.search([
+ ('employee_id', '=', employee.id),
+ ('state', '=', 'posted'),
+ ('is_off', '=', False),
+ ('schedule_date', '>=', today_local),
+ ('schedule_date', '<=', horizon_local),
+ ], order='schedule_date asc', limit=200):
+ day = sch.schedule_date
+ entries.append((
+ (day, int(round((sch.start_time or 0.0) * 60))),
+ day,
+ {
+ 'day_label': day.strftime('%a').upper(),
+ 'day_num': day.strftime('%d'),
+ 'date_full': day.strftime('%b %d, %Y'),
+ 'time_range': '%s - %s' % (
+ Schedule.fclk_float_to_display(sch.start_time),
+ Schedule.fclk_float_to_display(sch.end_time),
+ ),
+ 'duration_hours': round(sch.planned_hours or 0.0, 1),
+ 'role_name': sch.role_id.name if sch.role_id else '',
+ 'role_color': sch.role_id._get_color_from_code() if sch.role_id else '',
+ 'note': sch.note or '',
+ },
+ ))
+
+ entries.sort(key=lambda e: e[0])
+
+ groups = OrderedDict()
+ for _key, day, item in entries:
+ delta_days = (day - today_local).days
+ if delta_days == 0:
+ bucket_key = 'Today'
+ elif delta_days == 1:
+ bucket_key = 'Tomorrow'
+ elif 0 <= delta_days <= 6:
+ bucket_key = day.strftime('%A')
+ else:
+ bucket_key = day.strftime('%b %d')
+ groups.setdefault(bucket_key, []).append(item)
+
+ next_slot_data = None
+ if entries:
+ first = entries[0][2]
+ next_slot_data = {
+ 'date': entries[0][1].strftime('%a, %b %d'),
+ 'time': first['time_range'].split(' - ')[0],
+ 'role': first['role_name'],
+ }
+
+ values = {
+ 'employee': employee,
+ 'groups': groups,
+ 'slot_count': len(entries),
+ 'next_slot': next_slot_data,
+ 'page_name': 'fusion_clock_schedule',
+ 'show_payslips': 'hr.payslip' in request.env,
+ }
+ return request.render('fusion_clock.portal_schedule_page', values)
diff --git a/fusion_clock/controllers/shift_planner.py b/fusion_clock/controllers/shift_planner.py
index 1ffcb1b5..c762c70d 100644
--- a/fusion_clock/controllers/shift_planner.py
+++ b/fusion_clock/controllers/shift_planner.py
@@ -166,23 +166,7 @@ class FusionClockShiftPlanner(http.Controller):
end = start + timedelta(days=6)
employees = self._manager_employees()
Schedule = request.env['fusion.clock.schedule'].sudo()
-
- entries = Schedule.search([
- ('employee_id', 'in', employees.ids),
- ('schedule_date', '>=', start),
- ('schedule_date', '<=', end),
- ('state', '!=', 'posted'),
- ])
- posted_count = len(entries)
- affected = entries.mapped('employee_id')
- if entries:
- entries.write({'state': 'posted', 'posted_date': fields.Datetime.now()})
-
- notified = 0
- for employee in affected:
- if Schedule.fclk_email_posted_week(employee, start, end):
- notified += 1
-
+ posted_count, notified = Schedule.fclk_publish_range(employees, start, end)
return {
'success': True,
'posted': posted_count,
@@ -190,6 +174,30 @@ class FusionClockShiftPlanner(http.Controller):
'data': self._load_week_data(start),
}
+ @http.route('/fusion_clock/shift_planner/publish_range', type='jsonrpc', auth='user', methods=['POST'])
+ def publish_range(self, date_from=None, date_to=None, employee_ids=None, message=None,
+ week_start=None, **kw):
+ """Publish & Notify over an arbitrary date range, optionally limited to a
+ subset of employees, with an optional custom message in the email."""
+ if not self._check_manager():
+ return {'error': 'Access denied.'}
+ start = fields.Date.to_date(date_from) or self._week_start(week_start)
+ end = fields.Date.to_date(date_to) or (start + timedelta(days=6))
+ if end < start:
+ return {'success': False, 'message': 'End date must be on or after the start date.'}
+ employees = self._manager_employees()
+ if employee_ids:
+ wanted = {int(eid) for eid in employee_ids}
+ employees = employees.filtered(lambda e: e.id in wanted)
+ Schedule = request.env['fusion.clock.schedule'].sudo()
+ posted_count, notified = Schedule.fclk_publish_range(employees, start, end, message=message)
+ return {
+ 'success': True,
+ 'posted': posted_count,
+ 'notified': notified,
+ 'data': self._load_week_data(week_start),
+ }
+
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
def copy_previous_week(self, week_start=None, **kw):
if not self._check_manager():
diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py
index ac183f7e..65d6b67f 100644
--- a/fusion_clock/models/clock_schedule.py
+++ b/fusion_clock/models/clock_schedule.py
@@ -432,22 +432,23 @@ class FusionClockSchedule(models.Model):
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."""
+ def fclk_email_posted_range(self, employee, start, end, message=None):
+ """Email one employee a summary of their POSTED shifts between two
+ dates (inclusive). Optional ``message`` is shown above the schedule."""
employee = employee.sudo()
if not employee.work_email:
return False
from .hr_attendance import _fclk_email_wrap
entries = self.sudo().search([
('employee_id', '=', employee.id),
- ('schedule_date', '>=', week_start),
- ('schedule_date', '<=', week_end),
+ ('schedule_date', '>=', start),
+ ('schedule_date', '<=', end),
('state', '=', 'posted'),
])
by_date = {entry.schedule_date: entry for entry in entries}
rows = []
- day = week_start
- while day <= week_end:
+ day = start
+ while day <= end:
entry = by_date.get(day)
rows.append((
day.strftime('%a %b %d'),
@@ -455,20 +456,23 @@ class FusionClockSchedule(models.Model):
))
day += timedelta(days=1)
company = employee.company_id or self.env.company
+ summary = (
+ f'Hello {employee.name}, your shifts for '
+ f'{start.strftime("%b %d")} - {end.strftime("%b %d, %Y")} '
+ f'have been posted.'
+ )
+ if message:
+ summary += f'
{message}'
body = _fclk_email_wrap(
company_name=company.name or '',
title='Your Posted Schedule',
- summary=(
- f'Hello {employee.name}, your shifts for '
- f'{week_start.strftime("%b %d")} - {week_end.strftime("%b %d, %Y")} '
- f'have been posted.'
- ),
- sections=[('This Week', rows)],
- note='Log in to your portal for details.',
+ summary=summary,
+ sections=[('Schedule', rows)],
+ note='Log in to your portal for details.',
)
try:
mail = self.env['mail.mail'].sudo().create({
- 'subject': f'Your schedule: {week_start.strftime("%b %d")} - {week_end.strftime("%b %d")}',
+ 'subject': f'Your schedule: {start.strftime("%b %d")} - {end.strftime("%b %d")}',
'email_from': company.email or '',
'email_to': employee.work_email,
'body_html': body,
@@ -482,6 +486,36 @@ class FusionClockSchedule(models.Model):
)
return False
+ @api.model
+ def fclk_email_posted_week(self, employee, week_start, week_end):
+ """Back-compat wrapper — email one employee their posted week."""
+ return self.fclk_email_posted_range(employee, week_start, week_end)
+
+ @api.model
+ def fclk_publish_range(self, employees, start, end, message=None):
+ """Post every draft shift in [start, end] for the given employees and
+ email each affected employee. Returns (posted_count, notified_count)."""
+ Schedule = self.sudo()
+ domain = [
+ ('employee_id', 'in', employees.ids),
+ ('schedule_date', '>=', start),
+ ('schedule_date', '<=', end),
+ ('state', '!=', 'posted'),
+ ]
+ # Never auto-post open (unassigned) shifts (Phase B field).
+ if 'is_open' in Schedule._fields:
+ domain.append(('is_open', '=', False))
+ drafts = Schedule.search(domain)
+ posted = len(drafts)
+ affected = drafts.mapped('employee_id')
+ if drafts:
+ drafts.write({'state': 'posted', 'posted_date': fields.Datetime.now()})
+ notified = 0
+ for employee in affected:
+ if Schedule.fclk_email_posted_range(employee, start, end, message=message):
+ notified += 1
+ return posted, notified
+
class FusionClockScheduleAudit(models.Model):
_name = 'fusion.clock.schedule.audit'
diff --git a/fusion_clock/static/src/css/portal_schedule.css b/fusion_clock/static/src/css/portal_schedule.css
new file mode 100644
index 00000000..3e6b32b5
--- /dev/null
+++ b/fusion_clock/static/src/css/portal_schedule.css
@@ -0,0 +1,109 @@
+/* Fusion Planning - Portal Schedule
+ * Inherits Fusion Clock dark-theme tokens (--fclk-card, --fclk-green, etc.)
+ */
+
+/* ---- 4-tab nav fit (keep items grouped at center, just tighter padding) ---- */
+.fclk-nav-item {
+ padding: 8px 19px !important;
+}
+
+/* ---- Next Shift hero card ---- */
+.fpl-next-shift {
+ text-align: center;
+ padding: 20px 16px;
+}
+
+.fpl-next-label {
+ font-size: 11px;
+ color: var(--fclk-text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ margin-bottom: 6px;
+}
+
+.fpl-next-date {
+ font-size: 18px;
+ color: var(--fclk-text);
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.fpl-next-time {
+ font-size: 32px;
+ color: var(--fclk-green);
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ margin-bottom: 6px;
+}
+
+.fpl-next-role {
+ display: inline-block;
+ font-size: 12px;
+ color: var(--fclk-text-dim);
+ background: rgba(16, 185, 129, 0.08);
+ border: 1px solid rgba(16, 185, 129, 0.18);
+ padding: 4px 12px;
+ border-radius: 12px;
+}
+
+/* ---- Empty state ---- */
+.fpl-empty-card {
+ text-align: center;
+ padding: 28px 16px;
+}
+
+.fpl-empty-icon {
+ margin-bottom: 12px;
+ opacity: 0.7;
+}
+
+.fpl-empty-title {
+ font-size: 16px;
+ color: var(--fclk-text);
+ font-weight: 600;
+ margin-bottom: 6px;
+}
+
+.fpl-empty-sub {
+ font-size: 13px;
+ color: var(--fclk-text-dim);
+}
+
+/* ---- Group headers ---- */
+.fpl-group {
+ margin-bottom: 18px;
+}
+
+.fpl-group-title {
+ font-size: 13px;
+ color: var(--fclk-text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-weight: 600;
+ margin: 12px 16px 8px;
+}
+
+.fpl-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+/* ---- Shift item polish ---- */
+.fpl-shift-item .fclk-recent-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.fpl-shift-note {
+ font-size: 11px;
+ color: var(--fclk-text-dim);
+ margin-top: 2px;
+ font-style: italic;
+}
+
+/* ---- Bottom padding so nav doesn't cover last shift ---- */
+.fclk-container {
+ padding-bottom: 80px;
+}
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 ea739541..6ae2f330 100644
--- a/fusion_clock/static/src/js/fusion_clock_shift_planner.js
+++ b/fusion_clock/static/src/js/fusion_clock_shift_planner.js
@@ -49,6 +49,7 @@ export class FusionClockShiftPlanner extends Component {
showRepeat: false,
repeat: { interval: 1, unit: "week", type: "forever", until: "", number: 4 },
},
+ publish: { open: false, from: "", to: "", message: "" },
});
onWillStart(async () => {
@@ -327,6 +328,46 @@ export class FusionClockShiftPlanner extends Component {
this.state.saving = false;
}
+ togglePublishPanel() {
+ this.state.publish.open = !this.state.publish.open;
+ if (this.state.publish.open && !this.state.publish.from) {
+ this.state.publish.from = this.state.weekStart;
+ this.state.publish.to = this.state.weekEnd;
+ }
+ }
+
+ onPublishField(field, ev) {
+ this.state.publish[field] = ev.target.value;
+ }
+
+ async publishRange() {
+ const publish = this.state.publish;
+ this.state.saving = true;
+ try {
+ const result = await rpc("/fusion_clock/shift_planner/publish_range", {
+ date_from: publish.from,
+ date_to: publish.to,
+ message: publish.message,
+ week_start: this.state.weekStart,
+ });
+ if (result.error || result.success === false) {
+ this.notification.add(result.error || result.message || "Could not publish.", {
+ type: "danger",
+ });
+ } else {
+ this._applyData(result.data);
+ this.state.publish.open = false;
+ this.notification.add(
+ `Published ${result.posted} shift(s); notified ${result.notified} employee(s).`,
+ { type: "success" }
+ );
+ }
+ } catch (error) {
+ this.notification.add(error.message || "Could not publish.", { 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 3732a3da..230eebd0 100644
--- a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
+++ b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss
@@ -239,6 +239,23 @@
pointer-events: none;
}
+.fclk-planner__publish-panel {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin: 0 10px 10px;
+ padding: 10px 12px;
+ background: var(--fclk-planner-card, #ffffff);
+ border: 1px solid var(--fclk-planner-border, #d8dadd);
+ border-radius: 6px;
+
+ .fclk-planner__publish-msg {
+ flex: 1 1 220px;
+ min-width: 160px;
+ }
+}
+
.fclk-planner__repeat-panel {
border-top: 1px solid var(--fclk-planner-border, #d8dadd);
margin-top: 6px;
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 fc26481d..7b10e33f 100644
--- a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
+++ b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml
@@ -32,9 +32,23 @@
Post Schedule