docs(employee-portal): implementation plan (5 build tasks + entech smoke)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-30 21:30:21 -04:00
parent 4a9f31cef5
commit 47a6523e24

View File

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