Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-30-employee-portal-clock-payslips.md
2026-05-30 21:30:21 -04:00

36 KiB

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

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.scsso_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.pyshow_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:

        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:

        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:

    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:

        <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:

// 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.119.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:

# -*- 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):

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
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):

    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):

            '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:

    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:

    # =========================================================================
    # 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
    @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)
    @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
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 version="1.0" encoding="utf-8"?>
<odoo>

    <!-- Payslip List -->
    <template id="portal_payslip_list_page" name="Fusion Clock Payslips">
        <t t-call="portal.portal_layout">
            <t t-set="breadcrumbs_searchbar" t-value="False"/>
            <t t-set="no_breadcrumbs" t-value="True"/>
            <t t-set="no_header" t-value="True"/>

            <div class="fclk-app">
                <div class="fclk-reports-container">
                    <div class="fclk-ts-header" style="margin-bottom:24px;">
                        <h2>Payslips</h2>
                    </div>

                    <t t-if="payslips">
                        <t t-foreach="payslips" t-as="payslip">
                            <a t-attf-href="/my/clock/payslips/#{payslip.id}"
                               class="fclk-report-item fclk-payslip-item">
                                <div class="fclk-report-info">
                                    <h4>
                                        <t t-esc="payslip.date_from.strftime('%b %d')"/> -
                                        <t t-esc="payslip.date_to.strftime('%b %d, %Y')"/>
                                    </h4>
                                    <p>
                                        Net
                                        <span t-field="payslip.net_wage"
                                              t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                                    </p>
                                </div>
                                <span t-attf-class="fclk-payslip-status fclk-payslip-status--#{payslip.state}">
                                    <t t-if="payslip.state == 'paid'">Paid</t>
                                    <t t-else="">Done</t>
                                </span>
                            </a>
                        </t>
                    </t>
                    <t t-else="">
                        <div class="fclk-empty-state">
                            <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="1.5">
                                <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
                                <polyline points="14 2 14 8 20 8"/>
                            </svg>
                            <p>No payslips available yet.</p>
                            <p style="font-size:12px; margin-top:4px;">Finalized pay slips will appear here after each pay run.</p>
                        </div>
                    </t>

                    <t t-call="fusion_clock.portal_employee_navbar">
                        <t t-set="active" t-value="'payslips'"/>
                    </t>
                </div>
            </div>
        </t>
    </template>

    <!-- Payslip Detail — inline paystub -->
    <template id="portal_payslip_detail_page" name="Fusion Clock Payslip Detail">
        <t t-call="portal.portal_layout">
            <t t-set="breadcrumbs_searchbar" t-value="False"/>
            <t t-set="no_breadcrumbs" t-value="True"/>
            <t t-set="no_header" t-value="True"/>

            <div class="fclk-app">
                <div class="fclk-reports-container">
                    <div class="fclk-ts-header fclk-payslip-detail-header" style="margin-bottom:16px;">
                        <a href="/my/clock/payslips" class="fclk-payslip-back">&#8592; Payslips</a>
                        <h2>
                            <t t-esc="payslip.date_from.strftime('%b %d')"/> -
                            <t t-esc="payslip.date_to.strftime('%b %d, %Y')"/>
                        </h2>
                    </div>

                    <!-- Net pay highlight -->
                    <div class="fclk-status-card fclk-payslip-net">
                        <span class="fclk-payslip-net-label">Net Pay</span>
                        <span class="fclk-payslip-net-value"
                              t-field="payslip.net_wage"
                              t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                    </div>

                    <!-- Deductions -->
                    <div class="fclk-status-card fclk-payslip-section">
                        <h4 class="fclk-payslip-section-title">Deductions</h4>
                        <div class="fclk-payslip-row">
                            <span>CPP</span>
                            <span t-field="payslip.employee_cpp" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                        </div>
                        <div class="fclk-payslip-row" t-if="payslip.employee_cpp2">
                            <span>CPP2</span>
                            <span t-field="payslip.employee_cpp2" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                        </div>
                        <div class="fclk-payslip-row">
                            <span>EI</span>
                            <span t-field="payslip.employee_ei" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                        </div>
                        <div class="fclk-payslip-row">
                            <span>Income Tax</span>
                            <span t-field="payslip.employee_income_tax" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                        </div>
                        <div class="fclk-payslip-row fclk-payslip-row--total">
                            <span>Total Deductions</span>
                            <span t-field="payslip.total_employee_deductions" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                        </div>
                    </div>

                    <!-- Year to date -->
                    <div class="fclk-status-card fclk-payslip-section">
                        <h4 class="fclk-payslip-section-title">Year to Date</h4>
                        <div class="fclk-payslip-row">
                            <span>Gross</span>
                            <span t-field="payslip.ytd_gross" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                        </div>
                        <div class="fclk-payslip-row">
                            <span>CPP</span>
                            <span t-field="payslip.ytd_cpp" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                        </div>
                        <div class="fclk-payslip-row">
                            <span>EI</span>
                            <span t-field="payslip.ytd_ei" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                        </div>
                        <div class="fclk-payslip-row">
                            <span>Income Tax</span>
                            <span t-field="payslip.ytd_income_tax" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                        </div>
                        <div class="fclk-payslip-row fclk-payslip-row--total">
                            <span>Net</span>
                            <span t-field="payslip.ytd_net" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
                        </div>
                    </div>

                    <t t-if="has_pdf">
                        <a t-attf-href="/my/clock/payslips/#{payslip.id}/pdf"
                           class="fclk-payslip-pdf-btn">
                            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle; margin-right:6px;">
                                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
                                <polyline points="7 10 12 15 17 10"/>
                                <line x1="12" y1="15" x2="12" y2="3"/>
                            </svg>
                            Download PDF
                        </a>
                    </t>

                    <t t-call="fusion_clock.portal_employee_navbar">
                        <t t-set="active" t-value="'payslips'"/>
                    </t>
                </div>
            </div>
        </t>
    </template>

    <!-- Shared employee bottom nav (Clock / Timesheets / Reports / Payslips).
         `active` = clock|timesheets|reports|payslips. Payslips tab shows only
         when show_payslips is truthy. -->
    <template id="portal_employee_navbar" name="Fusion Clock Employee Navbar">
        <div class="fclk-nav-bar">
            <a href="/my/clock" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'clock' else ''}">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <circle cx="12" cy="12" r="10"/>
                    <polyline points="12 6 12 12 16 14"/>
                </svg>
                <span>Clock</span>
            </a>
            <a href="/my/clock/timesheets" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'timesheets' else ''}">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
                    <line x1="16" y1="2" x2="16" y2="6"/>
                    <line x1="8" y1="2" x2="8" y2="6"/>
                    <line x1="3" y1="10" x2="21" y2="10"/>
                </svg>
                <span>Timesheets</span>
            </a>
            <a href="/my/clock/reports" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'reports' else ''}">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
                    <polyline points="14 2 14 8 20 8"/>
                    <line x1="16" y1="13" x2="8" y2="13"/>
                    <line x1="16" y1="17" x2="8" y2="17"/>
                </svg>
                <span>Reports</span>
            </a>
            <t t-if="show_payslips">
                <a href="/my/clock/payslips" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'payslips' else ''}">
                    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                        <rect x="2" y="5" width="20" height="14" rx="2"/>
                        <line x1="2" y1="10" x2="22" y2="10"/>
                    </svg>
                    <span>Payslips</span>
                </a>
            </t>
        </div>
    </template>

</odoo>
  • Step 2: Register the file in the manifest

In fusion_clock/__manifest__.py, add to the data list near the other views/portal_* entries:

        'views/portal_payslip_templates.xml',

Also bump version (e.g. 19.0.3.11.819.0.3.12.0).

  • Step 3: Commit
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):

                    <t t-call="fusion_clock.portal_employee_navbar">
                        <t t-set="active" t-value="'clock'"/>
                    </t>

portal_timesheet_templates.xml:

                    <t t-call="fusion_clock.portal_employee_navbar">
                        <t t-set="active" t-value="'timesheets'"/>
                    </t>

portal_report_templates.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:

                    <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):

                    <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
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):

/* ===== 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
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
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)
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.