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:
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
|
||||
@@ -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():
|
||||
|
||||
24
fusion_clock/migrations/19.0.3.12.1/post-migrate.py
Normal file
24
fusion_clock/migrations/19.0.3.12.1/post-migrate.py
Normal 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'
|
||||
""")
|
||||
@@ -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'
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
170
fusion_clock/tests/test_schedule_driven.py
Normal file
170
fusion_clock/tests/test_schedule_driven.py
Normal 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.")
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
191
fusion_clock/views/portal_payslip_templates.xml
Normal file
191
fusion_clock/views/portal_payslip_templates.xml
Normal 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">← 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">← 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.
|
||||
@@ -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/%';
|
||||
```
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from . import test_portal_dashboard
|
||||
from . import test_employee_portal_gating
|
||||
|
||||
@@ -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', ''))
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user