Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
gsinghpal ff51035494 docs(brainstorm): quality dashboard redesign — action surface
User goal: 'all quality related updates at glance, all the flagged
tasks need to show right here so the manager can quickly follow up
and complete the task'. Current dashboard is a tab-router (6 numeric
tiles + click-to-drill) — flagged tasks aren't visible without
navigation.

Design (Hybrid layout, approved):
- Red 'Needs Attention Today' banner on top (up to 6 items, 2x3 grid)
  showing items that are overdue OR from critical customers
  (x_fc_rush / x_fc_vip / aerospace). Green 'all caught up' when zero.
- Per-type sections below in QM-urgency order: Certs / Holds / NCRs /
  RMAs / CAPAs / Checks. Each shows top 5 items inline + Open all
  link to the existing kanban.
- Single 'Open ->' button per row -> opens record form via act_window.
  No one-click action shortcuts (cert form is where Fischerscope +
  sign-off prereqs are validated).
- Drop the existing 'Quality Overview' header strip entirely.
- 60s poll cadence preserved.
- ?tab=certificates deep-link from awaiting-cert notification email
  preserved as scrollIntoView on the certs section.

Backend: replace /fp/quality/dashboard/counts with /snapshot. New
helper class FpQualityDashboardSnapshot builds banner + 6 sections in
one response. Cross-module reads sudo'd per Rule 13m; missing fields
gracefully degrade per Rule 13j defensive pattern.

Frontend: rewrite the OWL component. BannerCard + 6 SectionCards as
sub-components in the same JS file (not reused elsewhere yet).
Existing per-model kanbans untouched.

Self-review fixed 4 issues:
- _critical_customer_domain made per-type (was contradictory)
- OVERDUE_THRESHOLDS gained explicit use_due_date flag (CAPA branch)
- Template requirement called out: id='section-<type>' on each card
  for the deep-link scrollIntoView to work
- doAction call shape disambiguated for xmlid vs full dict

Spec: 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:07:38 -04:00

33 KiB
Raw Blame History

Quality Dashboard Redesign — Action Surface

Date: 2026-05-25 Status: Approved for implementation (brainstorming gate) Author: Brainstorming session (gsinghpal) Triggering incident: The current Quality Dashboard is a tab-router — it surfaces 6 numeric tiles and forces the QM to click into per-model kanbans to actually see and act on items. Two complaints: (1) lots of empty whitespace below the tile row, (2) flagged tasks that need QM attention aren't visible at a glance. The Quality Manager wants "all quality related updates at glance, all the flagged tasks need to show right here so the manager can quickly follow up and complete the task" (verbatim from session 2026-05-25).

Goal

Turn the Quality Dashboard from a router ("click here to see N records") into an action surface ("here are the records that need attention, click Open to act"). Specifically:

  1. Surface flagged items directly on the page — no extra navigation for the things that need the QM's eyes today.
  2. Distinguish urgent from routine — red "Needs Attention Today" banner at the top draws the eye to overdue + critical-customer items across all types; per-type sections below hold the routine queue.
  3. Preserve the existing per-model kanbans — "Open all →" links route to them unchanged. We're rebuilding the dashboard surface, not the underlying record management.
  4. Keep the existing notification deep-link working — the ?tab=certificates URL param from the awaiting-cert email (spec 2026-05-25-post-shop-cert-shipping-job-states) still lands the QM on the right section.

Out of scope (deferred to follow-on work)

  • Real-time push (bus.bus / WebSocket). 60-second poll is the cadence; instant updates would add infrastructure complexity for marginal benefit on a QM dashboard.
  • Filter chips on the dashboard (e.g. "show only my customer"). Sections themselves are the filter; per-section search lives inside the per-model kanban.
  • Per-QM saved layout (drag-reorder sections). Fixed order in v1 — Settings field is YAGNI for one user role.
  • Export / print of the dashboard. The underlying kanbans support print.
  • "My quality day" personalization ("Hi Lisa — 4 critical items…"). Fixed shared view in v1.
  • Inline action buttons that fire actions directly (e.g. one-click "Issue" on the cert row). Considered and rejected — the cert form is where Fischerscope review + sign-off prereqs are validated; bypassing the form risks issuing a cert before checks complete. Every row uses a single Open → button that navigates to the form.

Decisions reached during brainstorming

# Decision Rationale
D1 Hybrid layout — red "Needs Attention Today" banner on top + grouped sections per type below Banner forces urgent items to the front of the eye; sections preserve type semantics for routine work. User picked from a 4-option visual mockup over Unified Inbox / Grouped Only / Priority Stacks.
D2 Banner rule = (overdue per type) OR (critical customer + open) Catches the high-stakes Boeing CoC at 6h before it crosses the 24h overdue line. Simple enough to predict, more responsive than overdue-only.
D3 Critical customer = partner.x_fc_rush OR partner.x_fc_vip OR aerospace/regulated indicator (part_catalog_id.name ILIKE '%aerospace%' OR customer_spec_id.code ILIKE 'AS9100%' OR 'NADCAP%') Reuses existing partner flags (no new field to maintain). Aerospace/regulated catches high-stakes regulatory work even on non-rush/VIP customers.
D4 Banner size = up to 6 items in a 2×3 grid; if zero qualify, render a green "✓ All caught up" card instead of hiding Predictable layout, positive reinforcement when the queue is clear. "Showing 6 of N" footer when more than 6 qualify.
D5 Inline row action = single Open → button per row that opens the record form Safe (no one-click bypasses of in-form checks), consistent across all 6 types, easiest to implement. The verb-specific button alternative (Issue / Disposition / Review / etc.) was rejected as visual clutter without functional value.
D6 Drop the existing "Quality Overview" header strip (Open across all 6 / Overdue / 6 tab tiles) Redundant after the redesign — banner shows urgent, each section shows its own count + overdue subtotal. Recovered vertical space goes to actual content.
D7 Section order = Certificates → Holds → NCRs → RMAs → CAPAs → QC Checks QM-urgency order: Certs first because they block shipment + are time-sensitive post-shop; Holds second because they block production; then NCRs / RMAs / CAPAs / Checks in decreasing time-pressure. NOT alphabetical, NOT the existing tab order.
D8 Section content = top 5 items inline + "Open all →" link to the existing per-model kanban Top 5 covers the daily attention budget; deeper drill uses the kanban the QM already knows.
D9 Section header shows count + overdue subtotal Section is its own micro-summary — no need for the dropped header strip to repeat this.
D10 Critical-customer items get a badge in the banner ([RUSH], [VIP], [AS9100]) Tells the QM WHY the item is in the banner when it's not yet overdue. No badge = banner reason is overdue.
D11 Section that shows zero items still renders with an italic "No open items" row Predictable layout. The QM trusts the dashboard isn't lying about types being absent.
D12 An item may appear in BOTH the banner and its section Intentional. The banner is "urgent across all types"; the section is "your queue per type". Visual reinforcement of urgency, not duplication.
D13 Refresh cadence = keep the existing 60-second JS poll No bus.bus / WebSocket. 60s matches the existing pattern + is fine for a QM surface.
D14 ?tab=certificates deep-link preserved as scroll-into-view The notification email from spec 2026-05-25-post-shop-cert-shipping-job-states links here. Translate ?tab=<id> to document.getElementById('section-<id>').scrollIntoView() on mount.
D15 ACL enforced at click time, not pre-filtered in snapshot Pre-filtering would 4x the query cost. Odoo's standard ACL fires when act_window navigates to the record — user gets the usual access error if blocked. Acceptable since dashboard is QM-facing and QMs have read on all 6 models.

Architecture

┌─ PAGE LAYOUT (top → bottom) ─────────────────────────────────┐
│                                                               │
│  ┌─ NEEDS ATTENTION TODAY · N ────────────────────────────┐  │
│  │                                                         │  │
│  │  [ITEM] [ITEM] [ITEM]    ← 2 × 3 grid, up to 6 items   │  │
│  │  [ITEM] [ITEM] [ITEM]                                   │  │
│  │                                                         │  │
│  │  (or green "✓ All caught up" card when zero)            │  │
│  │  (or "Showing 6 of N — see sections" when overflow)     │  │
│  └─────────────────────────────────────────────────────────┘  │
│                                                               │
│  ┌─ 🏷️  CERTIFICATES · X open · Y overdue ── Open all → ───┐  │
│  │ ROW · ROW · ROW · ROW · ROW    ← top 5                  │  │
│  └─────────────────────────────────────────────────────────┘  │
│  ┌─ 🛑 HOLDS · X open ───────────────────── Open all → ───┐   │
│  │ ROW · ROW · ROW                                        │   │
│  └────────────────────────────────────────────────────────┘   │
│  ┌─ 🔬 NCRs · X open · Y overdue ──────── Open all → ──┐     │
│  │ ROW · ROW                                            │     │
│  └──────────────────────────────────────────────────────┘     │
│  ┌─ ↩️  RMAs · X open ────────────────── Open all → ────┐    │
│  │ ROW                                                  │    │
│  └──────────────────────────────────────────────────────┘    │
│  ┌─ 📋 CAPAs · X open ─────────────────── Open all → ──┐    │
│  │ ROW                                                  │    │
│  └──────────────────────────────────────────────────────┘    │
│  ┌─ ✓ QC CHECKS · X open ─────────────── Open all → ──┐     │
│  │ ROW                                                 │     │
│  └─────────────────────────────────────────────────────┘     │
│                                                               │
└───────────────────────────────────────────────────────────────┘

┌─ DATA FLOW (one endpoint, one render) ───────────────────────┐
│                                                               │
│   browser              POST /fp/quality/dashboard/snapshot    │
│     │                                                         │
│     ▼                                                         │
│   FpQualityDashboardSnapshot._build()                         │
│     │                                                         │
│     ├── _critical_customer_domain()  → reusable filter clause │
│     ├── _overdue_filter(type)        → per-type thresholds    │
│     │                                                         │
│     ├── _build_section('cert')   ─┐                           │
│     ├── _build_section('hold')    │                           │
│     ├── _build_section('ncr')     ├─ per-type queries         │
│     ├── _build_section('rma')     │  (sequential, ~50ms each) │
│     ├── _build_section('capa')    │                           │
│     └── _build_section('check')  ─┘                           │
│            │                                                  │
│            ▼                                                  │
│     _build_banner(candidates) → ranked top 6                  │
│            │                                                  │
│            ▼                                                  │
│     { banner: {...}, sections: [...] } returned as JSON       │
│     │                                                         │
│     ▼                                                         │
│   OWL component renders BannerCard + 6 SectionCards in order  │
│                                                               │
└───────────────────────────────────────────────────────────────┘

Backend — snapshot endpoint

Replace /fp/quality/dashboard/counts with /fp/quality/dashboard/snapshot

Why replace, not extend: the counts endpoint returned only numbers; the new endpoint returns numbers + actual records. The shape is incompatible. Grep confirms no other module calls the counts endpoint — it's only used by the dashboard JS we're rewriting.

Response shape

{
  "banner": {
    "items": [
      {
        "type": "cert",                  // cert|hold|ncr|rma|capa|check
        "id": 123,
        "name": "CoC-30058",
        "customer": "ABC Manufactoring",
        "subtitle": "14h overdue · awaiting issuance",
        "urgency": "overdue",            // "overdue" or "critical_customer"
        "critical_badge": null,          // "RUSH" | "VIP" | "AS9100" | null
        "open_action": {
          "res_model": "fp.certificate",
          "res_id": 123
        }
      }
      // ... up to 6 items
    ],
    "all_clear": false,                  // true when items list is empty
    "total_matching": 9                  // count BEFORE the top-6 truncation
  },
  "sections": [
    {
      "type": "cert",
      "label": "Certificates",
      "icon": "🏷️",
      "open": 5,
      "overdue": 3,
      "items": [                         // top 5 by urgency
        {
          "id": 123,
          "name": "CoC-30058",
          "customer": "ABC Manufactoring",
          "subtitle": "14h overdue",
          "urgency": "overdue",
          "open_action": {
            "res_model": "fp.certificate",
            "res_id": 123
          }
        }
        // ... up to 5
      ],
      "open_kanban_xmlid": "fusion_plating_certificates.action_fp_certificate"
    }
    // ... 6 sections in this order: cert, hold, ncr, rma, capa, check
  ],
  "computed_at": "2026-05-25T16:42:00"
}

Algorithm

# fusion_plating_quality/controllers/fp_quality_dashboard.py

# Per-type "overdue" thresholds (reused from existing counts endpoint —
# battle-tested):
# CAPA is the special case — its overdue rule is due_date < today,
# not create_date < cutoff. Marked with use_due_date=True so the
# overdue-filter dispatcher branches correctly.
OVERDUE_THRESHOLDS = {
    'cert':  {'days': 1,  'use_due_date': False, 'domain': [('state', '=', 'draft')]},
    'hold':  {'days': 3,  'use_due_date': False, 'domain': [('state', 'in', ('on_hold', 'under_review'))]},
    'ncr':   {'days': 7,  'use_due_date': False, 'domain': [('state', 'in', ('open', 'containment', 'disposition'))]},
    'rma':   {'days': 5,  'use_due_date': False, 'domain': [('state', '=', 'received')]},
    'capa':  {'days': None, 'use_due_date': True, 'domain': [('state', 'not in', ('closed', 'effective'))]},
    'check': {'days': 1,  'use_due_date': False, 'domain': [('state', '=', 'pending')]},
}

# Per-type config for the snapshot builder
TYPE_CONFIG = {
    'cert':  {'label': 'Certificates', 'icon': '🏷️',
              'model': 'fp.certificate',
              'kanban_xmlid': 'fusion_plating_certificates.action_fp_certificate',
              'partner_field': 'partner_id', 'name_field': 'name'},
    # ... etc
}

# Canonical order
SECTION_ORDER = ['cert', 'hold', 'ncr', 'rma', 'capa', 'check']


class FpQualityDashboardSnapshot:
    def __init__(self, env):
        self.env = env
        self.now = fields.Datetime.now()

    def build(self):
        candidates_for_banner = []
        sections = []
        for type_code in SECTION_ORDER:
            section = self._build_section(type_code)
            if section is None:
                continue  # model not installed (e.g. fp.certificate)
            sections.append(section)
            # Pull banner candidates: overdue OR critical-customer + open
            banner_candidates = self._fetch_banner_candidates(type_code)
            candidates_for_banner.extend(banner_candidates)
        banner = self._build_banner(candidates_for_banner)
        return {
            'banner': banner,
            'sections': sections,
            'computed_at': self.now.isoformat(),
        }

    def _critical_customer_domain(self, type_code):
        """Per-type domain fragment that matches records with critical-
        customer signals. ORed via |. Per D3 — uses existing partner
        flags + aerospace/regulated indicators. The per-type field
        names differ (e.g. cert uses partner_id directly; check uses
        job_id.partner_id), so the method dispatches per type.

        Returns a list-of-clauses ready to be combined with the
        type's overdue filter via Odoo OR domain prefix notation.
        Returns empty list when none of the optional fields exist.
        """
        # cert  → partner_id.x_fc_rush | partner_id.x_fc_vip |
        #         part_catalog_id.name ILIKE '%aerospace%' |
        #         customer_spec_id.code ILIKE 'AS9100%' | 'NADCAP%'
        # hold  → partner_id.x_fc_rush | x_fc_vip (no part/spec on hold)
        # ncr   → partner_id.x_fc_rush | x_fc_vip + part via job_id link
        # rma   → partner_id.x_fc_rush | x_fc_vip
        # capa  → partner_id.x_fc_rush | x_fc_vip (via linked ncr/rma)
        # check → job_id.partner_id.x_fc_rush | x_fc_vip + part via job_id
        ...

    def _overdue_filter(self, type_code):
        """Build overdue domain for the type. CAPA uses due_date < today;
        all others use create_date < (now - threshold_days)."""
        cfg = OVERDUE_THRESHOLDS[type_code]
        base = list(cfg['domain'])
        if type_code == 'capa':
            base += [('due_date', '<', self.now.date()),
                     ('due_date', '!=', False)]
        else:
            cutoff = self.now - timedelta(days=cfg['days'])
            base += [('create_date', '<', cutoff)]
        return base

    def _fetch_banner_candidates(self, type_code):
        """Per-type pull of records that qualify for the banner:
        (overdue) OR (critical-customer AND still-open).
        Returns list of dicts in the shape that _build_banner can sort.
        """
        # ... runs two searches per type (overdue + critical-customer-open),
        # dedupes by id, returns shaped dicts with urgency + critical_badge.
        ...

    def _build_section(self, type_code):
        """Return the section dict with top-5 items + counts.
        Returns None when the model isn't installed."""
        ...

    def _build_banner(self, candidates):
        """Rank candidates: overdue first (oldest first), then
        critical-customer-non-overdue (oldest first). Take top 6.
        Returns {items: [...], all_clear: bool, total_matching: int}."""
        ...

Sudo strategy — per Rule 13m, the snapshot reads cross-module fields (partner_id.x_fc_rush, part_catalog_id.name, customer_spec_id.code) that low-privilege roles might not have read on. The controller does request.env['fp.certificate'].sudo() etc. for the snapshot build. The open_action payload navigates via standard act_window which re-enforces ACL on click.

Defensive field-existence checks — the cross-module field reads are guarded:

partner = rec.partner_id
is_rush = bool(getattr(partner, 'x_fc_rush', False))
is_vip  = bool(getattr(partner, 'x_fc_vip', False))

If a field doesn't exist (module uninstalled), that signal is unavailable — the item can still qualify via the overdue path.

Frontend — OWL component tree

FpQualityDashboard           // top-level client action
├── BannerCard                // one card; shows items or all-clear
│   └── BannerItem[]           // 0-6 grid cells
└── SectionCard[]             // 6 in fixed order
    └── SectionRow[]           // up to 5 rows, or 1 italic "no open items"

Sub-components live in the same JS file (fp_quality_dashboard.js) — they're not reused anywhere else. If BannerItem later moves to a manager dashboard, then it migrates to components/.

Component state

this.state = useState({
  loading: true,
  snapshot: null,          // the whole response shape
  error: null,             // shown if RPC fails
});

// On mount:
//   1. await rpc('/fp/quality/dashboard/snapshot') → state.snapshot
//   2. if action.params.tab → scroll to section after render
//   3. start 60s setInterval to re-poll
//
// On row click — build the act_window dict explicitly. doAction
// accepts either a full action dict OR an xmlid string; we use the
// dict shape here because we're synthesising a form view from the
// snapshot payload (no act_window xmlid exists for "this specific
// record's form").
//   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',
//   });
//
// On "Open all →" click — pass the xmlid string directly. The
// action service in Odoo 19 resolves it via the registry. Confirmed
// pattern used by the existing dashboard's openTab() method which
// already passes a full dict; we use the xmlid form here because
// the snapshot ships pre-resolved xmlids and we don't want to
// re-encode the kanban view config in the snapshot payload.
//   this.action.doAction(section.open_kanban_xmlid);
//
// Note: if `doAction(xmlid_string)` ever stops working in a future
// Odoo version, the fallback is to ship the full act_window dict in
// the snapshot instead of just the xmlid string — change is local
// to the snapshot builder.

The notification email from spec 2026-05-25-post-shop-cert-shipping-job-states links to /odoo/action-fp_quality_dashboard?tab=certificates. The new dashboard reads this.props.action.context.params.tab on mount and, after first render, calls document.getElementById('section-<tab>').scrollIntoView({behavior: 'smooth'}).

Template requirement (don't forget at implementation): each <SectionCard> MUST render <div t-att-id="'section-' + section.type">. Without the IDs the scrollIntoView call no-ops silently and the deep-link still lands on the dashboard but doesn't focus the right section. The tab → section_id mapping is direct ('certificates' from the URL maps to 'cert' from the type code — the email's ?tab=certificates arrives as params.tab='certificates' so the JS needs a one-line normalize: const sectionType = tab.startsWith('cert') ? 'cert' : tab;).

No change to the email body needed.

SCSS — file structure + dark-mode

Single file: fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss. No partials — the dashboard is small enough.

Tokens: reuse $plant-card-bg, $plant-bg, $plant-card-border, $plant-text, $plant-muted from _plant_tokens.scss (loaded earlier in the manifest). Dark mode auto-flips via the existing @if $o-webclient-color-scheme == dark global override.

New scoped tokens (defined locally with light + dark variants):

$_qd-urgent-bg-light:   #fee2e2;
$_qd-urgent-bg-dark:    #3a1818;
$_qd-urgent-border:     #dc2626;

$_qd-good-bg-light:     #d1fae5;
$_qd-good-bg-dark:      #14281a;
$_qd-good-border:       #22c55e;

$_qd-section-head-bg-light: #fef3c7;
$_qd-section-head-bg-dark:  #3a2f15;

@if $o-webclient-color-scheme == dark {
    $_qd-urgent-bg-light: $_qd-urgent-bg-dark !global;
    $_qd-good-bg-light:   $_qd-good-bg-dark !global;
    $_qd-section-head-bg-light: $_qd-section-head-bg-dark !global;
}

Banner styling:

  • When items present: background: linear-gradient(135deg, $_qd-urgent-bg-light, $plant-card-bg); + border: 1px solid $_qd-urgent-border;
  • When zero items (all-clear): swap to green tokens.
  • Grid: display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; collapsing to 1fr below 900px.

Section styling:

  • Card pattern with header strip + body, gradients matching the existing plant kanban polish (Rule 9, no hardcoded hex outside the dark-branch defs).
  • Section header bg uses $_qd-section-head-bg-light (amber gradient).
  • Row hover: subtle background: rgba(0,0,0,0.03) lift.

File inventory

Modify

Path Change
fusion_plating_quality/controllers/fp_quality_dashboard.py Rewrite. Delete counts() route. Add snapshot() route + FpQualityDashboardSnapshot helper class. Same file, expanded.
fusion_plating_quality/static/src/js/fp_quality_dashboard.js Rewrite. Drop TABS array, selectTab, openTab. New shape: setup fetches snapshot, onOpenItem / onOpenSection actions. Add BannerCard + BannerItem + SectionCard + SectionRow sub-components in same file. Keep the ?tab= param parsing but translate to scrollIntoView.
fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml Rewrite. New template structure: outer wrapper + banner card + 6 section cards via t-foreach="snapshot.sections". No tab row.
fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss Rewrite. New token block, banner styles, section styles, row styles, mobile breakpoint. Reuse $plant-* base tokens.
fusion_plating_quality/__manifest__.py Version bump 19.0.7.0.019.0.8.0.0.

Create

Path Purpose
fusion_plating_quality/tests/test_dashboard_snapshot.py NEW — unit tests for the snapshot endpoint (algorithms, edge cases, missing-module guards)
fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py NEW — entech smoke script (RPC call, response-shape assertions, click-through smoke)

Untouched

  • All per-model kanban views — fusion.plating.quality.hold, fusion.plating.quality.check, fusion.plating.ncr, fusion.plating.capa, fusion.plating.rma, fp.certificate
  • All per-model form views + their action_* methods (Issue, Disposition, etc.)
  • The cert authority ACL guard (Rule 24 / spec 2026-05-25-post-shop) — fires unchanged from the cert form
  • The fp.notification.template + mail.activity infrastructure
  • The existing menu entry — same ir.actions.client xmlid, same menu entry, just different template/JS/CSS/controller behind it

Edge cases + defensive design

Case Behavior
Zero banner items Banner renders green ✓ All caught up — no critical items right now card. Sections still render below.
> 6 banner-eligible items Top 6 by urgency rank shown; footer line Showing 6 of N urgent items — see sections below
Section with zero open items Renders the card with one italic row No open items — predictable layout, no hidden states
Item appears in BOTH banner and section Intentional — banner is across-types urgency, section is per-type queue. Visual reinforcement.
fp.certificate (or any type's model) not installed _build_section('cert') returns None; section omitted from response. Banner skips cert candidates.
Cross-module field missing (e.g. partner.x_fc_rush not defined) getattr(partner, 'x_fc_rush', False) falls back to False — item only qualifies via overdue path
Cert created in the second BEFORE snapshot fires Negligible — 60s poll catches it next refresh. No real-time correctness requirement.
User lacks ACL on a record in their snapshot Open → opens the record via act_window; Odoo's standard ACL fires at navigation; user gets the standard access error. Not pre-filtered in the snapshot (would 4x query cost).
Snapshot RPC fails (network blip, DB lock) Frontend shows Couldn't refresh dashboard — retry in 60s banner. Last-known snapshot stays on screen. Same pattern as existing _refreshCounts.
Mobile / tablet < 900px Banner grid collapses 3-col → 1-col. Sections stay full-width. Row buttons keep ≥32px tap target.
Banner item's source section is below the fold No special handling — banner is its own surface. Click navigates via act_window regardless of section visibility.
Dark mode toggle mid-session Browser reload required (Odoo standard behavior). Tokens flip automatically via SCSS compile-time branch.

Testing strategy

Unit tests — fusion_plating_quality/tests/test_dashboard_snapshot.py (NEW)

Test Asserts
test_empty_db_returns_all_clear Empty DB → banner.all_clear == True, all 6 sections present with open == 0
test_overdue_cert_in_banner Cert created 25h ago → banner.items[0].type == 'cert', urgency == 'overdue'
test_vip_cert_in_banner_before_overdue Cert created 1h ago, customer.x_fc_vip=True → in banner with urgency == 'critical_customer' and critical_badge == 'VIP'
test_rush_partner_banner_badge partner.x_fc_rush=True → critical_badge == 'RUSH'
test_aerospace_part_banner_badge part.name='Aerospace Bracket' → critical_badge == 'AS9100' (or similar)
test_banner_caps_at_6_with_overflow_count 8 overdue items → banner.items length 6, total_matching == 8
test_banner_ranks_overdue_before_critical_customer Mix of 3 overdue + 5 VIP-non-overdue → first 3 are overdue, next 3 are VIP
test_section_order_is_canonical Response sections list ordered: cert, hold, ncr, rma, capa, check
test_section_top_5_only 8 items of one type → section.items length is 5; section.open == 8
test_missing_certificate_model_omits_section Mock fp.certificate not in env → no cert section, no traceback
test_missing_partner_field_falls_through Partner without x_fc_rush field → item evaluated via overdue path only, no AttributeError
test_snapshot_includes_computed_at_iso Response has computed_at as parseable ISO timestamp

Entech smoke script — fusion_plating_quality/scripts/bt_quality_dashboard_redesign.py (NEW)

Steps:

  1. Hit /fp/quality/dashboard/snapshot via odoo-shell-style RPC
  2. Assert response shape (banner + sections present, all keys present)
  3. Assert section order is canonical
  4. Assert each section has open_kanban_xmlid that resolves to a real ir.actions.act_window
  5. Pick first banner item → build the equivalent act_window from open_action → verify it resolves
  6. Print summary: open/overdue per section + banner item count

Manual QA on entech

  1. /odoo/action-fp_quality_dashboard as admin — verify banner + sections render in dark mode
  2. As a Sales Rep (lower ACL) — verify items render; click an item → expect ACL error if blocked
  3. Issue a draft cert via the cert form → reload dashboard → cert disappears from Certificates section
  4. Flag a partner x_fc_rush=True → reload → their open items get [RUSH] badge in banner
  5. Dark mode toggle in user prefs → reload → confirm gradient + green/red flips correctly
  6. Resize browser to < 900px → confirm banner collapses to 1 column, sections stay readable

Migration / rollback

  • No DB migration — purely a UI replacement. No schema changes.
  • No data backfill — the snapshot is computed at request time from existing data.
  • Rollback: git revert the implementation commits, -u fusion_plating_quality, asset cache bust. Affected users see the old tab-router until the revert deploys.

Files touched summary

fusion_plating_quality/
├── controllers/
│   └── fp_quality_dashboard.py          MODIFY (rewrite endpoint + helper class)
├── static/src/
│   ├── js/fp_quality_dashboard.js        MODIFY (rewrite component + sub-components)
│   ├── xml/fp_quality_dashboard.xml      MODIFY (rewrite template)
│   └── scss/fp_quality_dashboard.scss    MODIFY (rewrite styles)
├── tests/
│   └── test_dashboard_snapshot.py        CREATE (unit tests)
├── scripts/
│   └── bt_quality_dashboard_redesign.py  CREATE (entech smoke)
└── __manifest__.py                       MODIFY (version 19.0.7.0.0 → 19.0.8.0.0)

5 files modified, 2 created.

Open questions for implementation phase

  1. subtitle text per type — what's the second line on each row? For certs "14h overdue · awaiting issuance" is obvious; for NCRs "7d · disposition pending" works; CAPAs "due 5/30"; RMAs "received 2d ago". Settle the exact format during implementation. Not a design decision.
  2. Icon choice — emojis (🏷️ 🛑 🔬 ↩️ 📋 ✓) vs Font Awesome (fa-certificate, fa-stop-circle, etc.). Plant kanban uses emojis; consistency argues for emojis. Trivially swappable later.
  3. Per-section search/sort within the dashboard — out of scope for v1 (the per-model kanban has these via the standard search bar). Revisit if QM asks.
  4. Polling pause when tab is hiddendocument.visibilityState check could pause the poll when the user is on another tab. Nice-to-have, not in v1.

Status & deployment notes

Target version bump: fusion_plating_quality 19.0.7.0.0 → 19.0.8.0.0.

Deploy steps (mirrors the post-shop redesign flow):

  1. Sync the 5 modified files + 2 new files to entech /mnt/extra-addons/custom/fusion_plating_quality/
  2. -u fusion_plating_quality (no other modules — this is self-contained)
  3. Asset cache bust: DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';
  4. Restart odoo
  5. Run the smoke script via odoo-shell
  6. Manual browser verification at /odoo/action-fp_quality_dashboard

No coordination with other modules required — the dashboard's only callers are humans clicking the menu entry.