fix(manager-desk): unstick the spinner + live updates that don't flash

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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-18 18:06:04 -04:00
parent a660f1f05d
commit d29857078a
4 changed files with 160 additions and 15 deletions

View File

@@ -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
# ------------------------------------------------------------------

View File

@@ -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;

View File

@@ -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;

View File

@@ -14,12 +14,21 @@
<div>
<div class="o_fp_manager_title">
<i class="fa fa-user-md me-2"/>Manager Desk
<span class="o_fp_live_dot ms-2"
t-att-data-active="state.isFetching ? 'y' : 'n'"
t-att-title="'Updated ' + lastUpdatedLabel"/>
</div>
<div class="o_fp_manager_subtitle" t-if="state.overview">
<span t-esc="state.overview.user_name"/> · Updated just now
<span t-esc="state.overview.user_name"/>
<span class="text-muted">· live · updated <t t-esc="lastUpdatedLabel"/></span>
</div>
</div>
<div class="o_fp_manager_head_actions">
<button class="btn btn-outline-secondary"
t-on-click="refresh"
t-att-disabled="state.isFetching">
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
</button>
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : 'btn-outline-primary')"
t-on-click="toggleMode">
<i t-att-class="state.mode === 'quick' ? 'fa fa-list' : 'fa fa-th'"/>
@@ -29,6 +38,16 @@
</div>
</div>
<!-- Error banner — visible instead of a stuck spinner -->
<div t-if="state.loadError"
class="o_fp_tablet_message o_fp_msg_danger">
<i class="fa fa-exclamation-triangle me-2"/>
<span t-esc="state.loadError"/>
<button class="btn btn-sm btn-outline-danger ms-3" t-on-click="refresh">
Retry
</button>
</div>
<!-- ===== Flash message ===== -->
<div t-if="state.message"
t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
@@ -247,7 +266,7 @@
</section>
</div>
<div t-if="!state.overview" class="o_fp_empty">
<div t-if="!state.overview and !state.loadError" class="o_fp_empty">
<i class="fa fa-spinner fa-spin me-2"/>Loading manager data…
</div>
</div>