diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index b1ec1454..a5042c61 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -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', ], diff --git a/fusion_clock/controllers/clock_api.py b/fusion_clock/controllers/clock_api.py index fc2efe57..244af06a 100644 --- a/fusion_clock/controllers/clock_api.py +++ b/fusion_clock/controllers/clock_api.py @@ -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, ) diff --git a/fusion_clock/controllers/clock_kiosk.py b/fusion_clock/controllers/clock_kiosk.py index 55448d0d..3c19c04c 100644 --- a/fusion_clock/controllers/clock_kiosk.py +++ b/fusion_clock/controllers/clock_kiosk.py @@ -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', diff --git a/fusion_clock/controllers/clock_nfc_kiosk.py b/fusion_clock/controllers/clock_nfc_kiosk.py index 86731fad..40865e7e 100644 --- a/fusion_clock/controllers/clock_nfc_kiosk.py +++ b/fusion_clock/controllers/clock_nfc_kiosk.py @@ -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', diff --git a/fusion_clock/controllers/portal_clock.py b/fusion_clock/controllers/portal_clock.py index 6c22953e..5b7eb3d2 100644 --- a/fusion_clock/controllers/portal_clock.py +++ b/fusion_clock/controllers/portal_clock.py @@ -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/', 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//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), + ]) diff --git a/fusion_clock/controllers/shift_planner.py b/fusion_clock/controllers/shift_planner.py index e10f2505..6f3f6a3b 100644 --- a/fusion_clock/controllers/shift_planner.py +++ b/fusion_clock/controllers/shift_planner.py @@ -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(): diff --git a/fusion_clock/migrations/19.0.3.12.1/post-migrate.py b/fusion_clock/migrations/19.0.3.12.1/post-migrate.py new file mode 100644 index 00000000..a7a4b468 --- /dev/null +++ b/fusion_clock/migrations/19.0.3.12.1/post-migrate.py @@ -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' + """) diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py index 0c021653..e734cf6f 100644 --- a/fusion_clock/models/clock_schedule.py +++ b/fusion_clock/models/clock_schedule.py @@ -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 {employee.name}, your shifts for ' + f'{week_start.strftime("%b %d")} - {week_end.strftime("%b %d, %Y")} ' + f'have been posted.' + ), + sections=[('This Week', rows)], + note='Log in to your portal for details.', + ) + 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' diff --git a/fusion_clock/models/clock_shift.py b/fusion_clock/models/clock_shift.py index f788aeed..c86ad164 100644 --- a/fusion_clock/models/clock_shift.py +++ b/fusion_clock/models/clock_shift.py @@ -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).""" diff --git a/fusion_clock/models/hr_attendance.py b/fusion_clock/models/hr_attendance.py index 0f953cbd..9a551b67 100644 --- a/fusion_clock/models/hr_attendance.py +++ b/fusion_clock/models/hr_attendance.py @@ -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): diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py index fc808c9a..0b55524f 100644 --- a/fusion_clock/models/hr_employee.py +++ b/fusion_clock/models/hr_employee.py @@ -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): diff --git a/fusion_clock/models/res_config_settings.py b/fusion_clock/models/res_config_settings.py index df6b9fb5..308063f5 100644 --- a/fusion_clock/models/res_config_settings.py +++ b/fusion_clock/models/res_config_settings.py @@ -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', diff --git a/fusion_clock/static/src/css/portal_clock.css b/fusion_clock/static/src/css/portal_clock.css index dc4c377f..029e8290 100644 --- a/fusion_clock/static/src/css/portal_clock.css +++ b/fusion_clock/static/src/css/portal_clock.css @@ -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; +} diff --git a/fusion_clock/static/src/js/fusion_clock_shift_planner.js b/fusion_clock/static/src/js/fusion_clock_shift_planner.js index dae74005..f725dbe6 100644 --- a/fusion_clock/static/src/js/fusion_clock_shift_planner.js +++ b/fusion_clock/static/src/js/fusion_clock_shift_planner.js @@ -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; diff --git a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml index 1e34684b..e3a96537 100644 --- a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml +++ b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml @@ -28,6 +28,10 @@ Save () + @@ -100,7 +104,7 @@ - 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.") diff --git a/fusion_clock/tests/test_shift_planner.py b/fusion_clock/tests/test_shift_planner.py index 1a86aaaf..60b1299e 100644 --- a/fusion_clock/tests/test_shift_planner.py +++ b/fusion_clock/tests/test_shift_planner.py @@ -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) diff --git a/fusion_clock/views/clock_schedule_views.xml b/fusion_clock/views/clock_schedule_views.xml index f75da224..585c3ccd 100644 --- a/fusion_clock/views/clock_schedule_views.xml +++ b/fusion_clock/views/clock_schedule_views.xml @@ -20,6 +20,7 @@ + @@ -47,6 +48,8 @@ + + @@ -65,6 +68,9 @@ + + + diff --git a/fusion_clock/views/clock_shift_views.xml b/fusion_clock/views/clock_shift_views.xml index aadab6e8..2e86984f 100644 --- a/fusion_clock/views/clock_shift_views.xml +++ b/fusion_clock/views/clock_shift_views.xml @@ -39,6 +39,15 @@ + + + + + + + + + diff --git a/fusion_clock/views/portal_clock_templates.xml b/fusion_clock/views/portal_clock_templates.xml index 08389391..521bb7ca 100644 --- a/fusion_clock/views/portal_clock_templates.xml +++ b/fusion_clock/views/portal_clock_templates.xml @@ -95,6 +95,13 @@
+ + + + + + +

Hello,

@@ -305,6 +312,15 @@ Reports + + + + + + + Payslips + + diff --git a/fusion_clock/views/portal_payslip_templates.xml b/fusion_clock/views/portal_payslip_templates.xml new file mode 100644 index 00000000..02807d5b --- /dev/null +++ b/fusion_clock/views/portal_payslip_templates.xml @@ -0,0 +1,191 @@ + + + + + + + + + + diff --git a/fusion_clock/views/portal_report_templates.xml b/fusion_clock/views/portal_report_templates.xml index 6f82aa92..e8e5063c 100644 --- a/fusion_clock/views/portal_report_templates.xml +++ b/fusion_clock/views/portal_report_templates.xml @@ -86,6 +86,15 @@ Reports + + + + + + + Payslips + + diff --git a/fusion_clock/views/portal_timesheet_templates.xml b/fusion_clock/views/portal_timesheet_templates.xml index 3f17deaf..8bd142af 100644 --- a/fusion_clock/views/portal_timesheet_templates.xml +++ b/fusion_clock/views/portal_timesheet_templates.xml @@ -146,6 +146,15 @@ Reports + + + + + + + Payslips + + diff --git a/fusion_plating/docs/superpowers/plans/2026-05-30-employee-portal-clock-payslips.md b/fusion_plating/docs/superpowers/plans/2026-05-30-employee-portal-clock-payslips.md new file mode 100644 index 00000000..f21dde4a --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-30-employee-portal-clock-payslips.md @@ -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 `