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 ( draft) + +
+ + + + + +
+
diff --git a/fusion_clock/tests/test_publish_range.py b/fusion_clock/tests/test_publish_range.py new file mode 100644 index 00000000..c901f2f3 --- /dev/null +++ b/fusion_clock/tests/test_publish_range.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from datetime import date + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestPublishRange(TransactionCase): + + def setUp(self): + super().setUp() + self.Schedule = self.env['fusion.clock.schedule'] + self.emp = self.env['hr.employee'].create({ + 'name': 'Pat', 'work_email': 'pat@example.com'}) + + def _draft(self, day): + return self.Schedule.create({ + 'employee_id': self.emp.id, 'schedule_date': day, + 'start_time': 9.0, 'end_time': 17.0, 'state': 'draft'}) + + def test_publish_range_posts_drafts(self): + d1, d2 = date(2026, 6, 1), date(2026, 6, 3) + self._draft(d1) + self._draft(d2) + posted, _notified = self.Schedule.fclk_publish_range(self.emp, d1, d2) + self.assertEqual(posted, 2) + rows = self.Schedule.search([('employee_id', '=', self.emp.id)]) + self.assertTrue(all(r.state == 'posted' for r in rows)) + self.assertTrue(all(r.posted_date for r in rows)) + + def test_publish_range_skips_already_posted(self): + d = date(2026, 6, 1) + self.Schedule.create({ + 'employee_id': self.emp.id, 'schedule_date': d, + 'start_time': 9.0, 'end_time': 17.0, 'state': 'posted'}) + posted, _notified = self.Schedule.fclk_publish_range(self.emp, d, d) + self.assertEqual(posted, 0, "Already-posted rows are not re-posted") + + def test_publish_range_respects_bounds(self): + inside = self._draft(date(2026, 6, 5)) + outside = self._draft(date(2026, 6, 20)) + posted, _notified = self.Schedule.fclk_publish_range( + self.emp, date(2026, 6, 1), date(2026, 6, 7)) + self.assertEqual(posted, 1) + self.assertEqual(inside.state, 'posted') + self.assertEqual(outside.state, 'draft') + + def test_email_posted_range_no_email_returns_false(self): + emp2 = self.env['hr.employee'].create({'name': 'NoEmail'}) + self.assertFalse( + self.Schedule.fclk_email_posted_range(emp2, date(2026, 6, 1), date(2026, 6, 2))) diff --git a/fusion_clock/views/portal_clock_templates.xml b/fusion_clock/views/portal_clock_templates.xml index 54ded7ba..b4ce6b0b 100644 --- a/fusion_clock/views/portal_clock_templates.xml +++ b/fusion_clock/views/portal_clock_templates.xml @@ -303,6 +303,19 @@ Timesheets + + + + + + + + + + + + Schedule + diff --git a/fusion_clock/views/portal_payslip_templates.xml b/fusion_clock/views/portal_payslip_templates.xml index 02807d5b..69146716 100644 --- a/fusion_clock/views/portal_payslip_templates.xml +++ b/fusion_clock/views/portal_payslip_templates.xml @@ -64,6 +64,19 @@ Timesheets + + + + + + + + + + + + Schedule + @@ -166,6 +179,19 @@ Timesheets + + + + + + + + + + + + Schedule + diff --git a/fusion_clock/views/portal_report_templates.xml b/fusion_clock/views/portal_report_templates.xml index e8e5063c..7ee325b3 100644 --- a/fusion_clock/views/portal_report_templates.xml +++ b/fusion_clock/views/portal_report_templates.xml @@ -77,6 +77,19 @@ Timesheets + + + + + + + + + + + + Schedule + diff --git a/fusion_clock/views/portal_schedule_templates.xml b/fusion_clock/views/portal_schedule_templates.xml new file mode 100644 index 00000000..8d155b25 --- /dev/null +++ b/fusion_clock/views/portal_schedule_templates.xml @@ -0,0 +1,174 @@ + + + + +
+ + + diff --git a/fusion_clock/views/portal_timesheet_templates.xml b/fusion_clock/views/portal_timesheet_templates.xml index 9d9da1ca..95ba7740 100644 --- a/fusion_clock/views/portal_timesheet_templates.xml +++ b/fusion_clock/views/portal_timesheet_templates.xml @@ -128,6 +128,19 @@ Timesheets + + + + + + + + + + + + Schedule +