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 **/
|
/** @odoo-module **/
|
||||||
|
|
||||||
// Sub 12 Phase D — Unified Quality Dashboard.
|
// Quality Dashboard — action surface.
|
||||||
// Five tabs (Holds / Checks / NCRs / CAPAs / RMAs) backed by their list
|
// Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
|
||||||
// kanbans, with a header summary card showing open + overdue counts.
|
//
|
||||||
// Each tab embeds the corresponding model's kanban via an action service
|
// Single OWL component that fetches one snapshot from
|
||||||
// switch. The header counters refresh on tab switch and on a 60-second
|
// /fp/quality/dashboard/snapshot and renders:
|
||||||
// poll.
|
// - 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 { registry } from "@web/core/registry";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
|
||||||
const TABS = [
|
// 60s poll matches the cadence of the old dashboard.
|
||||||
{ id: "holds", label: "Holds", model: "fusion.plating.quality.hold", group: "state", domain: [["state", "in", ["on_hold", "under_review"]]] },
|
const POLL_INTERVAL_MS = 60000;
|
||||||
{ 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"]]] },
|
class BannerItem extends Component {
|
||||||
{ id: "rmas", label: "RMAs", model: "fusion.plating.rma", group: "stage_id", domain: [["state", "not in", ["closed", "cancelled"]]] },
|
static template = "fusion_plating_quality.BannerItem";
|
||||||
// Spec 2026-05-25 — Certificates tab. QM-owned queue of certs
|
static props = ["item", "onOpen"];
|
||||||
// awaiting issuance; drives the post-shop awaiting_cert workflow.
|
}
|
||||||
{ id: "certificates", label: "Certificates", model: "fp.certificate", group: "state", domain: [["state", "=", "draft"]] },
|
|
||||||
];
|
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 {
|
export class FpQualityDashboard extends Component {
|
||||||
static template = "fusion_plating_quality.FpQualityDashboard";
|
static template = "fusion_plating_quality.FpQualityDashboard";
|
||||||
|
static components = { BannerCard, SectionCard };
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.action = useService("action");
|
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({
|
this.state = useState({
|
||||||
activeTab: validTab ? validTab.id : "ncrs",
|
loading: true,
|
||||||
counts: TABS.reduce((acc, t) => ({ ...acc, [t.id]: { open: 0, overdue: 0 } }), {}),
|
snapshot: null,
|
||||||
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
onWillStart(async () => {
|
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(() => {
|
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(() => {
|
onWillUnmount(() => {
|
||||||
if (this._poll) clearInterval(this._poll);
|
if (this._poll) clearInterval(this._poll);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async _refreshCounts() {
|
async _refresh() {
|
||||||
try {
|
try {
|
||||||
const result = await rpc("/fp/quality/dashboard/counts");
|
const result = await rpc("/fp/quality/dashboard/snapshot");
|
||||||
if (result && typeof result === "object") {
|
this.state.snapshot = result;
|
||||||
for (const tab of TABS) {
|
this.state.error = null;
|
||||||
if (result[tab.id]) {
|
|
||||||
this.state.counts[tab.id] = result[tab.id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Best-effort; leave counts at zero on RPC failure.
|
console.warn("FpQualityDashboard: snapshot RPC failed", e);
|
||||||
console.warn("FpQualityDashboard: count refresh failed", e);
|
this.state.error = "Couldn't refresh dashboard — retry in 60s";
|
||||||
|
} finally {
|
||||||
|
this.state.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectTab(id) {
|
onOpenItem(item) {
|
||||||
this.state.activeTab = id;
|
// 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).
|
||||||
async openTab(tab) {
|
this.action.doAction({
|
||||||
// Open the model's full kanban view in the main app area.
|
|
||||||
await this.action.doAction({
|
|
||||||
type: "ir.actions.act_window",
|
type: "ir.actions.act_window",
|
||||||
name: tab.label,
|
res_model: item.open_action.res_model,
|
||||||
res_model: tab.model,
|
res_id: item.open_action.res_id,
|
||||||
view_mode: "kanban,list,form",
|
view_mode: "form",
|
||||||
views: [[false, "kanban"], [false, "list"], [false, "form"]],
|
views: [[false, "form"]],
|
||||||
domain: tab.domain,
|
target: "current",
|
||||||
context: { group_by: tab.group },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get tabs() {
|
onOpenKanban(section) {
|
||||||
return TABS;
|
// 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.
|
||||||
get totalOpen() {
|
this.action.doAction(section.open_kanban_xmlid);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,181 @@
|
|||||||
// Sub 12 Phase D — Unified Quality Dashboard styling.
|
// Quality Dashboard — action surface.
|
||||||
// Reuses the shopfloor SCSS tokens ($fp-page, $fp-card, $fp-border,
|
// Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md
|
||||||
// $fp-ink, $fp-accent, etc.) — they are bundled before us via the
|
//
|
||||||
// fusion_plating_shopfloor dep, so no @import is needed.
|
// 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 {
|
$o-webclient-color-scheme: bright !default;
|
||||||
background-color: $fp-page;
|
|
||||||
min-height: 100%;
|
|
||||||
|
|
||||||
.o_fp_card {
|
$_qd-urgent-bg-hex: #fee2e2;
|
||||||
background-color: $fp-card;
|
$_qd-urgent-bg-end-hex: #fff;
|
||||||
border: 1px solid $fp-border;
|
$_qd-urgent-border-hex: #dc2626;
|
||||||
border-radius: 8px;
|
$_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 {
|
// ===== Banner =====
|
||||||
min-width: 220px;
|
.o_fp_qd_banner {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid $plant-card-border;
|
||||||
}
|
}
|
||||||
|
.o_fp_qd_banner_urgent {
|
||||||
.o_fp_qd_tile {
|
background: linear-gradient(135deg, $qd-urgent-bg 0%, $qd-urgent-bg-end 100%);
|
||||||
cursor: pointer;
|
border-color: $qd-urgent-border;
|
||||||
min-width: 130px;
|
}
|
||||||
text-align: left;
|
.o_fp_qd_banner_clear {
|
||||||
transition: transform 0.08s ease-in-out, box-shadow 0.08s ease-in-out;
|
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 {
|
&:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 3px 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.o_fp_qd_banner_item_l1 {
|
||||||
.o_fp_qd_metric_label {
|
display: flex; align-items: center; gap: 6px; font-size: 13px;
|
||||||
font-size: 0.85em;
|
}
|
||||||
color: $fp-ink-mute;
|
.o_fp_qd_banner_item_type {
|
||||||
font-weight: 500;
|
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 {
|
// ===== Section =====
|
||||||
font-size: 1.6em;
|
.o_fp_qd_section {
|
||||||
font-weight: 700;
|
background: $plant-card-bg;
|
||||||
color: $fp-ink;
|
border: 1px solid $plant-card-border;
|
||||||
line-height: 1.1;
|
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 {
|
// ===== Row =====
|
||||||
margin-top: 0.25em;
|
.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 {
|
// ===== Mobile =====
|
||||||
min-height: 200px;
|
@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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,128 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<!-- ===== TOP-LEVEL DASHBOARD ===== -->
|
||||||
<t t-name="fusion_plating_quality.FpQualityDashboard">
|
<t t-name="fusion_plating_quality.FpQualityDashboard">
|
||||||
<div class="o_fp_quality_dashboard p-3">
|
<div class="o_fp_qd p-3">
|
||||||
<div class="o_fp_qd_header d-flex flex-wrap gap-3 mb-3">
|
<div t-if="state.loading" class="o_fp_qd_loading">Loading…</div>
|
||||||
<div class="o_fp_qd_summary o_fp_card flex-grow-1 p-3">
|
<div t-if="state.error" class="o_fp_qd_error">
|
||||||
<h2 class="mb-2">Quality Overview</h2>
|
<t t-esc="state.error"/>
|
||||||
<div class="d-flex gap-4">
|
|
||||||
<div>
|
|
||||||
<div class="o_fp_qd_metric_label">Open across all <t t-esc="tabs.length"/></div>
|
|
||||||
<div class="o_fp_qd_metric_value"><t t-esc="totalOpen"/></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="o_fp_qd_metric_label text-danger">Overdue</div>
|
|
||||||
<div class="o_fp_qd_metric_value text-danger"><t t-esc="totalOverdue"/></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<t t-foreach="tabs" t-as="tab" t-key="tab.id">
|
|
||||||
<button class="o_fp_qd_tile o_fp_card p-3 border-0"
|
|
||||||
t-att-class="{ 'o_fp_qd_active': state.activeTab === tab.id }"
|
|
||||||
t-on-click="() => this.selectTab(tab.id)">
|
|
||||||
<div class="o_fp_qd_metric_label"><t t-esc="tab.label"/></div>
|
|
||||||
<div class="o_fp_qd_metric_value">
|
|
||||||
<t t-esc="state.counts[tab.id]?.open || 0"/>
|
|
||||||
</div>
|
|
||||||
<div class="o_fp_qd_metric_sub text-muted small"
|
|
||||||
t-if="(state.counts[tab.id]?.overdue || 0) > 0">
|
|
||||||
<t t-esc="state.counts[tab.id].overdue"/> overdue
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</t>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="o_fp_qd_body">
|
<!-- ===== BANNER CARD ===== -->
|
||||||
<t t-foreach="tabs" t-as="tab" t-key="tab.id">
|
<t t-name="fusion_plating_quality.BannerCard">
|
||||||
<div t-if="state.activeTab === tab.id" class="o_fp_qd_panel o_fp_card p-4">
|
<div t-if="props.banner.all_clear"
|
||||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
class="o_fp_qd_banner o_fp_qd_banner_clear">
|
||||||
<div>
|
<div class="o_fp_qd_banner_clear_icon">✓</div>
|
||||||
<h3 class="mb-1"><t t-esc="tab.label"/></h3>
|
<div class="o_fp_qd_banner_clear_text">
|
||||||
<div class="text-muted small">
|
<strong>All caught up</strong> — no critical items right now
|
||||||
<t t-esc="state.counts[tab.id]?.open || 0"/> open
|
</div>
|
||||||
<t t-if="(state.counts[tab.id]?.overdue || 0) > 0">
|
</div>
|
||||||
— <t t-esc="state.counts[tab.id].overdue"/> overdue
|
<div t-else="" class="o_fp_qd_banner o_fp_qd_banner_urgent">
|
||||||
</t>
|
<div class="o_fp_qd_banner_head">
|
||||||
</div>
|
⚠️ NEEDS ATTENTION TODAY ·
|
||||||
</div>
|
<t t-esc="props.banner.total_matching"/>
|
||||||
<button class="btn btn-primary"
|
<span t-if="props.banner.total_matching > props.banner.items.length"
|
||||||
t-on-click="() => this.openTab(tab)">
|
class="o_fp_qd_banner_overflow">
|
||||||
Open <t t-esc="tab.label"/> Kanban
|
(showing <t t-esc="props.banner.items.length"/>
|
||||||
</button>
|
of <t t-esc="props.banner.total_matching"/> —
|
||||||
</div>
|
see sections below for the rest)
|
||||||
<p class="text-muted">
|
</span>
|
||||||
Click "Open Kanban" to drill into the full
|
</div>
|
||||||
<t t-esc="tab.label.toLowerCase()"/> board with stage / state grouping,
|
<div class="o_fp_qd_banner_grid">
|
||||||
drag-and-drop, and the standard filters.
|
<t t-foreach="props.banner.items"
|
||||||
</p>
|
t-as="item" t-key="item.type + '_' + item.id">
|
||||||
</div>
|
<BannerItem item="item" onOpen="props.onOpen"/>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</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>
|
</templates>
|
||||||
|
|||||||
Reference in New Issue
Block a user