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
|
# Overview snapshot — used on initial load + 30s auto-refresh
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@http.route('/fp/manager/overview', type='jsonrpc', auth='user')
|
@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
|
env = request.env
|
||||||
MrpWO = env.get('mrp.workorder')
|
MrpWO = env.get('mrp.workorder')
|
||||||
Production = env.get('mrp.production')
|
Production = env.get('mrp.production')
|
||||||
@@ -35,6 +50,7 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
'operators': [], 'tanks': [],
|
'operators': [], 'tanks': [],
|
||||||
'user_name': env.user.name,
|
'user_name': env.user.name,
|
||||||
'mrp_missing': True,
|
'mrp_missing': True,
|
||||||
|
'payload_hash': '',
|
||||||
}
|
}
|
||||||
# The assignment field lives in fusion_plating_bridge_mrp. If it's
|
# The assignment field lives in fusion_plating_bridge_mrp. If it's
|
||||||
# missing, the dashboard still renders but the worker pickers are
|
# 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 [])
|
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 = {
|
kpis = {
|
||||||
'unassigned_wos': MrpWO.search_count(domain_unassigned),
|
'unassigned_wos': MrpWO.search_count(domain_unassigned),
|
||||||
'active_wos': MrpWO.search_count(domain_active),
|
'active_wos': MrpWO.search_count(domain_active),
|
||||||
@@ -171,12 +203,10 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
('state', '=', 'done'),
|
('state', '=', 'done'),
|
||||||
('x_fc_portal_job_id.state', '=', 'ready_to_ship'),
|
('x_fc_portal_job_id.state', '=', 'ready_to_ship'),
|
||||||
]),
|
]),
|
||||||
'pending_accept_sos': env['sale.order'].search_count(
|
'pending_accept_sos': pending_accept_sos,
|
||||||
[('x_fc_workflow_stage', '=', 'assign_work')]
|
|
||||||
) if 'x_fc_workflow_stage' in env['sale.order']._fields else 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
payload = {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'kpis': kpis,
|
'kpis': kpis,
|
||||||
'unassigned': unassigned_cards,
|
'unassigned': unassigned_cards,
|
||||||
@@ -187,6 +217,18 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
'user_name': env.user.name,
|
'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
|
# Assign a worker to a WO
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -23,16 +23,22 @@ export class ManagerDashboard extends Component {
|
|||||||
|
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
overview: null,
|
overview: null,
|
||||||
loading: false,
|
loadError: "", // visible error instead of stuck spinner
|
||||||
mode: "quick", // quick | detailed
|
mode: "quick", // quick | detailed
|
||||||
expandedMoId: null,
|
expandedMoId: null,
|
||||||
message: "",
|
message: "",
|
||||||
messageType: "info",
|
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 () => {
|
onMounted(async () => {
|
||||||
await this.refresh();
|
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(() => {
|
onWillUnmount(() => {
|
||||||
@@ -41,16 +47,70 @@ export class ManagerDashboard extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
async refresh() {
|
||||||
|
if (this.state.isFetching) return; // don't stack polls
|
||||||
|
this.state.isFetching = true;
|
||||||
try {
|
try {
|
||||||
const payload = await rpc("/fp/manager/overview", {});
|
const payload = await rpc("/fp/manager/overview", {
|
||||||
if (payload && payload.ok) {
|
known_hash: this._lastHash,
|
||||||
this.state.overview = payload;
|
});
|
||||||
|
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) {
|
} 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") {
|
setMessage(text, type = "info") {
|
||||||
this.state.message = text;
|
this.state.message = text;
|
||||||
this.state.messageType = type;
|
this.state.messageType = type;
|
||||||
|
|||||||
@@ -27,6 +27,30 @@
|
|||||||
.o_fp_manager_title {
|
.o_fp_manager_title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
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 {
|
.o_fp_manager_subtitle {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
@@ -14,12 +14,21 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="o_fp_manager_title">
|
<div class="o_fp_manager_title">
|
||||||
<i class="fa fa-user-md me-2"/>Manager Desk
|
<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>
|
||||||
<div class="o_fp_manager_subtitle" t-if="state.overview">
|
<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>
|
</div>
|
||||||
<div class="o_fp_manager_head_actions">
|
<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')"
|
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : 'btn-outline-primary')"
|
||||||
t-on-click="toggleMode">
|
t-on-click="toggleMode">
|
||||||
<i t-att-class="state.mode === 'quick' ? 'fa fa-list' : 'fa fa-th'"/>
|
<i t-att-class="state.mode === 'quick' ? 'fa fa-list' : 'fa fa-th'"/>
|
||||||
@@ -29,6 +38,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ===== -->
|
<!-- ===== Flash message ===== -->
|
||||||
<div t-if="state.message"
|
<div t-if="state.message"
|
||||||
t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
|
t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
|
||||||
@@ -247,7 +266,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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…
|
<i class="fa fa-spinner fa-spin me-2"/>Loading manager data…
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user