diff --git a/fusion_plating/fusion_plating_quality/static/src/js/fp_quality_dashboard.js b/fusion_plating/fusion_plating_quality/static/src/js/fp_quality_dashboard.js index 77410153..ecf8a56d 100644 --- a/fusion_plating/fusion_plating_quality/static/src/js/fp_quality_dashboard.js +++ b/fusion_plating/fusion_plating_quality/static/src/js/fp_quality_dashboard.js @@ -1,103 +1,126 @@ /** @odoo-module **/ -// Sub 12 Phase D — Unified Quality Dashboard. -// Five tabs (Holds / Checks / NCRs / CAPAs / RMAs) backed by their list -// kanbans, with a header summary card showing open + overdue counts. -// Each tab embeds the corresponding model's kanban via an action service -// switch. The header counters refresh on tab switch and on a 60-second -// poll. +// 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 { 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"; -const TABS = [ - { id: "holds", label: "Holds", model: "fusion.plating.quality.hold", group: "state", domain: [["state", "in", ["on_hold", "under_review"]]] }, - { id: "checks", label: "Checks", model: "fusion.plating.quality.check", group: "state", domain: [] }, - { id: "ncrs", label: "NCRs", model: "fusion.plating.ncr", group: "stage_id", domain: [["state", "!=", "closed"]] }, - { id: "capas", label: "CAPAs", model: "fusion.plating.capa", group: "state", domain: [["state", "not in", ["closed", "effective"]]] }, - { id: "rmas", label: "RMAs", model: "fusion.plating.rma", group: "stage_id", domain: [["state", "not in", ["closed", "cancelled"]]] }, - // Spec 2026-05-25 — Certificates tab. QM-owned queue of certs - // awaiting issuance; drives the post-shop awaiting_cert workflow. - { id: "certificates", label: "Certificates", model: "fp.certificate", group: "state", domain: [["state", "=", "draft"]] }, -]; +// 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"); - // Spec 2026-05-25 — honor ?tab= deep-link from the - // cert_awaiting_issuance notification email so the QM lands - // directly on the Certificates tab. - const tabParam = this.props.action?.context?.params?.tab - || this.props.action?.params?.tab; - const validTab = TABS.find(t => t.id === tabParam); this.state = useState({ - activeTab: validTab ? validTab.id : "ncrs", - counts: TABS.reduce((acc, t) => ({ ...acc, [t.id]: { open: 0, overdue: 0 } }), {}), + loading: true, + snapshot: null, + error: null, }); onWillStart(async () => { - await this._refreshCounts(); + 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(() => { - this._poll = setInterval(() => this._refreshCounts(), 60000); + 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 _refreshCounts() { + async _refresh() { try { - const result = await rpc("/fp/quality/dashboard/counts"); - if (result && typeof result === "object") { - for (const tab of TABS) { - if (result[tab.id]) { - this.state.counts[tab.id] = result[tab.id]; - } - } - } + const result = await rpc("/fp/quality/dashboard/snapshot"); + this.state.snapshot = result; + this.state.error = null; } catch (e) { - // Best-effort; leave counts at zero on RPC failure. - console.warn("FpQualityDashboard: count refresh failed", e); + console.warn("FpQualityDashboard: snapshot RPC failed", e); + this.state.error = "Couldn't refresh dashboard — retry in 60s"; + } finally { + this.state.loading = false; } } - selectTab(id) { - this.state.activeTab = id; - } - - async openTab(tab) { - // Open the model's full kanban view in the main app area. - await this.action.doAction({ + 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", - name: tab.label, - res_model: tab.model, - view_mode: "kanban,list,form", - views: [[false, "kanban"], [false, "list"], [false, "form"]], - domain: tab.domain, - context: { group_by: tab.group }, + res_model: item.open_action.res_model, + res_id: item.open_action.res_id, + view_mode: "form", + views: [[false, "form"]], + target: "current", }); } - get tabs() { - return TABS; - } - - get totalOpen() { - return TABS.reduce( - (sum, t) => sum + (this.state.counts[t.id]?.open || 0), 0, - ); - } - - get totalOverdue() { - return TABS.reduce( - (sum, t) => sum + (this.state.counts[t.id]?.overdue || 0), 0, - ); + 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); } } diff --git a/fusion_plating/fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss b/fusion_plating/fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss index 8c632671..02cbe448 100644 --- a/fusion_plating/fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss +++ b/fusion_plating/fusion_plating_quality/static/src/scss/fp_quality_dashboard.scss @@ -1,57 +1,181 @@ -// Sub 12 Phase D — Unified Quality Dashboard styling. -// Reuses the shopfloor SCSS tokens ($fp-page, $fp-card, $fp-border, -// $fp-ink, $fp-accent, etc.) — they are bundled before us via the -// fusion_plating_shopfloor dep, so no @import is needed. +// 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-muted / +// $plant-card-border from _plant_tokens.scss (loaded earlier in the +// fusion_plating_shopfloor manifest — fusion_plating_quality depends +// on shopfloor so those tokens are visible). -.o_fp_quality_dashboard { - background-color: $fp-page; - min-height: 100%; +$o-webclient-color-scheme: bright !default; - .o_fp_card { - background-color: $fp-card; - border: 1px solid $fp-border; - border-radius: 8px; +$_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; } - .o_fp_qd_summary { - min-width: 220px; + // ===== Banner ===== + .o_fp_qd_banner { + border-radius: 10px; + padding: 14px 18px; + margin-bottom: 16px; + border: 1px solid $plant-card-border; } - - .o_fp_qd_tile { - cursor: pointer; - min-width: 130px; - text-align: left; - transition: transform 0.08s ease-in-out, box-shadow 0.08s ease-in-out; - + .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 2px 6px rgba(0, 0, 0, 0.08); - } - - &.o_fp_qd_active { - border: 2px solid $fp-accent; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + box-shadow: 0 3px 6px rgba(0,0,0,0.08); } } - - .o_fp_qd_metric_label { - font-size: 0.85em; - color: $fp-ink-mute; - font-weight: 500; + .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; } - .o_fp_qd_metric_value { - font-size: 1.6em; - font-weight: 700; - color: $fp-ink; - line-height: 1.1; + // ===== 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; } - .o_fp_qd_metric_sub { - margin-top: 0.25em; + // ===== 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; } } - .o_fp_qd_panel { - min-height: 200px; + // ===== 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; } + } } } diff --git a/fusion_plating/fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml b/fusion_plating/fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml index e668a809..0b8902a6 100644 --- a/fusion_plating/fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml +++ b/fusion_plating/fusion_plating_quality/static/src/xml/fp_quality_dashboard.xml @@ -1,65 +1,128 @@ + -
-
-
-

Quality Overview

-
-
-
Open across all
-
-
-
-
Overdue
-
-
-
-
- - - +
+
Loading…
+
+
+ + + + + + +
+ -
- -
-
-
-

-
- open - - — overdue - -
-
- -
-

- Click "Open Kanban" to drill into the full - board with stage / state grouping, - drag-and-drop, and the standard filters. -

-
+ + +
+
+
+ All caught up — no critical items right now +
+
+
+
+ ⚠️ NEEDS ATTENTION TODAY · + + + (showing + of — + see sections below for the rest) + +
+
+ +
+ + + + + + +
+
+ + + + · open + + · + + overdue + + + + +
+
+ No open items +
+ + + +
+
+ + +
+
+ + · + + + · + + +
+ +
+
+