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

59 KiB
Raw Blame History

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


File Inventory

NEW files:

  • fusion_plating_portal/views/fp_portal_shell.xmlportal.portal_layout inherit + sidebar markup
  • fusion_plating_portal/views/fp_portal_account_summary.xmlportal_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:

// ============================================================================
// 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
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

/**
 * 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
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:

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 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
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:

    # ==========================================================================
    # 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:

    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
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):

'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):

'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
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
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
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
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:

    @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:

    @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:

    @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:

    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:

<!-- DELETE this whole block -->
<template id="portal_my_fp_invoices" name="My Invoices">
    ... (existing thin invoice list body)
</template>
  • Step 5: Commit
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
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)
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
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:

    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):

    # ==========================================================================
    # 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):

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
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 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)
// 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).

'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
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
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
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
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/ "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
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.