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>
1416 lines
59 KiB
Markdown
1416 lines
59 KiB
Markdown
# 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}&filter_state=#{fk}&sort=#{sort}&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 + '&filter_state=' + filter_state + '&sort=date_desc&search=' + search"
|
||
t-att-selected="sort == 'date_desc'">Newest first</option>
|
||
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=date_asc&search=' + search"
|
||
t-att-selected="sort == 'date_asc'">Oldest first</option>
|
||
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=amount_desc&search=' + search"
|
||
t-att-selected="sort == 'amount_desc'">Largest amount</option>
|
||
<option t-att-value="'/my/account_summary?tab=' + active_tab + '&filter_state=' + filter_state + '&sort=amount_asc&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&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.*
|