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

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

// ============================================================================
// 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 (the home() 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_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:

<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.job schema
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_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):

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

    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:

<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-primaryo_fp_btn_primary, btn btn-outline-*o_fp_btn_secondary, btn-link / btn btn-lighto_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_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.