From d29857078ac39382795556dc47799b1300c89ac1 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 18:06:04 -0400 Subject: [PATCH] fix(manager-desk): unstick the spinner + live updates that don't flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the stuck "Loading manager data..." spinner: the overview endpoint included a search_count on sale.order.x_fc_workflow_stage, which is a non-stored computed field. Odoo 19 raised: ValueError: Cannot convert sale.order.x_fc_workflow_stage to SQL because it is not stored The controller silently logged the error; the JS caught and swallowed the RPC failure, leaving state.overview=null forever. So the UI just kept spinning while production changed around the manager. Fixes: 1. Controller (manager_controller.py) - "Awaiting assignment SOs" is now computed from STORED fields only: state='sale' AND x_fc_receiving_status='inspected' AND x_fc_assigned_manager_id=False Same stage, legal SQL. - Whole endpoint wrapped in try/except; failures return {'ok': False, 'error': '...'} so the UI can surface them instead of dying silently. - Response carries a payload_hash (md5 of the JSON body minus user_name). If the client sends back known_hash and nothing has moved, the server returns {'unchanged': True, 'payload_hash': ...} and the client skips the repaint entirely. Keeps the UI quiet between polls. 2. OWL component (manager_dashboard.js) - Poll cadence tightened from 30s → 8s (production-pace). - Unchanged payloads don't mutate state.overview → no re-render, no flash. Live dot just updates its tooltip. - Changed payloads do an in-place MERGE of the overview (copying scalars/arrays onto the existing reactive object) instead of replacing it wholesale. OWL's diff only re-renders rows that actually moved. - isFetching guard so overlapping polls can't stack up. - state.loadError surfaces backend errors in a red banner with a Retry button — no more silent spinner. 3. UX - Live dot next to the title: soft green at rest, bright green pulsing during a fetch. - "Updated Xs ago" subtitle uses a getter so the label freshens between polls. - Manual Refresh button next to Quick/Detailed toggle. - Spinner only appears on the genuine first load; gone forever once the first payload lands. Verified: the old crashing query now runs clean on demo data; odoo logs show zero errors for the last 5 minutes of polling. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controllers/manager_controller.py | 54 ++++++++++++-- .../static/src/js/manager_dashboard.js | 74 +++++++++++++++++-- .../static/src/scss/manager_dashboard.scss | 24 ++++++ .../static/src/xml/manager_dashboard.xml | 23 +++++- 4 files changed, 160 insertions(+), 15 deletions(-) diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py index 795fb85a..c132b6ae 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py @@ -22,7 +22,22 @@ class FpManagerDashboardController(http.Controller): # Overview snapshot — used on initial load + 30s auto-refresh # ------------------------------------------------------------------ @http.route('/fp/manager/overview', type='jsonrpc', auth='user') - def overview(self, facility_id=None): + def overview(self, facility_id=None, known_hash=None): + """Build the manager dashboard payload. + + `known_hash`: if the client sends back the hash of its last + overview, we compare and return `{'unchanged': True}` when + nothing has moved. Keeps the UI flicker-free between polls + while still catching every shop-floor change within a few + seconds. + """ + try: + return self._overview_payload(facility_id, known_hash) + except Exception as exc: # noqa: BLE001 + _logger.exception('Manager overview failed') + return {'ok': False, 'error': str(exc)} + + def _overview_payload(self, facility_id, known_hash): env = request.env MrpWO = env.get('mrp.workorder') Production = env.get('mrp.production') @@ -35,6 +50,7 @@ class FpManagerDashboardController(http.Controller): 'operators': [], 'tanks': [], 'user_name': env.user.name, 'mrp_missing': True, + 'payload_hash': '', } # The assignment field lives in fusion_plating_bridge_mrp. If it's # missing, the dashboard still renders but the worker pickers are @@ -160,7 +176,23 @@ class FpManagerDashboardController(http.Controller): for t in (Tank.search([]) if Tank is not None else []) ] - # KPI summary + # KPI summary — every query must use STORED fields only, otherwise + # Odoo raises "Cannot convert … to SQL because it is not stored". + # x_fc_workflow_stage is computed (non-stored); replicate the + # "awaiting assignment" stage directly via its stored antecedents. + SO = env['sale.order'] + so_fields = SO._fields + if ('x_fc_receiving_status' in so_fields + and 'x_fc_assigned_manager_id' in so_fields): + pending_accept_domain = [ + ('state', '=', 'sale'), + ('x_fc_receiving_status', '=', 'inspected'), + ('x_fc_assigned_manager_id', '=', False), + ] + pending_accept_sos = SO.search_count(pending_accept_domain) + else: + pending_accept_sos = 0 + kpis = { 'unassigned_wos': MrpWO.search_count(domain_unassigned), 'active_wos': MrpWO.search_count(domain_active), @@ -171,12 +203,10 @@ class FpManagerDashboardController(http.Controller): ('state', '=', 'done'), ('x_fc_portal_job_id.state', '=', 'ready_to_ship'), ]), - 'pending_accept_sos': env['sale.order'].search_count( - [('x_fc_workflow_stage', '=', 'assign_work')] - ) if 'x_fc_workflow_stage' in env['sale.order']._fields else 0, + 'pending_accept_sos': pending_accept_sos, } - return { + payload = { 'ok': True, 'kpis': kpis, 'unassigned': unassigned_cards, @@ -187,6 +217,18 @@ class FpManagerDashboardController(http.Controller): 'user_name': env.user.name, } + # Short-circuit: if nothing changed since last poll, skip repaint. + import hashlib, json + hashable = json.dumps( + {k: v for k, v in payload.items() if k != 'user_name'}, + sort_keys=True, default=str, + ) + payload_hash = hashlib.md5(hashable.encode('utf-8')).hexdigest() + payload['payload_hash'] = payload_hash + if known_hash and known_hash == payload_hash: + return {'ok': True, 'unchanged': True, 'payload_hash': payload_hash} + return payload + # ------------------------------------------------------------------ # Assign a worker to a WO # ------------------------------------------------------------------ diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js index d338d00e..3aa87104 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js @@ -23,16 +23,22 @@ export class ManagerDashboard extends Component { this.state = useState({ overview: null, - loading: false, - mode: "quick", // quick | detailed + loadError: "", // visible error instead of stuck spinner + mode: "quick", // quick | detailed expandedMoId: null, message: "", messageType: "info", + isFetching: false, // pulses the "updating" dot in the header + lastUpdated: null, // epoch ms of last successful payload }); + this._lastHash = null; // sent to server to skip unchanged polls + onMounted(async () => { await this.refresh(); - this._interval = setInterval(() => this.refresh(), 30000); + // 8s cadence: fast enough for production pace, light on the + // network since unchanged payloads short-circuit server-side. + this._interval = setInterval(() => this.refresh(), 8000); }); onWillUnmount(() => { @@ -41,16 +47,70 @@ export class ManagerDashboard extends Component { } async refresh() { + if (this.state.isFetching) return; // don't stack polls + this.state.isFetching = true; try { - const payload = await rpc("/fp/manager/overview", {}); - if (payload && payload.ok) { - this.state.overview = payload; + const payload = await rpc("/fp/manager/overview", { + known_hash: this._lastHash, + }); + if (!payload || payload.ok === false) { + const msg = (payload && payload.error) || "Manager Desk failed to load."; + this.state.loadError = msg; + return; } + this.state.loadError = ""; + // Unchanged short-circuit: keep the existing overview (no + // re-render), just bump lastUpdated for the live dot. + if (payload.unchanged) { + this.state.lastUpdated = Date.now(); + return; + } + this._lastHash = payload.payload_hash || null; + // First load: set wholesale. Subsequent loads: merge in place + // so OWL only re-renders the fields that actually moved. + if (!this.state.overview) { + this.state.overview = payload; + } else { + this._mergeOverview(this.state.overview, payload); + } + this.state.lastUpdated = Date.now(); } catch (err) { - // silent — next tick will retry + // Network / auth hiccup — surface it so the UI isn't a + // permanent spinner. + this.state.loadError = `Couldn't reach the server: ${err.message || err}`; + } finally { + this.state.isFetching = false; } } + /** + * Copy server payload fields onto the reactive state without losing + * the state reference. Only re-renders nodes whose backing value + * changed. Keeps the screen quiet between polls. + */ + _mergeOverview(target, source) { + // Top-level scalars + for (const k of ["user_name", "payload_hash", "mrp_missing"]) { + if (source[k] !== undefined) target[k] = source[k]; + } + // Dict slot + if (source.kpis) target.kpis = source.kpis; + // Arrays — replace whole so OWL's list diffing handles it cleanly + for (const k of ["unassigned", "active", "team", "operators", "tanks"]) { + if (Array.isArray(source[k])) target[k] = source[k]; + } + } + + /** Human-readable "updated Xs ago" label. */ + get lastUpdatedLabel() { + if (!this.state.lastUpdated) return ""; + const secs = Math.round((Date.now() - this.state.lastUpdated) / 1000); + if (secs < 2) return "live"; + if (secs < 60) return `${secs}s ago`; + const mins = Math.round(secs / 60); + return `${mins}m ago`; + } + setMessage(text, type = "info") { this.state.message = text; this.state.messageType = type; diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss index 63715753..7f3a2e08 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss @@ -27,6 +27,30 @@ .o_fp_manager_title { font-size: 1.5rem; font-weight: 600; + display: inline-flex; + align-items: center; + } + + // Small breathing dot that pulses while a poll is in flight. + // At rest: soft green. While fetching: brighter, animating. + .o_fp_live_dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background-color: color-mix(in srgb, var(--bs-success) 75%, transparent); + transition: background-color 160ms ease; + box-shadow: 0 0 0 0 transparent; + + &[data-active="y"] { + background-color: var(--bs-success); + animation: o_fp_live_pulse 1.0s ease-in-out infinite; + } + } + @keyframes o_fp_live_pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--bs-success) 55%, transparent); } + 70% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--bs-success) 0%, transparent); } + 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--bs-success) 0%, transparent); } } .o_fp_manager_subtitle { font-size: 0.95rem; diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml index 6dcab6a0..c9402e33 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml @@ -14,12 +14,21 @@
Manager Desk +
- · Updated just now + + · live · updated
+
+ +
+ + + +
+
@@ -247,7 +266,7 @@
-
+
Loading manager data…