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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user