docs(employee-portal): design spec for staff Clock + Payslips portal

Separate internal employees from the customer portal: suppress the
fusion_plating_portal sidebar for internal users, redirect them to the
clock page, and add a finalized-payslip view (inline paystub + optional
PDF) under /my/clock/payslips in fusion_clock.

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

View File

@@ -0,0 +1,190 @@
# Employee Portal — Clock + Payslips (separating staff from the customer portal)
**Date:** 2026-05-30
**Status:** Design approved — ready for implementation plan
**Deployment target:** entech (LXC 111 on pve-worker5, DB `admin`)
**Modules touched:** `fusion_plating_portal`, `fusion_clock` (+ optional `fusion_payroll` if a payslip PDF must be built)
---
## 1. Context & Problem
EN Technologies (entech) runs three relevant modules on one Odoo:
- `fusion_plating_portal` — the **customer** portal: a rich dashboard + a left sidebar shell wrapping every `/my/*` page.
- `fusion_clock` — employee clock-in/out, exposed on the front end at `/my/clock` (+ `/my/clock/timesheets`, `/my/clock/reports`). A polished dark, mobile-first UI with its own bottom nav.
- `fusion_payroll` — Canadian payroll; owns `hr.payslip` with full earnings/deductions/YTD data. **No employee self-service surface today** (payslips are backend-only).
Two concrete problems:
1. **The customer portal applies to everyone.** `fusion_plating_portal`'s `home()` override (`/my`, `/my/home`) returns the customer dashboard for *every* logged-in user, with no internal-vs-customer check. Internal employees who land on `/my` see a customer dashboard that is empty/irrelevant to them.
2. **The customer sidebar bleeds onto the clock page.** `fp_portal_shell` (`fusion_plating_portal/views/fp_portal_shell.xml`) inherits `portal.portal_layout` and injects the left sidebar into **all** `/my/*` pages, unconditionally. Because `fusion_clock`'s `/my/clock` page renders inside `portal.portal_layout`, it inherits that customer sidebar even though the clock UI was designed clean (it sets `no_header`/`no_breadcrumbs` and has its own bottom nav).
**Goal:** internal employees get a dedicated, clean employee portal (Clock + Payslips, no customer sidebar); external customers keep the customer portal exactly as it is.
---
## 2. Goals / Non-goals
**Goals**
- Internal staff never see the customer dashboard or the customer sidebar.
- Employees land on the Clock page and navigate Clock / Timesheets / Reports / **Payslips** via the existing bottom nav.
- Employees can view their own finalized pay slips (inline paystub) and download a PDF when one is available.
- Zero change to the customer experience.
**Non-goals (v1)**
- Profile/password editing in the employee portal (internal staff use the backend).
- Payslips for non-finalized states (draft / verify hidden).
- RMA/quote/customer features for employees.
- Building a new payslip PDF report *unless* Odoo's standard one is absent on entech (see §9 Open items).
- Cross-instance payroll (payslips are confirmed to live on the same Odoo).
---
## 3. Decisions (locked during brainstorming)
| # | Decision |
|---|----------|
| Audience split | **Internal users → employee portal; share/portal users → customer portal.** Detected via `request.env.user.share` (`False` = internal staff, `True` = customer). |
| Employee landing & nav | **Clock is home.** `/my` and `/my/home` redirect employees to `/my/clock`. The existing bottom nav gains a **Payslips** tab. **No left sidebar anywhere** for employees. |
| Payslip scope | **Finalized only**`hr.payslip` where `state in ('done','paid')`, scoped to the logged-in employee. From `fusion_payroll` on the same Odoo. |
| Payslip presentation | **Inline paystub page + Download-PDF button.** Inline always works; the PDF button appears only when a payslip PDF report exists on the server. |
| Architecture | **Approach 1**`fusion_clock` owns the employee-portal pages (incl. payslips, via a *soft* `hr.payslip` read); `fusion_plating_portal` fixes its own gating + redirect. No new module, no hard cross-dependency. |
| Sign out | A compact **Sign Out** affordance in the employee header (the bottom-nav UI lacks one today). |
---
## 4. Architecture — Approach 1
Two modules change; responsibilities stay where they naturally belong.
**`fusion_plating_portal`** (the module causing the bleed — fixes itself):
- Adds `fp_show_customer_sidebar` to the portal layout context = `request.env.user.share`.
- Gates its sidebar shell on that flag.
- Branches `home()`: internal user → redirect to `/my/clock`; customer → existing dashboard.
**`fusion_clock`** (owns all employee-portal pages + the bottom nav + the dark SCSS):
- Adds `/my/clock/payslips` (list) and `/my/clock/payslips/<id>` (inline paystub) routes.
- Reads `hr.payslip` through a **soft** check (`'hr.payslip' in request.env`) — **no** `fusion_payroll` dependency added, so `fusion_clock` stays installable without payroll (the Payslips tab simply doesn't appear).
- Adds the **Payslips** tab to the existing bottom nav.
**Why not the alternatives:** payslip page in `fusion_payroll` makes the bottom nav shared chrome across two modules (duplication or a new cross-dep); a dedicated `fusion_employee_portal` module is new scaffolding that would have to couple to the entech-specific customer module to win the `/my/home` override — and it conflicts with the "edit existing files, don't add modules" rule.
> **Why the redirect lives in `fusion_plating_portal.home()`:** that override is the one currently winning at `/my/home` on entech (it's why employees see the customer dashboard today). Editing it to branch is guaranteed to take effect on entech without depending on fragile multi-module MRO ordering (`fusion_clock` does **not** override `home()`).
---
## 5. Detailed design
### 5.1 Audience detection
Single signal: `request.env.user.share`.
- `share == True` → customer → customer portal (dashboard + sidebar).
- `share == False` → internal staff → employee portal (clock + payslips, no sidebar).
The clock pages already resolve the person via `hr.employee` where `user_id == env.user.id` (`_get_portal_employee`). An internal user with no employee record (e.g. a bare admin) gets the clean layout, an empty clock that redirects, and retains full backend access.
### 5.2 Suppress the sidebar for employees
In `fusion_plating_portal/controllers/portal.py`, `_prepare_portal_layout_values` adds:
```python
values['fp_show_customer_sidebar'] = request.env.user.share
```
In `fp_portal_shell.xml`, the `#wrap` replacement becomes conditional:
- `fp_show_customer_sidebar` true → render the full `.o_fp_portal_shell` (sidebar + `<main>$0</main>`).
- false → re-emit the raw original `#wrap` ($0) with no shell.
**Implementation note / risk:** the shell uses Odoo 19's `$0` re-emission inside a `position="replace"`. Putting `$0` in **both** a `t-if` and a `t-else` branch needs verifying — the inheritance engine appends a deep copy of the original node for *each* `$0` occurrence; both copies live in the compiled arch but only one renders at runtime (t-if/t-else). If that proves unreliable at load time, **fallback:** keep a single `$0` inside `.o_fp_portal_main` always, gate only the `<aside>` with `t-if`, and add a body/`<main>` modifier class so the main column goes full-width (and the clock UI renders edge-to-edge) when the sidebar is absent. Either way the customer path is byte-for-byte unchanged.
### 5.3 Redirect employees to Clock
`fusion_plating_portal.home()` (routes `['/my','/my/home']`), first lines:
```python
if not request.env.user.share: # internal staff
return request.redirect('/my/clock')
```
Customers fall through to the existing dashboard build untouched.
### 5.4 Bottom nav gains Payslips
The nav bar lives in three `fusion_clock` templates (`portal_clock_templates.xml`, `portal_timesheet_templates.xml`, `portal_report_templates.xml`) plus the new payslip templates. Add a 4th item **Payslips** (`/my/clock/payslips`) with an appropriate icon, marking the active tab per page. The tab is gated on a `show_payslips` flag (true only when `'hr.payslip' in env`) so `fusion_clock` stays clean on payroll-less deployments.
### 5.5 Payslips list — `GET /my/clock/payslips`
- Resolve employee via `_get_portal_employee()`; if none → redirect `/my/clock`.
- If `'hr.payslip' not in request.env` → redirect `/my/clock` (and the tab won't show anyway).
- Query: `hr.payslip` `sudo()` where `employee_id == employee.id` and `state in ('done','paid')`, ordered `date_to desc`.
- Card list (same dark styling as Reports): pay period (`date_from``date_to`), net pay (`net_wage`, `$` + currency), status chip (Paid / Done), link to detail.
- `page_name='payslips'` for nav active-state.
### 5.6 Payslip detail (inline paystub) — `GET /my/clock/payslips/<int:payslip_id>`
- **Ownership guard** (mirrors `portal_report_download`): browse the slip; if it doesn't exist, isn't `state in ('done','paid')`, or `employee_id != employee.id` → redirect `/my/clock/payslips`.
- Render a mobile-friendly paystub from data already on the record:
- Header: employee name, pay period, pay date, status.
- Earnings: gross / line items (`line_ids` totals).
- Deductions: `employee_cpp`, `employee_cpp2`, `employee_ei`, `employee_income_tax` (+ total).
- **Net pay** (highlighted).
- YTD block: `ytd_gross`, `ytd_cpp`, `ytd_cpp2`, `ytd_ei`, `ytd_income_tax`, `ytd_net`.
- **Download PDF** button: visible only when a payslip PDF report action exists on the server (see §9). Links to that report for this `payslip_id`. Routed through `fusion_pdf_preview` where applicable per repo convention (PDF → preview dialog), otherwise standard `/report/pdf/...`.
### 5.7 Sign out
Add a compact **Sign Out** control to the employee header area (e.g. a small icon next to the "Hello, {name}" greeting on the clock page, and consistently on the payslip pages) → `/web/session/logout?redirect=/`.
---
## 6. Files touched
**`fusion_plating_portal`**
- `controllers/portal.py``_prepare_portal_layout_values` (+`fp_show_customer_sidebar`), `home()` (employee redirect).
- `views/fp_portal_shell.xml` — gate the shell on `fp_show_customer_sidebar`.
- `__manifest__.py` — version bump.
**`fusion_clock`**
- `controllers/portal_clock.py``/my/clock/payslips` + `/my/clock/payslips/<id>` routes; `show_payslips` flag in existing page values.
- `views/portal_clock_templates.xml`, `views/portal_timesheet_templates.xml`, `views/portal_report_templates.xml` — add Payslips nav item.
- `views/portal_payslip_templates.xml`**new**: list + inline paystub.
- `static/src/scss/...` — payslip card/paystub styles (reuse `fclk-*` design language) + Sign Out control.
- `__manifest__.py` — register new view file; version bump.
**`fusion_payroll`** — only if §9 finds no payslip PDF report and we decide to build a minimal paystub report (separate, optional task).
---
## 7. Edge cases & guards
- Internal user, no `hr.employee` → clean layout, clock redirects to `/my`, backend still reachable.
- `fusion_payroll` not installed → Payslips tab hidden; routes redirect to `/my/clock`.
- Payslip not owned / not finalized → redirect to the list (no leak).
- Admin needs to preview the *customer* portal → use a customer test login (documented), since internal users are redirected.
- Customer experience: unchanged — sidebar + dashboard render exactly as before when `share == True`.
---
## 8. Testing
- **Internal user:** `/my` and `/my/home` → 302 to `/my/clock`; `/my/clock`, `/my/clock/timesheets`, `/my/clock/reports`, `/my/clock/payslips` render with **no** `.o_fp_portal_sidebar` in the DOM.
- **Customer (share) user:** `/my/home` → customer dashboard **with** sidebar; all existing customer pages unchanged.
- **Payslips list:** shows only the logged-in employee's `done`/`paid` slips; another employee's slips never appear.
- **Payslip detail:** ownership guard blocks a slip belonging to someone else / a draft slip (redirect).
- **PDF button:** present only when the payslip report action exists; downloads the right slip.
- **Payroll absent:** Payslips tab hidden; `/my/clock/payslips` redirects cleanly.
Use ephemeral test ports per repo rule:
```bash
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable \
--test-tags /fusion_clock,/fusion_plating_portal \
-u fusion_clock,fusion_plating_portal --stop-after-init \
--http-port=0 --gevent-port=0 2>&1 | tail -60
```
---
## 9. Open items (verify during implementation)
1. **Payslip PDF report on entech.** Confirm whether Odoo's repackaged `hr_payroll` ships a per-employee payslip report (`hr_payroll.action_report_payslip` / `report_payslip[_lang]`) on entech. If present → wire the Download-PDF button to it. If absent → keep inline-only for v1 and log a follow-up to build a minimal paystub report in `fusion_payroll`.
2. **Exact `hr.payslip.state` values.** Confirm the selection (`draft`/`verify`/`done`/`paid`/`cancel`) on the installed payroll so the `('done','paid')` filter and the status chip labels are exact.
3. **`$0`-in-two-branches** load-time behaviour (see §5.2) — adopt the fallback if the conditional double-`$0` doesn't compile cleanly.
---
## 10. Deployment (entech)
Bump versions, then update both modules and bust the asset cache (SCSS/JS changed):
```bash
# update modules
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_clock,fusion_plating_portal --stop-after-init\" && systemctl start odoo'"
# asset cache bust (if needed)
# DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';
```