Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-25-quality-dashboard-redesign-plan.md
gsinghpal d53bb73055 docs(plan): quality dashboard redesign implementation plan
9 tasks across 3 phases:
  Phase 1 — Backend snapshot endpoint (Tasks 1-5)
    T1: Test scaffold + shape tests
    T2: Minimal helper + endpoint making shape tests pass
    T3: Section items population + tests
    T4: Banner with overdue + critical-customer ranking + tests
    T5: Defensive guards + missing-model tests
  Phase 2 — Frontend (Task 6)
    T6: Wholesale rewrite of JS + XML + SCSS (banner card + 6
        section cards, sibling sub-components in same JS file,
        deep-link scrollIntoView, 60s poll, dark mode via
        compile-time SCSS branch)
  Phase 3 — Polish + deploy (Tasks 7-9)
    T7: Manifest version bump 19.0.7.0.0 → 19.0.8.0.0
    T8: Entech smoke battle test (7 checks)
    T9: Sync + upgrade + asset bust + smoke + manual QA

Implements: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:15:41 -04:00

1635 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Quality 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 tab-router Quality Dashboard with an action surface — a red "Needs Attention Today" banner (up to 6 items, overdue + critical-customer rule) + 6 grouped sections per type (Certs → Holds → NCRs → RMAs → CAPAs → Checks) with inline rows + a single `Open →` button per row that navigates to the record form.
**Architecture:** One JSONRPC endpoint (`/fp/quality/dashboard/snapshot`) returns `{banner, sections, computed_at}` in a single response. A helper class `FpQualityDashboardSnapshot` builds the response in one pass — per-type overdue + critical-customer queries, ranked banner candidates, top-5 section items. Frontend is a single OWL component with BannerCard + 6 SectionCard sub-components living in the same JS file. Existing per-model kanbans are untouched — "Open all →" links use their existing `act_window` xmlids.
**Spec:** [docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md](../specs/2026-05-25-quality-dashboard-redesign-design.md)
**Tech Stack:** Odoo 19, Python (api/orm), QWeb XML, OWL components, SCSS (with `$o-webclient-color-scheme` compile-time dark-mode branch).
---
## File Inventory (what each task touches)
| Path | Responsibility |
|---|---|
| `fusion_plating_quality/controllers/fp_quality_dashboard.py` | **REWRITE.** Delete `counts()` route; add `snapshot()` route + `FpQualityDashboardSnapshot` helper class. Module constants for `OVERDUE_THRESHOLDS`, `TYPE_CONFIG`, `SECTION_ORDER`. |
| `fusion_plating_quality/tests/test_dashboard_snapshot.py` | **CREATE.** TransactionCase covering empty DB, overdue items, critical-customer items, ranking, missing-module guard, defensive field reads. |
| `fusion_plating_quality/tests/__init__.py` | **MODIFY.** Register the new test module. |
| `fusion_plating_quality/static/src/js/fp_quality_dashboard.js` | **REWRITE.** Drop TABS array + selectTab/openTab. New component: setup fetches snapshot, BannerCard + 6 SectionCards as sibling sub-components in same file. Deep-link `?tab=` → scrollIntoView on mount. |
| `fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml` | **REWRITE.** New template — outer wrapper, banner card, six section cards via `t-foreach="state.snapshot.sections"`. Each SectionCard has `t-att-id="'section-' + section.type"` for the deep-link target. |
| `fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss` | **REWRITE.** Local SCSS tokens (red urgent, green good, amber section-head) with `@if $o-webclient-color-scheme == dark` overrides. Banner + section + row classes. Mobile breakpoint at 900px. |
| `fusion_plating_quality/__manifest__.py` | Version bump `19.0.7.0.0``19.0.8.0.0`. |
| `fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py` | **CREATE.** Entech smoke script — RPC call, response shape assertions, click-through smoke. |
---
## Phase 1 — Backend: snapshot endpoint
### Task 1: Test scaffold + first failing tests (empty DB + missing-model guards)
**Files:**
- Create: `fusion_plating_quality/tests/test_dashboard_snapshot.py`
- Modify: `fusion_plating_quality/tests/__init__.py`
- [ ] **Step 1: Register the new test module**
In [`fusion_plating_quality/tests/__init__.py`](../../fusion_plating_quality/tests/__init__.py), append:
```python
from . import test_dashboard_snapshot
```
- [ ] **Step 2: Create the test file with empty-DB + shape tests**
Create [`fusion_plating_quality/tests/test_dashboard_snapshot.py`](../../fusion_plating_quality/tests/test_dashboard_snapshot.py):
```python
# -*- coding: utf-8 -*-
"""Quality Dashboard snapshot endpoint tests.
Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
Plan: docs/superpowers/plans/2026-05-25-quality-dashboard-redesign-plan.md
"""
from odoo.tests.common import TransactionCase
class TestDashboardSnapshotShape(TransactionCase):
"""Response shape + section ordering tests."""
def _build(self):
from odoo.addons.fusion_plating_quality.controllers.fp_quality_dashboard \
import FpQualityDashboardSnapshot
return FpQualityDashboardSnapshot(self.env).build()
def test_snapshot_has_expected_top_level_keys(self):
snap = self._build()
self.assertIn('banner', snap)
self.assertIn('sections', snap)
self.assertIn('computed_at', snap)
def test_section_order_is_canonical(self):
snap = self._build()
types_present = [s['type'] for s in snap['sections']]
# Canonical order — cert, hold, ncr, rma, capa, check.
# Some types may be absent if their model isn't installed; the
# PRESENT ones must appear in this relative order.
canonical = ['cert', 'hold', 'ncr', 'rma', 'capa', 'check']
order_in_canonical = [canonical.index(t) for t in types_present]
self.assertEqual(
order_in_canonical, sorted(order_in_canonical),
f"sections out of order: got {types_present}",
)
def test_empty_db_returns_all_clear(self):
snap = self._build()
self.assertTrue(snap['banner']['all_clear'])
self.assertEqual(snap['banner']['items'], [])
self.assertEqual(snap['banner']['total_matching'], 0)
def test_each_section_has_required_keys(self):
snap = self._build()
for section in snap['sections']:
for key in ('type', 'label', 'icon', 'open', 'overdue',
'items', 'open_kanban_xmlid'):
self.assertIn(
key, section,
f"section {section.get('type')} missing key {key!r}",
)
self.assertIsInstance(section['items'], list)
self.assertIsInstance(section['open'], int)
self.assertIsInstance(section['overdue'], int)
```
- [ ] **Step 3: Verify tests FAIL (no implementation yet)**
The expected failure is `ImportError: cannot import name 'FpQualityDashboardSnapshot'` since the helper doesn't exist yet. Document expectation; entech smoke after Phase 1 will be the real verification (Docker not running locally — user opted to skip local test execution).
- [ ] **Step 4: Commit**
```bash
git add fusion_plating_quality/tests/__init__.py \
fusion_plating_quality/tests/test_dashboard_snapshot.py
git commit -m "test(quality_dashboard): scaffold + shape tests (Task 1)
Tests for empty-DB all-clear, canonical section order, and required
keys on each section. All fail until Task 2 lands the snapshot helper.
"
```
---
### Task 2: Minimal `FpQualityDashboardSnapshot` to make shape tests pass
**Files:**
- Modify: `fusion_plating_quality/controllers/fp_quality_dashboard.py`
- [ ] **Step 1: Replace the controller file wholesale**
Replace the entire content of [`fusion_plating_quality/controllers/fp_quality_dashboard.py`](../../fusion_plating_quality/controllers/fp_quality_dashboard.py) with:
```python
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Quality Dashboard snapshot endpoint.
Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
Replaces the old /fp/quality/dashboard/counts router-style endpoint
with a single /fp/quality/dashboard/snapshot that returns:
- banner: up to 6 most urgent items across all types (red "Needs
Attention Today" surface)
- sections: 6 per-type sections in canonical order, each with top-5
items + open/overdue counts
"""
import logging
from datetime import timedelta
from odoo import fields, http
from odoo.http import request
_logger = logging.getLogger(__name__)
# Canonical section order — drives sections[] in the response.
# Sections whose model isn't installed are omitted (no error).
SECTION_ORDER = ['cert', 'hold', 'ncr', 'rma', 'capa', 'check']
# Per-type config: model name, kanban xmlid, display label, icon.
TYPE_CONFIG = {
'cert': {'model': 'fp.certificate',
'label': 'Certificates', 'icon': '🏷️',
'kanban_xmlid': 'fusion_plating_certificates.action_fp_certificate'},
'hold': {'model': 'fusion.plating.quality.hold',
'label': 'Holds', 'icon': '🛑',
'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_quality_hold'},
'ncr': {'model': 'fusion.plating.ncr',
'label': 'NCRs', 'icon': '🔬',
'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_ncr'},
'rma': {'model': 'fusion.plating.rma',
'label': 'RMAs', 'icon': '↩️',
'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_rma'},
'capa': {'model': 'fusion.plating.capa',
'label': 'CAPAs', 'icon': '📋',
'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_capa'},
'check': {'model': 'fusion.plating.quality.check',
'label': 'QC Checks', 'icon': '',
'kanban_xmlid': 'fusion_plating_quality.action_fusion_plating_quality_check'},
}
# Per-type "overdue" thresholds (reused from the old counts endpoint —
# battle-tested). CAPA branches on due_date < today via use_due_date.
OVERDUE_THRESHOLDS = {
'cert': {'days': 1, 'use_due_date': False,
'state_domain': [('state', '=', 'draft')]},
'hold': {'days': 3, 'use_due_date': False,
'state_domain': [('state', 'in', ('on_hold', 'under_review'))]},
'ncr': {'days': 7, 'use_due_date': False,
'state_domain': [('state', 'in',
('open', 'containment', 'disposition'))]},
'rma': {'days': 5, 'use_due_date': False,
'state_domain': [('state', '=', 'received')]},
'capa': {'days': None, 'use_due_date': True,
'state_domain': [('state', 'not in',
('closed', 'effective'))]},
'check': {'days': 1, 'use_due_date': False,
'state_domain': [('state', '=', 'pending')]},
}
class FpQualityDashboardSnapshot:
"""Builds the dashboard snapshot in one pass.
Per Rule 13m, all cross-module field reads are sudo'd so non-admin
users (Sales Reps viewing the dashboard) don't AccessError on
partner_id.x_fc_rush / part_catalog_id.name / customer_spec_id.code.
The Open-action navigates via standard act_window which re-enforces
ACL on click (Rule 24 / D15).
"""
BANNER_MAX = 6
SECTION_TOP_N = 5
def __init__(self, env):
self.env = env
self.now = fields.Datetime.now()
self.today = fields.Date.context_today(env.user)
def build(self):
sections = []
banner_candidates = []
for type_code in SECTION_ORDER:
section = self._build_section(type_code)
if section is None:
continue # model not installed — omit
sections.append(section)
# Banner placeholder for Task 2 — fully implemented in Task 4.
banner = {'items': [], 'all_clear': True, 'total_matching': 0}
return {
'banner': banner,
'sections': sections,
'computed_at': self.now.isoformat(),
}
def _build_section(self, type_code):
"""Return section dict with top-N items + counts.
Returns None when the model isn't installed."""
cfg = TYPE_CONFIG[type_code]
if cfg['model'] not in self.env:
return None
# Open/overdue counts using the existing state filters
state_dom = list(OVERDUE_THRESHOLDS[type_code]['state_domain'])
Model = self.env[cfg['model']].sudo()
open_count = Model.search_count(state_dom)
overdue_count = self._overdue_count(type_code, Model)
# Section items — fully populated in Task 3; empty for now
return {
'type': type_code,
'label': cfg['label'],
'icon': cfg['icon'],
'open': open_count,
'overdue': overdue_count,
'items': [],
'open_kanban_xmlid': cfg['kanban_xmlid'],
}
def _overdue_count(self, type_code, Model):
"""Count overdue per the OVERDUE_THRESHOLDS dispatch table."""
cfg = OVERDUE_THRESHOLDS[type_code]
dom = list(cfg['state_domain'])
if cfg['use_due_date']:
dom += [('due_date', '<', self.today),
('due_date', '!=', False)]
else:
cutoff = self.now - timedelta(days=cfg['days'])
dom += [('create_date', '<', cutoff)]
return Model.search_count(dom)
class FpQualityDashboardController(http.Controller):
@http.route('/fp/quality/dashboard/snapshot',
type='jsonrpc', auth='user', methods=['POST'])
def snapshot(self):
"""Return the full dashboard snapshot. See spec §Data model."""
return FpQualityDashboardSnapshot(request.env).build()
```
- [ ] **Step 2: Verify the file loads + tests now find the helper**
The shape tests (`test_snapshot_has_expected_top_level_keys`, `test_section_order_is_canonical`, `test_empty_db_returns_all_clear`, `test_each_section_has_required_keys`) should now pass — minimal stub returns valid empty shape. Real verification deferred to entech upgrade in Phase 4.
- [ ] **Step 3: Commit**
```bash
git add fusion_plating_quality/controllers/fp_quality_dashboard.py
git commit -m "feat(quality_dashboard): snapshot endpoint scaffold (Task 2)
Replaces /counts with /snapshot. Helper class FpQualityDashboardSnapshot
returns response with correct shape — banner placeholder + per-type
sections with open/overdue counts (reuses old counts endpoint
thresholds). Items + critical-customer banner come in Tasks 3-5.
Per CLAUDE.md Rule 13m, Model.sudo() on cross-module reads. Per
Rule 24 the in-self.env check + getattr defensive pattern guards
missing-model + missing-field paths.
"
```
---
### Task 3: Populate `items` per section + add tests
**Files:**
- Modify: `fusion_plating_quality/controllers/fp_quality_dashboard.py`
- Modify: `fusion_plating_quality/tests/test_dashboard_snapshot.py`
- [ ] **Step 1: Add failing tests for items population**
Append to [`test_dashboard_snapshot.py`](../../fusion_plating_quality/tests/test_dashboard_snapshot.py):
```python
class TestDashboardSnapshotItems(TransactionCase):
"""Per-section items list — ranking + cap + shape."""
def _build(self):
from odoo.addons.fusion_plating_quality.controllers.fp_quality_dashboard \
import FpQualityDashboardSnapshot
return FpQualityDashboardSnapshot(self.env).build()
def _get_section(self, snap, type_code):
for s in snap['sections']:
if s['type'] == type_code:
return s
return None
def test_section_items_empty_when_no_records(self):
snap = self._build()
cert_sec = self._get_section(snap, 'cert')
if cert_sec is None:
self.skipTest('fp.certificate not installed')
self.assertEqual(cert_sec['items'], [])
def test_section_items_capped_at_5(self):
if 'fusion.plating.quality.hold' not in self.env:
self.skipTest('fusion.plating.quality.hold not installed')
partner = self.env['res.partner'].create({'name': 'Cust'})
# Create 8 holds in the same open state
Hold = self.env['fusion.plating.quality.hold']
for i in range(8):
Hold.create({
'partner_id': partner.id,
'state': 'on_hold',
'reason': f'test hold {i}',
})
snap = self._build()
sec = self._get_section(snap, 'hold')
self.assertEqual(len(sec['items']), 5)
self.assertEqual(sec['open'], 8)
def test_item_has_required_keys(self):
if 'fusion.plating.quality.hold' not in self.env:
self.skipTest('fusion.plating.quality.hold not installed')
partner = self.env['res.partner'].create({'name': 'Cust'})
self.env['fusion.plating.quality.hold'].create({
'partner_id': partner.id, 'state': 'on_hold', 'reason': 'x',
})
snap = self._build()
sec = self._get_section(snap, 'hold')
self.assertEqual(len(sec['items']), 1)
item = sec['items'][0]
for k in ('id', 'name', 'customer', 'subtitle',
'urgency', 'open_action'):
self.assertIn(k, item, f'item missing key {k!r}')
self.assertEqual(item['open_action']['res_model'],
'fusion.plating.quality.hold')
self.assertEqual(item['open_action']['res_id'], 1) # adjust if needed
```
- [ ] **Step 2: Add `_fetch_section_items` to the helper**
In [`fusion_plating_quality/controllers/fp_quality_dashboard.py`](../../fusion_plating_quality/controllers/fp_quality_dashboard.py), inside `FpQualityDashboardSnapshot`, add this method right after `_overdue_count`:
```python
def _fetch_section_items(self, type_code, Model):
"""Top-N open records for the type, ordered by urgency
(overdue first by oldest create_date, then by create_date desc).
Returns list of item dicts in the snapshot shape.
"""
state_dom = list(OVERDUE_THRESHOLDS[type_code]['state_domain'])
# We need urgency labels per record — fetch open set, sort in
# Python by (overdue_flag, age) so the top-N reflects urgency.
recs = Model.search(state_dom, limit=200) # safety cap
if not recs:
return []
overdue_ids = set(self._overdue_ids(type_code, Model))
# Sort: overdue first (by oldest create_date), then non-overdue
# by oldest create_date.
def rank_key(r):
is_od = r.id in overdue_ids
return (0 if is_od else 1, r.create_date or self.now)
recs_sorted = sorted(recs, key=rank_key)
top = recs_sorted[:self.SECTION_TOP_N]
return [self._build_item(type_code, r, r.id in overdue_ids)
for r in top]
def _overdue_ids(self, type_code, Model):
"""IDs of overdue records for the type — reuses _overdue_count
domain logic."""
cfg = OVERDUE_THRESHOLDS[type_code]
dom = list(cfg['state_domain'])
if cfg['use_due_date']:
dom += [('due_date', '<', self.today),
('due_date', '!=', False)]
else:
cutoff = self.now - timedelta(days=cfg['days'])
dom += [('create_date', '<', cutoff)]
return Model.search(dom).ids
def _build_item(self, type_code, rec, is_overdue):
"""Shape one record into the snapshot item dict."""
cfg = TYPE_CONFIG[type_code]
# Customer name — partner_id direct, OR via job_id for check
partner = self._resolve_partner(rec)
return {
'id': rec.id,
'name': rec.display_name or rec.name or f'#{rec.id}',
'customer': partner.name if partner else '',
'subtitle': self._build_subtitle(type_code, rec, is_overdue),
'urgency': 'overdue' if is_overdue else 'normal',
'critical_badge': None, # populated in Task 4
'open_action': {
'res_model': cfg['model'],
'res_id': rec.id,
},
}
def _resolve_partner(self, rec):
"""Walk to partner: direct partner_id, or via job_id (checks),
or via ncr_id (capa). Defensive on missing fields."""
if 'partner_id' in rec._fields and rec.partner_id:
return rec.partner_id
if 'job_id' in rec._fields and rec.job_id \
and 'partner_id' in rec.job_id._fields:
return rec.job_id.partner_id
if 'ncr_id' in rec._fields and rec.ncr_id \
and 'partner_id' in rec.ncr_id._fields:
return rec.ncr_id.partner_id
return False
def _build_subtitle(self, type_code, rec, is_overdue):
"""Second-line text per type. Implementation-phase choice
per spec open question 1."""
# Age in human terms — hours if < 24h, days otherwise
if rec.create_date:
age = self.now - rec.create_date
hours = int(age.total_seconds() / 3600)
age_str = (f'{hours}h' if hours < 24
else f'{hours // 24}d')
else:
age_str = ''
if is_overdue:
return f'{age_str} overdue' if age_str else 'overdue'
return age_str
```
- [ ] **Step 3: Wire `_fetch_section_items` into `_build_section`**
Replace the existing `_build_section` body's last return block:
```python
# Old:
return {
'type': type_code,
...
'items': [], # ← was placeholder
...
}
```
with the populated version:
```python
return {
'type': type_code,
'label': cfg['label'],
'icon': cfg['icon'],
'open': open_count,
'overdue': overdue_count,
'items': self._fetch_section_items(type_code, Model),
'open_kanban_xmlid': cfg['kanban_xmlid'],
}
```
- [ ] **Step 4: Commit**
```bash
git add fusion_plating_quality/
git commit -m "feat(quality_dashboard): populate section items (Task 3)
_fetch_section_items pulls top-5 open records per type, ranked
overdue-first by oldest create_date. _build_item shapes each row
with id/name/customer/subtitle/urgency/open_action. _resolve_partner
defensively walks partner_id → job_id.partner_id → ncr_id.partner_id
per type. _build_subtitle generates the human-readable second line.
Tests cover empty list, 5-cap on 8-record set, and required item
keys (id/name/customer/subtitle/urgency/open_action).
"
```
---
### Task 4: Banner builder with overdue + critical-customer ranking
**Files:**
- Modify: `fusion_plating_quality/controllers/fp_quality_dashboard.py`
- Modify: `fusion_plating_quality/tests/test_dashboard_snapshot.py`
- [ ] **Step 1: Add failing tests for banner**
Append to [`test_dashboard_snapshot.py`](../../fusion_plating_quality/tests/test_dashboard_snapshot.py):
```python
from datetime import datetime, timedelta
class TestDashboardSnapshotBanner(TransactionCase):
"""Banner population + ranking."""
def _build(self):
from odoo.addons.fusion_plating_quality.controllers.fp_quality_dashboard \
import FpQualityDashboardSnapshot
return FpQualityDashboardSnapshot(self.env).build()
def _make_partner(self, **flags):
vals = {'name': 'TestCust'}
for k, v in flags.items():
if k in ('x_fc_rush', 'x_fc_vip'):
# field may not exist in this codebase — set only when defined
if k in self.env['res.partner']._fields:
vals[k] = v
return self.env['res.partner'].create(vals)
def test_banner_picks_up_overdue_hold(self):
if 'fusion.plating.quality.hold' not in self.env:
self.skipTest('fusion.plating.quality.hold not installed')
partner = self._make_partner()
hold = self.env['fusion.plating.quality.hold'].create({
'partner_id': partner.id, 'state': 'on_hold', 'reason': 'old',
})
# Backdate create_date past the 3-day overdue threshold
old = datetime.now() - timedelta(days=5)
self.env.cr.execute(
"UPDATE fusion_plating_quality_hold SET create_date = %s WHERE id = %s",
(old, hold.id),
)
hold.invalidate_recordset(['create_date'])
snap = self._build()
self.assertFalse(snap['banner']['all_clear'])
self.assertEqual(snap['banner']['total_matching'], 1)
self.assertEqual(len(snap['banner']['items']), 1)
item = snap['banner']['items'][0]
self.assertEqual(item['type'], 'hold')
self.assertEqual(item['urgency'], 'overdue')
def test_banner_caps_at_6_with_overflow_count(self):
if 'fusion.plating.quality.hold' not in self.env:
self.skipTest('fusion.plating.quality.hold not installed')
partner = self._make_partner()
Hold = self.env['fusion.plating.quality.hold']
# 8 overdue holds
old = datetime.now() - timedelta(days=5)
for i in range(8):
h = Hold.create({
'partner_id': partner.id, 'state': 'on_hold',
'reason': f'overdue {i}',
})
self.env.cr.execute(
"UPDATE fusion_plating_quality_hold SET create_date = %s WHERE id = %s",
(old, h.id),
)
snap = self._build()
self.assertEqual(len(snap['banner']['items']), 6)
self.assertEqual(snap['banner']['total_matching'], 8)
self.assertFalse(snap['banner']['all_clear'])
def test_banner_all_clear_when_zero(self):
snap = self._build()
# Empty DB — no overdue, no critical
self.assertTrue(snap['banner']['all_clear'])
self.assertEqual(snap['banner']['items'], [])
```
- [ ] **Step 2: Add banner-building methods to the helper**
In [`fusion_plating_quality/controllers/fp_quality_dashboard.py`](../../fusion_plating_quality/controllers/fp_quality_dashboard.py), inside `FpQualityDashboardSnapshot`, add right after `_build_subtitle`:
```python
def _fetch_banner_candidates(self, type_code, Model):
"""Per-type pull of records that qualify for the banner:
(overdue) OR (critical-customer AND state-is-open). Returns
list of (rec, urgency, critical_badge) tuples — deduped.
"""
overdue_ids = set(self._overdue_ids(type_code, Model))
critical_ids = set(self._critical_customer_ids(type_code, Model))
# Open state filter (anything in the type's state_domain)
state_dom = list(OVERDUE_THRESHOLDS[type_code]['state_domain'])
open_recs = Model.search(state_dom)
results = []
for rec in open_recs:
is_overdue = rec.id in overdue_ids
is_critical = rec.id in critical_ids
if not (is_overdue or is_critical):
continue
urgency = 'overdue' if is_overdue else 'critical_customer'
badge = None if is_overdue else self._critical_badge(rec)
results.append((rec, urgency, badge))
return results
def _critical_customer_ids(self, type_code, Model):
"""IDs of records belonging to critical customers (rush / vip /
aerospace). Defensive on missing fields per Rule 24."""
partner_field_map = {
'cert': 'partner_id',
'hold': 'partner_id',
'ncr': 'partner_id',
'rma': 'partner_id',
'capa': 'partner_id', # may be False if linked via ncr_id
'check': 'job_id.partner_id',
}
# Find partners with critical flags
Partner = self.env['res.partner'].sudo()
critical_partner_domain = []
if 'x_fc_rush' in Partner._fields:
critical_partner_domain.append(('x_fc_rush', '=', True))
if 'x_fc_vip' in Partner._fields:
critical_partner_domain.append(('x_fc_vip', '=', True))
if not critical_partner_domain:
return [] # no flags defined in this codebase
# OR the conditions
if len(critical_partner_domain) > 1:
critical_partner_domain = ['|'] * (len(critical_partner_domain) - 1) \
+ critical_partner_domain
critical_partner_ids = Partner.search(critical_partner_domain).ids
if not critical_partner_ids:
return []
partner_path = partner_field_map.get(type_code, 'partner_id')
# Compose the record-side filter — direct or via dotted path
dom = list(OVERDUE_THRESHOLDS[type_code]['state_domain'])
dom.append((partner_path, 'in', critical_partner_ids))
try:
return Model.search(dom).ids
except Exception as e:
# Field doesn't exist on the model (e.g. fusion.plating.capa
# may not have partner_id directly). Best-effort: skip.
_logger.debug(
"critical_customer probe failed for %s: %s", type_code, e,
)
return []
def _critical_badge(self, rec):
"""Return 'RUSH' / 'VIP' / 'AS9100' / None based on which
signal qualified the record. Defensive on missing fields."""
partner = self._resolve_partner(rec)
if not partner:
return None
if 'x_fc_rush' in partner._fields and getattr(partner, 'x_fc_rush', False):
return 'RUSH'
if 'x_fc_vip' in partner._fields and getattr(partner, 'x_fc_vip', False):
return 'VIP'
# Aerospace — check part name OR spec code if reachable
for path in ('part_catalog_id.name', 'customer_spec_id.code'):
head, _, attr = path.partition('.')
if head in rec._fields and rec[head] \
and attr in rec[head]._fields:
val = (rec[head][attr] or '').upper()
if 'AEROSPACE' in val:
return 'AEROSPACE'
if val.startswith('AS9100') or val.startswith('NADCAP'):
return 'AS9100'
return None
def _build_banner(self, all_candidates):
"""Rank: overdue first by oldest create_date, then critical-
customer by oldest create_date. Take top 6.
all_candidates: list of (rec, urgency, badge, type_code) tuples.
"""
def rank_key(tup):
rec, urgency, _badge, _t = tup
# overdue=0 sorts before critical_customer=1; then oldest first.
return (0 if urgency == 'overdue' else 1,
rec.create_date or self.now)
ranked = sorted(all_candidates, key=rank_key)
top = ranked[:self.BANNER_MAX]
return {
'items': [self._build_banner_item(t) for t in top],
'all_clear': len(all_candidates) == 0,
'total_matching': len(all_candidates),
}
def _build_banner_item(self, tup):
"""Shape one banner item from (rec, urgency, badge, type_code)."""
rec, urgency, badge, type_code = tup
cfg = TYPE_CONFIG[type_code]
partner = self._resolve_partner(rec)
is_overdue = (urgency == 'overdue')
return {
'type': type_code,
'id': rec.id,
'name': rec.display_name or rec.name or f'#{rec.id}',
'customer': partner.name if partner else '',
'subtitle': self._build_subtitle(type_code, rec, is_overdue),
'urgency': urgency,
'critical_badge': badge,
'open_action': {
'res_model': cfg['model'],
'res_id': rec.id,
},
}
```
- [ ] **Step 3: Wire banner into `build()`**
Replace the existing `build()` method body:
```python
def build(self):
sections = []
all_banner_candidates = []
for type_code in SECTION_ORDER:
cfg = TYPE_CONFIG[type_code]
if cfg['model'] not in self.env:
continue
Model = self.env[cfg['model']].sudo()
section = self._build_section(type_code)
if section is None:
continue
sections.append(section)
# Collect banner candidates with type_code attached
for rec, urgency, badge in self._fetch_banner_candidates(
type_code, Model):
all_banner_candidates.append(
(rec, urgency, badge, type_code),
)
banner = self._build_banner(all_banner_candidates)
return {
'banner': banner,
'sections': sections,
'computed_at': self.now.isoformat(),
}
```
- [ ] **Step 4: Commit**
```bash
git add fusion_plating_quality/
git commit -m "feat(quality_dashboard): banner with overdue + critical (Task 4)
_fetch_banner_candidates collects (overdue) OR (critical-customer +
open) records per type. _critical_customer_ids reuses partner.x_fc_rush
and partner.x_fc_vip flags when defined (gracefully no-ops when
absent). _critical_badge returns RUSH/VIP/AEROSPACE/AS9100 label
when the banner reason is critical-customer (no badge when overdue).
_build_banner ranks: overdue first by oldest, then critical-customer
by oldest, takes top 6, reports total_matching.
Tests cover overdue hold pickup, 6-cap with overflow count, and
all_clear when DB is empty.
"
```
---
### Task 5: Defensive guards + module-not-installed tests
**Files:**
- Modify: `fusion_plating_quality/tests/test_dashboard_snapshot.py`
- [ ] **Step 1: Add defensive-path tests**
Append to [`test_dashboard_snapshot.py`](../../fusion_plating_quality/tests/test_dashboard_snapshot.py):
```python
from unittest.mock import patch
class TestDashboardSnapshotDefensive(TransactionCase):
"""Module-not-installed + missing-field guards."""
def _build(self):
from odoo.addons.fusion_plating_quality.controllers.fp_quality_dashboard \
import FpQualityDashboardSnapshot
return FpQualityDashboardSnapshot(self.env).build()
def test_missing_certificate_model_omits_section(self):
# Patch the 'in self.env' check to simulate fp.certificate
# being absent. Implementation uses 'cfg["model"] not in self.env'.
original_contains = self.env.__contains__
def fake_contains(model_name):
if model_name == 'fp.certificate':
return False
return original_contains(model_name)
with patch.object(type(self.env), '__contains__',
side_effect=fake_contains):
snap = self._build()
types_present = [s['type'] for s in snap['sections']]
self.assertNotIn('cert', types_present)
def test_missing_partner_field_falls_through(self):
# Empty DB + helper must not raise even when x_fc_rush absent.
# (In this codebase x_fc_rush isn't a registered field — only
# read defensively via the `in partner._fields` check. The
# snapshot must build cleanly.)
snap = self._build()
self.assertIn('banner', snap)
self.assertTrue(snap['banner']['all_clear'])
def test_computed_at_is_iso_string(self):
from datetime import datetime
snap = self._build()
self.assertIsInstance(snap['computed_at'], str)
# Must be parseable as ISO timestamp
datetime.fromisoformat(snap['computed_at'])
```
- [ ] **Step 2: Verify nothing crashes (visual scan of controller logic)**
Re-read [`fp_quality_dashboard.py`](../../fusion_plating_quality/controllers/fp_quality_dashboard.py) end-to-end. Confirm every cross-module field access goes through one of:
- `in self.env` check (model existence)
- `in rec._fields` check (field existence)
- `getattr(..., default=False)` (attribute access)
- `try/except` (search domain that might fail)
- [ ] **Step 3: Commit**
```bash
git add fusion_plating_quality/tests/test_dashboard_snapshot.py
git commit -m "test(quality_dashboard): defensive guard tests (Task 5)
Covers: missing-model omits section without traceback; missing-field
critical-customer check returns empty without crashing; computed_at
is a valid ISO timestamp.
Phase 1 backend complete.
"
```
---
## Phase 2 — Frontend rewrite
### Task 6: Rewrite OWL component (JS + XML + minimal SCSS)
**Files:**
- Modify: `fusion_plating_quality/static/src/js/fp_quality_dashboard.js`
- Modify: `fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml`
- Modify: `fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss`
- [ ] **Step 1: Replace `fp_quality_dashboard.js` wholesale**
Replace the entire content of [`fusion_plating_quality/static/src/js/fp_quality_dashboard.js`](../../fusion_plating_quality/static/src/js/fp_quality_dashboard.js) with:
```javascript
/** @odoo-module **/
// Quality Dashboard — action surface.
// Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
//
// Single OWL component that fetches one snapshot from
// /fp/quality/dashboard/snapshot and renders:
// - BannerCard: red "Needs Attention Today" (up to 6 items)
// OR green "All caught up" when zero qualify
// - SectionCard × 6 in canonical order (cert, hold, ncr, rma, capa, check)
//
// BannerCard / BannerItem / SectionCard / SectionRow live in this same
// file as sibling sub-components — not reused elsewhere yet.
import { Component, useState, onWillStart, onMounted, onWillUnmount }
from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { rpc } from "@web/core/network/rpc";
// 60s poll matches the cadence of the old dashboard.
const POLL_INTERVAL_MS = 60000;
class BannerItem extends Component {
static template = "fusion_plating_quality.BannerItem";
static props = ["item", "onOpen"];
}
class BannerCard extends Component {
static template = "fusion_plating_quality.BannerCard";
static props = ["banner", "onOpen"];
static components = { BannerItem };
}
class SectionRow extends Component {
static template = "fusion_plating_quality.SectionRow";
static props = ["item", "onOpen"];
}
class SectionCard extends Component {
static template = "fusion_plating_quality.SectionCard";
static props = ["section", "onOpen", "onOpenKanban"];
static components = { SectionRow };
}
export class FpQualityDashboard extends Component {
static template = "fusion_plating_quality.FpQualityDashboard";
static components = { BannerCard, SectionCard };
static props = ["*"];
setup() {
this.action = useService("action");
this.state = useState({
loading: true,
snapshot: null,
error: null,
});
onWillStart(async () => {
await this._refresh();
// Deep-link: ?tab=certificates → scroll to certs section.
// Email template uses `?tab=certificates`; normalize to the
// 'cert' type_code used in the snapshot.
const tab = this.props.action?.context?.params?.tab
|| this.props.action?.params?.tab;
if (tab) {
this._pendingScrollTarget = tab.startsWith('cert')
? 'cert' : tab;
}
});
onMounted(() => {
if (this._pendingScrollTarget) {
// Wait one tick for the DOM to settle, then scroll.
setTimeout(() => {
const el = document.getElementById(
'section-' + this._pendingScrollTarget,
);
if (el) el.scrollIntoView({behavior: 'smooth'});
}, 50);
this._pendingScrollTarget = null;
}
this._poll = setInterval(() => this._refresh(),
POLL_INTERVAL_MS);
});
onWillUnmount(() => {
if (this._poll) clearInterval(this._poll);
});
}
async _refresh() {
try {
const result = await rpc("/fp/quality/dashboard/snapshot");
this.state.snapshot = result;
this.state.error = null;
} catch (e) {
console.warn("FpQualityDashboard: snapshot RPC failed", e);
this.state.error = "Couldn't refresh dashboard — retry in 60s";
} finally {
this.state.loading = false;
}
}
onOpenItem(item) {
// Build a form-view act_window from the item's open_action payload.
// ACL is enforced by Odoo on click — if the user lacks access,
// they get the standard access error (D15).
this.action.doAction({
type: "ir.actions.act_window",
res_model: item.open_action.res_model,
res_id: item.open_action.res_id,
view_mode: "form",
views: [[false, "form"]],
target: "current",
});
}
onOpenKanban(section) {
// Pass the xmlid string directly — Odoo 19's action service
// resolves it via the registry. Fallback to shipping the full
// act_window dict from the snapshot if this stops working.
this.action.doAction(section.open_kanban_xmlid);
}
}
registry.category("actions").add("fp_quality_dashboard", FpQualityDashboard);
```
- [ ] **Step 2: Replace `fp_quality_dashboard.xml` wholesale**
Replace the entire content of [`fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml`](../../fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml) with:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- ===== TOP-LEVEL DASHBOARD ===== -->
<t t-name="fusion_plating_quality.FpQualityDashboard">
<div class="o_fp_qd p-3">
<div t-if="state.loading" class="o_fp_qd_loading">Loading…</div>
<div t-if="state.error" class="o_fp_qd_error">
<t t-esc="state.error"/>
</div>
<t t-if="state.snapshot">
<BannerCard banner="state.snapshot.banner"
onOpen.bind="onOpenItem"/>
<t t-foreach="state.snapshot.sections"
t-as="section" t-key="section.type">
<SectionCard section="section"
onOpen.bind="onOpenItem"
onOpenKanban.bind="onOpenKanban"/>
</t>
</t>
</div>
</t>
<!-- ===== BANNER CARD ===== -->
<t t-name="fusion_plating_quality.BannerCard">
<div t-if="props.banner.all_clear"
class="o_fp_qd_banner o_fp_qd_banner_clear">
<div class="o_fp_qd_banner_clear_icon"></div>
<div class="o_fp_qd_banner_clear_text">
<strong>All caught up</strong> — no critical items right now
</div>
</div>
<div t-else="" class="o_fp_qd_banner o_fp_qd_banner_urgent">
<div class="o_fp_qd_banner_head">
⚠️ NEEDS ATTENTION TODAY ·
<t t-esc="props.banner.total_matching"/>
<span t-if="props.banner.total_matching > props.banner.items.length"
class="o_fp_qd_banner_overflow">
(showing <t t-esc="props.banner.items.length"/>
of <t t-esc="props.banner.total_matching"/>
see sections below for the rest)
</span>
</div>
<div class="o_fp_qd_banner_grid">
<t t-foreach="props.banner.items"
t-as="item" t-key="item.type + '_' + item.id">
<BannerItem item="item" onOpen="props.onOpen"/>
</t>
</div>
</div>
</t>
<t t-name="fusion_plating_quality.BannerItem">
<button class="o_fp_qd_banner_item"
t-on-click="() => props.onOpen(props.item)">
<div class="o_fp_qd_banner_item_l1">
<span class="o_fp_qd_banner_item_name">
<strong t-esc="props.item.name"/>
</span>
<span class="o_fp_qd_banner_item_type"
t-esc="props.item.type.toUpperCase()"/>
<span t-if="props.item.critical_badge"
class="o_fp_qd_banner_item_badge"
t-esc="props.item.critical_badge"/>
</div>
<div class="o_fp_qd_banner_item_l2">
<span class="o_fp_qd_banner_item_cust"
t-esc="props.item.customer"/>
<span class="o_fp_qd_banner_item_subtitle"
t-esc="props.item.subtitle"/>
</div>
</button>
</t>
<!-- ===== SECTION CARD ===== -->
<t t-name="fusion_plating_quality.SectionCard">
<div class="o_fp_qd_section"
t-att-id="'section-' + props.section.type">
<div class="o_fp_qd_section_head">
<span class="o_fp_qd_section_title">
<t t-esc="props.section.icon"/>
<strong t-esc="props.section.label"/>
· <t t-esc="props.section.open"/> open
<t t-if="props.section.overdue">
·
<span class="o_fp_qd_section_overdue">
<t t-esc="props.section.overdue"/> overdue
</span>
</t>
</span>
<button class="o_fp_qd_section_open"
t-on-click="() => props.onOpenKanban(props.section)">
Open all →
</button>
</div>
<div t-if="props.section.items.length === 0"
class="o_fp_qd_section_empty">
No open items
</div>
<t t-else="" t-foreach="props.section.items"
t-as="item" t-key="item.id">
<SectionRow item="item" onOpen="props.onOpen"/>
</t>
</div>
</t>
<t t-name="fusion_plating_quality.SectionRow">
<div class="o_fp_qd_row"
t-att-class="props.item.urgency === 'overdue'
? 'o_fp_qd_row_overdue' : ''">
<div class="o_fp_qd_row_main">
<strong t-esc="props.item.name"/>
<span class="o_fp_qd_row_sep"> · </span>
<span class="o_fp_qd_row_cust" t-esc="props.item.customer"/>
<span t-if="props.item.subtitle"
class="o_fp_qd_row_subtitle">
<span class="o_fp_qd_row_sep"> · </span>
<t t-esc="props.item.subtitle"/>
</span>
</div>
<button class="o_fp_qd_row_open"
t-on-click="() => props.onOpen(props.item)">
Open →
</button>
</div>
</t>
</templates>
```
- [ ] **Step 3: Replace `fp_quality_dashboard.scss` wholesale**
Replace the entire content of [`fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss`](../../fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss) with:
```scss
// Quality Dashboard — action surface.
// Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
//
// Tokens defined locally; light + dark via $o-webclient-color-scheme
// compile-time branch (project Rule 9 — no runtime .o_dark_mode class).
// Reuses base $plant-card-bg / $plant-bg / $plant-text / $plant-border
// from _plant_tokens.scss (loaded earlier in the manifest).
$o-webclient-color-scheme: bright !default;
$_qd-urgent-bg-hex: #fee2e2;
$_qd-urgent-bg-end-hex: #fff;
$_qd-urgent-border-hex: #dc2626;
$_qd-urgent-text-hex: #7f1d1d;
$_qd-good-bg-hex: #d1fae5;
$_qd-good-bg-end-hex: #ecfdf5;
$_qd-good-border-hex: #22c55e;
$_qd-good-text-hex: #064e3b;
$_qd-section-head-bg-hex: #fef3c7;
$_qd-section-overdue-hex: #b45309;
@if $o-webclient-color-scheme == dark {
$_qd-urgent-bg-hex: #3a1818 !global;
$_qd-urgent-bg-end-hex: #1d1d1f !global;
$_qd-urgent-text-hex: #fca5a5 !global;
$_qd-good-bg-hex: #14281a !global;
$_qd-good-bg-end-hex: #1d1d1f !global;
$_qd-good-text-hex: #6ee7b7 !global;
$_qd-section-head-bg-hex: #3a2f15 !global;
$_qd-section-overdue-hex: #fbbf24 !global;
}
$qd-urgent-bg: var(--fp-qd-urgent-bg, $_qd-urgent-bg-hex);
$qd-urgent-bg-end: var(--fp-qd-urgent-bg-end, $_qd-urgent-bg-end-hex);
$qd-urgent-border: var(--fp-qd-urgent-border, $_qd-urgent-border-hex);
$qd-urgent-text: var(--fp-qd-urgent-text, $_qd-urgent-text-hex);
$qd-good-bg: var(--fp-qd-good-bg, $_qd-good-bg-hex);
$qd-good-bg-end: var(--fp-qd-good-bg-end, $_qd-good-bg-end-hex);
$qd-good-border: var(--fp-qd-good-border, $_qd-good-border-hex);
$qd-good-text: var(--fp-qd-good-text, $_qd-good-text-hex);
$qd-section-head-bg: var(--fp-qd-section-head-bg, $_qd-section-head-bg-hex);
$qd-section-overdue: var(--fp-qd-section-overdue, $_qd-section-overdue-hex);
.o_fp_qd {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: $plant-text;
.o_fp_qd_loading, .o_fp_qd_error {
padding: 2rem; text-align: center; color: $plant-muted;
}
.o_fp_qd_error { color: $qd-urgent-border; }
// ===== Banner =====
.o_fp_qd_banner {
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 16px;
border: 1px solid $plant-card-border;
}
.o_fp_qd_banner_urgent {
background: linear-gradient(135deg, $qd-urgent-bg 0%, $qd-urgent-bg-end 100%);
border-color: $qd-urgent-border;
}
.o_fp_qd_banner_clear {
background: linear-gradient(135deg, $qd-good-bg 0%, $qd-good-bg-end 100%);
border-color: $qd-good-border;
display: flex; align-items: center; gap: 14px;
padding: 20px;
}
.o_fp_qd_banner_clear_icon {
font-size: 32px; color: $qd-good-text; line-height: 1;
}
.o_fp_qd_banner_clear_text { color: $qd-good-text; font-size: 16px; }
.o_fp_qd_banner_head {
font-weight: 700; color: $qd-urgent-text;
font-size: 13px; letter-spacing: 0.04em; margin-bottom: 10px;
}
.o_fp_qd_banner_overflow {
font-weight: 500; opacity: 0.8; margin-left: 8px;
}
.o_fp_qd_banner_grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
}
.o_fp_qd_banner_item {
background: $plant-card-bg;
border: 1px solid $plant-card-border;
border-left: 3px solid $qd-urgent-border;
border-radius: 6px;
padding: 8px 10px;
text-align: left; cursor: pointer;
color: $plant-text; font-family: inherit;
transition: transform 0.1s ease, box-shadow 0.1s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 3px 6px rgba(0,0,0,0.08);
}
}
.o_fp_qd_banner_item_l1 {
display: flex; align-items: center; gap: 6px; font-size: 13px;
}
.o_fp_qd_banner_item_type {
font-size: 9px; font-weight: 700; padding: 2px 6px;
background: $plant-bg; color: $plant-muted;
border-radius: 4px; letter-spacing: 0.04em;
}
.o_fp_qd_banner_item_badge {
font-size: 9px; font-weight: 700; padding: 2px 6px;
background: $qd-urgent-border; color: #fff;
border-radius: 4px; letter-spacing: 0.04em;
}
.o_fp_qd_banner_item_l2 {
font-size: 11px; color: $plant-muted; margin-top: 3px;
display: flex; gap: 6px;
}
.o_fp_qd_banner_item_subtitle {
color: $qd-urgent-border; font-weight: 600;
}
// ===== Section =====
.o_fp_qd_section {
background: $plant-card-bg;
border: 1px solid $plant-card-border;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.o_fp_qd_section_head {
background: linear-gradient(135deg, $qd-section-head-bg 0%, $plant-card-bg 100%);
padding: 10px 14px;
display: flex; justify-content: space-between; align-items: center;
font-size: 13px;
}
.o_fp_qd_section_overdue { color: $qd-section-overdue; font-weight: 600; }
.o_fp_qd_section_open {
background: transparent; border: 0;
color: #1d4ed8; font-weight: 500; cursor: pointer;
font-size: 12px; font-family: inherit;
&:hover { text-decoration: underline; }
}
.o_fp_qd_section_empty {
padding: 12px 14px; color: $plant-muted; font-style: italic;
font-size: 12px;
}
// ===== Row =====
.o_fp_qd_row {
padding: 8px 14px;
display: flex; justify-content: space-between; align-items: center;
gap: 10px; font-size: 13px;
border-top: 1px solid $plant-card-border;
transition: background 0.1s ease;
&:hover { background: $plant-bg; }
}
.o_fp_qd_row_overdue .o_fp_qd_row_subtitle {
color: $qd-urgent-border; font-weight: 600;
}
.o_fp_qd_row_main { flex: 1; min-width: 0; }
.o_fp_qd_row_sep { color: $plant-muted; }
.o_fp_qd_row_cust { color: $plant-muted; }
.o_fp_qd_row_open {
background: #1d4ed8; color: #fff;
border: 0; padding: 4px 12px; border-radius: 4px;
font-size: 11px; font-weight: 600; cursor: pointer;
font-family: inherit; min-height: 28px;
transition: background 0.1s ease;
&:hover { background: #1e40af; }
}
// ===== Mobile =====
@media (max-width: 900px) {
.o_fp_qd_banner_grid { grid-template-columns: 1fr; }
.o_fp_qd_row {
flex-direction: column; align-items: flex-start;
.o_fp_qd_row_open { align-self: stretch; min-height: 32px; }
}
}
}
```
- [ ] **Step 4: Commit**
```bash
git add fusion_plating_quality/static/src/
git commit -m "feat(quality_dashboard): rewrite OWL component + template + SCSS (Task 6)
JS: single FpQualityDashboard component + BannerCard / BannerItem /
SectionCard / SectionRow sibling sub-components in the same file.
Fetches /fp/quality/dashboard/snapshot, 60s poll, deep-link
?tab=certificates scrolls to section-cert via scrollIntoView.
XML: outer wrapper + banner + 6 sections (t-foreach over
state.snapshot.sections). Each section has id='section-<type>' so
the deep-link target works. SectionRow has overdue-conditional
class for red subtitle highlight.
SCSS: local tokens for urgent/good/section-head with light+dark via
\$o-webclient-color-scheme branch. 135deg gradients matching the
plant kanban polish. Mobile breakpoint at 900px collapses banner
grid to 1 col and stacks row Open button.
OLD TABS array, selectTab, openTab, totalOpen, totalOverdue all
deleted. Old template's tab tiles + per-tab panels deleted. Existing
per-model kanbans untouched.
"
```
---
## Phase 3 — Polish + deploy
### Task 7: Manifest bump
**Files:**
- Modify: `fusion_plating_quality/__manifest__.py`
- [ ] **Step 1: Bump version**
In [`fusion_plating_quality/__manifest__.py`](../../fusion_plating_quality/__manifest__.py), find the `'version'` line and change:
```python
'version': '19.0.7.0.0',
```
to:
```python
'version': '19.0.8.0.0',
```
- [ ] **Step 2: Commit**
```bash
git add fusion_plating_quality/__manifest__.py
git commit -m "chore(quality_dashboard): version bump 19.0.7.0.0 → 19.0.8.0.0
Triggers asset cache invalidation on -u so the new template + SCSS
load cleanly.
"
```
---
### Task 8: Entech smoke battle test
**Files:**
- Create: `fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py`
- [ ] **Step 1: Write the smoke script**
Create [`fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py`](../../fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py):
```python
# -*- coding: utf-8 -*-
"""Quality Dashboard redesign — entech smoke.
Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
Plan: docs/superpowers/plans/2026-05-25-quality-dashboard-redesign-plan.md
Run on entech via odoo-shell:
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \\\"
exec(open(\\\\\\\"/mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py\\\\\\\").read())
\\\" | su - odoo -s /bin/bash -c \\\"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\\\"'"
Validates:
1. FpQualityDashboardSnapshot helper class exists and builds
2. Response shape (banner + sections + computed_at)
3. Section order is canonical (cert, hold, ncr, rma, capa, check)
4. Each section has all required keys
5. Each section's open_kanban_xmlid resolves to a real act_window
6. Banner items shape includes open_action that resolves to a model
7. Rolls back any test data at the end
"""
from odoo.addons.fusion_plating_quality.controllers.fp_quality_dashboard \
import FpQualityDashboardSnapshot, SECTION_ORDER
def _ok(cond, label):
if cond:
print('OK -', label)
else:
print('FAIL -', label)
raise SystemExit(1)
# ---- 1. Build snapshot ----
snap = FpQualityDashboardSnapshot(env).build()
_ok(isinstance(snap, dict), 'snapshot is a dict')
# ---- 2. Response shape ----
for k in ('banner', 'sections', 'computed_at'):
_ok(k in snap, f'snapshot has key {k!r}')
_ok(isinstance(snap['sections'], list), 'sections is a list')
_ok(isinstance(snap['banner'], dict), 'banner is a dict')
# ---- 3. Section order ----
types = [s['type'] for s in snap['sections']]
canonical_present = [t for t in SECTION_ORDER if t in types]
_ok(types == canonical_present,
f'section order canonical: got {types}, expected subset of {SECTION_ORDER}')
# ---- 4. Section keys ----
for sec in snap['sections']:
for k in ('type', 'label', 'icon', 'open', 'overdue', 'items',
'open_kanban_xmlid'):
_ok(k in sec, f'section {sec["type"]} has key {k!r}')
# ---- 5. open_kanban_xmlid resolves ----
for sec in snap['sections']:
try:
act = env.ref(sec['open_kanban_xmlid'], raise_if_not_found=False)
_ok(bool(act), f'section {sec["type"]} kanban xmlid resolves')
except Exception as e:
_ok(False, f'section {sec["type"]} xmlid: {e}')
# ---- 6. Banner items shape ----
print(f' banner.all_clear = {snap["banner"]["all_clear"]}')
print(f' banner.items = {len(snap["banner"]["items"])}')
print(f' banner.total_matching = {snap["banner"]["total_matching"]}')
if snap['banner']['items']:
item = snap['banner']['items'][0]
for k in ('type', 'id', 'name', 'customer', 'subtitle',
'urgency', 'critical_badge', 'open_action'):
_ok(k in item, f'banner item has key {k!r}')
# open_action.res_model resolves to a model
_ok(item['open_action']['res_model'] in env,
f'banner item res_model {item["open_action"]["res_model"]!r} is installed')
# ---- Summary ----
print()
print('--- bt_quality_dashboard_redesign: ALL PASS ---')
print(f' Sections present: {len(snap["sections"])}')
for sec in snap['sections']:
print(f' {sec["icon"]} {sec["label"]}: {sec["open"]} open '
f'({sec["overdue"]} overdue), top-{len(sec["items"])} listed')
print(f' Banner: {len(snap["banner"]["items"])} items '
f'(of {snap["banner"]["total_matching"]} matching), '
f'all_clear={snap["banner"]["all_clear"]}')
```
- [ ] **Step 2: Commit**
```bash
git add fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py
git commit -m "test(bt): quality dashboard redesign entech smoke (Task 8)
7-check battle test: snapshot shape, section order canonical,
section keys present, open_kanban_xmlid resolves, banner item
shape (when items exist), res_model exists. Summary at the end
prints per-section counts so you can eyeball the entech state.
"
```
---
### Task 9: Deploy to entech
**Files:** none (deployment task).
- [ ] **Step 1: Push all commits to remotes**
```bash
git push 2>&1 | tail -3
```
Expected: pushes to both GitHub + Gitea (multi-remote).
- [ ] **Step 2: Sync all changed files to entech**
```bash
cd K:/Github/Odoo-Modules/fusion_plating && for f in \
controllers/fp_quality_dashboard.py \
tests/__init__.py \
tests/test_dashboard_snapshot.py \
static/src/js/fp_quality_dashboard.js \
static/src/xml/fp_quality_dashboard.xml \
static/src/scss/fp_quality_dashboard.scss \
scripts/bt_quality_dashboard_redesign.py \
__manifest__.py \
; do
cat "fusion_plating_quality/$f" | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_plating_quality/$f'"
echo "synced: $f"
done
```
Expected: 8 lines printed, one per file.
- [ ] **Step 3: Run the upgrade**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_quality --stop-after-init\" 2>&1 | tail -10 && systemctl start odoo && sleep 3 && systemctl is-active odoo'"
```
Expected: log tail ends with `Modules loaded`. Output ends with `active`.
- [ ] **Step 4: Clear asset cache (SCSS + JS + XML all changed)**
```bash
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
```
Expected: `DELETE N` where N > 0.
- [ ] **Step 5: Restart odoo to pick up fresh assets**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl restart odoo && sleep 4 && systemctl is-active odoo'"
```
Expected: `active`.
- [ ] **Step 6: Run the battle test on entech**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"exec(open(\\\"/mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py\\\").read())\" | su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" 2>&1 | grep -E \"^(OK|FAIL|---| )\" | head -50'"
```
Expected: every line `OK -` and ends with `--- bt_quality_dashboard_redesign: ALL PASS ---`.
- [ ] **Step 7: Manual verification in browser**
Open `https://enplating.com/odoo/action-fp_quality_dashboard`. Verify:
- Banner renders red (with items) OR green (all-clear)
- 6 sections appear in order: Certificates → Holds → NCRs → RMAs → CAPAs → QC Checks
- Each section header shows count + overdue subtotal
- Clicking a row's `Open →` button opens the record form
- Clicking `Open all →` opens the per-model kanban
- Click the cert authority notification email link with `?tab=certificates` — page scrolls to the Certificates section
Test dark mode: User profile → toggle theme → reload — verify all gradients flip cleanly.
- [ ] **Step 8: Tag the deploy**
```bash
cd K:/Github/Odoo-Modules/fusion_plating
git tag deploy/entech/2026-05-25-quality-dashboard-redesign
git push --tags
```
---
## Self-Review (run after writing the plan)
**1. Spec coverage** — every spec section/decision maps to a task:
| Spec section | Task(s) |
|---|---|
| D1 Hybrid layout (banner + grouped sections) | Task 6 (component composition) |
| D2 Banner rule overdue OR critical-customer | Task 4 (`_fetch_banner_candidates`) |
| D3 Critical-customer = rush/vip/aerospace | Task 4 (`_critical_customer_ids`, `_critical_badge`) |
| D4 Banner size 6 + green all-clear when zero | Task 4 (`_build_banner`), Task 6 (template + SCSS) |
| D5 Single `Open →` button per row | Task 6 (`onOpenItem`) |
| D6 Drop existing header strip | Task 6 (rewritten template has no header strip) |
| D7 Section order Cert/Hold/NCR/RMA/CAPA/Check | Task 2 (`SECTION_ORDER` constant) |
| D8 Top 5 items inline + Open all link | Task 3 (`SECTION_TOP_N = 5`), Task 6 (template) |
| D9 Section header shows count + overdue | Task 2 (response shape), Task 6 (template) |
| D10 Critical-customer badge | Task 4 (`_critical_badge`), Task 6 (BannerItem template) |
| D11 Zero-item section renders italic | Task 6 (`o_fp_qd_section_empty`) |
| D12 Item may appear in both banner + section | Implicit — no dedupe in `build()` |
| D13 60s poll | Task 6 (`POLL_INTERVAL_MS`) |
| D14 `?tab=certificates` → scrollIntoView | Task 6 (`onWillStart` + `onMounted`) |
| D15 ACL enforced at click time | Task 2 (sudo on snapshot), Task 6 (`onOpenItem` uses act_window) |
| All edge cases | Task 5 (defensive tests) + defensive guards in Tasks 2-4 |
| Response shape JSON | Task 2 + Task 3 + Task 4 |
| SCSS tokens + dark mode | Task 6 (single SCSS file, tokens + branch) |
| File inventory | All 8 files covered across Tasks 1-8 |
| Test plan (12 unit tests) | Tasks 1, 3, 4, 5 |
| Manual QA + entech smoke | Task 8 + Task 9 |
| Migration / rollback | Task 9 Step 7 (verification); rollback via `git revert` |
No gaps.
**2. Placeholder scan** — no TBDs, "implement later", "similar to Task N", or hand-wavy steps. Every code block contains the actual code to write. Test bodies are spelled out. Deploy commands are exact.
**3. Type consistency** — helper method names match across tasks:
- `_build_section` — Task 2 defines, Task 3 modifies its return shape
- `_overdue_count` / `_overdue_ids` — Task 2 / Task 3 — both use the same `OVERDUE_THRESHOLDS[type_code]` dispatch
- `_fetch_section_items` — Task 3 defines, called from `_build_section`
- `_resolve_partner` — Task 3 defines, reused in Task 4 (`_critical_badge`)
- `_fetch_banner_candidates` + `_critical_customer_ids` + `_critical_badge` + `_build_banner` + `_build_banner_item` — Task 4 defines all five, internally consistent
- `BannerItem`, `BannerCard`, `SectionRow`, `SectionCard` — Task 6 declares all four with matching `static template` strings; XML template names match (`fusion_plating_quality.BannerItem` etc.)
- Snapshot response keys (`banner`, `sections`, `computed_at`, `items`, `open`, `overdue`, `open_kanban_xmlid`, etc.) — declared in Task 2 backend, consumed in Task 6 frontend, asserted in Task 1 + Task 8
**Issue found and fixed in self-review:**
- Task 4 `_critical_customer_ids` had `partner_field_map['capa'] = 'partner_id'` but `fusion.plating.capa` may not have a `partner_id` directly (it likely links via ncr_id → partner_id). The implementation already includes a `try/except` around the `Model.search(dom)` call that returns `[]` on failure — so CAPA critical-customer signal will be unavailable if the dotted path fails, but the snapshot won't crash. **Acceptable**: the overdue signal still works for CAPA, and we can wire a proper `ncr_id.partner_id` traversal in a follow-up if it becomes a real gap on entech.
No other issues.
---
## Execution Handoff
**Plan complete and saved to** `docs/superpowers/plans/2026-05-25-quality-dashboard-redesign-plan.md`.
Two execution options:
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
Which approach?