# 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) " ``` --- ### 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) " ``` --- ### 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 `
` or similar. Note the exact xpath anchor. - [ ] **Step 2: Write the shell template** Create `fusion_plating_portal/views/fp_portal_shell.xml`: ```xml ``` **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) " ``` --- ### 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) " ``` --- ### 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) " ``` --- ### 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/'], 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/'], 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 `