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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-25 12:24:52 -04:00
parent bfeca0ac32
commit 547e7d66a9
3 changed files with 368 additions and 158 deletions

View File

@@ -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=<name> 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);
}
}