Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md
gsinghpal 0593b70354 docs(portal): session handoff + sub-A IA spec + plan
Captures everything the next Claude session needs to pick up cold:
  - Live module versions on entech (portal 19.0.3.7.0, jobs/reports
    versions, all 5 tests green)
  - What shipped this session (24+ commits, summarised by area)
  - Sub-A (IA + sidebar) brainstorm decisions locked, spec written,
    plan ready to execute (11 tasks, 4 phases)
  - What's deferred (sub-B multi-user, sub-C search, drafts, real
    statements, RMA portal, top-recurring-parts) and WHY — so next
    session doesn't re-litigate
  - Gotchas hit + fixed this session that aren't obvious from code
  - Deploy recipe (file copy + module upgrade + cache bust) used 20+
    times this session

CLAUDE.md's Recent Session Handoff section now points to the new
handoff doc; the previous handoff is kept as 'superseded but kept
for context' below it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:21:21 -04:00

1416 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Customer Portal IA + Sidebar — 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:** Wrap every `/my/*` portal page in a sticky 240px left sidebar (grouped Dashboard → Activity → Documents → Account), replace 3 thin custom pages with Odoo defaults or new equivalents, and ship a new `/my/account_summary` page with 3 tabs (Invoices · Credit Memos · Statements) + Open Balance pill.
**Architecture:** New `fp_portal_shell` template inherits `portal.portal_layout` and injects the sidebar around every existing portal body — zero per-template edits for the chrome change. Sidebar data structure lives in one Python helper and feeds the template via `_prepare_portal_layout_values()`. New Account Summary page is a single controller + template, modeled on the existing Odoo `/my/invoices` portal pattern.
**Tech Stack:** Odoo 19 (Python + QWeb XML + SCSS), vanilla JS for mobile hamburger toggle, no JS framework. Deployment via SSH to entech LXC 111 (native Odoo, db `admin`).
**Spec:** [`docs/superpowers/specs/2026-05-17-portal-ia-sidebar-design.md`](../specs/2026-05-17-portal-ia-sidebar-design.md)
---
## File Inventory
**NEW files:**
- `fusion_plating_portal/views/fp_portal_shell.xml``portal.portal_layout` inherit + sidebar markup
- `fusion_plating_portal/views/fp_portal_account_summary.xml``portal_my_account_summary` template
- `fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss` — sidebar layout, sticky, active state, sections, mobile drawer
- `fusion_plating_portal/static/src/js/fp_portal_sidebar.js` — hamburger toggle (vanilla JS, ~20 lines)
**MODIFY files:**
- `fusion_plating_portal/controllers/portal.py` — add `portal_account_summary` route, 3 redirect routes, `_fp_sidebar_items()` helper, extend `_prepare_portal_layout_values()`
- `fusion_plating_portal/views/fp_portal_templates.xml` — delete the `portal_my_fp_invoices` template body (route is redirected)
- `fusion_plating_portal/tests/test_portal_dashboard.py` — add Account Summary tests (Open Balance, tab partitioning, filter, search)
- `fusion_plating_portal/__manifest__.py` — version bump 19.0.3.7.0 → 19.0.4.0.0 (sidebar is a significant chrome change, minor bump), register new XML + SCSS + JS files
**Decisions baked in:**
- **Statements tab in V1 is a placeholder** ("Monthly statements coming soon — contact your sales rep for a copy"). Real statement generation (account.followup integration OR cron-precomputed PDFs) is its own follow-up — spec §Open Items §2/§5.
- Sidebar item active-state matched by `page_name` (FP routes) OR URL-prefix (Odoo defaults), one helper.
---
# PHASE 1 — Sidebar Shell
Goal: every `/my/*` page renders inside the new sidebar wrap. No page bodies change. Empty Account Summary placeholder; redirects + tabbed view come in Phases 2 and 3.
### Task 1: Create `fp_portal_sidebar.scss`
**Files:**
- Create: `fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss`
- [ ] **Step 1: Write the sidebar SCSS**
Create the file with this content:
```scss
// ============================================================================
// Fusion Plating — Portal · Sidebar shell
// Sticky 240px left rail wrapping every /my/* page. Grouped sections
// (Dashboard / ACTIVITY / DOCUMENTS / ACCOUNT). Active page = mint
// gradient fill + brand teal left bar. Below 768px collapses to a
// hamburger drawer with backdrop.
// ============================================================================
.o_fp_portal_shell {
display: grid;
grid-template-columns: 240px 1fr;
gap: $fp-space-5;
align-items: start;
background: $fp-page-bg;
min-height: calc(100vh - 80px);
padding: $fp-space-4;
@media (max-width: 768px) {
grid-template-columns: 1fr;
gap: 0;
padding: $fp-space-3;
}
}
.o_fp_portal_sidebar {
position: sticky;
top: $fp-space-4;
background: $fp-card-bg;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-card;
padding: .85rem .5rem;
box-shadow: $fp-shadow-card;
font-family: $fp-font;
align-self: start;
.o_fp_sidebar_header {
padding: .45rem .9rem .7rem;
font-size: .62rem;
color: $fp-muted;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
border-bottom: 1px solid $fp-section-bg;
}
.o_fp_sidebar_section_label {
padding: .85rem .9rem .25rem;
font-size: .62rem;
color: $fp-muted-light;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.o_fp_sidebar_item {
display: flex;
align-items: center;
gap: .55rem;
padding: .5rem .9rem;
margin: .05rem .15rem;
color: $fp-text-body;
font-size: .85rem;
text-decoration: none;
border-radius: 6px;
border-left: 3px solid transparent;
transition: background .12s ease, color .12s ease;
&:hover {
background: $fp-section-bg;
color: $fp-teal-dark;
text-decoration: none;
}
&.o_fp_sidebar_active {
background: linear-gradient(90deg, $fp-mint 0%, $fp-mint-pastel 100%);
color: $fp-teal-dark;
font-weight: 600;
border-left: 3px solid $fp-teal;
}
.o_fp_sidebar_icon {
width: 1.15rem;
text-align: center;
flex-shrink: 0;
}
}
.o_fp_sidebar_footer {
border-top: 1px solid $fp-section-bg;
margin: .7rem .15rem 0;
padding-top: .5rem;
}
// Mobile: slide-in drawer
@media (max-width: 768px) {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px;
z-index: 1040;
transform: translateX(-100%);
transition: transform .2s ease;
border-radius: 0;
border-top: none;
border-bottom: none;
border-left: none;
margin: 0;
&.o_fp_open {
transform: translateX(0);
}
}
}
// Mobile hamburger button (above main content, hidden on desktop)
.o_fp_portal_hamburger {
display: none;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
background: $fp-card-bg;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-button;
color: $fp-teal;
margin-bottom: $fp-space-3;
cursor: pointer;
transition: background .12s ease;
&:hover { background: $fp-section-bg; }
@media (max-width: 768px) {
display: inline-flex;
}
}
// Backdrop behind the open mobile drawer
.o_fp_portal_backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 30, 30, .35);
z-index: 1030;
&.o_fp_open {
display: block;
}
}
```
- [ ] **Step 2: Verify file written**
Run: `ls -la K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss`
Expected: file exists, non-zero size.
- [ ] **Step 3: Commit**
```bash
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss && \
git commit -m "feat(portal): sidebar shell SCSS — sticky 240px rail + mobile drawer
Grouped sections via .o_fp_sidebar_section_label, active item gets
mint gradient fill + brand-teal left bar. Below 768px the sidebar
collapses to a fixed slide-in drawer (.o_fp_open class), with
.o_fp_portal_hamburger button + .o_fp_portal_backdrop as siblings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Create `fp_portal_sidebar.js`
**Files:**
- Create: `fusion_plating_portal/static/src/js/fp_portal_sidebar.js`
- [ ] **Step 1: Write the hamburger JS**
```javascript
/**
* Fusion Plating — Portal sidebar hamburger toggle.
* Vanilla JS — no OWL / no jQuery. Loaded on every /my/* page.
* Below 768px the sidebar is translateX(-100%); toggling
* .o_fp_open on both sidebar + backdrop shows/hides it.
*/
(function () {
"use strict";
function init() {
var sidebar = document.querySelector(".o_fp_portal_sidebar");
var hamburger = document.querySelector(".o_fp_portal_hamburger");
var backdrop = document.querySelector(".o_fp_portal_backdrop");
if (!sidebar || !hamburger || !backdrop) {
return; // sidebar not on this page (logged-out, error pages, etc.)
}
function toggleOpen(force) {
var willOpen = (typeof force === "boolean")
? force
: !sidebar.classList.contains("o_fp_open");
sidebar.classList.toggle("o_fp_open", willOpen);
backdrop.classList.toggle("o_fp_open", willOpen);
}
hamburger.addEventListener("click", function (e) {
e.preventDefault();
toggleOpen();
});
backdrop.addEventListener("click", function () {
toggleOpen(false);
});
// Close when navigating to a sidebar link on mobile
sidebar.querySelectorAll("a.o_fp_sidebar_item").forEach(function (a) {
a.addEventListener("click", function () {
if (window.innerWidth < 769) {
toggleOpen(false);
}
});
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
```
- [ ] **Step 2: Commit**
```bash
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/js/fp_portal_sidebar.js && \
git commit -m "feat(portal): mobile sidebar hamburger toggle (vanilla JS)
20 lines, no framework. Toggles .o_fp_open on sidebar + backdrop.
Backdrop click closes drawer; navigating a sidebar link on mobile
auto-closes. No-ops gracefully when sidebar isn't on the page
(logged-out, 500 pages, etc.).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Create `fp_portal_shell.xml`
**Files:**
- Create: `fusion_plating_portal/views/fp_portal_shell.xml`
- [ ] **Step 1: Inspect Odoo's portal.portal_layout template**
Run on entech to see the exact wrapper structure:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'grep -A 40 \"id=.portal_layout.\" /usr/lib/python3/dist-packages/odoo/addons/portal/views/portal_templates.xml | head -50'"
```
You're looking for the element that wraps page content — typically `<div id="wrap" class="o_portal_wrap">` or similar. Note the exact xpath anchor.
- [ ] **Step 2: Write the shell template**
Create `fusion_plating_portal/views/fp_portal_shell.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Wraps every /my/* page (FP custom + Odoo default) in the new
sidebar shell. Inherit on portal.portal_layout so we don't have
to edit every individual page template.
-->
<odoo>
<!-- ================================================================== -->
<!-- Inherit portal.portal_layout to wrap content in sidebar shell -->
<!-- ================================================================== -->
<template id="fp_portal_shell"
name="FP Portal Shell — Sidebar Wrap"
inherit_id="portal.portal_layout"
priority="50">
<!-- Wrap the existing portal body in a grid (sidebar | content).
#wrap is Odoo's canonical content wrapper inside portal_layout.
We wrap its CHILDREN, not the wrap itself, so we keep Odoo's
outer styling intact. -->
<xpath expr="//div[@id='wrap']" position="inside">
<t t-if="False">
<!-- placeholder kept to make the xpath unique even if Odoo's
wrap renders nothing for the current page -->
</t>
</xpath>
<xpath expr="//div[@id='wrap']/*" position="before">
<div class="o_fp_portal_shell">
<!-- 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 -->
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
<!-- Main content slot — Odoo's existing portal page body
renders into this main element below via the natural
inherit order. We open the div here; the closing tag
is injected by the after-anchor below. -->
<main class="o_fp_portal_main">
</div>
</xpath>
<xpath expr="//div[@id='wrap']/*[last()]" position="after">
</main>
<!-- close .o_fp_portal_shell (opened above) -->
</xpath>
</template>
<!-- ================================================================== -->
<!-- Sidebar template — rendered by fp_portal_shell -->
<!-- ================================================================== -->
<template id="fp_portal_sidebar" name="FP Portal Sidebar">
<aside class="o_fp_portal_sidebar">
<!-- Partner display name header -->
<div class="o_fp_sidebar_header">
<t t-out="fp_partner_display_name or 'My Account'"/>
</div>
<!-- Items, walked from the Python-side data structure -->
<t t-foreach="fp_sidebar_items or []" t-as="entry">
<!-- Section labels render as headers -->
<t t-if="entry.get('type') == 'section_label'">
<div class="o_fp_sidebar_section_label" t-out="entry['label']"/>
</t>
<!-- Items render as anchor links -->
<t t-elif="entry.get('type') == 'item'">
<a t-att-href="entry['url']"
t-attf-class="o_fp_sidebar_item #{'o_fp_sidebar_active' if entry.get('active') else ''}">
<span class="o_fp_sidebar_icon" t-out="entry.get('icon') or '•'"/>
<span t-out="entry['label']"/>
</a>
</t>
</t>
<!-- Footer: sign out -->
<div class="o_fp_sidebar_footer">
<a href="/web/session/logout?redirect=/" class="o_fp_sidebar_item">
<span class="o_fp_sidebar_icon"></span>
<span>Sign Out</span>
</a>
</div>
</aside>
</template>
</odoo>
```
**Note:** The xpath approach in Step 2 above (wrapping `#wrap`'s children) is the cleanest in theory but Odoo's `portal.portal_layout` actual structure may vary by version. The Step 1 inspection result might force a different anchor. If `#wrap` isn't present, the fallback is to inherit at `portal.frontend_layout` or `website.layout` level and use a `t-call` to the existing portal body. **Update this task inline with the actual anchor before committing.**
- [ ] **Step 3: Commit**
```bash
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/views/fp_portal_shell.xml && \
git commit -m "feat(portal): sidebar shell template + portal.portal_layout inherit
fp_portal_shell wraps every /my/* page (FP custom + Odoo default)
in a sticky-sidebar shell with no per-template edits. Sidebar markup
is a separate fp_portal_sidebar template that reads fp_sidebar_items
+ fp_partner_display_name from the page context.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: Sidebar data helper + layout values inject
**Files:**
- Modify: `fusion_plating_portal/controllers/portal.py`
- [ ] **Step 1: Add `_fp_sidebar_items()` helper to `FpCustomerPortal`**
Find the `_fp_get_partner_domain` helper in `fusion_plating_portal/controllers/portal.py` (around line 111). Immediately after it, add:
```python
# ==========================================================================
# Sidebar — items + active-state resolution
# ==========================================================================
# Sidebar item structure: list of dicts with `type` = 'item' | 'section_label'.
# Items have label / url / icon / key. Key matches either a page_name set by
# an FP route OR a URL prefix for Odoo default pages.
_FP_SIDEBAR_LAYOUT = [
{'type': 'item', 'key': 'fp_dashboard', 'label': 'Dashboard', 'icon': '🏠', 'url': '/my/home'},
{'type': 'section_label', 'label': 'Activity'},
{'type': 'item', 'key': 'fp_quote_requests','label': 'Quote Requests', 'icon': '📄', 'url': '/my/quote_requests'},
{'type': 'item', 'key': 'fp_configurator', 'label': 'Get a Quote', 'icon': '+', 'url': '/my/configurator'},
{'type': 'item', 'key': 'odoo_orders', 'label': 'Purchase Orders', 'icon': '🛒', 'url': '/my/orders'},
{'type': 'item', 'key': 'fp_jobs', 'label': 'Work Orders', 'icon': '⚙️', 'url': '/my/jobs'},
{'type': 'section_label', 'label': 'Documents'},
{'type': 'item', 'key': 'fp_certifications','label': 'Certifications', 'icon': '📑', 'url': '/my/certifications'},
{'type': 'item', 'key': 'fp_deliveries', 'label': 'Packing Slips', 'icon': '📦', 'url': '/my/deliveries'},
{'type': 'item', 'key': 'fp_account_summary','label': 'Account Summary', 'icon': '💰', 'url': '/my/account_summary'},
{'type': 'section_label', 'label': 'Account'},
{'type': 'item', 'key': 'odoo_account', 'label': 'Profile', 'icon': '👤', 'url': '/my/account'},
]
# Map either a page_name (set by FP routes) OR a URL prefix
# (for Odoo defaults that don't set page_name) to a sidebar item key.
_FP_PAGE_NAME_TO_SIDEBAR_KEY = {
'fp_dashboard': 'fp_dashboard',
'fp_quote_requests': 'fp_quote_requests',
'fp_quote_request': 'fp_quote_requests',
'fp_configurator': 'fp_configurator',
'fp_jobs': 'fp_jobs',
'fp_portal_job': 'fp_jobs',
'fp_certifications': 'fp_certifications',
'fp_deliveries': 'fp_deliveries',
'fp_account_summary': 'fp_account_summary',
}
_FP_URL_PREFIX_TO_SIDEBAR_KEY = [
# Order matters — first match wins, so list longer prefixes first.
('/my/orders', 'odoo_orders'),
('/my/quotes', 'odoo_orders'), # /my/quotes is also sale_portal
('/my/invoices', 'fp_account_summary'),
('/my/account_summary', 'fp_account_summary'),
('/my/account', 'odoo_account'),
('/my/security', 'odoo_account'),
('/my/home', 'fp_dashboard'),
('/my', 'fp_dashboard'), # /my (no trailing) -> dashboard
]
def _fp_resolve_active_sidebar_key(self, url, page_name):
"""Resolve which sidebar item should be marked active for this request."""
if page_name and page_name in self._FP_PAGE_NAME_TO_SIDEBAR_KEY:
return self._FP_PAGE_NAME_TO_SIDEBAR_KEY[page_name]
if url:
for prefix, key in self._FP_URL_PREFIX_TO_SIDEBAR_KEY:
if url.startswith(prefix):
return key
return None
def _fp_sidebar_items(self, url, page_name):
"""Return the sidebar item list with the right item marked active."""
active_key = self._fp_resolve_active_sidebar_key(url, page_name)
out = []
for entry in self._FP_SIDEBAR_LAYOUT:
if entry.get('type') == 'item':
copy = dict(entry)
copy['active'] = (active_key == entry['key'])
out.append(copy)
else:
out.append(entry)
return out
```
- [ ] **Step 2: Extend `_prepare_portal_layout_values` to inject sidebar data**
In the same file, find `_prepare_portal_layout_values` (around line 29). Replace its body with:
```python
def _prepare_portal_layout_values(self):
values = super()._prepare_portal_layout_values()
# Resolve current URL + page_name for sidebar active-state
url = request.httprequest.path if request else ''
page_name = values.get('page_name')
values['fp_sidebar_items'] = self._fp_sidebar_items(url, page_name)
# Partner display name for the sidebar header
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
values['fp_partner_display_name'] = commercial.name or partner.name
return values
```
**IMPORTANT**: this overrides the same-name method we previously edited (counters). Make sure NOT to lose the counter-injection — the existing override builds `fp_quote_request_count`, `fp_portal_job_count`, etc. The new code should ADD to the values dict, not replace what's there. Verify the existing override still increments the counters; if not, restore that logic alongside the new sidebar data.
- [ ] **Step 3: Commit**
```bash
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/controllers/portal.py && \
git commit -m "feat(portal): _fp_sidebar_items helper + layout-values inject
Drives the sidebar from a single Python data structure
(_FP_SIDEBAR_LAYOUT). Active state resolved by page_name lookup OR
URL-prefix match (so Odoo default pages like /my/orders and
/my/account light up correctly). _prepare_portal_layout_values
extends super() so existing counter injection (fp_quote_request_count
etc.) keeps firing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: Register Phase 1 files + version bump
**Files:**
- Modify: `fusion_plating_portal/__manifest__.py`
- [ ] **Step 1: Bump version + register new files**
Change `'version': '19.0.3.7.0'``'version': '19.0.4.0.0'`.
In the `'data'` list, add `'views/fp_portal_shell.xml'` near the TOP (right after macros, before any template that might call sidebar context vars):
```python
'data': [
'security/fp_portal_security.xml',
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'views/fp_portal_macros.xml',
'views/fp_portal_shell.xml', # NEW — must load early
'views/fp_quote_request_views.xml',
'views/fp_portal_dashboard.xml',
'views/fp_portal_templates.xml',
'views/fp_portal_configurator_templates.xml',
'views/fp_portal_breadcrumbs.xml',
'views/fp_sale_order_portal.xml',
'views/fp_menu.xml',
],
```
In the `'assets'` block, add `fp_portal_sidebar.scss` (after `fp_portal_dashboard.scss`, before the legacy catch-all) and `fp_portal_sidebar.js` (with the JS files at the end):
```python
'assets': {
'web.assets_frontend': [
'fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss',
'fusion_plating_portal/static/src/scss/fp_portal_buttons.scss',
'fusion_plating_portal/static/src/scss/fp_portal_badges.scss',
'fusion_plating_portal/static/src/scss/fp_portal_cards.scss',
'fusion_plating_portal/static/src/scss/fp_portal_stepper.scss',
'fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss',
'fusion_plating_portal/static/src/scss/fp_portal_timeline.scss',
'fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss', # NEW
'fusion_plating_portal/static/src/scss/fusion_plating_portal.scss',
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
'fusion_plating_portal/static/src/js/fp_portal_sidebar.js', # NEW
],
},
```
- [ ] **Step 2: Commit**
```bash
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/__manifest__.py && \
git commit -m "chore(portal): bump 19.0.4.0.0 + register sidebar shell + JS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: Deploy Phase 1 to entech + visual verify
**Files:** (deployment, no edits)
- [ ] **Step 1: Copy 6 changed/new files to entech**
```bash
for f in \
static/src/scss/fp_portal_sidebar.scss \
static/src/js/fp_portal_sidebar.js \
views/fp_portal_shell.xml \
controllers/portal.py \
__manifest__.py; do
cat "K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/$f" | \
ssh pve-worker5 "pct exec 111 -- bash -c 'mkdir -p \$(dirname /mnt/extra-addons/custom/fusion_plating_portal/$f) && cat > /mnt/extra-addons/custom/fusion_plating_portal/$f'"
done
```
- [ ] **Step 2: Upgrade module + restart**
```bash
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_plating_portal --test-tags=fp_portal --stop-after-init 2>&1 | tail -15\" && systemctl start odoo'"
```
Expected: registry loaded clean, all existing tests still pass.
- [ ] **Step 3: Bust asset cache**
```bash
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
```
- [ ] **Step 4: Visual smoke test**
Open in a logged-in browser, walk each URL and confirm sidebar appears with correct active item:
| URL | Expected active sidebar item |
|---|---|
| https://enplating.com/my/home | Dashboard |
| https://enplating.com/my/quote_requests | Quote Requests |
| https://enplating.com/my/configurator | Get a Quote |
| https://enplating.com/my/orders (Odoo) | Purchase Orders |
| https://enplating.com/my/jobs | Work Orders |
| https://enplating.com/my/certifications | Certifications |
| https://enplating.com/my/deliveries | Packing Slips |
| https://enplating.com/my/account (Odoo) | Profile |
Also test mobile: shrink browser below 768px, sidebar should disappear; click hamburger button (top-left of main content), drawer slides in; click backdrop or a sidebar link, drawer closes.
If any URL doesn't show the sidebar, the `portal.portal_layout` inherit xpath in Task 3 didn't catch — revisit Step 1 of Task 3 (inspect Odoo's wrap structure) and adjust the xpath anchor.
---
# PHASE 2 — Page Audit Redirects
Goal: 3 legacy URLs redirect cleanly to their new homes. No content changes, just routes.
### Task 7: Add 3 redirects + delete thin templates
**Files:**
- Modify: `fusion_plating_portal/controllers/portal.py`
- Modify: `fusion_plating_portal/views/fp_portal_templates.xml`
- [ ] **Step 1: Replace `portal_my_fp_invoices` body with redirect**
Find the `portal_my_fp_invoices` route handler in `fusion_plating_portal/controllers/portal.py` (grep for `/my/fp_invoices`). Replace the entire method body with:
```python
@http.route(
['/my/fp_invoices', '/my/fp_invoices/page/<int:page>'],
type='http', auth='user', website=True,
)
def portal_my_fp_invoices(self, **kw):
"""Legacy URL — redirected to /my/account_summary (Sub-A IA)."""
return request.redirect('/my/account_summary')
```
- [ ] **Step 2: Replace `portal_my_purchase_orders` body with redirect**
Find `portal_my_purchase_orders` and replace its method body:
```python
@http.route(
['/my/purchase_orders', '/my/purchase_orders/page/<int:page>'],
type='http', auth='user', website=True,
)
def portal_my_purchase_orders(self, **kw):
"""Legacy URL — redirected to Odoo default /my/orders (Sub-A IA)."""
return request.redirect('/my/orders')
```
- [ ] **Step 3: Replace `portal_my_quote_request_new` (or equivalent) with redirect**
Find the GET handler for `/my/quote_requests/new`. Replace its body so a GET redirects to the configurator — but the POST handler (the actual form submit) MUST be preserved untouched because the existing RFQ form still submits there. If they share one method, split them:
```python
@http.route(
['/my/quote_requests/new'],
type='http', auth='user', website=True,
methods=['GET'],
)
def portal_my_quote_request_new_get(self, **kw):
"""GET — legacy entry point, redirected to the configurator wizard."""
return request.redirect('/my/configurator/new')
# POST kept for back-compat with the existing RFQ form button.
# If the form is fully retired in a later phase, drop this method.
@http.route(
['/my/quote_requests/new'],
type='http', auth='user', website=True,
methods=['POST'], csrf=True,
)
def portal_my_quote_request_new_post(self, **kw):
# ... existing POST body, unchanged
...
```
If you find a single method handling both GET + POST, factor the redirect out:
```python
def portal_my_quote_request_new(self, **kw):
if request.httprequest.method == 'GET':
return request.redirect('/my/configurator/new')
# ... existing POST body
```
- [ ] **Step 4: Delete the thin invoice + PO template bodies**
In `fusion_plating_portal/views/fp_portal_templates.xml`, find `<template id="portal_my_fp_invoices"` and `<template id="portal_my_purchase_orders"` — delete each whole template block (it's never rendered now). Sample diff:
```xml
<!-- DELETE this whole block -->
<template id="portal_my_fp_invoices" name="My Invoices">
... (existing thin invoice list body)
</template>
```
- [ ] **Step 5: Commit**
```bash
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/controllers/portal.py fusion_plating_portal/views/fp_portal_templates.xml && \
git commit -m "feat(portal): redirect 3 legacy URLs to consolidated homes (Sub-A IA)
- /my/fp_invoices -> /my/account_summary
- /my/purchase_orders -> /my/orders (Odoo default)
- /my/quote_requests/new (GET) -> /my/configurator/new
(POST handler preserved for back-compat with the existing RFQ form
button; will be removed after the form is fully retired)
Thin templates deleted: portal_my_fp_invoices, portal_my_purchase_orders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 8: Deploy Phase 2 + verify redirects
**Files:** (deployment)
- [ ] **Step 1: Copy 2 changed files**
```bash
for f in \
controllers/portal.py \
views/fp_portal_templates.xml; do
cat "K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/$f" | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_portal/$f'"
done
```
- [ ] **Step 2: Restart odoo (no schema change, just controller + template reload)**
```bash
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_plating_portal --stop-after-init 2>&1 | tail -10\" && systemctl start odoo'"
```
- [ ] **Step 3: Verify redirects with curl**
```bash
curl -s -o /dev/null -w "/my/fp_invoices -> %{redirect_url} (%{http_code})\n" https://enplating.com/my/fp_invoices
curl -s -o /dev/null -w "/my/purchase_orders -> %{redirect_url} (%{http_code})\n" https://enplating.com/my/purchase_orders
curl -sL -o /dev/null -w "/my/quote_requests/new -> %{url_effective} (%{http_code})\n" https://enplating.com/my/quote_requests/new
```
Unauthenticated curl hits the login redirect first (303 → /web/login), so the chain ends at login. To confirm the intended redirect specifically, you'd need to test with a logged-in session or check the route registration in `ir.http`. For now, 303 status is enough to confirm the route is wired and not 500'ing.
---
# PHASE 3 — Account Summary
Goal: `/my/account_summary` page exists with 3 tabs (Invoices · Credit Memos · Statements), Open Balance pill, search + filter pills + sort + pagination.
### Task 9: Add `portal_account_summary` controller route + tests
**Files:**
- Modify: `fusion_plating_portal/controllers/portal.py`
- Modify: `fusion_plating_portal/tests/test_portal_dashboard.py`
- [ ] **Step 1: Write the failing test FIRST**
Append to `fusion_plating_portal/tests/test_portal_dashboard.py`:
```python
def test_account_summary_partitions_invoices_and_credits(self):
"""Account Summary helper splits posted moves by move_type."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
Move = self.env['account.move']
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'invoice_line_ids': [(0, 0, {
'name': 'Test plating',
'quantity': 1,
'price_unit': 250.00,
})],
})
inv.action_post()
cm = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_refund',
'invoice_date': '2026-05-02',
'invoice_line_ids': [(0, 0, {
'name': 'Test credit',
'quantity': 1,
'price_unit': 50.00,
})],
})
cm.action_post()
controller = FpCustomerPortal()
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices',
filter_state='all',
search='',
sort='date_desc',
page=1,
)
# Tab=invoices -> only out_invoice
names = data['records'].mapped('name')
self.assertIn(inv.name, names)
self.assertNotIn(cm.name, names)
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='credit_memos',
filter_state='all',
search='',
sort='date_desc',
page=1,
)
names = data['records'].mapped('name')
self.assertIn(cm.name, names)
self.assertNotIn(inv.name, names)
def test_account_summary_open_balance_sums_residuals(self):
"""Open Balance pill = sum of amount_residual across open invoices."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
Move = self.env['account.move']
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'invoice_line_ids': [(0, 0, {
'name': 'Open inv',
'quantity': 1,
'price_unit': 750.00,
})],
})
inv.action_post()
controller = FpCustomerPortal()
open_balance = controller._fp_account_summary_open_balance(
self.partner.commercial_partner_id,
)
# The 750 invoice has amount_residual = 750 until paid
self.assertEqual(open_balance, 750.00)
def test_account_summary_search_matches_name_and_ref(self):
"""Search box filters by invoice number OR customer PO (ref)."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
Move = self.env['account.move']
inv = Move.create({
'partner_id': self.partner.id,
'move_type': 'out_invoice',
'invoice_date': '2026-05-01',
'ref': 'PO-CUSTOMER-99999',
'invoice_line_ids': [(0, 0, {
'name': 'Sale',
'quantity': 1,
'price_unit': 100.0,
})],
})
inv.action_post()
controller = FpCustomerPortal()
# Search by ref (customer PO)
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices', filter_state='all',
search='99999', sort='date_desc', page=1,
)
self.assertIn(inv, data['records'])
# Search that matches nothing
data = controller._fp_account_summary_data(
self.partner.commercial_partner_id,
tab='invoices', filter_state='all',
search='zzznotfoundzzz', sort='date_desc', page=1,
)
self.assertNotIn(inv, data['records'])
```
- [ ] **Step 2: Implement the helpers + route**
In `fusion_plating_portal/controllers/portal.py`, add this BEFORE the existing `# DASHBOARD` section (near line 116):
```python
# ==========================================================================
# Account Summary (Sub-A IA) — invoices + credits + statements
# ==========================================================================
_FP_ACCOUNT_SUMMARY_TABS = [
('invoices', 'Invoices', 'out_invoice'),
('credit_memos', 'Credit Memos', 'out_refund'),
('statements', 'Statements', None), # placeholder in V1
]
_FP_ACCOUNT_SUMMARY_FILTERS = ['open', 'closed', 'all']
_FP_ACCOUNT_SUMMARY_SORTS = {
'date_desc': 'invoice_date desc, id desc',
'date_asc': 'invoice_date asc, id asc',
'amount_desc': 'amount_total desc, id desc',
'amount_asc': 'amount_total asc, id asc',
}
_FP_ACCOUNT_SUMMARY_PER_PAGE = 10
def _fp_account_summary_open_balance(self, commercial_partner):
"""Sum of amount_residual across this partner's open invoices."""
moves = self.env['account.move'].sudo().search([
('partner_id', 'child_of', commercial_partner.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('amount_residual', '>', 0),
])
return sum(moves.mapped('amount_residual'))
def _fp_account_summary_data(self, commercial_partner, tab, filter_state,
search, sort, page):
"""Return {records, total, pager_offset} for one tab+filter combination.
tab — 'invoices' | 'credit_memos' | 'statements'
filter_state — 'open' | 'closed' | 'all'
search — substring matched against name OR ref (case-insensitive)
sort — key from _FP_ACCOUNT_SUMMARY_SORTS
page — 1-indexed
"""
if tab == 'statements':
# V1 placeholder — Statements is a 'coming soon' tab.
return {'records': self.env['account.move'].browse(), 'total': 0,
'offset': 0}
# Resolve move_type from tab key
move_type = next(
(mt for k, _l, mt in self._FP_ACCOUNT_SUMMARY_TABS if k == tab),
'out_invoice',
)
domain = [
('partner_id', 'child_of', commercial_partner.id),
('move_type', '=', move_type),
('state', '=', 'posted'),
]
if filter_state == 'open':
domain.append(('amount_residual', '>', 0))
elif filter_state == 'closed':
domain.append(('amount_residual', '=', 0))
if search:
domain.append('|')
domain.append(('name', 'ilike', search))
domain.append(('ref', 'ilike', search))
Move = self.env['account.move'].sudo()
order = self._FP_ACCOUNT_SUMMARY_SORTS.get(sort, 'invoice_date desc')
total = Move.search_count(domain)
offset = max(0, (page - 1) * self._FP_ACCOUNT_SUMMARY_PER_PAGE)
records = Move.search(domain, order=order, limit=self._FP_ACCOUNT_SUMMARY_PER_PAGE, offset=offset)
return {'records': records, 'total': total, 'offset': offset}
@http.route(
['/my/account_summary', '/my/account_summary/page/<int:page>'],
type='http', auth='user', website=True,
)
def portal_account_summary(self, page=1, tab='invoices',
filter_state='open', search='', sort='date_desc',
**kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
# Sanitize inputs
if tab not in [k for k, _l, _t in self._FP_ACCOUNT_SUMMARY_TABS]:
tab = 'invoices'
if filter_state not in self._FP_ACCOUNT_SUMMARY_FILTERS:
filter_state = 'open'
if sort not in self._FP_ACCOUNT_SUMMARY_SORTS:
sort = 'date_desc'
data = self._fp_account_summary_data(
commercial, tab, filter_state, search, sort, page,
)
open_balance = self._fp_account_summary_open_balance(commercial)
pager = portal_pager(
url='/my/account_summary',
url_args={'tab': tab, 'filter_state': filter_state,
'search': search, 'sort': sort},
total=data['total'],
page=page,
step=self._FP_ACCOUNT_SUMMARY_PER_PAGE,
)
values = {
'page_name': 'fp_account_summary',
'records': data['records'],
'tabs': self._FP_ACCOUNT_SUMMARY_TABS,
'active_tab': tab,
'filter_state': filter_state,
'search': search,
'sort': sort,
'open_balance': open_balance,
'currency': commercial.property_account_receivable_id.currency_id
if commercial.property_account_receivable_id else request.env.company.currency_id,
'pager': pager,
'total': data['total'],
}
return request.render('fusion_plating_portal.portal_my_account_summary', values)
```
- [ ] **Step 3: Run the 3 new tests**
Deploy + run from entech (see Task 14 for the full deploy step — for now use this quick form):
```bash
cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/controllers/portal.py | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_portal/controllers/portal.py'"
cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/tests/test_portal_dashboard.py | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_portal/tests/test_portal_dashboard.py'"
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 --test-tags=fp_portal --stop-after-init 2>&1 | tail -25\" && systemctl start odoo'"
```
Expected: 3 new tests pass (`test_account_summary_partitions_invoices_and_credits`, `test_account_summary_open_balance_sums_residuals`, `test_account_summary_search_matches_name_and_ref`), all prior tests still green.
Iterate on the helper code until tests pass. The template won't be wired yet so `/my/account_summary` will 404 if you try the URL — that's expected; comes in next task.
- [ ] **Step 4: Commit**
```bash
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/controllers/portal.py fusion_plating_portal/tests/test_portal_dashboard.py && \
git commit -m "feat(portal): account_summary controller + 3 unit tests
New /my/account_summary route. Splits posted account.move into
Invoices (out_invoice) / Credit Memos (out_refund) / Statements
(V1 placeholder). Open Balance helper sums amount_residual across
open invoices for the partner's commercial tree.
Search filters name OR ref (customer PO). Sort options: date desc/asc,
amount desc/asc. Filter pills: open / closed / all.
Tests cover the tab partitioning, the open-balance sum, and the
search behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 10: Create `fp_portal_account_summary.xml` template
**Files:**
- Create: `fusion_plating_portal/views/fp_portal_account_summary.xml`
- [ ] **Step 1: Write the template**
Create `fusion_plating_portal/views/fp_portal_account_summary.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<template id="portal_my_account_summary" name="Account Summary">
<t t-call="portal.portal_layout">
<div class="o_fp_account_summary">
<!-- Page header: title + Open Balance pill -->
<div class="d-flex justify-content-between align-items-baseline mb-3">
<h3 class="mb-0" style="color: var(--fp-text, #111827)">Account Summary</h3>
<div class="o_fp_badge o_fp_badge_paid" t-if="open_balance">
<span class="o_fp_badge_dot"/>
Open Balance:
<span t-field="open_balance"
t-options='{"widget": "monetary", "display_currency": currency}'/>
</div>
<span class="o_fp_badge" t-else=""
style="background:#f3f7f6;color:#374151">
Open Balance: $0.00
</span>
</div>
<!-- Tab strip -->
<ul class="nav nav-tabs mb-3" role="tablist">
<t t-foreach="tabs" t-as="tab_entry">
<li class="nav-item">
<a t-attf-href="/my/account_summary?tab=#{tab_entry[0]}"
t-attf-class="nav-link #{'active' if active_tab == tab_entry[0] else ''}"
t-out="tab_entry[1]"/>
</li>
</t>
</ul>
<!-- Filter pills + search + sort -->
<t t-if="active_tab != 'statements'">
<div class="d-flex flex-wrap align-items-center gap-3 mb-3">
<div class="d-flex align-items-center gap-2">
<span class="text-muted small">Showing:</span>
<t t-foreach="['open', 'closed', 'all']" t-as="fk">
<a t-attf-href="/my/account_summary?tab=#{active_tab}&amp;filter_state=#{fk}&amp;sort=#{sort}&amp;search=#{search}"
t-attf-class="o_fp_filter_pill #{'o_fp_filter_pill_active' if filter_state == fk else ''}"
t-out="fk.capitalize()"/>
</t>
</div>
<form method="GET" action="/my/account_summary" class="d-flex gap-1 ms-auto m-0">
<input type="hidden" name="tab" t-att-value="active_tab"/>
<input type="hidden" name="filter_state" t-att-value="filter_state"/>
<input type="hidden" name="sort" t-att-value="sort"/>
<input type="text" name="search" t-att-value="search"
placeholder="Search invoice # or PO #"
class="form-control form-control-sm"
style="max-width: 260px"/>
<button type="submit" class="o_fp_btn_secondary o_fp_btn_sm">Search</button>
</form>
<select onchange="window.location.href = this.value"
class="form-select form-select-sm" style="max-width: 200px">
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&amp;filter_state=' + filter_state + '&amp;sort=date_desc&amp;search=' + search"
t-att-selected="sort == 'date_desc'">Newest first</option>
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&amp;filter_state=' + filter_state + '&amp;sort=date_asc&amp;search=' + search"
t-att-selected="sort == 'date_asc'">Oldest first</option>
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&amp;filter_state=' + filter_state + '&amp;sort=amount_desc&amp;search=' + search"
t-att-selected="sort == 'amount_desc'">Largest amount</option>
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&amp;filter_state=' + filter_state + '&amp;sort=amount_asc&amp;search=' + search"
t-att-selected="sort == 'amount_asc'">Smallest amount</option>
</select>
</div>
</t>
<!-- Table -->
<t t-if="active_tab == 'statements'">
<div class="o_fp_card text-center text-muted" style="padding: 2rem">
<p>Monthly statements coming soon.</p>
<p class="small">
For a copy in the meantime, contact your sales rep at EN Plating.
</p>
</div>
</t>
<t t-elif="not records">
<div class="o_fp_card text-center text-muted" style="padding: 1.5rem">
<t t-if="search">No results for "<t t-out="search"/>".</t>
<t t-else="">No records in this tab.</t>
</div>
</t>
<t t-else="">
<div class="o_fp_card" style="padding: 0; overflow: hidden">
<table class="table mb-0">
<thead>
<tr>
<th>#</th>
<th>Status</th>
<th>Posted On</th>
<th>PO #</th>
<th>Due Date</th>
<th class="text-end">Balance</th>
<th class="text-end">View PDF</th>
</tr>
</thead>
<tbody>
<tr t-foreach="records" t-as="move">
<td t-out="move.name"/>
<td>
<t t-if="move.amount_residual == 0">
<span class="o_fp_badge o_fp_badge_paid"><span class="o_fp_badge_dot"/>Closed</span>
</t>
<t t-else="">
<span class="o_fp_badge o_fp_badge_in_progress"><span class="o_fp_badge_dot"/>Open</span>
</t>
</td>
<td>
<span t-if="move.invoice_date"
t-field="move.invoice_date"
t-options='{"widget": "date"}'/>
</td>
<td t-out="move.ref or ''"/>
<td>
<span t-if="move.invoice_date_due"
t-field="move.invoice_date_due"
t-options='{"widget": "date"}'/>
</td>
<td class="text-end">
<span t-field="move.amount_residual"
t-options='{"widget": "monetary", "display_currency": move.currency_id}'/>
</td>
<td class="text-end">
<a t-attf-href="/my/invoices/#{move.id}?report_type=pdf&amp;download=true"
class="o_fp_btn_ghost o_fp_btn_sm">View PDF</a>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pager -->
<div class="d-flex justify-content-between align-items-center mt-3"
t-if="pager and pager.get('page_count', 0) > 1">
<div class="text-muted small">
Showing
<t t-out="pager['offset'] + 1"/><t t-out="min(pager['offset'] + 10, total)"/>
of <t t-out="total"/>
</div>
<ul class="pagination mb-0">
<t t-foreach="pager.get('pages', [])" t-as="p">
<li t-attf-class="page-item #{'active' if p['num'] == pager['page']['num'] else ''}">
<a class="page-link" t-att-href="p['url']" t-out="p['num']"/>
</li>
</t>
</ul>
</div>
</t>
</div>
</t>
</template>
</odoo>
```
- [ ] **Step 2: Add small SCSS for the filter pills** (append to `fp_portal_dashboard.scss` near the bottom)
```scss
// Filter pills used by Account Summary (also reusable elsewhere)
.o_fp_filter_pill {
display: inline-block;
padding: .25rem .75rem;
border-radius: $fp-radius-pill;
background: $fp-section-bg;
color: $fp-muted;
font-size: .8rem;
text-decoration: none;
transition: background .12s ease, color .12s ease;
&:hover { background: $fp-mint; color: $fp-teal-dark; text-decoration: none; }
&.o_fp_filter_pill_active {
background: $fp-gradient-primary;
color: #fff;
font-weight: 600;
}
}
```
- [ ] **Step 3: Register the new template in manifest data list**
Open `fusion_plating_portal/__manifest__.py`. In the `'data'` list, add `'views/fp_portal_account_summary.xml'` near the other portal view files (order after `fp_portal_templates.xml` is fine since the template doesn't depend on anything in templates).
```python
'data': [
'security/fp_portal_security.xml',
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'views/fp_portal_macros.xml',
'views/fp_portal_shell.xml',
'views/fp_quote_request_views.xml',
'views/fp_portal_dashboard.xml',
'views/fp_portal_templates.xml',
'views/fp_portal_account_summary.xml', # NEW
'views/fp_portal_configurator_templates.xml',
'views/fp_portal_breadcrumbs.xml',
'views/fp_sale_order_portal.xml',
'views/fp_menu.xml',
],
```
- [ ] **Step 4: Commit**
```bash
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/views/fp_portal_account_summary.xml fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss fusion_plating_portal/__manifest__.py && \
git commit -m "feat(portal): Account Summary template (3 tabs, filter, search, sort, pager)
Tabs: Invoices / Credit Memos / Statements (V1 placeholder).
Page header carries the Open Balance pill. Per-tab filter pills
(Open/Closed/All), search box (name OR ref), sort dropdown
(newest/oldest/largest/smallest), 10-per-page pager.
Empty states: 'No results for X' for failed searches, 'No records
in this tab' for empty result sets, and the dedicated Statements
'coming soon' card. Statements tab hides the filter/search/sort
strip — nothing to filter yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
# PHASE 4 — Deploy + Smoke Test
### Task 11: Deploy Phase 3 + run tests + visual sweep
**Files:** (deployment)
- [ ] **Step 1: Copy all Phase 3 files to entech**
```bash
for f in \
controllers/portal.py \
views/fp_portal_account_summary.xml \
static/src/scss/fp_portal_dashboard.scss \
tests/test_portal_dashboard.py \
__manifest__.py; do
cat "K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/$f" | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_portal/$f'"
done
```
- [ ] **Step 2: Upgrade module + run tests**
```bash
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_plating_portal --test-tags=fp_portal --stop-after-init 2>&1 | tail -25\" && systemctl start odoo'"
```
Expected: all existing portal tests still pass + 3 new Account Summary tests pass.
- [ ] **Step 3: Bust asset cache**
```bash
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
```
- [ ] **Step 4: Visual smoke sweep — every URL**
Logged in as admin (or a portal user), visit and confirm:
| URL | Expected |
|---|---|
| /my/home | Sidebar visible, "Dashboard" active, dashboard renders normally |
| /my/jobs | "Work Orders" active |
| /my/jobs/<id> | "Work Orders" active (parent highlighted on detail) |
| /my/quote_requests | "Quote Requests" active |
| /my/configurator | "Get a Quote" active |
| /my/orders | "Purchase Orders" active (Odoo default page) |
| /my/certifications | "Certifications" active |
| /my/deliveries | "Packing Slips" active |
| /my/account_summary | "Account Summary" active, page renders with 3 tabs |
| /my/account_summary?tab=credit_memos | Credit Memos tab active |
| /my/account_summary?tab=statements | Statements tab shows "coming soon" card |
| /my/account_summary?filter_state=closed | Closed filter pill active, only closed invoices shown |
| /my/account_summary?search=PO123 | Filtered to matching invoices |
| /my/account (Odoo) | "Profile" active |
| /my/fp_invoices (legacy) | Redirects to /my/account_summary |
| /my/purchase_orders (legacy) | Redirects to /my/orders |
| /my/quote_requests/new (GET, legacy) | Redirects to /my/configurator/new |
Mobile: shrink browser below 768px on `/my/home`, sidebar hides, hamburger appears, clicking hamburger reveals drawer, clicking a link closes it.
- [ ] **Step 5: Tag the phase done**
```bash
cd K:/Github/Odoo-Modules/fusion_plating && git tag portal-sub-a-shipped
```
---
# Done
After Task 11 the sidebar + page audit + Account Summary are all live. Sub-projects B (multi-user) and C (search) are ready to consume the sidebar slots defined here.
## Self-Review
**1. Spec coverage:**
- ✅ Sidebar shell (Tasks 1-6): every `/my/*` page wrapped (template inherit on `portal.portal_layout`)
- ✅ Sidebar items list (Task 4): matches spec exactly (Dashboard, Activity/Quote Requests/Get a Quote/Purchase Orders/Work Orders, Documents/Certifications/Packing Slips/Account Summary, Account/Profile)
- ✅ Active state via page_name + URL prefix (Task 4 `_fp_resolve_active_sidebar_key`)
- ✅ Mobile collapse + hamburger (Task 2 JS + Task 1 SCSS @media)
- ✅ 3 legacy redirects (Task 7): /my/fp_invoices, /my/purchase_orders, /my/quote_requests/new
- ✅ Account Summary URL + tabs + Open Balance + filters + search + sort + pager (Tasks 9, 10)
- ✅ Statements V1 placeholder explicitly handled (Task 10 template, Task 9 controller short-circuit)
- ✅ Unit tests for the 3 main account_summary behaviours (Task 9 Step 1)
- ✅ Page audit deletions (Task 7 Step 4)
**2. Placeholder scan:**
- One soft area in Task 3: the xpath anchor in `fp_portal_shell.xml` may need adjustment if Odoo's `portal.portal_layout` doesn't have `<div id="wrap">` in our version. Task 3 Step 1 calls this out and tells the engineer to inspect first. Acceptable.
- No "TODO" / "TBD" / "fill in details" in any task body.
**3. Type consistency:**
- `_FP_SIDEBAR_LAYOUT` (Task 4) entries use keys `type / key / label / icon / url` consistently. Template (Task 3) reads exactly these. ✓
- `_FP_ACCOUNT_SUMMARY_TABS` is a list of (key, label, move_type) tuples — Task 9 iterates with this shape, Task 10 template iterates with the same shape. ✓
- `_fp_account_summary_data` return shape `{records, total, offset}` consistent between Task 9 implementation and Task 9 tests. ✓
- Helper names match between tasks: `_fp_account_summary_open_balance`, `_fp_account_summary_data`, `_fp_resolve_active_sidebar_key`, `_fp_sidebar_items` — all defined in Task 4 / Task 9, all referenced exactly once with the same name. ✓
---
*Sub-projects B (multi-user) and C (portal search) will add items to the SIDEBAR_LAYOUT data structure and possibly a search input above the Dashboard entry — both extensions, no restructure needed.*