Spec covers the brainstormed design: jobs-forward layout, V2 stepper with timestamps, EN Plating teal/gradient palette, 4 doc categories. Plan decomposes implementation into 4 independently-deployable phases (tokens+buttons -> dashboard -> jobs detail -> cosmetic sweep) with 27 tasks total. Also adds .gitignore so .superpowers/ brainstorm artifacts stay untracked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
95 KiB
Customer Portal Dashboard Redesign — 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: Replace the bland 6-card /my/home with a jobs-forward customer dashboard, add a per-job detail page with vertical timeline + grouped documents, and apply EN Plating's teal/green brand palette with gradient CTAs across the portal.
Architecture: Pure server-rendered QWeb on top of existing FpCustomerPortal controller. New SCSS token system + macros power 4 phases that ship independently (tokens+buttons → dashboard → jobs detail → cosmetic sweep). No new models in V1; one optional timestamp-field addition on fusion.plating.portal.job if Phase 3 investigation requires it.
Tech Stack: Odoo 19 (Python + QWeb XML + SCSS), Bootstrap 5 utility classes for layout, no JS framework. Deployment via SSH to entech LXC 111 (native Odoo, db admin).
Spec: docs/superpowers/specs/2026-05-17-portal-dashboard-redesign-design.md
Mockups: .superpowers/brainstorm/1800-1778997036/content/branded-dashboard.html (dashboard), job-detail.html (detail). Visual fidelity ≈ pixel-match these.
File Inventory
NEW files:
fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss— brand palette + gradient + radius + shadow varsfusion_plating_portal/static/src/scss/fp_portal_buttons.scss— gradient buttons (primary/secondary/ghost/danger)fusion_plating_portal/static/src/scss/fp_portal_badges.scss— status badges with dot + glowfusion_plating_portal/static/src/scss/fp_portal_cards.scss— card shells + KPI tilesfusion_plating_portal/static/src/scss/fp_portal_stepper.scss— circular numbered stepper geometryfusion_plating_portal/static/src/scss/fp_portal_timeline.scss— vertical timeline for detail pagefusion_plating_portal/static/src/scss/fp_portal_dashboard.scss— jobs-forward grid + secondary panelsfusion_plating_portal/views/fp_portal_macros.xml— shared QWeb macros (stepper, badge, doc chip, doc group)fusion_plating_portal/tests/__init__.py— test packagefusion_plating_portal/tests/test_portal_dashboard.py— controller helper unit tests
MODIFY files:
fusion_plating_portal/controllers/portal.py— extendhome(), rewriteportal_my_job(), add helpersfusion_plating_portal/views/fp_portal_dashboard.xml— rewritefp_portal_home_dashboardtemplatefusion_plating_portal/views/fp_portal_templates.xml— rewriteportal_my_jobs+portal_my_jobtemplatesfusion_plating_portal/views/fp_quote_request_views.xml— Phase 4 cosmetic tokenisationfusion_plating_portal/views/fp_portal_configurator_templates.xml— Phase 4 cosmetic tokenisationfusion_plating_portal/static/src/scss/fusion_plating_portal.scss— trim to catch-allfusion_plating_portal/__manifest__.py— version bump + new assets + new datafusion_plating_portal/models/fp_portal_job.py— conditionally add stage Datetime fields (Phase 3 Task 17, only if Task 16 investigation requires)
PHASE 1 — Tokens + Button System
Goal: ship the brand palette + gradient button system on its own. Visible change is buttons only. Independently deployable.
Task 1: Create _fp_portal_tokens.scss
Files:
-
Create:
fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss -
Step 1: Write the file with brand variables
Create K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss with this exact content:
// ============================================================================
// Fusion Plating — Customer Portal · Design Tokens
// Brand palette pulled from enplating.com live CSS (2026-05-17).
// Loaded first in web.assets_frontend so every later SCSS file sees these.
// Per Odoo 19 SCSS rules (CLAUDE.md rule 8/9): no @import; tokens are SCSS
// variables that downstream files reference directly, NOT CSS custom props.
// ============================================================================
// Brand palette
$fp-teal-light: #2eaf93;
$fp-teal: #1a6b59;
$fp-teal-dark: #0e3d2f;
$fp-teal-deep: #0a3528;
$fp-mint: #cbf3e6;
$fp-mint-pastel: #f0fdf9;
$fp-aqua: #9ae5d4;
// Surfaces
$fp-page-bg: #f8fafb;
$fp-section-bg: #f3f7f6;
$fp-card-bg: #ffffff;
$fp-card-border: #e5e7eb;
$fp-card-border-dark: #d1d5db;
// Text
$fp-text: #111827;
$fp-text-body: #374151;
$fp-muted: #6b7280;
$fp-muted-light: #9ca3af;
$fp-disabled: #d1d5db;
// Status (functional, NOT brand)
$fp-amber: #f59e0b;
$fp-amber-bg: #fef3c7;
$fp-amber-text: #92400e;
$fp-success: #22c55e;
$fp-success-text: #15803d;
$fp-success-bg: #f0fdf4;
$fp-danger: #ef4444;
$fp-danger-dark: #b91c1c;
$fp-danger-bg: #fef2f2;
// Gradients
$fp-gradient-primary: linear-gradient(135deg, $fp-teal-light 0%, $fp-teal 100%);
$fp-gradient-danger: linear-gradient(135deg, $fp-danger 0%, $fp-danger-dark 100%);
$fp-gradient-mint: linear-gradient(135deg, $fp-mint-pastel 0%, $fp-mint 100%);
$fp-gradient-icon: linear-gradient(135deg, $fp-mint 0%, $fp-aqua 100%);
$fp-gradient-secondary: linear-gradient(180deg, #fff 0%, $fp-section-bg 100%);
$fp-gradient-tab: linear-gradient(180deg, $fp-section-bg 0%, $fp-mint 100%);
// Shadows
$fp-shadow-card: 0 1px 2px rgba(0, 0, 0, .03);
$fp-shadow-card-hover: 0 1px 3px rgba(0, 0, 0, .04), 0 4px 12px rgba(0, 0, 0, .04);
$fp-shadow-button: 0 1px 3px rgba(26, 107, 89, .25), 0 4px 12px rgba(26, 107, 89, .18);
$fp-shadow-button-hover:0 2px 4px rgba(26, 107, 89, .30), 0 6px 16px rgba(26, 107, 89, .22);
$fp-shadow-danger: 0 1px 3px rgba(185, 28, 28, .25), 0 4px 12px rgba(185, 28, 28, .15);
$fp-glow-ring-teal: 0 0 0 4px rgba(46, 175, 147, .20);
$fp-glow-ring-amber: 0 0 0 4px rgba(245, 158, 11, .20);
// Geometry
$fp-radius-pill: 9999px;
$fp-radius-card: 14px;
$fp-radius-button: 9px;
$fp-radius-chip: 8px;
$fp-radius-icon: 7px;
$fp-radius-tile: 11px;
// Spacing scale (rem)
$fp-space-1: .25rem;
$fp-space-2: .5rem;
$fp-space-3: .7rem;
$fp-space-4: 1rem;
$fp-space-5: 1.25rem;
$fp-space-6: 1.5rem;
// Typography
$fp-font: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
$fp-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, monospace;
// Dark-mode placeholder — DEFERRED per spec.
// When implementing, branch on $o-webclient-color-scheme per CLAUDE.md rule 9.
// Example pattern (do NOT enable now):
// @if $o-webclient-color-scheme == dark {
// $fp-page-bg: #0e1f1b !global;
// $fp-card-bg: #1a2b27 !global;
// // ...
// }
- Step 2: Verify file exists
Run: ls -la K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss
Expected: file present, non-zero size.
- Step 3: Commit (atomic)
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss && \
git commit -m "feat(portal): add brand design tokens partial
EN Plating teal palette + gradient/shadow/radius/spacing/typography
tokens. Single source of truth for the customer portal redesign.
Tokens load first in web.assets_frontend so downstream SCSS sees them.
Refs spec: docs/superpowers/specs/2026-05-17-portal-dashboard-redesign-design.md"
Task 2: Create fp_portal_buttons.scss
Files:
-
Create:
fusion_plating_portal/static/src/scss/fp_portal_buttons.scss -
Step 1: Write the gradient button system
Create K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_buttons.scss with this content:
// ============================================================================
// Fusion Plating — Portal · Button system
// Gradient primary CTA, soft secondary, ghost tertiary, gradient danger.
// All states use class hooks under .o_fp_btn_* so they don't fight Bootstrap.
// ============================================================================
.o_fp_btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $fp-space-2;
padding: .55rem 1.1rem;
border-radius: $fp-radius-button;
font-family: $fp-font;
font-size: .85rem;
font-weight: 600;
line-height: 1.1;
border: none;
cursor: pointer;
text-decoration: none;
transition: transform .08s ease, box-shadow .15s ease;
user-select: none;
&:focus-visible {
outline: 2px solid $fp-teal;
outline-offset: 2px;
}
&:active { transform: translateY(1px); }
&:disabled,
&.disabled {
opacity: .55;
cursor: not-allowed;
pointer-events: none;
}
}
// PRIMARY — gradient teal CTA
.o_fp_btn_primary {
@extend .o_fp_btn;
background: $fp-gradient-primary;
color: #fff;
box-shadow: $fp-shadow-button;
text-shadow: 0 1px 0 rgba(0, 0, 0, .08);
&:hover { box-shadow: $fp-shadow-button-hover; color: #fff; }
}
// SECONDARY — outlined, very subtle gradient
.o_fp_btn_secondary {
@extend .o_fp_btn;
background: $fp-gradient-secondary;
color: $fp-teal;
border: 1px solid $fp-card-border-dark;
box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
&:hover { background: $fp-section-bg; color: $fp-teal-dark; }
}
// GHOST — text-only with subtle hover
.o_fp_btn_ghost {
@extend .o_fp_btn;
background: transparent;
color: $fp-teal;
font-weight: 500;
padding: .45rem .85rem;
&:hover { background: rgba(46, 175, 147, .08); color: $fp-teal-dark; }
}
// DANGER — gradient red
.o_fp_btn_danger {
@extend .o_fp_btn;
background: $fp-gradient-danger;
color: #fff;
box-shadow: $fp-shadow-danger;
&:hover { color: #fff; }
}
// MINT-PILL — soft branded "view all" affordance
.o_fp_btn_mint {
@extend .o_fp_btn;
background: $fp-gradient-mint;
color: $fp-teal;
border: 1px solid $fp-aqua;
font-weight: 600;
&:hover { color: $fp-teal-dark; }
}
// Size modifiers
.o_fp_btn_sm { padding: .35rem .75rem; font-size: .76rem; }
.o_fp_btn_lg { padding: .75rem 1.4rem; font-size: .95rem; }
- Step 2: Verify file
Run: ls -la K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_buttons.scss
Expected: file present.
- Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/scss/fp_portal_buttons.scss && \
git commit -m "feat(portal): gradient button system (primary/secondary/ghost/danger/mint)
Five button variants under .o_fp_btn_* classes that don't fight
Bootstrap. Primary uses the brand teal gradient with mint-tinted
shadow; danger uses the red gradient. Focus/hover/active states
included."
Task 3: Register Phase 1 assets in manifest + version bump
Files:
-
Modify:
fusion_plating_portal/__manifest__.py -
Step 1: Read current manifest
Read fusion_plating_portal/__manifest__.py lines 1-78 to see current asset block.
- Step 2: Bump version and add Phase 1 assets
Modify the manifest:
Change 'version': '19.0.2.3.0' to 'version': '19.0.3.0.0'.
Replace the 'assets' block with:
'assets': {
'web.assets_frontend': [
# Tokens MUST be first so every later file sees the variables.
'fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss',
# Phase 1 — button system
'fusion_plating_portal/static/src/scss/fp_portal_buttons.scss',
# Catch-all legacy rules (last)
'fusion_plating_portal/static/src/scss/fusion_plating_portal.scss',
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
],
},
- Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/__manifest__.py && \
git commit -m "chore(portal): bump version 19.0.3.0.0 + register Phase 1 SCSS
Tokens partial loaded first; buttons SCSS loaded next; legacy
catch-all stays last. Per CLAUDE.md rule 8 every SCSS file is a
separate entry (no @import allowed in Odoo 19 custom SCSS)."
Task 4: Deploy Phase 1 to entech + visual verify
Files: (deployment, no files)
- Step 1: Copy the 3 new/modified files to entech
cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss'"
cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_buttons.scss | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_portal/static/src/scss/fp_portal_buttons.scss'"
cat K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/__manifest__.py | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_portal/__manifest__.py'"
- Step 2: Upgrade module + restart odoo
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 -15 && systemctl start odoo'"
Expected: "Modules loaded" + "Registry loaded" in tail, no traceback for fusion_plating_portal.
- Step 3: Verify service up + version bumped
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl is-active odoo'"
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -t -c \\\"SELECT latest_version FROM ir_module_module WHERE name='fusion_plating_portal';\\\"\""
Expected: active and 19.0.3.0.0.
- Step 4: Human visual check
Open https://enplating.com/my/home, log in as admin. Buttons should not have changed visually yet (no template references them). This step confirms only that the SCSS compiles without breaking the bundle. If /my/home 500s, check tail -50 /var/log/odoo/odoo-server.log on entech for SCSS compile errors.
Run: curl -sI -o /dev/null -w "%{http_code}\n" https://enplating.com/my/home
Expected: 303 (redirect to login) — anything else means the page broke.
Task 5: Visual sanity end-of-phase
Same as Task 4 Step 4 — confirm no regression. Phase 1 is dormant until templates reference the new classes (Phase 2 onwards). No further commits in this phase.
PHASE 2 — Macros + Jobs-Forward Dashboard
Goal: rewrite /my/home to use the jobs-forward layout from branded-dashboard.html. Tokens + buttons + new component SCSS files + macros + dashboard template + 3 new welcome-line counts in the controller.
Task 6: Create fp_portal_badges.scss
Files:
-
Create:
fusion_plating_portal/static/src/scss/fp_portal_badges.scss -
Step 1: Write the badge SCSS
// ============================================================================
// Fusion Plating — Portal · Status badges
// Pill with coloured dot + soft glow halo. Maps directly to fp.portal.job.state
// (and similar enum fields on quote / invoice / delivery).
// ============================================================================
.o_fp_badge {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .25rem .7rem;
border-radius: $fp-radius-pill;
font-family: $fp-font;
font-size: .7rem;
font-weight: 600;
line-height: 1.1;
white-space: nowrap;
.o_fp_badge_dot {
width: 7px;
height: 7px;
border-radius: $fp-radius-pill;
flex-shrink: 0;
}
}
// State mapping — extend with `class="o_fp_badge o_fp_badge_<state>"`.
.o_fp_badge_received,
.o_fp_badge_new {
background: $fp-section-bg;
color: $fp-text-body;
.o_fp_badge_dot { background: $fp-muted; }
}
.o_fp_badge_in_progress,
.o_fp_badge_quoted {
background: $fp-mint;
color: $fp-teal-dark;
.o_fp_badge_dot { background: $fp-teal; box-shadow: 0 0 0 3px rgba(26, 107, 89, .18); }
}
.o_fp_badge_quality_check,
.o_fp_badge_under_review {
background: $fp-amber-bg;
color: $fp-amber-text;
.o_fp_badge_dot { background: $fp-amber; box-shadow: 0 0 0 3px rgba(245, 158, 11, .18); }
}
.o_fp_badge_ready_to_ship,
.o_fp_badge_accepted,
.o_fp_badge_paid {
background: $fp-success-bg;
color: $fp-success-text;
.o_fp_badge_dot { background: $fp-success; }
}
.o_fp_badge_shipped,
.o_fp_badge_complete {
background: $fp-success-bg;
color: $fp-success-text;
.o_fp_badge_dot { background: $fp-success; }
}
.o_fp_badge_declined,
.o_fp_badge_overdue,
.o_fp_badge_hold {
background: $fp-danger-bg;
color: $fp-danger-dark;
.o_fp_badge_dot { background: $fp-danger; }
}
- Step 2: Verify file and Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/scss/fp_portal_badges.scss && \
git commit -m "feat(portal): status badge pills with dot + glow halo"
Task 7: Create fp_portal_cards.scss
Files:
-
Create:
fusion_plating_portal/static/src/scss/fp_portal_cards.scss -
Step 1: Write the card SCSS
// ============================================================================
// Fusion Plating — Portal · Card shells + KPI tiles + doc chips
// ============================================================================
// Generic card shell
.o_fp_card {
background: $fp-card-bg;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-card;
padding: $fp-space-5;
box-shadow: $fp-shadow-card;
}
.o_fp_card_compact {
@extend .o_fp_card;
padding: $fp-space-3 $fp-space-4;
border-radius: $fp-radius-tile;
}
.o_fp_card_hoverable {
transition: box-shadow .15s ease, transform .08s ease;
&:hover {
box-shadow: $fp-shadow-card-hover;
transform: translateY(-1px);
}
}
// KPI tile (the 4-tile strip across the top of the dashboard)
.o_fp_kpi_tile {
@extend .o_fp_card_compact;
.o_fp_kpi_label {
font-size: .66rem;
color: $fp-muted;
text-transform: uppercase;
letter-spacing: .05em;
font-weight: 600;
margin-bottom: .2rem;
}
.o_fp_kpi_value {
font-size: 1.5rem;
font-weight: 700;
color: $fp-text;
line-height: 1;
}
.o_fp_kpi_hint {
font-size: .7rem;
margin-top: .2rem;
color: $fp-muted;
&.o_fp_hint_action {
color: $fp-teal;
font-weight: 500;
}
&.o_fp_hint_success { color: $fp-success-text; font-weight: 500; }
&.o_fp_hint_warn { color: $fp-amber-text; font-weight: 500; }
}
// Highlighted KPI (the In-Flight Jobs hero metric)
&.o_fp_kpi_hero {
background: $fp-gradient-mint;
border-color: $fp-aqua;
.o_fp_kpi_label,
.o_fp_kpi_value {
color: $fp-teal-dark;
}
}
}
// Doc chip (compact attachment pill)
.o_fp_doc_chip {
display: inline-flex;
align-items: center;
gap: .35rem;
padding: .25rem .55rem;
background: $fp-section-bg;
color: $fp-teal;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-chip;
font-size: .7rem;
font-weight: 500;
text-decoration: none;
&:hover {
background: $fp-mint;
color: $fp-teal-dark;
}
&.o_fp_doc_chip_pending {
background: $fp-card-bg;
color: $fp-muted-light;
border: 1px dashed $fp-card-border-dark;
cursor: default;
}
}
// Document row (used inside grouped doc panel)
.o_fp_doc_row {
display: flex;
align-items: center;
padding: .55rem .7rem;
background: $fp-page-bg;
border-radius: $fp-radius-chip;
margin-bottom: .4rem;
text-decoration: none;
transition: background .12s ease;
&:hover { background: $fp-section-bg; }
.o_fp_doc_icon {
width: 32px;
height: 32px;
border-radius: $fp-radius-icon;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: .85rem;
margin-right: .7rem;
flex-shrink: 0;
}
.o_fp_doc_meta { flex: 1; min-width: 0; }
.o_fp_doc_name {
font-size: .84rem;
color: $fp-text;
font-weight: 500;
}
.o_fp_doc_sub {
font-size: .7rem;
color: $fp-muted-light;
}
.o_fp_doc_action {
color: $fp-teal;
font-size: .74rem;
text-decoration: none;
font-weight: 500;
padding: .25rem .5rem;
}
// Icon color variants — tint per doc category
.o_fp_doc_icon_input { background: #eff6ff; color: #1e40af; }
.o_fp_doc_icon_drawing { background: $fp-success-bg; color: $fp-success-text; }
.o_fp_doc_icon_spec { background: $fp-amber-bg; color: $fp-amber-text; }
.o_fp_doc_icon_quality { background: $fp-mint; color: $fp-teal-dark; }
.o_fp_doc_icon_shipping { background: $fp-mint-pastel; color: $fp-teal; }
.o_fp_doc_icon_pending { background: $fp-section-bg; color: $fp-muted-light; }
// Pending state for not-yet-generated docs
&.o_fp_doc_row_pending {
background: $fp-card-bg;
border: 1px dashed $fp-card-border;
opacity: .9;
cursor: default;
.o_fp_doc_name, .o_fp_doc_sub { color: $fp-muted-light; }
}
}
// Doc group label
.o_fp_doc_group_label {
font-size: .7rem;
color: $fp-muted;
text-transform: uppercase;
letter-spacing: .04em;
font-weight: 600;
margin-bottom: .45rem;
}
- Step 2: Verify and Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/scss/fp_portal_cards.scss && \
git commit -m "feat(portal): card shells, KPI tiles, doc chips + rows"
Task 8: Create fp_portal_stepper.scss
Files:
-
Create:
fusion_plating_portal/static/src/scss/fp_portal_stepper.scss -
Step 1: Write the stepper SCSS
// ============================================================================
// Fusion Plating — Portal · Numbered stepper
// Horizontal circle+line stepper for job progress on dashboard cards.
// 5 steps fixed (Received / Inspected / Plating / QC / Ship) by default;
// macro accepts variable step count.
// ============================================================================
.o_fp_stepper {
display: flex;
align-items: center;
margin-bottom: .35rem;
.o_fp_step_circle {
width: 24px;
height: 24px;
border-radius: $fp-radius-pill;
display: flex;
align-items: center;
justify-content: center;
font-family: $fp-font;
font-size: .65rem;
font-weight: 700;
flex-shrink: 0;
background: $fp-card-bg;
border: 1.5px solid $fp-card-border;
color: $fp-muted-light;
}
.o_fp_step_done {
background: $fp-gradient-primary;
color: #fff;
border: none;
box-shadow: 0 1px 2px rgba(26, 107, 89, .25);
}
.o_fp_step_active {
background: $fp-card-bg;
color: $fp-teal;
border: 2.5px solid $fp-teal;
box-shadow: $fp-glow-ring-teal;
}
.o_fp_step_active_warn {
// Used when the active step is in QC (amber)
background: $fp-card-bg;
color: $fp-amber-text;
border: 2.5px solid $fp-amber;
box-shadow: $fp-glow-ring-amber;
}
.o_fp_step_line {
flex: 1;
height: 2px;
margin: 0 3px;
background: $fp-card-border;
&.o_fp_step_line_done { background: $fp-teal; }
&.o_fp_step_line_warn { background: $fp-amber; }
}
}
// Step labels row below the stepper
.o_fp_step_labels {
display: flex;
justify-content: space-between;
font-size: .68rem;
.o_fp_step_label {
text-align: center;
flex: 1;
.o_fp_step_label_title {
color: $fp-muted-light;
font-weight: 500;
}
.o_fp_step_label_time {
color: $fp-disabled;
font-size: .6rem;
}
&.o_fp_step_label_done {
.o_fp_step_label_title { color: $fp-text-body; }
.o_fp_step_label_time { color: $fp-muted-light; }
}
&.o_fp_step_label_active {
.o_fp_step_label_title { color: $fp-teal; font-weight: 700; }
.o_fp_step_label_time { color: $fp-teal; }
}
&.o_fp_step_label_active_warn {
.o_fp_step_label_title { color: $fp-amber-text; font-weight: 700; }
.o_fp_step_label_time { color: $fp-amber-text; }
}
}
}
- Step 2: Verify and Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/scss/fp_portal_stepper.scss && \
git commit -m "feat(portal): numbered horizontal stepper with state classes"
Task 9: Create fp_portal_dashboard.scss
Files:
-
Create:
fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss -
Step 1: Write the dashboard layout SCSS
// ============================================================================
// Fusion Plating — Portal · Dashboard layout
// Jobs-forward grid: welcome strip → KPI tile row → hero jobs section →
// secondary panel strip.
// ============================================================================
.o_fp_dashboard {
background: $fp-page-bg;
padding: $fp-space-6;
border-radius: $fp-radius-card;
border: 1px solid $fp-card-border;
font-family: $fp-font;
}
.o_fp_welcome {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: $fp-space-4;
flex-wrap: wrap;
gap: $fp-space-3;
.o_fp_welcome_title {
font-size: 1.15rem;
font-weight: 600;
color: $fp-text;
margin-bottom: .18rem;
}
.o_fp_welcome_sub {
font-size: .82rem;
color: $fp-muted;
}
}
.o_fp_kpi_row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $fp-space-3;
margin-bottom: $fp-space-5;
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
}
.o_fp_jobs_hero {
margin-bottom: $fp-space-5;
.o_fp_section_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $fp-space-3;
.o_fp_section_title {
font-weight: 600;
font-size: 1rem;
color: $fp-text;
}
}
// Status filter tabs on the jobs hero
.o_fp_status_tabs {
display: flex;
gap: .35rem;
font-size: .74rem;
align-items: center;
background: $fp-card-bg;
border: 1px solid $fp-card-border;
border-radius: $fp-radius-button;
padding: .2rem;
.o_fp_status_tab {
padding: .25rem .6rem;
border-radius: 6px;
color: $fp-muted;
cursor: pointer;
text-decoration: none;
&.active {
background: $fp-gradient-tab;
color: $fp-teal-dark;
font-weight: 600;
}
}
}
.o_fp_view_all {
text-align: center;
padding: .45rem;
a { @extend .o_fp_btn_mint; }
}
}
.o_fp_job_card {
@extend .o_fp_card;
padding: $fp-space-4;
border-radius: $fp-radius-tile;
margin-bottom: $fp-space-3;
box-shadow: $fp-shadow-card;
.o_fp_job_header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $fp-space-3;
.o_fp_job_ref {
font-weight: 600;
color: $fp-text;
font-size: .98rem;
}
.o_fp_job_meta {
color: $fp-muted;
font-size: .8rem;
margin-left: .65rem;
}
}
.o_fp_job_docs {
display: flex;
flex-wrap: wrap;
gap: .35rem;
margin-top: $fp-space-3;
padding-top: .6rem;
border-top: 1px solid $fp-section-bg;
}
}
.o_fp_secondary_panels {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $fp-space-3;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
.o_fp_panel {
@extend .o_fp_card_compact;
.o_fp_panel_title {
font-weight: 600;
font-size: .82rem;
color: $fp-text;
margin-bottom: .5rem;
display: flex;
align-items: center;
gap: .4rem;
.o_fp_panel_icon {
background: $fp-gradient-icon;
color: $fp-teal-dark;
width: 24px;
height: 24px;
border-radius: $fp-radius-icon;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: .78rem;
}
}
.o_fp_panel_row {
font-size: .72rem;
color: $fp-muted;
margin-top: .2rem;
&:first-of-type { margin-top: 0; }
}
}
}
- Step 2: Verify and Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss && \
git commit -m "feat(portal): jobs-forward dashboard layout SCSS
Welcome strip + 4-tile KPI row + jobs hero + secondary 3-panel strip.
Responsive at 768px (KPI grid → 2x2, secondary → stacked)."
Task 10: Create fp_portal_macros.xml
Files:
-
Create:
fusion_plating_portal/views/fp_portal_macros.xml -
Step 1: Write the macros file
Create fusion_plating_portal/views/fp_portal_macros.xml:
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Shared QWeb macros for the customer portal redesign.
Every template should t-call these instead of inlining stepper/badge/doc HTML.
-->
<odoo>
<!-- ================================================================== -->
<!-- Status badge — pass state (string) and label (string) -->
<!-- ================================================================== -->
<template id="fp_portal_status_badge" name="Portal: Status Badge">
<span t-attf-class="o_fp_badge o_fp_badge_#{state}">
<span class="o_fp_badge_dot"/>
<t t-out="label"/>
</span>
</template>
<!-- ================================================================== -->
<!-- Numbered horizontal stepper — pass `steps` list of dicts: -->
<!-- {label, status: 'done'|'active'|'pending', time_label} -->
<!-- active_state: 'normal' (teal) or 'warn' (amber) -->
<!-- ================================================================== -->
<template id="fp_portal_stepper" name="Portal: Numbered Stepper">
<t t-set="active_state" t-value="active_state or 'normal'"/>
<div class="o_fp_stepper">
<t t-foreach="steps" t-as="step">
<!-- circle -->
<div t-attf-class="o_fp_step_circle #{
'o_fp_step_done' if step['status'] == 'done' else
(('o_fp_step_active_warn' if active_state == 'warn' else 'o_fp_step_active') if step['status'] == 'active' else '')
}">
<t t-if="step['status'] == 'done'">✓</t>
<t t-elif="step['status'] in ('active', 'pending')">
<t t-out="step_index + 1"/>
</t>
</div>
<!-- connecting line (omit after last circle) -->
<t t-if="not step_last">
<div t-attf-class="o_fp_step_line #{
'o_fp_step_line_done' if step['status'] == 'done' else
('o_fp_step_line_warn' if active_state == 'warn' and step['status'] == 'active' else '')
}"/>
</t>
</t>
</div>
<!-- Labels under -->
<div class="o_fp_step_labels">
<t t-foreach="steps" t-as="step">
<div t-attf-class="o_fp_step_label #{
'o_fp_step_label_done' if step['status'] == 'done' else
(('o_fp_step_label_active_warn' if active_state == 'warn' else 'o_fp_step_label_active') if step['status'] == 'active' else '')
}">
<div class="o_fp_step_label_title" t-out="step['label']"/>
<div class="o_fp_step_label_time" t-out="step.get('time_label') or ''"/>
</div>
</t>
</div>
</template>
<!-- ================================================================== -->
<!-- Doc chip (compact) — pass doc dict {icon, label, url, pending} -->
<!-- ================================================================== -->
<template id="fp_portal_doc_chip" name="Portal: Doc Chip">
<t t-if="doc.get('pending')">
<span class="o_fp_doc_chip o_fp_doc_chip_pending">
<t t-out="doc.get('icon') or '📑'"/>
<span t-out="doc['label']"/> · pending
</span>
</t>
<t t-else="">
<a t-att-href="doc['url']" class="o_fp_doc_chip">
<t t-out="doc.get('icon') or '📄'"/>
<span t-out="doc['label']"/>
</a>
</t>
</template>
<!-- ================================================================== -->
<!-- Doc group (detail page) — pass label + docs list of dicts: -->
<!-- {label, sub, url, icon_class, pending} -->
<!-- ================================================================== -->
<template id="fp_portal_doc_group" name="Portal: Doc Group">
<div class="o_fp_doc_group" style="margin-bottom: 1.1rem">
<div class="o_fp_doc_group_label" t-out="group_label"/>
<t t-foreach="docs" t-as="doc">
<t t-if="doc.get('pending')">
<div class="o_fp_doc_row o_fp_doc_row_pending">
<span t-attf-class="o_fp_doc_icon o_fp_doc_icon_pending">📑</span>
<div class="o_fp_doc_meta">
<div class="o_fp_doc_name" t-out="doc['label']"/>
<div class="o_fp_doc_sub" t-out="doc.get('sub') or ''"/>
</div>
<span style="color: #cbd5e1; font-size: .72rem">—</span>
</div>
</t>
<t t-else="">
<a t-att-href="doc['url']" class="o_fp_doc_row">
<span t-attf-class="o_fp_doc_icon #{doc.get('icon_class') or 'o_fp_doc_icon_input'}">
<t t-out="doc.get('icon') or '📄'"/>
</span>
<div class="o_fp_doc_meta">
<div class="o_fp_doc_name" t-out="doc['label']"/>
<div class="o_fp_doc_sub" t-out="doc.get('sub') or ''"/>
</div>
<span class="o_fp_doc_action">↓ Download</span>
</a>
</t>
</t>
</div>
</template>
</odoo>
- Step 2: Verify and Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/views/fp_portal_macros.xml && \
git commit -m "feat(portal): shared QWeb macros (badge, stepper, doc chip, doc group)
Macros take dict args so callers never reach into the underlying
records — keeps templates testable + makes the stepper reusable
on dashboard cards AND detail-page if needed."
Task 11: Add welcome-summary counts + tests
Files:
-
Create:
fusion_plating_portal/tests/__init__.py -
Create:
fusion_plating_portal/tests/test_portal_dashboard.py -
Modify:
fusion_plating_portal/controllers/portal.py(thehome()method) -
Step 1: Create the test package init
Create fusion_plating_portal/tests/__init__.py:
from . import test_portal_dashboard
- Step 2: Write the failing test
Create fusion_plating_portal/tests/test_portal_dashboard.py:
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1.
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install', 'fp_portal')
class TestPortalDashboard(TransactionCase):
"""Welcome-line summary counts for the redesigned /my/home."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({
'name': 'Test Customer Co.',
'email': 'test@example.com',
})
cls.portal_user = cls.env['res.users'].create({
'name': 'Portal Tester',
'login': 'portal_tester',
'partner_id': cls.partner.id,
'group_ids': [(6, 0, [cls.env.ref('base.group_portal').id])],
})
Job = cls.env['fusion.plating.portal.job']
# 2 active, 1 ready_to_ship, 1 shipped (should not count)
cls.job_received = Job.create({
'name': 'WO-TEST-001', 'partner_id': cls.partner.id, 'state': 'received'})
cls.job_in_progress = Job.create({
'name': 'WO-TEST-002', 'partner_id': cls.partner.id, 'state': 'in_progress'})
cls.job_ready = Job.create({
'name': 'WO-TEST-003', 'partner_id': cls.partner.id, 'state': 'ready_to_ship'})
cls.job_shipped = Job.create({
'name': 'WO-TEST-004', 'partner_id': cls.partner.id, 'state': 'shipped'})
# 1 quoted RFQ (counts as awaiting_review), 1 new (does not count)
Quote = cls.env['fusion.plating.quote.request']
cls.quote_quoted = Quote.create({
'name': 'QR-TEST-001', 'partner_id': cls.partner.id, 'state': 'quoted'})
cls.quote_new = Quote.create({
'name': 'QR-TEST-002', 'partner_id': cls.partner.id, 'state': 'new'})
def test_welcome_counts_separates_active_from_ready_from_review(self):
"""The 3 welcome-line numbers split correctly across states."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
# Call the private helper directly so we don't need a full http request.
controller = FpCustomerPortal()
# active = received + in_progress + quality_check
active = self.env['fusion.plating.portal.job'].search_count([
('partner_id', 'child_of', self.partner.commercial_partner_id.id),
('state', 'in', ['received', 'in_progress', 'quality_check']),
])
awaiting_review = self.env['fusion.plating.quote.request'].search_count([
('partner_id', 'child_of', self.partner.commercial_partner_id.id),
('state', '=', 'quoted'),
])
ready_to_ship = self.env['fusion.plating.portal.job'].search_count([
('partner_id', 'child_of', self.partner.commercial_partner_id.id),
('state', '=', 'ready_to_ship'),
])
self.assertEqual(active, 2)
self.assertEqual(awaiting_review, 1)
self.assertEqual(ready_to_ship, 1)
- Step 3: Run the test (will fail because tests/init.py not registered)
Run on entech (after deploy in Task 14):
ssh pve-worker5 "pct exec 111 -- 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 -30'"
Expected at this point: tests don't run yet — the package needs to be picked up after a module upgrade.
- Step 4: Add the welcome-line counts to
home()
Modify fusion_plating_portal/controllers/portal.py — find the def home(self, **kw): method (renamed during the 2026-05-17 hotfix). Just BEFORE the existing values = { block, add:
# Welcome-line summary counts (jobs-forward dashboard).
active_job_count = Job.search_count([
('partner_id', 'child_of', commercial.id),
('state', 'in', ['received', 'in_progress', 'quality_check']),
])
awaiting_review_count = Quote.search_count([
('partner_id', 'child_of', commercial.id),
('state', '=', 'quoted'),
])
ready_to_ship_count = Job.search_count([
('partner_id', 'child_of', commercial.id),
('state', '=', 'ready_to_ship'),
])
Then in the values = { dict, after the existing job entries, add:
# Welcome-line summary
'active_job_count': active_job_count,
'awaiting_review_count': awaiting_review_count,
'ready_to_ship_count': ready_to_ship_count,
- Step 5: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/tests/ fusion_plating_portal/controllers/portal.py && \
git commit -m "feat(portal): welcome-line summary counts on /my/home + tests
Adds active_job_count, awaiting_review_count, ready_to_ship_count
to the dashboard context. Tests verify partition is correct across
the fp.portal.job and fp.quote.request state machines."
Task 12: Rewrite fp_portal_home_dashboard template
Files:
-
Modify:
fusion_plating_portal/views/fp_portal_dashboard.xml -
Step 1: Read the existing template (lines 1-350) to remember the structure.
-
Step 2: Replace the
fp_portal_home_dashboardtemplate body
In fusion_plating_portal/views/fp_portal_dashboard.xml, find the <template id="fp_portal_home_dashboard" ...> and replace EVERYTHING inside the outer <t t-call="portal.portal_layout"> (the welcome header + quick actions + 6-card grid) with this:
<div class="o_fp_dashboard mt-3">
<!-- Welcome strip -->
<div class="o_fp_welcome">
<div>
<div class="o_fp_welcome_title">
Welcome back, <span t-out="partner.name"/>
</div>
<div class="o_fp_welcome_sub">
<t t-out="active_job_count"/> active job<t t-if="active_job_count != 1">s</t>
<t t-if="awaiting_review_count"> · <t t-out="awaiting_review_count"/> awaiting your review</t>
<t t-if="ready_to_ship_count"> · <t t-out="ready_to_ship_count"/> ready to ship</t>
</div>
</div>
<a href="/my/configurator" class="o_fp_btn_primary">
<i class="fa fa-plus"/> Get a Quote
</a>
</div>
<!-- KPI tiles -->
<div class="o_fp_kpi_row">
<div class="o_fp_kpi_tile">
<div class="o_fp_kpi_label">Open Quotes</div>
<div class="o_fp_kpi_value" t-out="quote_count"/>
<a href="/my/quote_requests" class="o_fp_kpi_hint o_fp_hint_action">View quotes →</a>
</div>
<div class="o_fp_kpi_tile">
<div class="o_fp_kpi_label">Active POs</div>
<div class="o_fp_kpi_value" t-out="po_count"/>
<a href="/my/purchase_orders" class="o_fp_kpi_hint">View POs →</a>
</div>
<div class="o_fp_kpi_tile o_fp_kpi_hero">
<div class="o_fp_kpi_label">In-Flight Jobs</div>
<div class="o_fp_kpi_value" t-out="active_job_count"/>
<t t-if="ready_to_ship_count">
<div class="o_fp_kpi_hint o_fp_hint_success">
<t t-out="ready_to_ship_count"/> ready to ship ✓
</div>
</t>
</div>
<div class="o_fp_kpi_tile">
<div class="o_fp_kpi_label">Invoices</div>
<div class="o_fp_kpi_value" t-out="invoice_count"/>
<a href="/my/fp_invoices" class="o_fp_kpi_hint">View invoices →</a>
</div>
</div>
<!-- Active jobs hero -->
<div class="o_fp_jobs_hero">
<div class="o_fp_section_header">
<div class="o_fp_section_title">Active Work Orders</div>
<a href="/my/jobs" class="o_fp_btn_ghost">All Jobs →</a>
</div>
<t t-if="recent_jobs">
<t t-foreach="recent_jobs[:3]" t-as="job">
<div class="o_fp_job_card">
<div class="o_fp_job_header">
<div>
<a t-att-href="'/my/jobs/%s' % job.id"
class="o_fp_job_ref text-decoration-none"
t-out="job.name"/>
<span class="o_fp_job_meta">
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
</span>
</div>
<t t-call="fusion_plating_portal.fp_portal_status_badge">
<t t-set="state" t-value="job.state"/>
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
</t>
</div>
<!-- Compact stepper inline -->
<t t-set="state_idx" t-value="['received','in_progress','quality_check','ready_to_ship','shipped'].index(job.state) if job.state in ['received','in_progress','quality_check','ready_to_ship','shipped'] else 0"/>
<t t-set="steps" t-value="[
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
]"/>
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
<!-- Doc chips: CoC + packing list (V1: just these two, Phase 3 expands) -->
<div class="o_fp_job_docs">
<t t-if="job.coc_attachment_id">
<t t-call="fusion_plating_portal.fp_portal_doc_chip">
<t t-set="doc" t-value="{'icon': '📑', 'label': 'CoC', 'url': '/my/jobs/%s/coc' % job.id}"/>
</t>
</t>
<t t-else="">
<t t-call="fusion_plating_portal.fp_portal_doc_chip">
<t t-set="doc" t-value="{'icon': '📑', 'label': 'CoC', 'pending': True}"/>
</t>
</t>
<t t-if="job.tracking_ref">
<span class="o_fp_doc_chip">📦 <span t-out="job.tracking_ref"/></span>
</t>
</div>
</div>
</t>
<t t-if="job_count > 3">
<div class="o_fp_view_all">
<a href="/my/jobs">View all <t t-out="job_count"/> jobs →</a>
</div>
</t>
</t>
<t t-else="">
<div class="o_fp_card text-center text-muted">
No active jobs yet.
<a href="/my/configurator" class="o_fp_btn_primary o_fp_btn_sm mt-2">+ Get Your First Quote</a>
</div>
</t>
</div>
<!-- Secondary panels -->
<div class="o_fp_secondary_panels">
<div class="o_fp_panel">
<div class="o_fp_panel_title">
<span class="o_fp_panel_icon">📑</span> Recent Certifications
</div>
<t t-if="recent_certs">
<t t-foreach="recent_certs[:3]" t-as="cert">
<div class="o_fp_panel_row">
<a t-att-href="'/my/jobs/%s' % cert.id" class="text-decoration-none">
CoC <span t-out="cert.name"/>
</a>
<t t-if="cert.actual_ship_date"> · <span t-field="cert.actual_ship_date" t-options='{"widget": "date"}'/></t>
</div>
</t>
</t>
<t t-else="">
<div class="o_fp_panel_row text-muted">No certifications yet.</div>
</t>
</div>
<div class="o_fp_panel">
<div class="o_fp_panel_title">
<span class="o_fp_panel_icon">📦</span> Recent Packing Slips
</div>
<t t-if="recent_deliveries">
<t t-foreach="recent_deliveries[:3]" t-as="d">
<div class="o_fp_panel_row">
<span t-out="d.name"/>
<t t-if="d.date_done"> · <span t-field="d.date_done" t-options='{"widget": "date"}'/></t>
</div>
</t>
</t>
<t t-else="">
<div class="o_fp_panel_row text-muted">No deliveries yet.</div>
</t>
</div>
<div class="o_fp_panel">
<div class="o_fp_panel_title">
<span class="o_fp_panel_icon">💰</span> Recent Invoices
</div>
<t t-if="recent_invoices">
<t t-foreach="recent_invoices[:3]" t-as="inv">
<div class="o_fp_panel_row">
<a t-att-href="'/my/fp_invoices/%s' % inv.id" class="text-decoration-none" t-out="inv.name"/>
<t t-if="inv.amount_total"> · <span t-field="inv.amount_total" t-options='{"widget": "monetary", "display_currency": inv.currency_id}'/></t>
<t t-if="inv.payment_state == 'paid'"> · <span class="o_fp_badge o_fp_badge_paid"><span class="o_fp_badge_dot"/>Paid</span></t>
</div>
</t>
</t>
<t t-else="">
<div class="o_fp_panel_row text-muted">No invoices yet.</div>
</t>
</div>
</div>
</div>
- Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/views/fp_portal_dashboard.xml && \
git commit -m "feat(portal): rewrite /my/home as jobs-forward dashboard
Welcome strip → 4-tile KPI row (In-Flight Jobs is the hero) →
Active Work Orders section with 3 most-recent V2 cards →
3-panel secondary strip (Certs / Packing Slips / Invoices).
Uses the new badge/stepper/doc-chip macros."
Task 13: Register Phase 2 assets + data in manifest
Files:
-
Modify:
fusion_plating_portal/__manifest__.py -
Step 1: Bump version + register new assets + new data file
Change 'version': '19.0.3.0.0' to 'version': '19.0.3.1.0'.
In 'data': [...], add 'views/fp_portal_macros.xml' AFTER the security entries and BEFORE the existing view files (load macros before any template that t-calls them):
'data': [
'security/fp_portal_security.xml',
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'views/fp_portal_macros.xml', # NEW — macros first
'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',
],
Update the 'assets' block in registration order:
'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/fusion_plating_portal.scss',
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
],
},
- 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.3.1.0 + register Phase 2 SCSS/data"
Task 14: Deploy Phase 2 to entech + run tests + visual verify
Files: (deployment)
- Step 1: Copy all changed/new files to entech
for f in \
static/src/scss/fp_portal_badges.scss \
static/src/scss/fp_portal_cards.scss \
static/src/scss/fp_portal_stepper.scss \
static/src/scss/fp_portal_dashboard.scss \
views/fp_portal_macros.xml \
views/fp_portal_dashboard.xml \
controllers/portal.py \
tests/__init__.py \
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 'mkdir -p \$(dirname /mnt/extra-addons/custom/fusion_plating_portal/$f) && cat > /mnt/extra-addons/custom/fusion_plating_portal/$f'"
done
- Step 2: Upgrade + 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 -50\" && systemctl start odoo'"
Expected: Modules loaded + Registry loaded + test output showing test_welcome_counts_separates_active_from_ready_from_review ... ok. If tests fail, fix in controllers/portal.py and retry.
- Step 3: Bust asset cache (CSS cached aggressively)
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
- Step 4: Human visual check
Open https://enplating.com/my/home in browser. Expected to match branded-dashboard.html mockup: welcome strip with "Welcome back, ", 4 KPI tiles with mint hero, Active Work Orders section with V2 stepper cards, 3 panels at the bottom.
If any mismatch — log it as a TODO; do NOT block on pixel parity here (Phase 4 will iron out residuals).
- Step 5: Phase 2 commit summary tag
cd K:/Github/Odoo-Modules/fusion_plating && \
git tag portal-phase2-shipped && \
echo "Phase 2 shipped"
PHASE 3 — Jobs List + Detail Page
Goal: rewrite /my/jobs (list) + /my/jobs/<id> (detail) with the vertical-timeline + grouped-documents layout.
Task 15: Investigate timestamp + document linking sources
Files: (investigation only — produces notes inline in this plan)
- Step 1: Inspect
fusion.plating.portal.jobschema
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c '\\\\d fusion_plating_portal_job'\""
Expected columns include at least: id, name, partner_id, state, received_date, target_ship_date, actual_ship_date, coc_attachment_id, packing_list_attachment_id, tracking_ref, invoice_ref, quantity, notes. Note presence of any *_at Datetime columns or _date Date columns.
- Step 2: Look for state-change tracking messages
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"SELECT model, tracking_value_ids, date FROM mail_message WHERE model = 'fusion.plating.portal.job' AND tracking_value_ids IS NOT NULL ORDER BY date DESC LIMIT 5;\\\"\""
If rows exist with state-change tracking, the chatter holds per-state timestamps via mail.tracking.value records linked to the message → field that changed. This is one viable source for timeline timestamps.
- Step 3: Decide V1 timestamp approach
Choose ONE based on Steps 1-2:
- A. Use existing date fields only:
received_datefor "Received";actual_ship_datefor "Shipped"; the 3 middle stages get the chatter-tracking message date if available, else fall back to "—". Simplest, no schema change. - B. Add per-stage Datetime fields to the model:
received_at,in_progress_started_at,qc_started_at,ready_to_ship_at,shipped_at. Populated viawrite()override that snapshotsfields.Datetime.now()whenstatechanges. Cleanest, but schema change.
Recommended default: B — small, additive, makes the timeline reliable without joining chatter at render time. Document the decision in this plan's task notes.
- Step 4: Decide V1 document-linking approach
fusion.plating.portal.job has direct fields: coc_attachment_id, packing_list_attachment_id. Other doc categories (PO, Drawing, Specification) require either:
- A. Add
sale_order_idMany2one tofp.portal.joband pull docs via SO chatter + lines. - B. Use only the chatter attachments on the portal job itself, with filename heuristics to categorise. Customer uploads attach to the portal job manually.
- C. V1 ships with just
coc_attachment_id+packing_list_attachment_idsurfaced; "From You" / "Specifications" groups show "No documents yet" placeholders.
Recommended default: C for V1, A for V2. Ship the detail page with the structure ready (4 groups visible), but the empty groups simply render their placeholder rows. V2 (separate change) adds the SO link and populates the missing groups.
- Step 5: Update the spec inline with chosen approaches
Edit docs/superpowers/specs/2026-05-17-portal-dashboard-redesign-design.md. Update the "Open items" section §1 and §4 with the chosen approaches and rationale. Update the doc-categorisation table to reflect V1 (placeholder rows for the 3 currently-unpopulated groups). No commit — this is documentation cleanup, bundled with Phase 3's commit.
Task 16: Add per-stage Datetime fields to fusion.plating.portal.job (if Task 15 chose option B)
Files:
- Modify:
fusion_plating_portal/models/fp_portal_job.py
Skip this task if Task 15 chose option A.
- Step 1: Add fields +
write()snapshot
In fusion_plating_portal/models/fp_portal_job.py, add these fields after the existing actual_ship_date field (around line 65):
received_at = fields.Datetime(
string='Received Timestamp',
readonly=True,
help='Auto-set when state first reaches `received`.',
)
in_progress_started_at = fields.Datetime(
string='In Progress Started At',
readonly=True,
)
qc_started_at = fields.Datetime(
string='QC Started At',
readonly=True,
)
ready_to_ship_at = fields.Datetime(
string='Ready to Ship At',
readonly=True,
)
shipped_at = fields.Datetime(
string='Shipped At',
readonly=True,
)
Add a write override at the end of the class (before # end of class):
_STATE_TO_TS_FIELD = {
'received': 'received_at',
'in_progress': 'in_progress_started_at',
'quality_check': 'qc_started_at',
'ready_to_ship': 'ready_to_ship_at',
'shipped': 'shipped_at',
}
def write(self, vals):
if 'state' in vals:
new_state = vals['state']
ts_field = self._STATE_TO_TS_FIELD.get(new_state)
if ts_field:
now = fields.Datetime.now()
for rec in self:
if not rec[ts_field]:
vals.setdefault(ts_field, now)
# All records in `self` get the same timestamp.
# If you need per-record nuance, split the loop.
break
return super().write(vals)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
# Snapshot the initial state's timestamp.
now = fields.Datetime.now()
for rec in records:
ts_field = self._STATE_TO_TS_FIELD.get(rec.state)
if ts_field and not rec[ts_field]:
rec[ts_field] = now
return records
- Step 2: Write a test for the snapshot behavior
Append to fusion_plating_portal/tests/test_portal_dashboard.py:
def test_state_change_snapshots_timestamp(self):
"""write({'state': 'in_progress'}) sets in_progress_started_at."""
from odoo import fields as odoo_fields
Job = self.env['fusion.plating.portal.job']
job = Job.create({
'name': 'WO-TS-001',
'partner_id': self.partner.id,
'state': 'received',
})
self.assertTrue(job.received_at, 'received_at set on create')
before = odoo_fields.Datetime.now()
job.state = 'in_progress'
self.assertTrue(job.in_progress_started_at, 'in_progress_started_at set')
self.assertGreaterEqual(job.in_progress_started_at, before)
# received_at must not be overwritten when state advances
self.assertTrue(job.received_at)
- Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/models/fp_portal_job.py fusion_plating_portal/tests/test_portal_dashboard.py && \
git commit -m "feat(portal): per-stage timestamps on fp.portal.job
Adds received_at, in_progress_started_at, qc_started_at,
ready_to_ship_at, shipped_at — snapshotted on state change via
write() override. Required for the vertical-timeline rendering on
the job detail page (Phase 3)."
Task 17: Add _fp_get_stage_timeline() helper + tests
Files:
-
Modify:
fusion_plating_portal/controllers/portal.py(add helper) -
Modify:
fusion_plating_portal/tests/test_portal_dashboard.py(add test) -
Step 1: Write the failing test FIRST
Append to tests/test_portal_dashboard.py:
def test_stage_timeline_for_job_in_quality_check(self):
"""Timeline returns 5 entries with correct status flags."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
Job = self.env['fusion.plating.portal.job']
job = Job.create({
'name': 'WO-TL-001',
'partner_id': self.partner.id,
'state': 'received',
})
job.state = 'in_progress'
job.state = 'quality_check'
timeline = FpCustomerPortal()._fp_get_stage_timeline(job)
self.assertEqual(len(timeline), 5)
statuses = [s['status'] for s in timeline]
# 2 done (received, in_progress), 1 active (QC), 2 pending (ready, shipped)
self.assertEqual(statuses, ['done', 'done', 'active', 'pending', 'pending'])
labels = [s['label'] for s in timeline]
self.assertEqual(labels, ['Received', 'In Progress', 'Quality Check',
'Ready to Ship', 'Shipped'])
# Done stages have a started_at value
self.assertIsNotNone(timeline[0]['started_at'])
self.assertIsNotNone(timeline[1]['started_at'])
# Active stage also has started_at
self.assertIsNotNone(timeline[2]['started_at'])
# Pending stages should not
self.assertIsNone(timeline[3]['started_at'])
self.assertIsNone(timeline[4]['started_at'])
- Step 2: Implement the helper on
FpCustomerPortal
Add to fusion_plating_portal/controllers/portal.py inside the FpCustomerPortal class, after the existing _fp_get_partner_domain helper (around line 114):
_FP_STAGE_DEFS = [
('received', 'Received', 'received_at'),
('in_progress', 'In Progress', 'in_progress_started_at'),
('quality_check', 'Quality Check', 'qc_started_at'),
('ready_to_ship', 'Ready to Ship', 'ready_to_ship_at'),
('shipped', 'Shipped', 'shipped_at'),
]
def _fp_get_stage_timeline(self, job):
"""Build a 5-entry timeline for the detail-page vertical view.
Returns a list of dicts in stage order. Each dict has:
label, status ('done'|'active'|'pending'), started_at (datetime|None),
time_label (formatted string), notes (str).
"""
state_order = [s[0] for s in self._FP_STAGE_DEFS]
try:
current_idx = state_order.index(job.state)
except ValueError:
current_idx = 0 # state out of model — should not happen
out = []
for i, (state_key, label, ts_field) in enumerate(self._FP_STAGE_DEFS):
if i < current_idx:
status = 'done'
elif i == current_idx:
status = 'active'
else:
status = 'pending'
ts = job[ts_field] if hasattr(job, ts_field) else None
time_label = ''
if ts:
time_label = ts.strftime('%b %d · %-I:%M%p').lower().replace('am', 'a').replace('pm', 'p')
elif status == 'pending':
# Use target_ship_date as fallback for the last stage
if state_key == 'shipped' and job.target_ship_date:
time_label = 'est. ' + job.target_ship_date.strftime('%b %d')
out.append({
'label': label,
'status': status,
'started_at': ts or None,
'time_label': time_label,
'notes': '',
})
return out
- Step 3: Run the test
ssh pve-worker5 "pct exec 111 -- bash -c '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 -30\"'"
Expected: test_stage_timeline_for_job_in_quality_check ... ok.
- 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): _fp_get_stage_timeline helper for detail-page timeline
Builds a 5-entry list (label, status, started_at, time_label, notes)
ordered by stage. Status partitions stages into done/active/pending
based on current job state. Time labels use lowercase am/pm to match
the mockup typography."
Task 18: Add _fp_group_documents() helper + tests
Files:
-
Modify:
fusion_plating_portal/controllers/portal.py -
Modify:
fusion_plating_portal/tests/test_portal_dashboard.py -
Step 1: Write failing test
Append to tests/test_portal_dashboard.py:
def test_group_documents_v1_returns_4_groups(self):
"""V1 doc grouping returns 4 groups; non-empty only for CoC + packing."""
from odoo.addons.fusion_plating_portal.controllers.portal import FpCustomerPortal
Job = self.env['fusion.plating.portal.job']
# Make a fake CoC attachment
att = self.env['ir.attachment'].create({
'name': 'CoC_WO-TEST.pdf',
'datas': b'',
'res_model': 'fusion.plating.portal.job',
})
job = Job.create({
'name': 'WO-DOC-001',
'partner_id': self.partner.id,
'state': 'shipped',
'coc_attachment_id': att.id,
})
groups = FpCustomerPortal()._fp_group_documents(job)
self.assertEqual(len(groups), 4)
keys = [g['key'] for g in groups]
self.assertEqual(keys, ['from_you', 'specs', 'quality', 'shipping'])
# Quality group has the CoC doc populated
quality = next(g for g in groups if g['key'] == 'quality')
self.assertTrue(any(d['label'] == 'Certificate of Conformance' and not d.get('pending')
for d in quality['docs']))
- Step 2: Implement the helper
Add to controllers/portal.py, after _fp_get_stage_timeline:
def _fp_group_documents(self, job):
"""Build the 4-group document panel for the job detail page.
V1: surfaces only the directly-attached fields on fp.portal.job
(coc_attachment_id, packing_list_attachment_id). All other groups
render placeholder/pending rows. V2 (separate change) wires in
sale_order_id, quote_request_id, part_catalog_id sources.
Returns a list of 4 dicts: {key, label, docs}, where docs is a list
of {label, sub, url, icon_class, icon, pending}.
"""
groups = [
{'key': 'from_you', 'label': 'From You', 'docs': []},
{'key': 'specs', 'label': 'Specifications', 'docs': []},
{'key': 'quality', 'label': 'Quality', 'docs': []},
{'key': 'shipping', 'label': 'Shipping', 'docs': []},
]
# FROM YOU — V1: empty placeholder
groups[0]['docs'].append({
'label': 'Customer documents',
'sub': 'Upload your PO and drawings via your sales contact for now',
'pending': True,
'icon': '📄',
})
# SPECIFICATIONS — V1: empty placeholder
groups[1]['docs'].append({
'label': 'Customer Specification',
'sub': 'Will appear when EN Plating links the spec',
'pending': True,
'icon': '📋',
})
# QUALITY — CoC from coc_attachment_id
if job.coc_attachment_id:
groups[2]['docs'].append({
'label': 'Certificate of Conformance',
'sub': 'EN Plating · %s · %s' % (
job.actual_ship_date and job.actual_ship_date.strftime('%b %d') or '',
self._fp_size_label(job.coc_attachment_id),
),
'url': '/my/jobs/%s/coc' % job.id,
'icon_class': 'o_fp_doc_icon_quality',
'icon': '📑',
})
else:
groups[2]['docs'].append({
'label': 'Certificate of Conformance',
'sub': 'Will appear after QC completes',
'pending': True,
'icon': '📑',
})
# SHIPPING — packing list + tracking
if job.packing_list_attachment_id:
groups[3]['docs'].append({
'label': 'Packing Slip',
'sub': 'EN Plating · %s · %s' % (
job.actual_ship_date and job.actual_ship_date.strftime('%b %d') or '',
self._fp_size_label(job.packing_list_attachment_id),
),
'url': '/web/content/%s?download=true' % job.packing_list_attachment_id.id,
'icon_class': 'o_fp_doc_icon_shipping',
'icon': '📦',
})
else:
groups[3]['docs'].append({
'label': 'Packing Slip · Tracking #',
'sub': 'Available when shipped' + (' — ' + job.tracking_ref if job.tracking_ref else ''),
'pending': not job.tracking_ref,
'icon': '📦',
})
return groups
def _fp_size_label(self, attachment):
"""Render file_size as a friendly KB / MB string. Empty if unknown."""
if not attachment or not attachment.file_size:
return ''
size = attachment.file_size
if size < 1024:
return '%d B' % size
if size < 1024 * 1024:
return '%.0f KB' % (size / 1024)
return '%.1f MB' % (size / (1024 * 1024))
- Step 3: Run the test
ssh pve-worker5 "pct exec 111 -- bash -c '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 -30\"'"
Expected: test_group_documents_v1_returns_4_groups ... ok.
- 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): _fp_group_documents helper for detail-page doc panel
V1 surfaces only the fields directly on fp.portal.job (CoC + packing
list). Other 3 groups render placeholder rows. V2 will wire in
sale.order linking for full doc surfacing."
Task 19: Create fp_portal_timeline.scss
Files:
-
Create:
fusion_plating_portal/static/src/scss/fp_portal_timeline.scss -
Step 1: Write the timeline SCSS
// ============================================================================
// Fusion Plating — Portal · Vertical timeline (job detail page)
// ============================================================================
.o_fp_timeline {
position: relative;
// Spine (gray default)
&::before {
content: '';
position: absolute;
left: 9px;
top: 10px;
bottom: 10px;
width: 2px;
background: $fp-card-border;
}
// Active portion (filled to height of completed stages, set inline by template)
.o_fp_timeline_spine_active {
position: absolute;
left: 9px;
top: 10px;
width: 2px;
background: $fp-teal;
// height set inline via style attribute
}
.o_fp_timeline_item {
position: relative;
padding-left: 2rem;
padding-bottom: 1.1rem;
&:last-child { padding-bottom: 0; }
.o_fp_timeline_dot {
position: absolute;
left: 0;
top: 0;
width: 20px;
height: 20px;
border-radius: $fp-radius-pill;
background: $fp-card-bg;
border: 2px solid $fp-card-border;
}
&.o_fp_timeline_done .o_fp_timeline_dot {
background: $fp-gradient-primary;
border: none;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: .65rem;
font-weight: 700;
}
&.o_fp_timeline_active .o_fp_timeline_dot {
background: $fp-card-bg;
border: 2.5px solid $fp-teal;
box-shadow: $fp-glow-ring-teal;
}
.o_fp_timeline_title {
font-size: .92rem;
color: $fp-text;
font-weight: 500;
line-height: 1.2;
}
&.o_fp_timeline_active .o_fp_timeline_title {
color: $fp-teal;
font-weight: 700;
font-size: .95rem;
}
&.o_fp_timeline_pending .o_fp_timeline_title {
color: $fp-muted;
}
.o_fp_timeline_time {
font-size: .78rem;
color: $fp-muted;
margin-top: .2rem;
}
&.o_fp_timeline_active .o_fp_timeline_time { color: $fp-teal; }
&.o_fp_timeline_pending .o_fp_timeline_time { color: $fp-disabled; }
.o_fp_timeline_note {
font-size: .74rem;
color: $fp-text-body;
margin-top: .35rem;
padding: .4rem .6rem;
background: $fp-page-bg;
border-radius: 6px;
display: inline-block;
&.o_fp_timeline_note_success { background: $fp-success-bg; color: $fp-success-text; }
&.o_fp_timeline_note_active { background: #eff6ff; color: $fp-teal-dark; line-height: 1.4; }
}
}
}
// Detail-page outer wrapper
.o_fp_job_detail {
background: $fp-page-bg;
padding: $fp-space-6;
border-radius: $fp-radius-card;
border: 1px solid $fp-card-border;
font-family: $fp-font;
.o_fp_job_detail_hero {
@extend .o_fp_card;
margin-bottom: $fp-space-5;
padding-bottom: $fp-space-4;
h2 { margin: 0 0 .35rem 0; font-size: 1.5rem; color: $fp-text; font-weight: 600; }
.o_fp_detail_label {
font-size: .7rem;
color: $fp-muted;
letter-spacing: .05em;
text-transform: uppercase;
font-weight: 500;
margin-bottom: .25rem;
}
.o_fp_detail_subtitle {
color: $fp-text-body;
font-size: .92rem;
margin-bottom: .7rem;
}
.o_fp_detail_facts {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
color: $fp-text-body;
font-size: .82rem;
.o_fp_fact_label { color: $fp-muted-light; }
.o_fp_fact_value { color: $fp-text; font-weight: 600; }
}
}
.o_fp_job_detail_grid {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: $fp-space-5;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.o_fp_job_detail_footer {
@extend .o_fp_card;
margin-top: $fp-space-5;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
.o_fp_related_links {
font-size: .82rem;
color: $fp-text-body;
a { color: $fp-teal; text-decoration: none; margin: 0 .55rem; }
a.disabled { color: $fp-muted-light; }
}
}
}
- Step 2: Verify and Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/scss/fp_portal_timeline.scss && \
git commit -m "feat(portal): vertical timeline + detail-page wrapper SCSS"
Task 20: Rewrite portal_my_jobs template
Files:
-
Modify:
fusion_plating_portal/views/fp_portal_templates.xml -
Step 1: Find the existing template at line 431
Read fusion_plating_portal/views/fp_portal_templates.xml lines 428-498 to locate the portal_my_jobs template.
- Step 2: Replace the template body
Replace the template body (everything inside <template id="portal_my_jobs" ...> ... </template>) with:
<template id="portal_my_jobs" name="My Work Orders">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<t t-call="portal.portal_searchbar">
<t t-set="title">Work Orders</t>
</t>
<t t-if="not jobs">
<div class="o_fp_card text-center text-muted">
<p class="mb-2">You have no plating jobs yet.</p>
<a href="/my/configurator" class="o_fp_btn_primary o_fp_btn_sm mt-2">+ Get Your First Quote</a>
</div>
</t>
<t t-if="jobs">
<div class="o_fp_dashboard">
<t t-foreach="jobs" t-as="job">
<div class="o_fp_job_card">
<div class="o_fp_job_header">
<div>
<a t-att-href="'/my/jobs/%s' % job.id"
class="o_fp_job_ref text-decoration-none"
t-out="job.name"/>
<span class="o_fp_job_meta">
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
</span>
</div>
<t t-call="fusion_plating_portal.fp_portal_status_badge">
<t t-set="state" t-value="job.state"/>
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
</t>
</div>
<t t-set="state_idx" t-value="['received','in_progress','quality_check','ready_to_ship','shipped'].index(job.state) if job.state in ['received','in_progress','quality_check','ready_to_ship','shipped'] else 0"/>
<t t-set="steps" t-value="[
{'label': 'Received', 'status': 'done' if state_idx > 0 else 'active', 'time_label': ''},
{'label': 'Inspected', 'status': 'done' if state_idx > 1 else ('active' if state_idx == 1 else 'pending'), 'time_label': ''},
{'label': 'Plating', 'status': 'done' if state_idx > 2 else ('active' if state_idx == 2 else 'pending'), 'time_label': ''},
{'label': 'QC', 'status': 'done' if state_idx > 3 else ('active' if state_idx == 3 else 'pending'), 'time_label': ''},
{'label': 'Ship', 'status': 'done' if state_idx > 4 else ('active' if state_idx == 4 else 'pending'), 'time_label': ''},
]"/>
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
</div>
</t>
</div>
</t>
</t>
</template>
- Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/views/fp_portal_templates.xml && \
git commit -m "feat(portal): rewrite /my/jobs list with V2 stepper cards"
Task 21: Rewrite portal_my_job template + update controller
Files:
-
Modify:
fusion_plating_portal/views/fp_portal_templates.xml -
Modify:
fusion_plating_portal/controllers/portal.py(theportal_my_jobmethod) -
Step 1: Update the controller method to pass timeline + doc groups
In controllers/portal.py, find def portal_my_job(self, job_id, access_token=None, **kw): and modify the values prep:
def portal_my_job(self, job_id, access_token=None, **kw):
try:
job_sudo = self._document_check_access(
'fusion.plating.portal.job',
job_id,
access_token,
)
except (AccessError, MissingError):
return request.redirect('/my')
values = self._fp_portal_job_get_page_view_values(
job_sudo, access_token, **kw
)
values['progress_percent'] = job_sudo._progress_percent()
values['stage_timeline'] = self._fp_get_stage_timeline(job_sudo)
values['doc_groups'] = self._fp_group_documents(job_sudo)
# Spine-fill percent for the timeline (visual progress indicator).
done_count = sum(1 for s in values['stage_timeline'] if s['status'] == 'done')
active_count = sum(1 for s in values['stage_timeline'] if s['status'] == 'active')
# Spine fill = done + half of active (covers up to the active dot).
values['timeline_spine_pct'] = int(((done_count + 0.5 * active_count) / 5) * 100)
return request.render(
'fusion_plating_portal.portal_my_job',
values,
)
- Step 2: Replace the
portal_my_jobtemplate body
Find <template id="portal_my_job" ...> (around line 502 in fp_portal_templates.xml) and replace its body with:
<template id="portal_my_job" name="My Work Order">
<t t-call="portal.portal_layout">
<div class="o_fp_job_detail">
<!-- Hero header -->
<div class="o_fp_job_detail_hero">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<div class="o_fp_detail_label">Work Order</div>
<h2><span t-out="job.name"/></h2>
<div t-if="job.process_type_ids" class="o_fp_detail_subtitle">
<span t-out="', '.join(job.process_type_ids.mapped('name'))"/>
</div>
<div class="o_fp_detail_facts">
<div t-if="job.quantity">
<span class="o_fp_fact_label">Qty </span>
<span class="o_fp_fact_value" t-out="job.quantity"/>
</div>
<div t-if="job.received_date">
<span class="o_fp_fact_label">Received </span>
<span class="o_fp_fact_value" t-field="job.received_date" t-options='{"widget": "date"}'/>
</div>
<div t-if="job.target_ship_date">
<span class="o_fp_fact_label">ETA </span>
<span class="o_fp_fact_value" t-field="job.target_ship_date" t-options='{"widget": "date"}'/>
</div>
<div t-if="job.tracking_ref">
<span class="o_fp_fact_label">Tracking </span>
<span class="o_fp_fact_value" t-out="job.tracking_ref"/>
</div>
</div>
</div>
<div class="d-flex flex-column align-items-end gap-2">
<t t-call="fusion_plating_portal.fp_portal_status_badge">
<t t-set="state" t-value="job.state"/>
<t t-set="label" t-value="dict(job._fields['state']._description_selection(job.env)).get(job.state)"/>
</t>
</div>
</div>
</div>
<!-- Two-column grid: timeline | docs -->
<div class="o_fp_job_detail_grid">
<!-- Timeline -->
<div class="o_fp_card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div style="font-weight:600;color:#111827;font-size:1rem">Progress</div>
<span style="font-size:.7rem;color:#6b7280">
<t t-out="progress_percent"/>% complete
</span>
</div>
<div class="o_fp_timeline">
<div class="o_fp_timeline_spine_active" t-attf-style="height: #{timeline_spine_pct}%"/>
<t t-foreach="stage_timeline" t-as="step">
<div t-attf-class="o_fp_timeline_item o_fp_timeline_#{step['status']}">
<div class="o_fp_timeline_dot">
<t t-if="step['status'] == 'done'">✓</t>
</div>
<div class="o_fp_timeline_title" t-out="step['label']"/>
<div class="o_fp_timeline_time" t-out="step['time_label']"/>
</div>
</t>
</div>
</div>
<!-- Documents -->
<div class="o_fp_card">
<div class="d-flex justify-content-between align-items-center mb-3">
<div style="font-weight:600;color:#111827;font-size:1rem">Documents</div>
<span style="font-size:.7rem;color:#6b7280">
<t t-out="sum(len(g['docs']) for g in doc_groups if not all(d.get('pending') for d in g['docs']))"/> files
</span>
</div>
<t t-foreach="doc_groups" t-as="group">
<t t-call="fusion_plating_portal.fp_portal_doc_group">
<t t-set="group_label" t-value="group['label']"/>
<t t-set="docs" t-value="group['docs']"/>
</t>
</t>
</div>
</div>
<!-- Footer -->
<div class="o_fp_job_detail_footer">
<div class="o_fp_related_links">
<span style="color:#9ca3af">Related:</span>
<a t-if="job.invoice_ref" href="#" t-out="'Invoice ' + job.invoice_ref"/>
<a t-else="" class="disabled">Invoice (pending)</a>
</div>
<a href="/my/jobs" class="o_fp_btn_secondary">← Back to all jobs</a>
</div>
</div>
</t>
</template>
- Step 3: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/views/fp_portal_templates.xml fusion_plating_portal/controllers/portal.py && \
git commit -m "feat(portal): rewrite /my/jobs/<id> detail page with timeline + doc panel
Two-column grid: vertical timeline (5 stages with per-stage timestamps)
on the left, grouped document panel (4 categories) on the right. Hero
header carries WO ref + part / qty / ETA / tracking facts."
Task 22: Register Phase 3 assets + version bump
Files:
-
Modify:
fusion_plating_portal/__manifest__.py -
Step 1: Bump version + add timeline.scss to assets
Change version 19.0.3.1.0 → 19.0.3.2.0. Add fp_portal_timeline.scss to the assets list AFTER fp_portal_dashboard.scss:
'fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss',
'fusion_plating_portal/static/src/scss/fp_portal_timeline.scss', # NEW
'fusion_plating_portal/static/src/scss/fusion_plating_portal.scss',
- 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.3.2.0 + register timeline SCSS"
Task 23: Deploy Phase 3 to entech + run tests + visual verify
Files: (deployment)
- Step 1: Copy all Phase 3 files to entech
for f in \
static/src/scss/fp_portal_timeline.scss \
views/fp_portal_templates.xml \
controllers/portal.py \
models/fp_portal_job.py \
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 + 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 -60\" && systemctl start odoo'"
Expected: all 4 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 check
/my/jobs
Open https://enplating.com/my/jobs. Expected: list of jobs, each rendered as a V2 card with stepper. Click into one to confirm the detail page renders the timeline + 4 doc groups.
- Step 5: Tag Phase 3 shipped
cd K:/Github/Odoo-Modules/fusion_plating && git tag portal-phase3-shipped
PHASE 4 — Cosmetic Sweep
Goal: apply the new token system to the remaining /my/* pages without structural changes.
Task 24: Tokenise fp_quote_request_views.xml
Files:
-
Modify:
fusion_plating_portal/views/fp_quote_request_views.xml -
Step 1: Read the file to find any hardcoded button styles or badge classes.
ls -la K:/Github/Odoo-Modules/fusion_plating/fusion_plating_portal/views/fp_quote_request_views.xml
Read it in full, then for each <button class="btn btn-primary ..."> swap to class="o_fp_btn_primary"; for <button class="btn btn-outline-..."> swap to class="o_fp_btn_secondary". For state-coloured badges (badge bg-... or badge text-bg-...), swap to the corresponding o_fp_badge o_fp_badge_<state> class via the macro.
- Step 2: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/views/fp_quote_request_views.xml && \
git commit -m "style(portal): tokenise quote request views (buttons + badges)"
Task 25: Tokenise fp_portal_configurator_templates.xml
Files:
-
Modify:
fusion_plating_portal/views/fp_portal_configurator_templates.xml -
Step 1: Same pattern as Task 24
Read, then swap every btn btn-primary → o_fp_btn_primary, btn btn-outline-* → o_fp_btn_secondary, btn-link / btn btn-light → o_fp_btn_ghost. Leave Bootstrap's grid + utility classes untouched.
- Step 2: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/views/fp_portal_configurator_templates.xml && \
git commit -m "style(portal): tokenise configurator (RFQ wizard) buttons"
Task 26: Trim fusion_plating_portal.scss
Files:
-
Modify:
fusion_plating_portal/static/src/scss/fusion_plating_portal.scss -
Step 1: Audit + trim
Read the file. Move anything that the new partials cover (.o_fp_portal_card, badge styles, button styles) — DELETE the duplicates. Keep only rules that are still genuinely needed (e.g., portal-layout overrides specific to FP that don't fit into the new partials).
- Step 2: Commit
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/static/src/scss/fusion_plating_portal.scss && \
git commit -m "refactor(portal): trim legacy catch-all SCSS, deduplicate vs new partials"
Task 27: Final Phase 4 deploy + visual sweep across all /my/* pages
Files: (deployment + manifest version bump)
- Step 1: Bump version to 19.0.3.3.0 + commit
# Edit __manifest__.py: 19.0.3.2.0 → 19.0.3.3.0
cd K:/Github/Odoo-Modules/fusion_plating && \
git add fusion_plating_portal/__manifest__.py && \
git commit -m "chore(portal): bump 19.0.3.3.0 — Phase 4 cosmetic sweep"
- Step 2: Copy all changed files
for f in \
views/fp_quote_request_views.xml \
views/fp_portal_configurator_templates.xml \
static/src/scss/fusion_plating_portal.scss \
__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 3: Upgrade + 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 --stop-after-init 2>&1 | tail -20\" && systemctl start odoo'"
- Step 4: Bust cache + visual sweep
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
Walk these URLs in the browser (logged in as admin):
-
/my/home — dashboard (Phase 2 confirmed working)
-
/my/jobs — list (Phase 3)
-
/my/jobs/ — detail (Phase 3)
-
/my/quote_requests — list (Phase 4)
-
/my/quote_requests/ — detail
-
/my/quote_requests/new — RFQ wizard
-
/my/configurator — RFQ configurator
-
/my/purchase_orders, /my/fp_invoices, /my/deliveries, /my/certifications — confirm buttons + badges adopt the new tokens
-
Step 5: Tag Phase 4 shipped
cd K:/Github/Odoo-Modules/fusion_plating && git tag portal-phase4-shipped
Done
After Task 27, the portal redesign ships. Defer items:
- V2 doc grouping (add
sale_order_idonfp.portal.job, pull PO + drawings via SO link). - Mobile breakpoints beyond the 768px ones in the SCSS.
- Dark-mode
$o-webclient-color-schemebranch in_fp_portal_tokens.scss. - Operator-name visibility decision (today the detail page doesn't show one; if EN Plating wants it, surface via a future
operator_name_per_stagefield).
Self-Review
Spec coverage: Every section of the spec maps to at least one task:
- Locked design decisions → Tasks 1, 2, 6-9, 10, 12, 21 (all template + SCSS work)
- Scope (in/out/deferred) → enforced by phase structure (Phases 1-3 = in scope; Phase 4 = cosmetic-only; deferred items called out at end)
- Architecture (controllers, templates, SCSS) → Tasks 11, 17, 18, 21 (controller), 10, 12, 20, 21 (templates), 1-9, 19 (SCSS)
- Migration/deployment → Tasks 4, 14, 23, 27 (one per phase)
- Open items §1 timestamps → Task 15-16 explicitly resolve
- Open items §4 document linking → Task 15 Step 4 + Task 18 implementation (V1 placeholder strategy)
Placeholder scan: No "TODO", "TBD", "fill in details", "similar to Task N" — all code blocks are concrete. Test code is real, runnable. Commit messages are filled in.
Type consistency: Method names match between definition and call:
_fp_get_stage_timeline(Task 17) called fromportal_my_job(Task 21) ✓_fp_group_documents(Task 18) called fromportal_my_job(Task 21) ✓_fp_size_label(Task 18) called from_fp_group_documents(Task 18) ✓- Macros named
fp_portal_status_badge,fp_portal_stepper,fp_portal_doc_chip,fp_portal_doc_group(Task 10) — t-call'd with the same names in Tasks 12, 20, 21 ✓ - SCSS classes
.o_fp_btn_primary,.o_fp_card,.o_fp_kpi_tile,.o_fp_stepper,.o_fp_timeline_itemconsistently used across SCSS files and templates ✓
End of plan.