Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-17-portal-dashboard-redesign-plan.md
gsinghpal eac337c058 docs(portal): add dashboard redesign spec + implementation plan
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>
2026-05-17 02:36:02 -04:00

2593 lines
95 KiB
Markdown

# 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`](../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 vars
- `fusion_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 + glow
- `fusion_plating_portal/static/src/scss/fp_portal_cards.scss` — card shells + KPI tiles
- `fusion_plating_portal/static/src/scss/fp_portal_stepper.scss` — circular numbered stepper geometry
- `fusion_plating_portal/static/src/scss/fp_portal_timeline.scss` — vertical timeline for detail page
- `fusion_plating_portal/static/src/scss/fp_portal_dashboard.scss` — jobs-forward grid + secondary panels
- `fusion_plating_portal/views/fp_portal_macros.xml` — shared QWeb macros (stepper, badge, doc chip, doc group)
- `fusion_plating_portal/tests/__init__.py` — test package
- `fusion_plating_portal/tests/test_portal_dashboard.py` — controller helper unit tests
**MODIFY files:**
- `fusion_plating_portal/controllers/portal.py` — extend `home()`, rewrite `portal_my_job()`, add helpers
- `fusion_plating_portal/views/fp_portal_dashboard.xml` — rewrite `fp_portal_home_dashboard` template
- `fusion_plating_portal/views/fp_portal_templates.xml` — rewrite `portal_my_jobs` + `portal_my_job` templates
- `fusion_plating_portal/views/fp_quote_request_views.xml` — Phase 4 cosmetic tokenisation
- `fusion_plating_portal/views/fp_portal_configurator_templates.xml` — Phase 4 cosmetic tokenisation
- `fusion_plating_portal/static/src/scss/fusion_plating_portal.scss` — trim to catch-all
- `fusion_plating_portal/__manifest__.py` — version bump + new assets + new data
- `fusion_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:
```scss
// ============================================================================
// 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)**
```bash
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:
```scss
// ============================================================================
// 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**
```bash
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:
```python
'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**
```bash
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**
```bash
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'"
```
```bash
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'"
```
```bash
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**
```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 -15 && systemctl start odoo'"
```
Expected: "Modules loaded" + "Registry loaded" in tail, no traceback for `fusion_plating_portal`.
- [ ] **Step 3: Verify service up + version bumped**
```bash
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**
```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**
```bash
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**
```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**
```bash
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**
```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**
```bash
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**
```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**
```bash
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
<?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**
```bash
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` (the `home()` method)
- [ ] **Step 1: Create the test package init**
Create `fusion_plating_portal/tests/__init__.py`:
```python
from . import test_portal_dashboard
```
- [ ] **Step 2: Write the failing test**
Create `fusion_plating_portal/tests/test_portal_dashboard.py`:
```python
# -*- 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):
```bash
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:
```python
# 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:
```python
# 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**
```bash
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_dashboard` template 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:
```xml
<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**
```bash
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):
```python
'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:
```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/fusion_plating_portal.scss',
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
],
},
```
- [ ] **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.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**
```bash
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**
```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 -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)**
```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: Human visual check**
Open https://enplating.com/my/home in browser. Expected to match `branded-dashboard.html` mockup: welcome strip with "Welcome back, <Name>", 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**
```bash
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.job` schema**
```bash
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**
```bash
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_date` for "Received"; `actual_ship_date` for "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 via `write()` override that snapshots `fields.Datetime.now()` when `state` changes. 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_id` Many2one** to `fp.portal.job` and 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_id` surfaced; "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):
```python
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`):
```python
_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`:
```python
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**
```bash
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`:
```python
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):
```python
_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**
```bash
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**
```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): _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`:
```python
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`:
```python
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**
```bash
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**
```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): _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**
```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**
```bash
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:
```xml
<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**
```bash
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` (the `portal_my_job` method)
- [ ] **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:
```python
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_job` template body**
Find `<template id="portal_my_job" ...>` (around line 502 in `fp_portal_templates.xml`) and replace its body with:
```xml
<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**
```bash
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`:
```python
'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**
```bash
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**
```bash
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**
```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 -60\" && systemctl start odoo'"
```
Expected: all 4 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 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**
```bash
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.
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
# 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**
```bash
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**
```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 -20\" && systemctl start odoo'"
```
- [ ] **Step 4: Bust cache + visual sweep**
```bash
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/<any id> — detail (Phase 3)
- /my/quote_requests — list (Phase 4)
- /my/quote_requests/<id> — 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**
```bash
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_id` on `fp.portal.job`, pull PO + drawings via SO link).
- Mobile breakpoints beyond the 768px ones in the SCSS.
- Dark-mode `$o-webclient-color-scheme` branch 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_stage` field).
---
## 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 from `portal_my_job` (Task 21) ✓
- `_fp_group_documents` (Task 18) called from `portal_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_item` consistently used across SCSS files and templates ✓
---
*End of plan.*