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
CustomerPortalsubclass into one MRO, soFpCustomerPortal._prepare_portal_layout_valuesruns on the clock pages too — the gating flag reaches them. - On entech,
FpCustomerPortal.home()is the active/my/homehandler (that's why employees see the customer dashboard today). Editing it is the reliable fix. .o_fp_portal_shellis a CSS grid240px 1fr; hiding only the<aside>leaves a 240px gutter, so the grid must collapse to1frwhen there's no sidebar..fclk-appalready hides Odoo's.o_portal_navbar+ footer via CSS; only the FP sidebar leaks onto/my/clock..fclk-nav-barisdisplay: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— addfp_show_customer_sidebarflag; redirect internal-with-employee users inhome(). - 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-sidebarrule. - 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_payslipsflag 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 onshow_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.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:
# -*- 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_payslipsflag 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.numberis the standard slip reference onhr.payslip; if absent on this install, theor payslip.idfallback 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">← 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.8 → 19.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_payslipsfrom 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 ofportal_clock.cssfor 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.payslipfields 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
/myredirects 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/homestill shows the dashboard with the sidebar; all customer pages unchanged.
- Log in as an internal employee → landing on
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.payslipstate filter uses the confirmed('done','paid'); the risky double-$0was 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; flagfp_show_customer_sidebar; context keyshow_payslips; navactivevaluesclock|timesheets|reports|payslips— consistent across tasks.