diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 7fed7795..5b817167 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.33.1.6', + 'version': '19.0.33.1.7', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js index 76b09510..06521134 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js @@ -45,6 +45,10 @@ export class FpJobWorkspace extends Component { data: null, jobId: null, focusStepId: null, + // Reactive monotonic tick — bumped every 1s by _tickInterval so + // the active-step timer re-renders without an RPC. The template + // reads tickNow and re-runs formatActiveStepElapsed each second. + tickNow: Date.now(), }); onMounted(async () => { @@ -53,13 +57,53 @@ export class FpJobWorkspace extends Component { this.state.focusStepId = params.focus_step_id || null; await this.refresh(); this._refreshInterval = setInterval(() => this.refresh(), 15000); + // 1s tick — pure client-side; no RPC. Drives the live timer + // on the active step's badge area. + this._tickInterval = setInterval(() => { + this.state.tickNow = Date.now(); + }, 1000); }); onWillUnmount(() => { if (this._refreshInterval) clearInterval(this._refreshInterval); + if (this._tickInterval) clearInterval(this._tickInterval); }); } + // ---- Live timer helpers (Spec 2026-05-24 follow-up) ------------------- + // formatActiveStepElapsed reads step.date_started_iso (UTC string from + // the payload), parses to ms, and returns HH:MM:SS elapsed since. + // Returns '' when no start time. Re-runs every 1s via state.tickNow. + + formatActiveStepElapsed(step) { + if (!step || !step.date_started_iso) return ""; + // Parse "YYYY-MM-DD HH:MM:SS" as UTC (controller uses fp_format which + // formats in UTC by default for ISO-style strings). + const isoUtc = step.date_started_iso.replace(" ", "T") + "Z"; + const startedMs = Date.parse(isoUtc); + if (!startedMs || isNaN(startedMs)) return ""; + // touch state.tickNow so OWL re-evaluates this getter every tick + const now = this.state.tickNow || Date.now(); + const totalSec = Math.max(0, Math.floor((now - startedMs) / 1000)); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + const pad = (n) => (n < 10 ? "0" + n : "" + n); + return pad(h) + ":" + pad(m) + ":" + pad(s); + } + + isActiveStepOvertime(step) { + // True when elapsed > 1.5× expected duration (drives red colour). + // duration_expected is in minutes. + if (!step || !step.date_started_iso || !step.duration_expected) return false; + const isoUtc = step.date_started_iso.replace(" ", "T") + "Z"; + const startedMs = Date.parse(isoUtc); + if (!startedMs || isNaN(startedMs)) return false; + const now = this.state.tickNow || Date.now(); + const elapsedMin = (now - startedMs) / 1000 / 60; + return elapsedMin > step.duration_expected * 1.5; + } + // ---- Data refresh ------------------------------------------------------ async refresh() { if (!this.state.jobId) return; diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss index 52573b92..3cdac437 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss @@ -632,3 +632,34 @@ $_ws-text-hex: #1d1d1f; align-items: center; justify-content: center; } + +// ============================================================================= +// LIVE TIMER on active step (2026-05-24 follow-up) +// Ticks every 1s via state.tickNow; flips red when > 1.5x expected duration. +// ============================================================================= + +.o_fp_ws_step_timer { + display: inline-flex; + align-items: center; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-weight: 700; + font-variant-numeric: tabular-nums; + background: #d1fae5; + color: #064e3b; + padding: 0.2rem 0.6rem; + border-radius: 4px; + font-size: 0.95rem; + margin-left: 0.4rem; + letter-spacing: 0.02em; +} + +.o_fp_ws_step_timer_over { + background: #fee2e2; + color: #7f1d1d; + animation: o_fp_ws_timer_pulse 1.5s ease-in-out infinite; +} + +@keyframes o_fp_ws_timer_pulse { + 0%, 100% { opacity: 1.0; } + 50% { opacity: 0.6; } +} diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml index 332ec3e2..366bc826 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml @@ -226,13 +226,21 @@ (step.blocker_kind !== 'none' ? ' blocked' : '')" t-att-data-step-id="step.id"> - +