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