Merge: employee portal — staff Clock + Payslips, customer-sidebar gating

Internal staff now land on /my/clock with no customer sidebar; new
finalized-payslip portal under /my/clock/payslips (inline paystub from
payslip.line_ids + PDF). Customers' portal is unchanged. Live on entech.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-30 22:02:28 -04:00
32 changed files with 1997 additions and 172 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.3.11.8',
'version': '19.0.3.12.1',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """
@@ -78,6 +78,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/portal_clock_templates.xml',
'views/portal_timesheet_templates.xml',
'views/portal_report_templates.xml',
'views/portal_payslip_templates.xml',
'views/kiosk_templates.xml',
'views/kiosk_nfc_templates.xml',
],

View File

@@ -110,7 +110,8 @@ class FusionClockAPI(http.Controller):
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
return
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
if not day_plan.get('scheduled'):
# No late/early penalties on days the employee isn't scheduled to work.
return
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
@@ -282,7 +283,8 @@ class FusionClockAPI(http.Controller):
now = fields.Datetime.now()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
# "Unscheduled" = a posted OFF day OR a day with no schedule at all.
is_scheduled_off = not day_plan.get('scheduled')
geo_info = {
'latitude': latitude,
@@ -325,7 +327,7 @@ class FusionClockAPI(http.Controller):
if is_scheduled_off:
self._log_activity(
employee, 'unscheduled_shift',
f"Clocked in on a scheduled OFF day at {location.name}.",
f"Clocked in on an unscheduled day at {location.name}.",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source=source,
@@ -335,7 +337,7 @@ class FusionClockAPI(http.Controller):
request.env['hr.attendance'].sudo()._fclk_notify_office(
office_user_id,
f"Unscheduled Shift: {employee.name}",
f"{employee.name} clocked in on a scheduled OFF day.",
f"{employee.name} clocked in on an unscheduled day.",
'hr.attendance',
attendance.id,
)

View File

@@ -103,7 +103,7 @@ class FusionClockKiosk(http.Controller):
now = fields.Datetime.now()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
is_scheduled_off = not day_plan.get('scheduled')
geo_info = {
'latitude': latitude,
@@ -133,7 +133,7 @@ class FusionClockKiosk(http.Controller):
if is_scheduled_off:
api._log_activity(
employee, 'unscheduled_shift',
f"Kiosk clock-in on a scheduled OFF day at {location.name}",
f"Kiosk clock-in on an unscheduled day at {location.name}",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source='kiosk',

View File

@@ -324,7 +324,7 @@ class FusionClockNfcKiosk(http.Controller):
now = fields.Datetime.now()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
is_scheduled_off = not day_plan.get('scheduled')
geo_info = {
'latitude': 0,
@@ -352,7 +352,7 @@ class FusionClockNfcKiosk(http.Controller):
if is_scheduled_off:
api._log_activity(
employee, 'unscheduled_shift',
f"NFC kiosk clock-in on a scheduled OFF day at {location.name}",
f"NFC kiosk clock-in on an unscheduled day at {location.name}",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',

View File

@@ -65,6 +65,20 @@ class FusionClockPortal(CustomerPortal):
], limit=1)
return employee
def _payroll_available(self):
"""True when fusion_payroll (hr.payslip) is installed on this DB."""
return 'hr.payslip' in request.env
def _get_my_payslips(self, employee):
"""Finalized payslips for this employee, newest first.
Caller must ensure payroll is installed (see _payroll_available).
"""
return request.env['hr.payslip'].sudo().search(
[('employee_id', '=', employee.id), ('state', 'in', ('done', 'paid'))],
order='date_to desc, id desc',
)
# =========================================================================
# Clock Page
# =========================================================================
@@ -157,6 +171,7 @@ class FusionClockPortal(CustomerPortal):
'google_maps_key': google_maps_key,
'enable_sounds': enable_sounds,
'locations_json': locations_json,
'show_payslips': self._payroll_available(),
'page_name': 'clock',
}
return request.render('fusion_clock.portal_clock_page', values)
@@ -234,6 +249,7 @@ class FusionClockPortal(CustomerPortal):
'total_hours': round(total_hours, 1),
'net_hours': round(net_hours, 1),
'total_breaks': round(total_breaks, 0),
'show_payslips': self._payroll_available(),
'page_name': 'timesheets',
}
return request.render('fusion_clock.portal_timesheet_page', values)
@@ -257,6 +273,7 @@ class FusionClockPortal(CustomerPortal):
values = {
'employee': employee,
'reports': reports,
'show_payslips': self._payroll_available(),
'page_name': 'clock_reports',
}
return request.render('fusion_clock.portal_report_page', values)
@@ -285,3 +302,64 @@ class FusionClockPortal(CustomerPortal):
('Content-Disposition', f'attachment; filename="{filename}"'),
],
)
# =========================================================================
# Payslips
# =========================================================================
@http.route('/my/clock/payslips', type='http', auth='user', website=True)
def portal_payslips(self, **kw):
"""List the employee's finalized pay slips."""
employee = self._get_portal_employee()
if not employee or not self._payroll_available():
return request.redirect('/my/clock')
values = {
'employee': employee,
'payslips': self._get_my_payslips(employee),
'show_payslips': True,
'page_name': 'payslips',
}
return request.render('fusion_clock.portal_payslip_list_page', values)
@http.route('/my/clock/payslips/<int:payslip_id>', type='http', auth='user', website=True)
def portal_payslip_detail(self, payslip_id, **kw):
"""Inline paystub for one finalized slip the employee owns."""
employee = self._get_portal_employee()
if not employee or not self._payroll_available():
return request.redirect('/my/clock')
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
if not payslip.exists() or payslip.employee_id.id != employee.id \
or payslip.state not in ('done', 'paid'):
return request.redirect('/my/clock/payslips')
pdf_report = request.env['ir.actions.report'].sudo().search(
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
values = {
'employee': employee,
'payslip': payslip,
'has_pdf': bool(pdf_report),
'show_payslips': True,
'page_name': 'payslips',
}
return request.render('fusion_clock.portal_payslip_detail_page', values)
@http.route('/my/clock/payslips/<int:payslip_id>/pdf', type='http', auth='user', website=True)
def portal_payslip_pdf(self, payslip_id, **kw):
"""Render the standard payslip PDF (sudo) for a slip the employee owns."""
employee = self._get_portal_employee()
if not employee or not self._payroll_available():
return request.redirect('/my/clock')
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
if not payslip.exists() or payslip.employee_id.id != employee.id \
or payslip.state not in ('done', 'paid'):
return request.redirect('/my/clock/payslips')
report = request.env['ir.actions.report'].sudo().search(
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
if not report:
return request.redirect('/my/clock/payslips/%s' % payslip_id)
pdf_content, _ctype = report._render_qweb_pdf(report.id, [payslip.id])
slip_ref = payslip.number if 'number' in payslip._fields else False
filename = 'Payslip-%s.pdf' % (slip_ref or payslip.id)
return request.make_response(pdf_content, headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', 'attachment; filename="%s"' % filename),
])

View File

@@ -155,6 +155,41 @@ class FusionClockShiftPlanner(http.Controller):
'data': self._load_week_data(week_start),
}
@http.route('/fusion_clock/shift_planner/post_week', type='jsonrpc', auth='user', methods=['POST'])
def post_week(self, week_start=None, **kw):
"""Publish (post) the viewed week's draft entries so automation acts on
them, and email each newly-affected employee their posted shifts."""
if not self._check_manager():
return {'error': 'Access denied.'}
start = self._week_start(week_start)
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
return {
'success': True,
'posted': posted_count,
'notified': notified,
'data': self._load_week_data(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():

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Backfill schedule state on upgrade to 19.0.3.12.0.
Before this version there was no draft/posted concept — every dated
``fusion.clock.schedule`` entry was authoritative and drove reminders, absence
checks and penalties. The new ``state`` field defaults to 'draft', and the
schedule resolver now only acts on POSTED entries. Without this backfill, every
pre-existing schedule entry would silently become draft on upgrade and stop
driving automation. Mark all pre-existing entries 'posted' to preserve prior
behaviour. (Runs only on upgrade, never on a fresh install.)
"""
def migrate(cr, version):
if not version:
return
cr.execute("""
UPDATE fusion_clock_schedule
SET state = 'posted',
posted_date = COALESCE(posted_date, now())
WHERE state IS NULL OR state = 'draft'
""")

View File

@@ -2,11 +2,15 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
import re
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
_logger = logging.getLogger(__name__)
class FusionClockSchedule(models.Model):
_name = 'fusion.clock.schedule'
@@ -72,6 +76,15 @@ class FusionClockSchedule(models.Model):
compute='_compute_display_name',
store=True,
)
state = fields.Selection(
[('draft', 'Draft'), ('posted', 'Posted')],
string='Status',
default='draft',
index=True,
help="Only POSTED entries drive reminders, absence checks and penalties. "
"Draft entries are ignored by automation until the team lead posts them.",
)
posted_date = fields.Datetime(string='Posted On', readonly=True)
_employee_date_unique = models.Constraint(
'UNIQUE(employee_id, schedule_date)',
@@ -288,6 +301,10 @@ class FusionClockSchedule(models.Model):
'end_time': parsed.get('end_time') or 0.0,
'break_minutes': parsed.get('break_minutes') or 0.0,
'note': payload.get('note') or False,
# Any planner edit returns the cell to draft; it must be re-posted
# before automation acts on it.
'state': 'draft',
'posted_date': False,
}
if existing:
existing.write(vals)
@@ -321,6 +338,7 @@ class FusionClockSchedule(models.Model):
return {
'schedule_id': schedule.id,
'source': 'schedule',
'state': schedule.state,
'input': schedule.fclk_display_value(),
'label': schedule.fclk_display_value(),
'is_off': schedule.is_off,
@@ -336,7 +354,8 @@ class FusionClockSchedule(models.Model):
plan = employee._get_fclk_day_plan(date_obj)
return {
'schedule_id': False,
'source': plan.get('source') or 'fallback',
'source': plan.get('source') or 'none',
'state': False,
'input': plan.get('label') or '',
'label': plan.get('label') or '',
'is_off': plan.get('is_off', False),
@@ -349,6 +368,57 @@ class FusionClockSchedule(models.Model):
'note': '',
}
@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."""
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),
('state', '=', 'posted'),
])
by_date = {entry.schedule_date: entry for entry in entries}
rows = []
day = week_start
while day <= week_end:
entry = by_date.get(day)
rows.append((
day.strftime('%a %b %d'),
entry.fclk_display_value() if entry else 'Not scheduled',
))
day += timedelta(days=1)
company = employee.company_id or self.env.company
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.',
)
try:
mail = self.env['mail.mail'].sudo().create({
'subject': f'Your schedule: {week_start.strftime("%b %d")} - {week_end.strftime("%b %d")}',
'email_from': company.email or '',
'email_to': employee.work_email,
'body_html': body,
'auto_delete': True,
})
mail.send()
return True
except Exception as exc:
_logger.error(
"Fusion Clock: failed to email posted schedule to %s: %s", employee.name, exc
)
return False
class FusionClockScheduleAudit(models.Model):
_name = 'fusion.clock.schedule.audit'

View File

@@ -42,6 +42,17 @@ class FusionClockShift(models.Model):
)
active = fields.Boolean(default=True)
color = fields.Char(string='Color', default='#3B82F6')
# Weekday pattern — which days this recurring shift applies as the baseline
# when there is no posted planner entry for the day. Default Mon-Fri.
day_mon = fields.Boolean(string='Mon', default=True)
day_tue = fields.Boolean(string='Tue', default=True)
day_wed = fields.Boolean(string='Wed', default=True)
day_thu = fields.Boolean(string='Thu', default=True)
day_fri = fields.Boolean(string='Fri', default=True)
day_sat = fields.Boolean(string='Sat', default=False)
day_sun = fields.Boolean(string='Sun', default=False)
employee_ids = fields.One2many(
'hr.employee',
'x_fclk_shift_id',
@@ -56,6 +67,17 @@ class FusionClockShift(models.Model):
for rec in self:
rec.employee_count = len(rec.employee_ids)
def covers_weekday(self, date):
"""Return True if this recurring shift applies on the given date's
weekday (Mon=0 .. Sun=6)."""
self.ensure_one()
date_obj = fields.Date.to_date(date)
if not date_obj:
return False
days = (self.day_mon, self.day_tue, self.day_wed, self.day_thu,
self.day_fri, self.day_sat, self.day_sun)
return bool(days[date_obj.weekday()])
@property
def scheduled_hours(self):
"""Return the scheduled work hours for this shift (excluding break)."""

View File

@@ -250,64 +250,55 @@ class HrAttendance(models.Model):
@api.model
def _cron_fusion_auto_clock_out(self):
"""Cron job: auto clock-out employees after shift + grace period."""
"""Cron job: safety-net auto clock-out.
Overtime past the scheduled end is expected, so this NEVER closes a shift
at the scheduled end. It only closes an attendance left open longer than
the max-shift safety cap (someone forgot to clock out), and flags the
employee to explain on their next clock-in.
"""
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_auto_clockout', 'True') != 'True':
return
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '12.0'))
grace_min = float(ICP.get_param('fusion_clock.grace_period_minutes', '15'))
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
now = fields.Datetime.now()
open_attendances = self.sudo().search([
('check_out', '=', False),
])
open_attendances = self.sudo().search([('check_out', '=', False)])
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
for att in open_attendances:
check_in = att.check_in
if not check_in:
continue
effective_deadline = check_in + timedelta(hours=max_shift)
if now <= effective_deadline:
continue
employee = att.employee_id
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
max_deadline = check_in + timedelta(hours=max_shift)
day_plan = employee._get_fclk_day_plan(check_in_date)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
effective_deadline = max_deadline
else:
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
deadline = scheduled_out + timedelta(minutes=grace_min)
effective_deadline = min(deadline, max_deadline)
if now > effective_deadline:
clock_out_time = min(effective_deadline, now)
try:
clock_out_time = effective_deadline
try:
with self.env.cr.savepoint():
att.sudo().write({
'check_out': clock_out_time,
'x_fclk_auto_clocked_out': True,
'x_fclk_grace_used': True,
'x_fclk_clock_source': 'auto',
})
# Apply break deduction
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
if (att.worked_hours or 0) >= threshold:
break_min = employee._get_fclk_break_minutes(check_in_date)
att.sudo().write({'x_fclk_break_minutes': break_min})
att.sudo().write(
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
)
att.sudo().message_post(
body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h",
f"(max-shift cap reached). Net hours: {att.x_fclk_net_hours:.1f}h",
message_type='comment',
subtype_xmlid='mail.mt_note',
)
# Log to activity log
ActivityLog.create({
'employee_id': employee.id,
'log_type': 'auto_clock_out',
@@ -317,11 +308,7 @@ class HrAttendance(models.Model):
'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False,
'source': 'system',
})
# Set pending reason
employee.sudo().write({'x_fclk_pending_reason': True})
# Notify office user
self._fclk_notify_office(
office_user_id,
f"Auto Clock-Out: {employee.name}",
@@ -330,16 +317,15 @@ class HrAttendance(models.Model):
'hr.attendance',
att.id,
)
_logger.info(
"Fusion Clock: Auto clocked out %s (attendance %s)",
employee.name, att.id,
)
except Exception as e:
_logger.error(
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
att.id, str(e),
)
except Exception as e:
_logger.error(
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
att.id, str(e),
)
@api.model
def _cron_fusion_wipe_old_photos(self):
@@ -407,127 +393,144 @@ class HrAttendance(models.Model):
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
for emp in employees:
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
try:
with self.env.cr.savepoint():
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
if yesterday.weekday() >= 5:
continue
day_plan = emp._get_fclk_day_plan(yesterday)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
continue
# Only days the employee was actually scheduled to work
# (posted shift or covering recurring shift) can count as an
# absence. Off days and unscheduled days are never flagged.
if not emp._get_fclk_day_plan(yesterday).get('scheduled'):
continue
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
holidays = self.env['resource.calendar.leaves'].sudo().search([
('resource_id', '=', False),
('date_from', '<=', day_end),
('date_to', '>=', day_start),
])
if holidays:
continue
holidays = self.env['resource.calendar.leaves'].sudo().search([
('resource_id', '=', False),
('date_from', '<=', day_end),
('date_to', '>=', day_start),
])
if holidays:
continue
att_count = self.sudo().search_count([
('employee_id', '=', emp.id),
('check_in', '>=', day_start),
('check_in', '<', day_end),
])
if att_count > 0:
continue
att_count = self.sudo().search_count([
('employee_id', '=', emp.id),
('check_in', '>=', day_start),
('check_in', '<', day_end),
])
if att_count > 0:
continue
leave = LeaveRequest.search([
('employee_id', '=', emp.id),
('leave_date', '=', yesterday),
], limit=1)
if leave:
continue
leave = LeaveRequest.search([
('employee_id', '=', emp.id),
('leave_date', '=', yesterday),
], limit=1)
if leave:
continue
ActivityLog.create({
'employee_id': emp.id,
'log_type': 'absent',
'log_date': day_start,
'description': f"No attendance recorded for {yesterday}",
'source': 'system',
})
ActivityLog.create({
'employee_id': emp.id,
'log_type': 'absent',
'log_date': day_start,
'description': f"No attendance recorded for {yesterday}",
'source': 'system',
})
emp.sudo().write({'x_fclk_pending_reason': True})
emp.sudo().write({'x_fclk_pending_reason': True})
month_start = yesterday.replace(day=1)
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
absence_count = ActivityLog.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'absent'),
('log_date', '>=', month_boundary_start),
])
month_start = yesterday.replace(day=1)
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
absence_count = ActivityLog.search_count([
('employee_id', '=', emp.id),
('log_type', '=', 'absent'),
('log_date', '>=', month_boundary_start),
])
if absence_count >= max_absences:
self._fclk_notify_office(
office_user_id,
f"Excessive Absences: {emp.name}",
f"{emp.name} has {absence_count} absences this month "
f"(threshold: {max_absences}). Please review.",
'hr.employee',
emp.id,
)
if absence_count >= max_absences:
self._fclk_notify_office(
office_user_id,
f"Excessive Absences: {emp.name}",
f"{emp.name} has {absence_count} absences this month "
f"(threshold: {max_absences}). Please review.",
'hr.employee',
emp.id,
)
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
except Exception as e:
_logger.error("Fusion Clock: absence check failed for %s: %s", emp.name, e)
@api.model
def _cron_fusion_employee_reminders(self):
"""Cron job: send clock-in/out reminders to employees."""
"""Cron job: schedule-driven clock-in / clock-out reminders.
Reminders only go to employees actually SCHEDULED to work today (posted
shift or covering recurring shift). Someone not scheduled — or whose
shift simply hasn't started yet — is never pinged.
"""
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_employee_notifications', 'True') != 'True':
return
reminder_in_min = float(ICP.get_param('fusion_clock.reminder_before_shift_minutes', '30'))
reminder_out_min = float(ICP.get_param('fusion_clock.reminder_before_end_minutes', '15'))
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
now = fields.Datetime.now()
employees = self.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
])
for emp in employees:
today = get_local_today(self.env, emp)
try:
with self.env.cr.savepoint():
today = get_local_today(self.env, emp)
if not emp._get_fclk_day_plan(today).get('scheduled'):
continue
if emp.x_fclk_last_reminder_date == today:
continue
if today.weekday() >= 5:
continue
day_plan = emp._get_fclk_day_plan(today)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
continue
is_checked_in = emp.attendance_state == 'checked_in'
if emp.x_fclk_last_reminder_date == today:
continue
scheduled_in, scheduled_out = emp._get_fclk_scheduled_times(today)
is_checked_in = emp.attendance_state == 'checked_in'
# Missed clock-in reminder
reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min)
if not is_checked_in and now > reminder_deadline:
today_start, _ = get_local_day_boundaries(self.env, today, emp)
has_attendance = self.sudo().search_count([
('employee_id', '=', emp.id),
('check_in', '>=', today_start),
])
if has_attendance == 0:
self._fclk_send_employee_reminder(
emp,
"Clock-In Reminder",
f"Hi {emp.name}, you haven't clocked in yet today. "
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
)
emp.sudo().write({'x_fclk_last_reminder_date': today})
# Clock-out reminder
reminder_before_end = scheduled_out - timedelta(minutes=reminder_out_min)
if is_checked_in and now > reminder_before_end and now < scheduled_out:
self._fclk_send_employee_reminder(
emp,
"Clock-Out Reminder",
f"Hi {emp.name}, your shift ends at {_fclk_utc_to_local_str(scheduled_out, emp)}. "
f"Don't forget to clock out.",
)
emp.sudo().write({'x_fclk_last_reminder_date': today})
if not is_checked_in:
# Missed clock-in — only after THIS employee's own shift
# start (+ threshold), so a late shift is never pinged early.
scheduled_in, _scheduled_out = emp._get_fclk_scheduled_times(today)
if now <= scheduled_in + timedelta(minutes=reminder_in_min):
continue
today_start, _ = get_local_day_boundaries(self.env, today, emp)
has_attendance = self.sudo().search_count([
('employee_id', '=', emp.id),
('check_in', '>=', today_start),
])
if has_attendance == 0:
self._fclk_send_employee_reminder(
emp,
"Clock-In Reminder",
f"Hi {emp.name}, you haven't clocked in yet today. "
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
)
emp.sudo().write({'x_fclk_last_reminder_date': today})
else:
# Still-clocked-in nudge (OT-aware): only as the max-shift
# safety cap approaches, never at the scheduled end.
open_att = self.sudo().search([
('employee_id', '=', emp.id),
('check_out', '=', False),
], order='check_in desc', limit=1)
if not open_att or not open_att.check_in:
continue
cap = open_att.check_in + timedelta(hours=max_shift)
if cap - timedelta(minutes=reminder_out_min) < now < cap:
self._fclk_send_employee_reminder(
emp,
"Clock-Out Reminder",
f"Hi {emp.name}, you're still clocked in. "
f"Remember to clock out when you leave.",
)
emp.sudo().write({'x_fclk_last_reminder_date': today})
except Exception as e:
_logger.error("Fusion Clock: reminder failed for %s: %s", emp.name, e)
@api.model
def _cron_fusion_weekly_summary(self):

View File

@@ -132,18 +132,25 @@ class HrEmployee(models.Model):
], limit=1)
def _get_fclk_day_plan(self, date):
"""Return the effective plan for a local date.
"""Return the effective plan for a local date, with an explicit
``scheduled`` flag that ALL attendance automation keys off.
Dated schedules are the source of truth. If none exists, the legacy
employee shift/global settings remain the fallback.
Resolution order:
1. POSTED planner entry (``fusion.clock.schedule`` state='posted').
Draft entries are ignored, so the recurring baseline still applies
until the team lead posts the schedule.
2. The employee's recurring shift, IF it covers this weekday.
3. Otherwise: not scheduled. The global default times are returned
only as a display hint; ``scheduled`` stays False so nothing fires.
"""
self.ensure_one()
Schedule = self.env['fusion.clock.schedule'].sudo()
schedule = self._get_fclk_schedule_for_date(date)
if schedule:
if schedule and schedule.state == 'posted':
return {
'source': 'schedule',
'schedule_id': schedule.id,
'scheduled': not schedule.is_off,
'is_off': schedule.is_off,
'start_time': schedule.start_time,
'end_time': schedule.end_time,
@@ -151,12 +158,14 @@ class HrEmployee(models.Model):
'hours': schedule.planned_hours,
'label': schedule.fclk_display_value(),
}
if self.x_fclk_shift_id:
shift = self.x_fclk_shift_id
shift = self.x_fclk_shift_id
if shift and shift.covers_weekday(date):
hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
return {
'source': 'fallback',
'source': 'shift',
'schedule_id': False,
'scheduled': True,
'is_off': False,
'start_time': shift.start_time,
'end_time': shift.end_time,
@@ -168,23 +177,21 @@ class HrEmployee(models.Model):
),
}
# Not scheduled — global default times are a display hint only.
ICP = self.env['ir.config_parameter'].sudo()
start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30'))
hours = max((end_time - start_time) - (break_minutes / 60.0), 0.0)
return {
'source': 'fallback',
'source': 'none',
'schedule_id': False,
'scheduled': False,
'is_off': False,
'start_time': start_time,
'end_time': end_time,
'break_minutes': break_minutes,
'hours': hours,
'label': '%s - %s' % (
Schedule.fclk_float_to_display(start_time),
Schedule.fclk_float_to_display(end_time),
),
'hours': 0.0,
'label': '',
}
def _get_fclk_break_minutes(self, date=None):

View File

@@ -56,8 +56,11 @@ class ResConfigSettings(models.TransientModel):
fclk_max_shift_hours = fields.Float(
string='Max Shift Length (hours)',
config_parameter='fusion_clock.max_shift_hours',
default=12.0,
help="Maximum shift length before auto clock-out (safety net).",
default=16.0,
help="Safety-net cap: an attendance left open longer than this is "
"auto-clocked-out (assumed forgot-to-clock-out). Overtime up to this "
"cap is never cut off, so set it comfortably above your longest real "
"shift + overtime.",
)
fclk_enable_penalties = fields.Boolean(
string='Enable Penalty Tracking',

View File

@@ -1661,3 +1661,91 @@ html.o_dark #fclk-portal-fab {
width: 260px;
}
}
/* ============================================================
Employee portal — Payslips, 4-item nav, sign out
(uses the --fclk-* palette above, so light/dark just works)
============================================================ */
/* Keep 4 nav items comfortable on narrow phones */
.fclk-nav-bar .fclk-nav-item { min-width: 64px; }
/* Sign out (clock header, top-right) */
.fclk-header { position: relative; }
.fclk-signout {
position: absolute;
top: 0;
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 10px;
color: var(--fclk-text-muted);
background: var(--fclk-card);
border: 1px solid var(--fclk-card-border);
text-decoration: none;
}
.fclk-signout:hover { color: var(--fclk-text); }
/* Payslip list rows (extend .fclk-report-item) */
.fclk-payslip-item { text-decoration: none; color: inherit; cursor: pointer; }
.fclk-payslip-status {
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 999px;
white-space: nowrap;
}
.fclk-payslip-status--paid { background: var(--fclk-green-glow); color: var(--fclk-green); }
.fclk-payslip-status--done { background: var(--fclk-hover-bg); color: var(--fclk-text-muted); }
/* Payslip detail (inline paystub) */
.fclk-payslip-detail-header .fclk-payslip-back {
display: inline-block;
font-size: 13px;
color: var(--fclk-green);
text-decoration: none;
margin-bottom: 6px;
}
.fclk-payslip-net {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.fclk-payslip-net-label { font-size: 13px; color: var(--fclk-text-muted); }
.fclk-payslip-net-value { font-size: 26px; font-weight: 700; color: var(--fclk-green); }
.fclk-payslip-section { margin-bottom: 16px; }
.fclk-payslip-section-title {
font-size: 13px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--fclk-text-muted);
margin: 0 0 10px;
}
.fclk-payslip-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
color: var(--fclk-text);
border-bottom: 1px solid var(--fclk-card-border);
}
.fclk-payslip-row:last-child { border-bottom: none; }
.fclk-payslip-row--total { font-weight: 700; }
.fclk-payslip-pdf-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 14px;
margin-bottom: 90px; /* clear the fixed bottom nav */
border-radius: 12px;
background: var(--fclk-green);
color: #fff;
font-weight: 600;
text-decoration: none;
}

View File

@@ -30,6 +30,7 @@ export class FusionClockShiftPlanner extends Component {
error: "",
dirtyCount: 0,
invalidCount: 0,
draftCount: 0,
collapsed: {},
editor: {
open: false,
@@ -89,6 +90,15 @@ export class FusionClockShiftPlanner extends Component {
this.state.shifts = data.shifts || [];
this.state.dirtyCount = 0;
this.state.invalidCount = 0;
let draft = 0;
for (const emp of this.state.employees) {
for (const key in emp.cells || {}) {
if (emp.cells[key] && emp.cells[key].state === "draft") {
draft += 1;
}
}
}
this.state.draftCount = draft;
this.state.error = "";
this.closeCellEditor();
}
@@ -194,6 +204,34 @@ export class FusionClockShiftPlanner extends Component {
}
}
async postWeek() {
if (this.state.dirtyCount) {
this.notification.add("Save your changes before posting.", { type: "warning" });
return;
}
if (!window.confirm("Post this week's schedule? Employees will be emailed their shifts, and reminders/absence checks will start using it.")) {
return;
}
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/post_week", {
week_start: this.state.weekStart,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
} else {
this._applyData(result.data);
this.notification.add(
`Posted ${result.posted || 0} shift(s); notified ${result.notified || 0} employee(s).`,
{ type: "success" },
);
}
} catch (error) {
this.notification.add(error.message || "Could not post the schedule.", { type: "danger" });
}
this.state.saving = false;
}
openCellEditor(employee, day, ev) {
if (this.state.loading || this.state.saving) {
return;

View File

@@ -28,6 +28,10 @@
Save
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
</button>
<button class="btn btn-success" t-on-click="() => this.postWeek()" t-att-disabled="state.loading or state.saving or state.dirtyCount" t-att-title="state.dirtyCount ? 'Save your changes before posting' : 'Publish this week and email employees their shifts'">
<i class="fa fa-paper-plane me-1"/> Post Schedule
<t t-if="state.draftCount">(<t t-esc="state.draftCount"/> draft)</t>
</button>
</div>
</div>
@@ -100,7 +104,7 @@
</td>
<t t-foreach="state.days" t-as="day" t-key="employee.id + '_' + day.date">
<t t-set="cell" t-value="employee.cells[day.date]"/>
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source === 'fallback' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source !== 'schedule' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
<input class="fclk-planner__shift-input"
t-att-value="cell.input"

View File

@@ -4,3 +4,4 @@ from . import test_nfc_models
from . import test_clock_nfc_kiosk
from . import test_shift_planner
from . import test_photo_retention
from . import test_schedule_driven

View File

@@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import date, timedelta
from odoo import fields
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
try:
from freezegun import freeze_time
except ImportError: # freezegun may not be present on the runtime image
freeze_time = None
# 2026-06-01 is a Monday, 2026-06-02 a Tuesday, 2026-06-06 a Saturday.
MON = date(2026, 6, 1)
TUE = date(2026, 6, 2)
SAT = date(2026, 6, 6)
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestScheduleDriven(TransactionCase):
"""Attendance automation must be driven by the employee's real schedule
(posted planner entry -> recurring shift), never the global default."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Employee = cls.env['hr.employee']
cls.Schedule = cls.env['fusion.clock.schedule']
cls.Shift = cls.env['fusion.clock.shift']
cls.Attendance = cls.env['hr.attendance']
cls.Log = cls.env['fusion.clock.activity.log']
cls.ICP = cls.env['ir.config_parameter'].sudo()
cls.emp = cls.Employee.create({
'name': 'Schedule Test',
'x_fclk_enable_clock': True,
'work_email': 'sched.test@example.com',
'tz': 'UTC',
})
# Mon-Fri 09:00-17:00 recurring baseline (assigned per-test where needed).
cls.shift = cls.Shift.create({
'name': 'Test Day Shift',
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
'day_mon': True, 'day_tue': True, 'day_wed': True,
'day_thu': True, 'day_fri': True, 'day_sat': False, 'day_sun': False,
})
cls.ICP.set_param('fusion_clock.enable_employee_notifications', 'True')
cls.ICP.set_param('fusion_clock.max_shift_hours', '16')
def _post(self, day, **vals):
v = {
'employee_id': self.emp.id, 'schedule_date': day, 'state': 'posted',
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
}
v.update(vals)
return self.Schedule.create(v)
# ----- resolver matrix (time-independent) -----
def test_posted_working_is_scheduled(self):
self._post(MON)
plan = self.emp._get_fclk_day_plan(MON)
self.assertTrue(plan['scheduled'])
self.assertEqual(plan['source'], 'schedule')
def test_posted_off_is_not_scheduled(self):
self._post(MON, is_off=True)
plan = self.emp._get_fclk_day_plan(MON)
self.assertFalse(plan['scheduled'])
self.assertTrue(plan['is_off'])
self.assertEqual(plan['source'], 'schedule')
def test_draft_entry_is_ignored(self):
self.emp.x_fclk_shift_id = self.shift
self._post(MON, state='draft') # draft on a Monday the shift covers
plan = self.emp._get_fclk_day_plan(MON)
# Draft ignored -> falls through to the recurring baseline.
self.assertTrue(plan['scheduled'])
self.assertEqual(plan['source'], 'shift')
def test_recurring_shift_covers_weekday(self):
self.emp.x_fclk_shift_id = self.shift
plan = self.emp._get_fclk_day_plan(MON)
self.assertTrue(plan['scheduled'])
self.assertEqual(plan['source'], 'shift')
def test_recurring_shift_skips_uncovered_weekday(self):
self.emp.x_fclk_shift_id = self.shift
plan = self.emp._get_fclk_day_plan(SAT) # Saturday not in the pattern
self.assertFalse(plan['scheduled'])
self.assertEqual(plan['source'], 'none')
def test_nothing_scheduled(self):
plan = self.emp._get_fclk_day_plan(MON) # no posted entry, no shift
self.assertFalse(plan['scheduled'])
self.assertEqual(plan['source'], 'none')
self.assertEqual(plan['label'], '') # portal card -> "Not scheduled"
def test_planner_edit_resets_to_draft(self):
posted = self._post(MON)
self.assertEqual(posted.state, 'posted')
# Re-applying the cell via the planner path must drop it back to draft.
self.Schedule.fclk_apply_planner_cell(self.emp, MON, {'input': '8:00 - 16:00'})
self.assertEqual(posted.state, 'draft')
# ----- reminder cron -----
def test_no_reminder_when_not_scheduled(self):
# Not scheduled today -> the cron must stay completely silent.
self.Attendance._cron_fusion_employee_reminders()
self.assertNotEqual(self.emp.x_fclk_last_reminder_date, fields.Date.context_today(self.emp))
def test_reminder_fires_for_scheduled_late(self):
if freeze_time is None:
self.skipTest("freezegun not available")
with freeze_time("2026-06-01 12:00:00"): # Monday noon, shift started 09:00
self._post(MON, start_time=9.0)
self.Attendance._cron_fusion_employee_reminders()
self.assertEqual(self.emp.x_fclk_last_reminder_date, MON)
def test_no_early_reminder_for_late_shift(self):
if freeze_time is None:
self.skipTest("freezegun not available")
with freeze_time("2026-06-01 12:00:00"): # noon, but shift starts 14:00
self._post(MON, start_time=14.0, end_time=22.0)
self.Attendance._cron_fusion_employee_reminders()
self.assertFalse(self.emp.x_fclk_last_reminder_date)
# ----- absence cron -----
def test_absence_for_scheduled_noshow(self):
if freeze_time is None:
self.skipTest("freezegun not available")
with freeze_time("2026-06-02 09:00:00"): # Tuesday -> yesterday = Monday
self._post(MON) # scheduled Monday, no attendance
self.Attendance._cron_fusion_check_absences()
self.assertEqual(self.Log.search_count([
('employee_id', '=', self.emp.id), ('log_type', '=', 'absent'),
]), 1)
def test_no_absence_when_not_scheduled(self):
if freeze_time is None:
self.skipTest("freezegun not available")
with freeze_time("2026-06-02 09:00:00"): # yesterday Monday, nothing scheduled
self.Attendance._cron_fusion_check_absences()
self.assertEqual(self.Log.search_count([
('employee_id', '=', self.emp.id), ('log_type', '=', 'absent'),
]), 0)
# ----- auto clock-out (OT-aware safety cap) -----
def test_auto_clockout_only_past_cap(self):
now = fields.Datetime.now()
recent = self.Attendance.create({
'employee_id': self.emp.id,
'check_in': now - timedelta(hours=2),
})
emp2 = self.Employee.create({
'name': 'Schedule Test 2', 'x_fclk_enable_clock': True, 'tz': 'UTC',
})
stale = self.Attendance.create({
'employee_id': emp2.id,
'check_in': now - timedelta(hours=17),
})
self.Attendance._cron_fusion_auto_clock_out()
self.assertFalse(recent.check_out, "Under-cap shift must stay open (overtime).")
self.assertTrue(stale.check_out, "Over-cap shift must be auto-closed.")

View File

@@ -102,15 +102,18 @@ class TestShiftPlannerModels(TransactionCase):
'start_time': 10.0,
'end_time': 18.0,
'break_minutes': 60,
'state': 'posted',
})
planned = self.employee._get_fclk_day_plan(planned_date)
fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1))
# Posted dated entry wins; the next day (no entry) falls back to the
# employee's recurring shift, which now reports source 'shift'.
self.assertEqual(planned['source'], 'schedule')
self.assertEqual(planned['start_time'], 10.0)
self.assertEqual(planned['hours'], 7.0)
self.assertEqual(fallback['source'], 'fallback')
self.assertEqual(fallback['source'], 'shift')
self.assertEqual(fallback['start_time'], 8.0)

View File

@@ -20,6 +20,7 @@
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<field name="planned_hours"/>
<field name="state" widget="badge" decoration-success="state == 'posted'" decoration-warning="state == 'draft'"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
@@ -47,6 +48,8 @@
</group>
<group>
<field name="note"/>
<field name="state"/>
<field name="posted_date" readonly="1"/>
<field name="department_id" readonly="1"/>
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
</group>
@@ -65,6 +68,9 @@
<field name="schedule_date"/>
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
<separator/>
<filter name="posted" string="Posted" domain="[('state', '=', 'posted')]"/>
<filter name="draft" string="Draft" domain="[('state', '=', 'draft')]"/>
<filter name="group_department" string="Department" context="{'group_by': 'department_id'}"/>
<filter name="group_date" string="Date" context="{'group_by': 'schedule_date'}"/>
</search>

View File

@@ -39,6 +39,15 @@
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group string="Working Days" col="7">
<field name="day_mon"/>
<field name="day_tue"/>
<field name="day_wed"/>
<field name="day_thu"/>
<field name="day_fri"/>
<field name="day_sat"/>
<field name="day_sun"/>
</group>
<group string="Assigned Employees">
<field name="employee_ids" nolabel="1" colspan="2">
<list>

View File

@@ -95,6 +95,13 @@
<!-- Header -->
<div class="fclk-header">
<a href="/web/session/logout?redirect=/" class="fclk-signout" title="Sign Out">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</a>
<div class="fclk-date" id="fclk-date-display"></div>
<h1 class="fclk-greeting">Hello, <t t-esc="employee.name.split(' ')[0]"/></h1>
</div>
@@ -305,6 +312,15 @@
</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>

View File

@@ -0,0 +1,191 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Payslip List -->
<template id="portal_payslip_list_page" name="Fusion Clock Payslips">
<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-reports-container">
<div class="fclk-ts-header" style="margin-bottom:24px;">
<h2>Payslips</h2>
</div>
<t t-if="payslips">
<t t-foreach="payslips" t-as="payslip">
<a t-attf-href="/my/clock/payslips/#{payslip.id}"
class="fclk-report-item fclk-payslip-item">
<div class="fclk-report-info">
<h4>
<t t-esc="payslip.date_from.strftime('%b %d')"/> -
<t t-esc="payslip.date_to.strftime('%b %d, %Y')"/>
</h4>
<p>
Net
<span t-field="payslip.net_wage"
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</p>
</div>
<span t-attf-class="fclk-payslip-status fclk-payslip-status--#{payslip.state}">
<t t-if="payslip.state == 'paid'">Paid</t>
<t t-else="">Done</t>
</span>
</a>
</t>
</t>
<t t-else="">
<div class="fclk-empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="1.5">
<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"/>
</svg>
<p>No payslips available yet.</p>
<p style="font-size:12px; margin-top:4px;">Finalized pay slips will appear here after each pay run.</p>
</div>
</t>
<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/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>
<a href="/my/clock/payslips" 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="2" y="5" width="20" height="14" rx="2"/>
<line x1="2" y1="10" x2="22" y2="10"/>
</svg>
<span>Payslips</span>
</a>
</div>
</div>
</div>
</t>
</template>
<!-- Payslip Detail — inline paystub -->
<template id="portal_payslip_detail_page" name="Fusion Clock Payslip Detail">
<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-reports-container">
<div class="fclk-ts-header fclk-payslip-detail-header" style="margin-bottom:16px;">
<a href="/my/clock/payslips" class="fclk-payslip-back">&#8592; Payslips</a>
<h2>
<t t-esc="payslip.date_from.strftime('%b %d')"/> -
<t t-esc="payslip.date_to.strftime('%b %d, %Y')"/>
</h2>
</div>
<!-- Net pay highlight -->
<div class="fclk-status-card fclk-payslip-net">
<span class="fclk-payslip-net-label">Net Pay</span>
<span class="fclk-payslip-net-value"
t-field="payslip.net_wage"
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
<!-- Breakdown — iterate the payslip's computed lines so this
works for any payroll provider (enterprise hr_payroll or
the custom fusion_payroll), independent of one field schema. -->
<div class="fclk-status-card fclk-payslip-section">
<h4 class="fclk-payslip-section-title">Breakdown</h4>
<t t-foreach="payslip.line_ids" t-as="line">
<div t-attf-class="fclk-payslip-row#{' fclk-payslip-row--total' if line.code == 'NET' else ''}">
<span t-esc="line.name"/>
<span t-field="line.total"
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
</t>
<t t-if="not payslip.line_ids">
<div class="fclk-payslip-row">
<span>Gross</span>
<span t-field="payslip.gross_wage"
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
<div class="fclk-payslip-row fclk-payslip-row--total">
<span>Net</span>
<span t-field="payslip.net_wage"
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
</t>
</div>
<t t-if="has_pdf">
<a t-attf-href="/my/clock/payslips/#{payslip.id}/pdf"
class="fclk-payslip-pdf-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle; margin-right:6px;">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download PDF
</a>
</t>
<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/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>
<a href="/my/clock/payslips" 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="2" y="5" width="20" height="14" rx="2"/>
<line x1="2" y1="10" x2="22" y2="10"/>
</svg>
<span>Payslips</span>
</a>
</div>
</div>
</div>
</t>
</template>
</odoo>

View File

@@ -86,6 +86,15 @@
</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>

View File

@@ -146,6 +146,15 @@
</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>

View File

@@ -0,0 +1,759 @@
# Employee Portal — Clock + Payslips Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give internal staff a clean employee portal (Clock + Payslips, no customer sidebar) while customers keep the existing customer portal unchanged.
**Architecture:** `fusion_plating_portal` gates its own sidebar shell and redirects internal users to the clock page. `fusion_clock` owns all employee-portal pages — it adds finalized-payslip list + inline paystub routes under `/my/clock/payslips`, reading `hr.payslip` through a soft (`'hr.payslip' in env`) check so it never hard-depends on `fusion_payroll`.
**Tech Stack:** Odoo 19, `portal` controllers (`CustomerPortal`), QWeb templates, CSS (`portal_clock.css`), SCSS (`fp_portal_sidebar.scss`).
**Spec:** [2026-05-30-employee-portal-design.md](../specs/2026-05-30-employee-portal-design.md)
**Key facts established during planning (do not re-derive):**
- Odoo merges every `CustomerPortal` subclass into one MRO, so `FpCustomerPortal._prepare_portal_layout_values` runs on the clock pages too — the gating flag reaches them.
- On entech, `FpCustomerPortal.home()` is the active `/my/home` handler (that's why employees see the customer dashboard today). Editing it is the reliable fix.
- `.o_fp_portal_shell` is a CSS grid `240px 1fr`; hiding only the `<aside>` leaves a 240px gutter, so the grid must collapse to `1fr` when there's no sidebar.
- `.fclk-app` already hides Odoo's `.o_portal_navbar` + footer via CSS; only the FP sidebar leaks onto `/my/clock`.
- `.fclk-nav-bar` is `display:flex; justify-content:center` — a 4th nav item fits.
- Clock/timesheet/report pages set `no_breadcrumbs=True` + `no_header=True`; the new payslip pages must do the same.
---
## File Structure
**`fusion_plating_portal`** (gating only — customer module fixes itself)
- Modify `controllers/portal.py` — add `fp_show_customer_sidebar` flag; redirect internal-with-employee users in `home()`.
- Modify `views/fp_portal_shell.xml` — gate sidebar pieces + add grid-collapse modifier class.
- Modify `static/src/scss/fp_portal_sidebar.scss``o_fp_portal_shell--no-sidebar` rule.
- Modify `tests/` — HttpCase for redirect + sidebar gating.
- Modify `__manifest__.py` — version bump.
**`fusion_clock`** (owns the employee-portal pages)
- Modify `controllers/portal_clock.py``show_payslips` flag on the 3 existing pages; 3 new payslip routes (list / detail / pdf).
- Create `views/portal_payslip_templates.xml` — payslip list + inline paystub.
- Modify `views/portal_clock_templates.xml`, `views/portal_timesheet_templates.xml`, `views/portal_report_templates.xml` — add Payslips nav tab (gated on `show_payslips`) + a Sign Out control in the header.
- Modify `static/src/css/portal_clock.css` — payslip list/detail styles, 4-item nav, sign-out button.
- Modify `__manifest__.py` — register new view file; version bump.
---
## Task 1: Gate the customer sidebar + redirect employees (`fusion_plating_portal`)
**Files:**
- Modify: `fusion_plating/fusion_plating_portal/controllers/portal.py`
- Modify: `fusion_plating/fusion_plating_portal/views/fp_portal_shell.xml`
- Modify: `fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss`
- Modify: `fusion_plating/fusion_plating_portal/__manifest__.py`
- [ ] **Step 1: Add the sidebar flag to the layout values**
In `controllers/portal.py`, inside `_prepare_portal_layout_values` (currently ends with `return values` after setting `fp_partner_display_name`), add the flag before the return:
```python
values['fp_partner_display_name'] = commercial.name or partner.name
# Internal staff (share=False) get the clean employee experience — no
# customer sidebar. Customers (share=True / portal users) keep it.
values['fp_show_customer_sidebar'] = bool(request.env.user.share)
return values
```
- [ ] **Step 2: Carry the flag onto detail pages**
In `controllers/portal.py`, `_get_page_view_values` already setdefaults `fp_sidebar_items` and `fp_partner_display_name` from `layout`. Add the new key right after them:
```python
values.setdefault('fp_sidebar_items', layout.get('fp_sidebar_items'))
values.setdefault('fp_partner_display_name', layout.get('fp_partner_display_name'))
values.setdefault('fp_show_customer_sidebar', layout.get('fp_show_customer_sidebar'))
return values
```
- [ ] **Step 3: Redirect internal-with-employee users off the customer dashboard**
In `controllers/portal.py`, at the very top of `home()` (currently `def home(self, **kw):` then `partner = request.env.user.partner_id`), insert the guard FIRST:
```python
def home(self, **kw):
# Internal staff don't belong on the customer dashboard. Send them to
# the employee clock portal — but only when fusion_clock is installed
# (x_fclk_enable_clock proves it) AND the user actually has an employee
# record, otherwise we'd bounce them into /my/clock -> /my -> loop.
user = request.env.user
if not user.share and 'hr.employee' in request.env:
Employee = request.env['hr.employee'].sudo()
if 'x_fclk_enable_clock' in Employee._fields and \
Employee.search_count([('user_id', '=', user.id)]):
return request.redirect('/my/clock')
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
# ... existing body unchanged ...
```
- [ ] **Step 4: Gate the sidebar in the shell + collapse the grid**
In `views/fp_portal_shell.xml`, replace the `#wrap` xpath body (the `<div class="o_fp_portal_shell"> ... </div>` block) with the gated version. The `<main>$0</main>` stays a single `$0` (safe); the hamburger, backdrop, and sidebar are wrapped in `t-if`; the shell gets a modifier class when there's no sidebar:
```xml
<xpath expr="//div[@id='wrap']" position="replace">
<div t-attf-class="o_fp_portal_shell#{'' if (fp_show_customer_sidebar if fp_show_customer_sidebar is defined else True) else ' o_fp_portal_shell--no-sidebar'}">
<t t-if="fp_show_customer_sidebar if fp_show_customer_sidebar is defined else True">
<!-- Mobile hamburger (shown only below 768px via SCSS) -->
<button type="button"
class="o_fp_portal_hamburger d-md-none"
aria-label="Open navigation">
<i class="fa fa-bars"/>
</button>
<!-- Backdrop for mobile drawer (hidden by default) -->
<div class="o_fp_portal_backdrop"/>
<!-- Sidebar navigation component -->
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
</t>
<!-- Main content area — original #wrap re-emitted here via $0 -->
<main class="o_fp_portal_main">$0</main>
</div>
</xpath>
```
- [ ] **Step 5: Add the grid-collapse SCSS rule**
In `static/src/scss/fp_portal_sidebar.scss`, immediately after the `.o_fp_portal_shell { ... }` block (closes at the `}` after the `@media` block, around line 22), add:
```scss
// Internal staff (employee portal) — no customer sidebar. Collapse the grid
// to a single column so the page content isn't pushed right by the now-empty
// 240px sidebar track.
.o_fp_portal_shell--no-sidebar {
grid-template-columns: 1fr;
gap: 0;
}
```
- [ ] **Step 6: Bump the module version**
In `fusion_plating/fusion_plating_portal/__manifest__.py`, bump `version` (e.g. `19.0.4.4.1``19.0.4.5.0`).
- [ ] **Step 7: Write the HttpCase test**
Create or extend a test file `fusion_plating/fusion_plating_portal/tests/test_employee_portal_gating.py`:
```python
# -*- coding: utf-8 -*-
from odoo.tests import tagged, HttpCase
@tagged('post_install', '-at_install')
class TestEmployeePortalGating(HttpCase):
def test_customer_sees_sidebar_on_home(self):
# The demo portal user is a share user -> customer portal with sidebar.
portal_user = self.env.ref('base.demo_user0', raise_if_not_found=False) \
or self.env['res.users'].search([('share', '=', True)], limit=1)
self.assertTrue(portal_user, "need a share/portal user for this test")
self.authenticate(portal_user.login, portal_user.login)
r = self.url_open('/my/home')
self.assertEqual(r.status_code, 200)
self.assertIn('o_fp_portal_sidebar', r.text,
"customer should still see the FP sidebar shell")
def test_internal_employee_redirected_to_clock(self):
internal = self.env['res.users'].create({
'name': 'Shop Hand', 'login': 'shop_hand_test',
'password': 'shop_hand_test',
'group_ids': [(6, 0, [self.env.ref('base.group_user').id])],
})
self.assertFalse(internal.share)
self.env['hr.employee'].create({'name': 'Shop Hand', 'user_id': internal.id})
self.authenticate(internal.login, internal.login)
# Don't follow the redirect (fusion_clock may not be installed in this
# DB) — just assert we're bounced toward /my/clock.
r = self.url_open('/my/home', allow_redirects=False)
self.assertIn(r.status_code, (301, 302, 303, 307, 308))
self.assertIn('/my/clock', r.headers.get('Location', ''))
```
- [ ] **Step 8: Run the test**
Run (note: requires `hr` installed in the test DB; `hr.employee` create needs it):
```bash
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable \
--test-tags /fusion_plating_portal -u fusion_plating_portal \
--stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40
```
Expected: both tests PASS. If no Odoo dev container is running, this verification moves to the entech smoke test (Task 6) — note it and continue.
- [ ] **Step 9: Commit**
```bash
git add fusion_plating/fusion_plating_portal/controllers/portal.py \
fusion_plating/fusion_plating_portal/views/fp_portal_shell.xml \
fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss \
fusion_plating/fusion_plating_portal/__manifest__.py \
fusion_plating/fusion_plating_portal/tests/test_employee_portal_gating.py
git commit -m "feat(fusion_plating_portal): hide customer sidebar from internal staff + redirect them to the clock portal"
```
---
## Task 2: Payslip backend routes (`fusion_clock`)
**Files:**
- Modify: `fusion_clock/controllers/portal_clock.py`
- [ ] **Step 1: Add a `show_payslips` flag to the three existing pages**
In `controllers/portal_clock.py`, add a tiny helper on `FusionClockPortal` (near `_get_portal_employee`):
```python
def _payroll_available(self):
"""True when fusion_payroll (hr.payslip) is installed on this DB."""
return 'hr.payslip' in request.env
```
Then in each of `portal_clock`, `portal_timesheets`, and `portal_reports`, add `'show_payslips'` to the `values` dict that is rendered (alongside the existing `page_name`):
```python
'page_name': 'clock',
'show_payslips': self._payroll_available(),
```
(repeat the `'show_payslips': self._payroll_available(),` line in the `portal_timesheets` values and the `portal_reports` values).
- [ ] **Step 2: Add the payslip helper to find the employee's finalized slips**
In `controllers/portal_clock.py`, add a helper:
```python
def _get_my_payslips(self, employee):
"""Finalized payslips for this employee, newest first. Empty when
payroll isn't installed."""
if not self._payroll_available() or not employee:
return request.env['hr.payslip'].browse() if self._payroll_available() else []
return request.env['hr.payslip'].sudo().search(
[('employee_id', '=', employee.id), ('state', 'in', ('done', 'paid'))],
order='date_to desc, id desc',
)
```
- [ ] **Step 3: Add the payslip list route**
In `controllers/portal_clock.py`, after the reports routes, add:
```python
# =========================================================================
# Payslips
# =========================================================================
@http.route('/my/clock/payslips', type='http', auth='user', website=True)
def portal_payslips(self, **kw):
"""List the employee's finalized pay slips."""
employee = self._get_portal_employee()
if not employee:
return request.redirect('/my/clock')
if not self._payroll_available():
return request.redirect('/my/clock')
payslips = self._get_my_payslips(employee)
values = {
'employee': employee,
'payslips': payslips,
'show_payslips': True,
'page_name': 'payslips',
}
return request.render('fusion_clock.portal_payslip_list_page', values)
```
- [ ] **Step 4: Add the payslip detail (inline paystub) route**
```python
@http.route('/my/clock/payslips/<int:payslip_id>', type='http', auth='user', website=True)
def portal_payslip_detail(self, payslip_id, **kw):
"""Inline paystub for one finalized slip the employee owns."""
employee = self._get_portal_employee()
if not employee or not self._payroll_available():
return request.redirect('/my/clock')
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
if not payslip.exists() or payslip.employee_id.id != employee.id \
or payslip.state not in ('done', 'paid'):
return request.redirect('/my/clock/payslips')
# PDF availability: any qweb-pdf report bound to hr.payslip.
pdf_report = request.env['ir.actions.report'].sudo().search(
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
values = {
'employee': employee,
'payslip': payslip,
'has_pdf': bool(pdf_report),
'show_payslips': True,
'page_name': 'payslips',
}
return request.render('fusion_clock.portal_payslip_detail_page', values)
```
- [ ] **Step 5: Add the PDF download route (sudo render + ownership guard)**
```python
@http.route('/my/clock/payslips/<int:payslip_id>/pdf', type='http', auth='user', website=True)
def portal_payslip_pdf(self, payslip_id, **kw):
"""Render the standard payslip PDF (sudo) for a slip the employee owns."""
employee = self._get_portal_employee()
if not employee or not self._payroll_available():
return request.redirect('/my/clock')
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
if not payslip.exists() or payslip.employee_id.id != employee.id \
or payslip.state not in ('done', 'paid'):
return request.redirect('/my/clock/payslips')
report = request.env['ir.actions.report'].sudo().search(
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
if not report:
return request.redirect('/my/clock/payslips/%s' % payslip_id)
pdf, _ = report._render_qweb_pdf(report.report_name, [payslip.id])
filename = 'Payslip-%s.pdf' % (payslip.number or payslip.id)
return request.make_response(pdf, headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', 'attachment; filename="%s"' % filename),
])
```
> Note: `payslip.number` is the standard slip reference on `hr.payslip`; if absent on this install, the `or payslip.id` fallback covers it.
- [ ] **Step 6: Commit**
```bash
git add fusion_clock/controllers/portal_clock.py
git commit -m "feat(fusion_clock): portal routes for employee payslips (list / inline paystub / pdf)"
```
---
## Task 3: Payslip templates (`fusion_clock`)
**Files:**
- Create: `fusion_clock/views/portal_payslip_templates.xml`
- Modify: `fusion_clock/__manifest__.py`
- [ ] **Step 1: Create the templates file**
Create `fusion_clock/views/portal_payslip_templates.xml`. The list clones the Reports page; the detail is an inline paystub. Both carry the 4-item bottom nav with Payslips active. Money uses `t-field` monetary with `payslip.currency_id`.
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Payslip List -->
<template id="portal_payslip_list_page" name="Fusion Clock Payslips">
<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-reports-container">
<div class="fclk-ts-header" style="margin-bottom:24px;">
<h2>Payslips</h2>
</div>
<t t-if="payslips">
<t t-foreach="payslips" t-as="payslip">
<a t-attf-href="/my/clock/payslips/#{payslip.id}"
class="fclk-report-item fclk-payslip-item">
<div class="fclk-report-info">
<h4>
<t t-esc="payslip.date_from.strftime('%b %d')"/> -
<t t-esc="payslip.date_to.strftime('%b %d, %Y')"/>
</h4>
<p>
Net
<span t-field="payslip.net_wage"
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</p>
</div>
<span t-attf-class="fclk-payslip-status fclk-payslip-status--#{payslip.state}">
<t t-if="payslip.state == 'paid'">Paid</t>
<t t-else="">Done</t>
</span>
</a>
</t>
</t>
<t t-else="">
<div class="fclk-empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="1.5">
<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"/>
</svg>
<p>No payslips available yet.</p>
<p style="font-size:12px; margin-top:4px;">Finalized pay slips will appear here after each pay run.</p>
</div>
</t>
<t t-call="fusion_clock.portal_employee_navbar">
<t t-set="active" t-value="'payslips'"/>
</t>
</div>
</div>
</t>
</template>
<!-- Payslip Detail — inline paystub -->
<template id="portal_payslip_detail_page" name="Fusion Clock Payslip Detail">
<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-reports-container">
<div class="fclk-ts-header fclk-payslip-detail-header" style="margin-bottom:16px;">
<a href="/my/clock/payslips" class="fclk-payslip-back">&#8592; Payslips</a>
<h2>
<t t-esc="payslip.date_from.strftime('%b %d')"/> -
<t t-esc="payslip.date_to.strftime('%b %d, %Y')"/>
</h2>
</div>
<!-- Net pay highlight -->
<div class="fclk-status-card fclk-payslip-net">
<span class="fclk-payslip-net-label">Net Pay</span>
<span class="fclk-payslip-net-value"
t-field="payslip.net_wage"
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
<!-- Deductions -->
<div class="fclk-status-card fclk-payslip-section">
<h4 class="fclk-payslip-section-title">Deductions</h4>
<div class="fclk-payslip-row">
<span>CPP</span>
<span t-field="payslip.employee_cpp" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
<div class="fclk-payslip-row" t-if="payslip.employee_cpp2">
<span>CPP2</span>
<span t-field="payslip.employee_cpp2" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
<div class="fclk-payslip-row">
<span>EI</span>
<span t-field="payslip.employee_ei" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
<div class="fclk-payslip-row">
<span>Income Tax</span>
<span t-field="payslip.employee_income_tax" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
<div class="fclk-payslip-row fclk-payslip-row--total">
<span>Total Deductions</span>
<span t-field="payslip.total_employee_deductions" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
</div>
<!-- Year to date -->
<div class="fclk-status-card fclk-payslip-section">
<h4 class="fclk-payslip-section-title">Year to Date</h4>
<div class="fclk-payslip-row">
<span>Gross</span>
<span t-field="payslip.ytd_gross" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
<div class="fclk-payslip-row">
<span>CPP</span>
<span t-field="payslip.ytd_cpp" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
<div class="fclk-payslip-row">
<span>EI</span>
<span t-field="payslip.ytd_ei" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
<div class="fclk-payslip-row">
<span>Income Tax</span>
<span t-field="payslip.ytd_income_tax" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
<div class="fclk-payslip-row fclk-payslip-row--total">
<span>Net</span>
<span t-field="payslip.ytd_net" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
</div>
</div>
<t t-if="has_pdf">
<a t-attf-href="/my/clock/payslips/#{payslip.id}/pdf"
class="fclk-payslip-pdf-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle; margin-right:6px;">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download PDF
</a>
</t>
<t t-call="fusion_clock.portal_employee_navbar">
<t t-set="active" t-value="'payslips'"/>
</t>
</div>
</div>
</t>
</template>
<!-- Shared employee bottom nav (Clock / Timesheets / Reports / Payslips).
`active` = clock|timesheets|reports|payslips. Payslips tab shows only
when show_payslips is truthy. -->
<template id="portal_employee_navbar" name="Fusion Clock Employee Navbar">
<div class="fclk-nav-bar">
<a href="/my/clock" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'clock' else ''}">
<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" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'timesheets' else ''}">
<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/reports" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'reports' else ''}">
<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" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'payslips' else ''}">
<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>
</template>
</odoo>
```
- [ ] **Step 2: Register the file in the manifest**
In `fusion_clock/__manifest__.py`, add to the `data` list near the other `views/portal_*` entries:
```python
'views/portal_payslip_templates.xml',
```
Also bump `version` (e.g. `19.0.3.11.8``19.0.3.12.0`).
- [ ] **Step 3: Commit**
```bash
git add fusion_clock/views/portal_payslip_templates.xml fusion_clock/__manifest__.py
git commit -m "feat(fusion_clock): payslip list + inline paystub templates and shared employee navbar"
```
---
## Task 4: Add the Payslips tab to the three existing pages + Sign Out (`fusion_clock`)
**Files:**
- Modify: `fusion_clock/views/portal_clock_templates.xml`
- Modify: `fusion_clock/views/portal_timesheet_templates.xml`
- Modify: `fusion_clock/views/portal_report_templates.xml`
- [ ] **Step 1: Replace each page's inline nav bar with the shared navbar**
In all three files, replace the existing `<div class="fclk-nav-bar"> ... </div>` block with a call to the shared template, setting the correct `active` value:
`portal_clock_templates.xml` (the main clock page):
```xml
<t t-call="fusion_clock.portal_employee_navbar">
<t t-set="active" t-value="'clock'"/>
</t>
```
`portal_timesheet_templates.xml`:
```xml
<t t-call="fusion_clock.portal_employee_navbar">
<t t-set="active" t-value="'timesheets'"/>
</t>
```
`portal_report_templates.xml`:
```xml
<t t-call="fusion_clock.portal_employee_navbar">
<t t-set="active" t-value="'reports'"/>
</t>
```
> The shared navbar reads `show_payslips` from context (set by the controller in Task 2 Step 1) so the Payslips tab only appears when payroll is installed.
- [ ] **Step 2: Add a Sign Out control to the clock header**
In `portal_clock_templates.xml`, the header block is:
```xml
<div class="fclk-header">
<div class="fclk-date" id="fclk-date-display"></div>
<h1 class="fclk-greeting">Hello, <t t-esc="employee.name.split(' ')[0]"/></h1>
</div>
```
Add a sign-out link inside it (top-right):
```xml
<div class="fclk-header">
<a href="/web/session/logout?redirect=/" class="fclk-signout" title="Sign Out">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</a>
<div class="fclk-date" id="fclk-date-display"></div>
<h1 class="fclk-greeting">Hello, <t t-esc="employee.name.split(' ')[0]"/></h1>
</div>
```
- [ ] **Step 3: Commit**
```bash
git add fusion_clock/views/portal_clock_templates.xml \
fusion_clock/views/portal_timesheet_templates.xml \
fusion_clock/views/portal_report_templates.xml
git commit -m "feat(fusion_clock): add Payslips tab to employee nav + Sign Out in clock header"
```
---
## Task 5: Payslip + nav + sign-out styles (`fusion_clock`)
**Files:**
- Modify: `fusion_clock/static/src/css/portal_clock.css`
- [ ] **Step 1: Append the new styles**
Append to `static/src/css/portal_clock.css` (reuses the existing `--fclk-*` CSS variables already defined in the file for light/dark):
```css
/* ===== Employee nav: keep 4 items comfortable on narrow phones ===== */
.fclk-nav-bar .fclk-nav-item { min-width: 64px; }
/* ===== Sign out (clock header, top-right) ===== */
.fclk-header { position: relative; }
.fclk-signout {
position: absolute;
top: 0;
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 10px;
color: var(--fclk-text-muted, #9ca3af);
background: var(--fclk-card, rgba(255,255,255,0.04));
border: 1px solid var(--fclk-card-border, rgba(255,255,255,0.08));
text-decoration: none;
}
.fclk-signout:hover { color: var(--fclk-text, #fff); }
/* ===== Payslip list rows (extends .fclk-report-item) ===== */
.fclk-payslip-item { text-decoration: none; cursor: pointer; }
.fclk-payslip-status {
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 999px;
}
.fclk-payslip-status--paid { background: rgba(16,185,129,0.15); color: #10B981; }
.fclk-payslip-status--done { background: rgba(107,114,128,0.18); color: #9ca3af; }
/* ===== Payslip detail (inline paystub) ===== */
.fclk-payslip-detail-header .fclk-payslip-back {
display: inline-block;
font-size: 13px;
color: var(--fclk-accent, #10B981);
text-decoration: none;
margin-bottom: 6px;
}
.fclk-payslip-net {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.fclk-payslip-net-label { font-size: 13px; color: var(--fclk-text-muted, #9ca3af); }
.fclk-payslip-net-value { font-size: 26px; font-weight: 700; color: var(--fclk-accent, #10B981); }
.fclk-payslip-section { margin-bottom: 16px; }
.fclk-payslip-section-title {
font-size: 13px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--fclk-text-muted, #9ca3af);
margin: 0 0 10px;
}
.fclk-payslip-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
color: var(--fclk-text, #e5e7eb);
border-bottom: 1px solid var(--fclk-card-border, rgba(255,255,255,0.06));
}
.fclk-payslip-row:last-child { border-bottom: none; }
.fclk-payslip-row--total { font-weight: 700; }
.fclk-payslip-pdf-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 14px;
margin-bottom: 90px; /* clear the fixed bottom nav */
border-radius: 12px;
background: var(--fclk-accent, #10B981);
color: #fff;
font-weight: 600;
text-decoration: none;
}
```
> CSS-variable names assume the existing `--fclk-*` palette in this file. During execution, grep the top of `portal_clock.css` for the exact variable names (`--fclk-card`, `--fclk-accent`, `--fclk-text*`, `--fclk-card-border`) and adjust if they differ; fall back to the hardcoded hex already provided.
- [ ] **Step 2: Commit**
```bash
git add fusion_clock/static/src/css/portal_clock.css
git commit -m "style(fusion_clock): payslip list/detail, 4-item nav, and sign-out styles"
```
---
## Task 6: Verify end-to-end (entech smoke + asset cache bust)
- [ ] **Step 1: Confirm the `hr.payslip` fields used actually exist on entech**
The detail template references `employee_cpp`, `employee_cpp2`, `employee_ei`, `employee_income_tax`, `total_employee_deductions`, `ytd_gross`, `ytd_cpp`, `ytd_ei`, `ytd_income_tax`, `ytd_net`, `net_wage`, `currency_id`, `state`, `number`, `date_from`, `date_to`. These were read from `fusion_payroll/models/hr_payslip.py` (the YTD + employer/employee blocks). On entech, verify quickly via shell that they resolve; if any is absent on the installed payroll, remove that single row from the template.
- [ ] **Step 2: Deploy to entech**
```bash
cat fusion_clock/... | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_clock/...'" # push each changed file
# (and the fusion_plating_portal files under /mnt/extra-addons/custom/fusion_plating_portal/...)
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_clock,fusion_plating_portal --stop-after-init\" && systemctl start odoo'"
```
- [ ] **Step 3: Bust the asset cache (CSS/SCSS changed)**
```bash
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
ssh pve-worker5 "pct exec 111 -- systemctl restart odoo"
```
- [ ] **Step 4: Manual checks (hard-refresh the browser each time)**
- Log in as an internal employee → landing on `/my` redirects to `/my/clock`; **no left sidebar** on Clock / Timesheets / Reports / Payslips.
- Bottom nav shows 4 tabs; Payslips opens the list; only the employee's own Done/Paid slips appear.
- Open a payslip → inline paystub renders; Download PDF present only if a payslip report exists; PDF downloads the right slip.
- Try another employee's payslip id in the URL → redirected to the list (no leak).
- Log in as a customer (share user) → `/my/home` still shows the dashboard **with** the sidebar; all customer pages unchanged.
---
## Self-Review notes
- **Spec coverage:** audience split (T1 S1/S3), sidebar suppression (T1 S4/S5), employee redirect (T1 S3), bottom-nav Payslips (T3/T4), finalized-only payslip list (T2 S2), inline paystub + PDF button (T2 S4/S5, T3), sign out (T4 S2), guards for payroll-absent + ownership (T2). All covered.
- **Open items from the spec:** PDF report is detected at runtime (no hard dependency); `hr.payslip` state filter uses the confirmed `('done','paid')`; the risky double-`$0` was avoided in favour of a single `$0` + grid-collapse modifier.
- **Type/name consistency:** template id `portal_payslip_list_page` / `portal_payslip_detail_page` / `portal_employee_navbar`; flag `fp_show_customer_sidebar`; context key `show_payslips`; nav `active` values `clock|timesheets|reports|payslips` — consistent across tasks.

View File

@@ -0,0 +1,190 @@
# Employee Portal — Clock + Payslips (separating staff from the customer portal)
**Date:** 2026-05-30
**Status:** Design approved — ready for implementation plan
**Deployment target:** entech (LXC 111 on pve-worker5, DB `admin`)
**Modules touched:** `fusion_plating_portal`, `fusion_clock` (+ optional `fusion_payroll` if a payslip PDF must be built)
---
## 1. Context & Problem
EN Technologies (entech) runs three relevant modules on one Odoo:
- `fusion_plating_portal` — the **customer** portal: a rich dashboard + a left sidebar shell wrapping every `/my/*` page.
- `fusion_clock` — employee clock-in/out, exposed on the front end at `/my/clock` (+ `/my/clock/timesheets`, `/my/clock/reports`). A polished dark, mobile-first UI with its own bottom nav.
- `fusion_payroll` — Canadian payroll; owns `hr.payslip` with full earnings/deductions/YTD data. **No employee self-service surface today** (payslips are backend-only).
Two concrete problems:
1. **The customer portal applies to everyone.** `fusion_plating_portal`'s `home()` override (`/my`, `/my/home`) returns the customer dashboard for *every* logged-in user, with no internal-vs-customer check. Internal employees who land on `/my` see a customer dashboard that is empty/irrelevant to them.
2. **The customer sidebar bleeds onto the clock page.** `fp_portal_shell` (`fusion_plating_portal/views/fp_portal_shell.xml`) inherits `portal.portal_layout` and injects the left sidebar into **all** `/my/*` pages, unconditionally. Because `fusion_clock`'s `/my/clock` page renders inside `portal.portal_layout`, it inherits that customer sidebar even though the clock UI was designed clean (it sets `no_header`/`no_breadcrumbs` and has its own bottom nav).
**Goal:** internal employees get a dedicated, clean employee portal (Clock + Payslips, no customer sidebar); external customers keep the customer portal exactly as it is.
---
## 2. Goals / Non-goals
**Goals**
- Internal staff never see the customer dashboard or the customer sidebar.
- Employees land on the Clock page and navigate Clock / Timesheets / Reports / **Payslips** via the existing bottom nav.
- Employees can view their own finalized pay slips (inline paystub) and download a PDF when one is available.
- Zero change to the customer experience.
**Non-goals (v1)**
- Profile/password editing in the employee portal (internal staff use the backend).
- Payslips for non-finalized states (draft / verify hidden).
- RMA/quote/customer features for employees.
- Building a new payslip PDF report *unless* Odoo's standard one is absent on entech (see §9 Open items).
- Cross-instance payroll (payslips are confirmed to live on the same Odoo).
---
## 3. Decisions (locked during brainstorming)
| # | Decision |
|---|----------|
| Audience split | **Internal users → employee portal; share/portal users → customer portal.** Detected via `request.env.user.share` (`False` = internal staff, `True` = customer). |
| Employee landing & nav | **Clock is home.** `/my` and `/my/home` redirect employees to `/my/clock`. The existing bottom nav gains a **Payslips** tab. **No left sidebar anywhere** for employees. |
| Payslip scope | **Finalized only**`hr.payslip` where `state in ('done','paid')`, scoped to the logged-in employee. From `fusion_payroll` on the same Odoo. |
| Payslip presentation | **Inline paystub page + Download-PDF button.** Inline always works; the PDF button appears only when a payslip PDF report exists on the server. |
| Architecture | **Approach 1**`fusion_clock` owns the employee-portal pages (incl. payslips, via a *soft* `hr.payslip` read); `fusion_plating_portal` fixes its own gating + redirect. No new module, no hard cross-dependency. |
| Sign out | A compact **Sign Out** affordance in the employee header (the bottom-nav UI lacks one today). |
---
## 4. Architecture — Approach 1
Two modules change; responsibilities stay where they naturally belong.
**`fusion_plating_portal`** (the module causing the bleed — fixes itself):
- Adds `fp_show_customer_sidebar` to the portal layout context = `request.env.user.share`.
- Gates its sidebar shell on that flag.
- Branches `home()`: internal user → redirect to `/my/clock`; customer → existing dashboard.
**`fusion_clock`** (owns all employee-portal pages + the bottom nav + the dark SCSS):
- Adds `/my/clock/payslips` (list) and `/my/clock/payslips/<id>` (inline paystub) routes.
- Reads `hr.payslip` through a **soft** check (`'hr.payslip' in request.env`) — **no** `fusion_payroll` dependency added, so `fusion_clock` stays installable without payroll (the Payslips tab simply doesn't appear).
- Adds the **Payslips** tab to the existing bottom nav.
**Why not the alternatives:** payslip page in `fusion_payroll` makes the bottom nav shared chrome across two modules (duplication or a new cross-dep); a dedicated `fusion_employee_portal` module is new scaffolding that would have to couple to the entech-specific customer module to win the `/my/home` override — and it conflicts with the "edit existing files, don't add modules" rule.
> **Why the redirect lives in `fusion_plating_portal.home()`:** that override is the one currently winning at `/my/home` on entech (it's why employees see the customer dashboard today). Editing it to branch is guaranteed to take effect on entech without depending on fragile multi-module MRO ordering (`fusion_clock` does **not** override `home()`).
---
## 5. Detailed design
### 5.1 Audience detection
Single signal: `request.env.user.share`.
- `share == True` → customer → customer portal (dashboard + sidebar).
- `share == False` → internal staff → employee portal (clock + payslips, no sidebar).
The clock pages already resolve the person via `hr.employee` where `user_id == env.user.id` (`_get_portal_employee`). An internal user with no employee record (e.g. a bare admin) gets the clean layout, an empty clock that redirects, and retains full backend access.
### 5.2 Suppress the sidebar for employees
In `fusion_plating_portal/controllers/portal.py`, `_prepare_portal_layout_values` adds:
```python
values['fp_show_customer_sidebar'] = request.env.user.share
```
In `fp_portal_shell.xml`, the `#wrap` replacement becomes conditional:
- `fp_show_customer_sidebar` true → render the full `.o_fp_portal_shell` (sidebar + `<main>$0</main>`).
- false → re-emit the raw original `#wrap` ($0) with no shell.
**Implementation note / risk:** the shell uses Odoo 19's `$0` re-emission inside a `position="replace"`. Putting `$0` in **both** a `t-if` and a `t-else` branch needs verifying — the inheritance engine appends a deep copy of the original node for *each* `$0` occurrence; both copies live in the compiled arch but only one renders at runtime (t-if/t-else). If that proves unreliable at load time, **fallback:** keep a single `$0` inside `.o_fp_portal_main` always, gate only the `<aside>` with `t-if`, and add a body/`<main>` modifier class so the main column goes full-width (and the clock UI renders edge-to-edge) when the sidebar is absent. Either way the customer path is byte-for-byte unchanged.
### 5.3 Redirect employees to Clock
`fusion_plating_portal.home()` (routes `['/my','/my/home']`), first lines:
```python
if not request.env.user.share: # internal staff
return request.redirect('/my/clock')
```
Customers fall through to the existing dashboard build untouched.
### 5.4 Bottom nav gains Payslips
The nav bar lives in three `fusion_clock` templates (`portal_clock_templates.xml`, `portal_timesheet_templates.xml`, `portal_report_templates.xml`) plus the new payslip templates. Add a 4th item **Payslips** (`/my/clock/payslips`) with an appropriate icon, marking the active tab per page. The tab is gated on a `show_payslips` flag (true only when `'hr.payslip' in env`) so `fusion_clock` stays clean on payroll-less deployments.
### 5.5 Payslips list — `GET /my/clock/payslips`
- Resolve employee via `_get_portal_employee()`; if none → redirect `/my/clock`.
- If `'hr.payslip' not in request.env` → redirect `/my/clock` (and the tab won't show anyway).
- Query: `hr.payslip` `sudo()` where `employee_id == employee.id` and `state in ('done','paid')`, ordered `date_to desc`.
- Card list (same dark styling as Reports): pay period (`date_from``date_to`), net pay (`net_wage`, `$` + currency), status chip (Paid / Done), link to detail.
- `page_name='payslips'` for nav active-state.
### 5.6 Payslip detail (inline paystub) — `GET /my/clock/payslips/<int:payslip_id>`
- **Ownership guard** (mirrors `portal_report_download`): browse the slip; if it doesn't exist, isn't `state in ('done','paid')`, or `employee_id != employee.id` → redirect `/my/clock/payslips`.
- Render a mobile-friendly paystub from data already on the record:
- Header: employee name, pay period, pay date, status.
- Earnings: gross / line items (`line_ids` totals).
- Deductions: `employee_cpp`, `employee_cpp2`, `employee_ei`, `employee_income_tax` (+ total).
- **Net pay** (highlighted).
- YTD block: `ytd_gross`, `ytd_cpp`, `ytd_cpp2`, `ytd_ei`, `ytd_income_tax`, `ytd_net`.
- **Download PDF** button: visible only when a payslip PDF report action exists on the server (see §9). Links to that report for this `payslip_id`. Routed through `fusion_pdf_preview` where applicable per repo convention (PDF → preview dialog), otherwise standard `/report/pdf/...`.
### 5.7 Sign out
Add a compact **Sign Out** control to the employee header area (e.g. a small icon next to the "Hello, {name}" greeting on the clock page, and consistently on the payslip pages) → `/web/session/logout?redirect=/`.
---
## 6. Files touched
**`fusion_plating_portal`**
- `controllers/portal.py``_prepare_portal_layout_values` (+`fp_show_customer_sidebar`), `home()` (employee redirect).
- `views/fp_portal_shell.xml` — gate the shell on `fp_show_customer_sidebar`.
- `__manifest__.py` — version bump.
**`fusion_clock`**
- `controllers/portal_clock.py``/my/clock/payslips` + `/my/clock/payslips/<id>` routes; `show_payslips` flag in existing page values.
- `views/portal_clock_templates.xml`, `views/portal_timesheet_templates.xml`, `views/portal_report_templates.xml` — add Payslips nav item.
- `views/portal_payslip_templates.xml`**new**: list + inline paystub.
- `static/src/scss/...` — payslip card/paystub styles (reuse `fclk-*` design language) + Sign Out control.
- `__manifest__.py` — register new view file; version bump.
**`fusion_payroll`** — only if §9 finds no payslip PDF report and we decide to build a minimal paystub report (separate, optional task).
---
## 7. Edge cases & guards
- Internal user, no `hr.employee` → clean layout, clock redirects to `/my`, backend still reachable.
- `fusion_payroll` not installed → Payslips tab hidden; routes redirect to `/my/clock`.
- Payslip not owned / not finalized → redirect to the list (no leak).
- Admin needs to preview the *customer* portal → use a customer test login (documented), since internal users are redirected.
- Customer experience: unchanged — sidebar + dashboard render exactly as before when `share == True`.
---
## 8. Testing
- **Internal user:** `/my` and `/my/home` → 302 to `/my/clock`; `/my/clock`, `/my/clock/timesheets`, `/my/clock/reports`, `/my/clock/payslips` render with **no** `.o_fp_portal_sidebar` in the DOM.
- **Customer (share) user:** `/my/home` → customer dashboard **with** sidebar; all existing customer pages unchanged.
- **Payslips list:** shows only the logged-in employee's `done`/`paid` slips; another employee's slips never appear.
- **Payslip detail:** ownership guard blocks a slip belonging to someone else / a draft slip (redirect).
- **PDF button:** present only when the payslip report action exists; downloads the right slip.
- **Payroll absent:** Payslips tab hidden; `/my/clock/payslips` redirects cleanly.
Use ephemeral test ports per repo rule:
```bash
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable \
--test-tags /fusion_clock,/fusion_plating_portal \
-u fusion_clock,fusion_plating_portal --stop-after-init \
--http-port=0 --gevent-port=0 2>&1 | tail -60
```
---
## 9. Open items (verify during implementation)
1. **Payslip PDF report on entech.** Confirm whether Odoo's repackaged `hr_payroll` ships a per-employee payslip report (`hr_payroll.action_report_payslip` / `report_payslip[_lang]`) on entech. If present → wire the Download-PDF button to it. If absent → keep inline-only for v1 and log a follow-up to build a minimal paystub report in `fusion_payroll`.
2. **Exact `hr.payslip.state` values.** Confirm the selection (`draft`/`verify`/`done`/`paid`/`cancel`) on the installed payroll so the `('done','paid')` filter and the status chip labels are exact.
3. **`$0`-in-two-branches** load-time behaviour (see §5.2) — adopt the fallback if the conditional double-`$0` doesn't compile cleanly.
---
## 10. Deployment (entech)
Bump versions, then update both modules and bust the asset cache (SCSS/JS changed):
```bash
# update modules
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_clock,fusion_plating_portal --stop-after-init\" && systemctl start odoo'"
# asset cache bust (if needed)
# DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';
```

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Customer Portal',
'version': '19.0.4.4.1',
'version': '19.0.4.5.0',
'category': 'Manufacturing/Plating',
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
'CoC downloads, invoice access.',

View File

@@ -194,6 +194,9 @@ class FpCustomerPortal(CustomerPortal):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
values['fp_partner_display_name'] = commercial.name or partner.name
# Internal staff (share=False) get the clean employee experience — no
# customer sidebar. Customers (share=True / portal users) keep it.
values['fp_show_customer_sidebar'] = bool(request.env.user.share)
return values
def _get_page_view_values(self, document, access_token, values, session_history, no_breadcrumbs, **kwargs):
@@ -208,6 +211,7 @@ class FpCustomerPortal(CustomerPortal):
layout = self._prepare_portal_layout_values()
values.setdefault('fp_sidebar_items', layout.get('fp_sidebar_items'))
values.setdefault('fp_partner_display_name', layout.get('fp_partner_display_name'))
values.setdefault('fp_show_customer_sidebar', layout.get('fp_show_customer_sidebar'))
return values
# ==========================================================================
@@ -616,6 +620,16 @@ class FpCustomerPortal(CustomerPortal):
website=True,
)
def home(self, **kw):
# Internal staff don't belong on the customer dashboard. Send them to
# the employee clock portal — but only when fusion_clock is installed
# (x_fclk_enable_clock proves it) AND the user actually has an employee
# record, otherwise /my/clock -> /my would bounce into a redirect loop.
user = request.env.user
if not user.share and 'hr.employee' in request.env:
Employee = request.env['hr.employee'].sudo()
if 'x_fclk_enable_clock' in Employee._fields and \
Employee.search_count([('user_id', '=', user.id)]):
return request.redirect('/my/clock')
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id

View File

@@ -21,6 +21,14 @@
}
}
// Internal staff (employee portal) — no customer sidebar. Collapse the grid
// to a single column so page content isn't pushed right by the now-empty
// 240px sidebar track.
.o_fp_portal_shell--no-sidebar {
grid-template-columns: 1fr;
gap: 0;
}
.o_fp_portal_sidebar {
position: sticky;
top: $fp-space-4;

View File

@@ -1 +1,2 @@
from . import test_portal_dashboard
from . import test_employee_portal_gating

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1.
from odoo.tests import HttpCase, tagged
@tagged('post_install', '-at_install', 'fp_portal')
class TestEmployeePortalGating(HttpCase):
"""Internal staff get the clean employee experience (no customer sidebar,
redirected off the customer dashboard); customers are untouched."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.customer_partner = cls.env['res.partner'].create({
'name': 'Gating Customer Co.',
'email': 'gating_customer@example.com',
})
cls.customer_user = cls.env['res.users'].create({
'name': 'Gating Portal User',
'login': 'gating_portal_user',
'password': 'gating_portal_user',
'partner_id': cls.customer_partner.id,
'group_ids': [(6, 0, [cls.env.ref('base.group_portal').id])],
})
def test_customer_sees_sidebar_on_home(self):
"""A share/portal user still gets the FP customer sidebar shell."""
self.assertTrue(self.customer_user.share)
self.authenticate('gating_portal_user', 'gating_portal_user')
r = self.url_open('/my/home')
self.assertEqual(r.status_code, 200)
self.assertIn('o_fp_portal_sidebar', r.text,
"customer should still see the FP sidebar shell")
def test_internal_employee_redirected_to_clock(self):
"""An internal user with an employee record is bounced to /my/clock.
Only meaningful when fusion_clock is installed (the redirect guard
checks for its x_fclk_enable_clock field, so it never sends anyone to
a non-existent /my/clock). Skip otherwise.
"""
if 'hr.employee' not in self.env:
self.skipTest('hr not installed')
if 'x_fclk_enable_clock' not in self.env['hr.employee']._fields:
self.skipTest('fusion_clock not installed — redirect intentionally inert')
internal = self.env['res.users'].create({
'name': 'Shop Hand',
'login': 'gating_shop_hand',
'password': 'gating_shop_hand',
'group_ids': [(6, 0, [self.env.ref('base.group_user').id])],
})
self.assertFalse(internal.share)
self.env['hr.employee'].create({'name': 'Shop Hand', 'user_id': internal.id})
self.authenticate('gating_shop_hand', 'gating_shop_hand')
# Don't follow the redirect — just assert we're bounced toward /my/clock.
r = self.url_open('/my/home', allow_redirects=False)
self.assertIn(r.status_code, (301, 302, 303, 307, 308))
self.assertIn('/my/clock', r.headers.get('Location', ''))

View File

@@ -57,17 +57,21 @@
content slot inside #wrap is preserved verbatim.
-->
<xpath expr="//div[@id='wrap']" position="replace">
<div class="o_fp_portal_shell">
<!-- Mobile hamburger (shown only below 768px via SCSS) -->
<button type="button"
class="o_fp_portal_hamburger d-md-none"
aria-label="Open navigation">
<i class="fa fa-bars"/>
</button>
<!-- Backdrop for mobile drawer (hidden by default) -->
<div class="o_fp_portal_backdrop"/>
<!-- Sidebar navigation component -->
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
<div t-attf-class="o_fp_portal_shell#{'' if (fp_show_customer_sidebar if fp_show_customer_sidebar is defined else True) else ' o_fp_portal_shell--no-sidebar'}">
<!-- Sidebar chrome only for customers (share users). Internal
staff get the clean employee experience with no sidebar. -->
<t t-if="fp_show_customer_sidebar if fp_show_customer_sidebar is defined else True">
<!-- Mobile hamburger (shown only below 768px via SCSS) -->
<button type="button"
class="o_fp_portal_hamburger d-md-none"
aria-label="Open navigation">
<i class="fa fa-bars"/>
</button>
<!-- Backdrop for mobile drawer (hidden by default) -->
<div class="o_fp_portal_backdrop"/>
<!-- Sidebar navigation component -->
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
</t>
<!-- Main content area — original #wrap re-emitted here via $0 -->
<main class="o_fp_portal_main">$0</main>
</div>