feat(fusion_clock): Publish & Notify range + portal Schedule fold-in [A6-A7]
Generalise post_week into fclk_publish_range/fclk_email_posted_range + planner Publish… panel + publish_range endpoint. Fold the /my/clock/schedule controller+template+css from fusion_planning into fusion_clock (native schedule only, role colour); inline Schedule nav across all portal pages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
92
fusion_clock/controllers/portal_schedule.py
Normal file
92
fusion_clock/controllers/portal_schedule.py
Normal file
@@ -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)
|
||||
@@ -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():
|
||||
|
||||
@@ -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 <strong>{employee.name}</strong>, your shifts for '
|
||||
f'<strong>{start.strftime("%b %d")} - {end.strftime("%b %d, %Y")}</strong> '
|
||||
f'have been posted.'
|
||||
)
|
||||
if message:
|
||||
summary += f'<br/><br/>{message}'
|
||||
body = _fclk_email_wrap(
|
||||
company_name=company.name or '',
|
||||
title='Your Posted Schedule',
|
||||
summary=(
|
||||
f'Hello <strong>{employee.name}</strong>, your shifts for '
|
||||
f'<strong>{week_start.strftime("%b %d")} - {week_end.strftime("%b %d, %Y")}</strong> '
|
||||
f'have been posted.'
|
||||
),
|
||||
sections=[('This Week', rows)],
|
||||
note='Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> for details.',
|
||||
summary=summary,
|
||||
sections=[('Schedule', rows)],
|
||||
note='Log in to <a href="/my/clock/schedule" style="color:#10B981;">your portal</a> 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'
|
||||
|
||||
109
fusion_clock/static/src/css/portal_schedule.css
Normal file
109
fusion_clock/static/src/css/portal_schedule.css
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -32,9 +32,23 @@
|
||||
<i class="fa fa-paper-plane me-1"/> Post Schedule
|
||||
<t t-if="state.draftCount">(<t t-esc="state.draftCount"/> draft)</t>
|
||||
</button>
|
||||
<button class="btn btn-outline-success" t-on-click="() => this.togglePublishPanel()" t-att-disabled="state.loading or state.saving" title="Publish a custom date range and notify employees">
|
||||
<i class="fa fa-calendar-check-o me-1"/> Publish…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.publish.open" class="fclk-planner__publish-panel">
|
||||
<label>From <input type="date" t-att-value="state.publish.from" t-on-change="(ev) => this.onPublishField('from', ev)"/></label>
|
||||
<label>To <input type="date" t-att-value="state.publish.to" t-on-change="(ev) => this.onPublishField('to', ev)"/></label>
|
||||
<input type="text" class="fclk-planner__publish-msg" placeholder="Optional message to employees…"
|
||||
t-att-value="state.publish.message" t-on-change="(ev) => this.onPublishField('message', ev)"/>
|
||||
<button class="btn btn-success btn-sm" t-on-click="() => this.publishRange()" t-att-disabled="state.saving">
|
||||
<i class="fa fa-paper-plane me-1"/> Publish & Notify
|
||||
</button>
|
||||
<button class="btn btn-light btn-sm" t-on-click="() => this.togglePublishPanel()">Cancel</button>
|
||||
</div>
|
||||
|
||||
<t t-if="state.error">
|
||||
<div class="alert alert-danger mx-3 mt-3"><t t-esc="state.error"/></div>
|
||||
</t>
|
||||
|
||||
55
fusion_clock/tests/test_publish_range.py
Normal file
55
fusion_clock/tests/test_publish_range.py
Normal file
@@ -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)))
|
||||
@@ -303,6 +303,19 @@
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/schedule" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
<line x1="8" y1="14" x2="10" y2="14"/>
|
||||
<line x1="14" y1="14" x2="16" y2="14"/>
|
||||
<line x1="8" y1="18" x2="10" y2="18"/>
|
||||
<line x1="14" y1="18" x2="16" y2="18"/>
|
||||
</svg>
|
||||
<span>Schedule</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
|
||||
@@ -64,6 +64,19 @@
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/schedule" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
<line x1="8" y1="14" x2="10" y2="14"/>
|
||||
<line x1="14" y1="14" x2="16" y2="14"/>
|
||||
<line x1="8" y1="18" x2="10" y2="18"/>
|
||||
<line x1="14" y1="18" x2="16" y2="18"/>
|
||||
</svg>
|
||||
<span>Schedule</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
@@ -166,6 +179,19 @@
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/schedule" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
<line x1="8" y1="14" x2="10" y2="14"/>
|
||||
<line x1="14" y1="14" x2="16" y2="14"/>
|
||||
<line x1="8" y1="18" x2="10" y2="18"/>
|
||||
<line x1="14" y1="18" x2="16" y2="18"/>
|
||||
</svg>
|
||||
<span>Schedule</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
|
||||
@@ -77,6 +77,19 @@
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/schedule" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
<line x1="8" y1="14" x2="10" y2="14"/>
|
||||
<line x1="14" y1="14" x2="16" y2="14"/>
|
||||
<line x1="8" y1="18" x2="10" y2="18"/>
|
||||
<line x1="14" y1="18" x2="16" y2="18"/>
|
||||
</svg>
|
||||
<span>Schedule</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" class="fclk-nav-item fclk-nav-active">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
|
||||
174
fusion_clock/views/portal_schedule_templates.xml
Normal file
174
fusion_clock/views/portal_schedule_templates.xml
Normal file
@@ -0,0 +1,174 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="portal_schedule_page" name="Fusion Clock My Schedule">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="False"/>
|
||||
<t t-set="no_breadcrumbs" t-value="True"/>
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
|
||||
<div class="fclk-app">
|
||||
<div class="fclk-container">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="fclk-header">
|
||||
<div class="fclk-date">My Schedule</div>
|
||||
<h1 class="fclk-greeting">Hello, <t t-esc="employee.name.split(' ')[0]"/></h1>
|
||||
</div>
|
||||
|
||||
<!-- Next Shift Card (if any upcoming) -->
|
||||
<t t-if="next_slot">
|
||||
<div class="fclk-status-card fpl-next-shift">
|
||||
<div class="fpl-next-label">Next Shift</div>
|
||||
<div class="fpl-next-date"><t t-esc="next_slot['date']"/></div>
|
||||
<div class="fpl-next-time"><t t-esc="next_slot['time']"/></div>
|
||||
<div class="fpl-next-role" t-if="next_slot['role']">
|
||||
<t t-esc="next_slot['role']"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="fclk-status-card fpl-empty-card">
|
||||
<div class="fpl-empty-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="1.5">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="fpl-empty-title">No upcoming shifts</div>
|
||||
<div class="fpl-empty-sub">Your manager hasn't published any shifts yet.</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Summary Row -->
|
||||
<div class="fclk-stats-row" t-if="slot_count">
|
||||
<div class="fclk-stat-card">
|
||||
<div class="fclk-stat-header">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
</svg>
|
||||
<span>Upcoming</span>
|
||||
</div>
|
||||
<div class="fclk-stat-value">
|
||||
<t t-esc="slot_count"/>
|
||||
</div>
|
||||
<div class="fclk-stat-target">shifts scheduled</div>
|
||||
</div>
|
||||
<div class="fclk-stat-card">
|
||||
<div class="fclk-stat-header">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#10B981" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>Total Hours</span>
|
||||
</div>
|
||||
<div class="fclk-stat-value">
|
||||
<t t-esc="'%.1f' % sum(s['duration_hours'] for items in groups.values() for s in items)"/>h
|
||||
</div>
|
||||
<div class="fclk-stat-target">across upcoming</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grouped Schedule List -->
|
||||
<t t-foreach="groups.items()" t-as="group">
|
||||
<div class="fpl-group">
|
||||
<div class="fpl-group-title">
|
||||
<t t-esc="group[0]"/>
|
||||
</div>
|
||||
<div class="fpl-list">
|
||||
<t t-foreach="group[1]" t-as="item">
|
||||
<div class="fclk-recent-item fpl-shift-item">
|
||||
<div class="fclk-recent-date">
|
||||
<div class="fclk-recent-day-name">
|
||||
<t t-esc="item['day_label']"/>
|
||||
</div>
|
||||
<div class="fclk-recent-day-num">
|
||||
<t t-esc="item['day_num']"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-recent-info">
|
||||
<div class="fclk-recent-location">
|
||||
<t t-if="item['role_name']">
|
||||
<t t-esc="item['role_name']"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
Shift
|
||||
</t>
|
||||
</div>
|
||||
<div class="fclk-recent-times">
|
||||
<t t-esc="item['time_range']"/>
|
||||
</div>
|
||||
<div class="fpl-shift-note" t-if="item['note']">
|
||||
<t t-esc="item['note']"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fclk-recent-hours">
|
||||
<t t-esc="'%.1f' % item['duration_hours']"/>h
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Navigation Bar -->
|
||||
<div class="fclk-nav-bar">
|
||||
<a href="/my/clock" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>Clock</span>
|
||||
</a>
|
||||
<a href="/my/clock/timesheets" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/schedule" class="fclk-nav-item fclk-nav-active">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
<line x1="8" y1="14" x2="10" y2="14"/>
|
||||
<line x1="14" y1="14" x2="16" y2="14"/>
|
||||
<line x1="8" y1="18" x2="10" y2="18"/>
|
||||
<line x1="14" y1="18" x2="16" y2="18"/>
|
||||
</svg>
|
||||
<span>Schedule</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<span>Reports</span>
|
||||
</a>
|
||||
<t t-if="show_payslips">
|
||||
<a href="/my/clock/payslips" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||
</svg>
|
||||
<span>Payslips</span>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -128,6 +128,19 @@
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/schedule" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
<line x1="8" y1="14" x2="10" y2="14"/>
|
||||
<line x1="14" y1="14" x2="16" y2="14"/>
|
||||
<line x1="8" y1="18" x2="10" y2="18"/>
|
||||
<line x1="14" y1="18" x2="16" y2="18"/>
|
||||
</svg>
|
||||
<span>Schedule</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
|
||||
Reference in New Issue
Block a user