diff --git a/fusion_plating/fusion_plating_logistics/__manifest__.py b/fusion_plating/fusion_plating_logistics/__manifest__.py
index 7dc44d75..edf21e1b 100644
--- a/fusion_plating/fusion_plating_logistics/__manifest__.py
+++ b/fusion_plating/fusion_plating_logistics/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Logistics',
- 'version': '19.0.3.1.0',
+ 'version': '19.0.3.2.0',
'category': 'Manufacturing/Plating',
'summary': (
'Pickup & delivery for plating shops: vehicle master, driver '
@@ -42,6 +42,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'depends': [
'fusion_plating',
'fusion_plating_configurator',
+ 'fusion_plating_receiving', # Shared "Shipping & Receiving" menu root
'hr',
'mail',
],
diff --git a/fusion_plating/fusion_plating_logistics/views/fp_menu.xml b/fusion_plating/fusion_plating_logistics/views/fp_menu.xml
index a8078f12..e61fc4e0 100644
--- a/fusion_plating/fusion_plating_logistics/views/fp_menu.xml
+++ b/fusion_plating/fusion_plating_logistics/views/fp_menu.xml
@@ -6,42 +6,50 @@
-->
-
+
+
+
+
+
+
+
+
+
+ name="Logistics (legacy)"
+ parent="fusion_plating.menu_fp_config"
+ sequence="999"
+ active="False"/>
+ sequence="40"/>
+ sequence="50"/>
+ sequence="60"/>
+ sequence="70"/>
+ sequence="80"/>
diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py
index cec06670..521e92c6 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.24.8.0',
+ 'version': '19.0.24.12.0',
'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/controllers/shopfloor_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py
index 72981678..754157c6 100644
--- a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py
+++ b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py
@@ -1206,6 +1206,28 @@ class FpShopfloorController(http.Controller):
c.get('date_deadline_iso') or FAR_FUTURE,
c.get('id') or 0,
))
+ # Cap urgency_pulse to top 3 critical cards per column
+ # (v19.0.24.10.0). With 267+ overdue cards across the board,
+ # 267 simultaneous infinite CSS keyframe animations were
+ # hammering the compositor and causing drag-drop stutter.
+ # Top 3 per column = ~30 active animations max, plenty
+ # visible, and the static red border on the card still
+ # signals "this one needs attention" for the rest.
+ critical_kept = 0
+ for c in cards:
+ if c.get('urgency_pulse'):
+ if critical_kept < 3:
+ critical_kept += 1
+ else:
+ c['urgency_pulse'] = False
+ # Flag every critical card so the template can apply a
+ # plain class instead of relying on the `:has()` CSS
+ # selector — `:has()` re-evaluates on every layout pass
+ # and was the real reason 5–6 second freezes happened
+ # during drag-drop on a busy board (v19.0.24.11.0).
+ c['is_urgent'] = c.get('urgency_band') in (
+ 'hot', 'overdue', 'bake_risk',
+ )
# ---- Column order = recipe flow (v19.0.24.8.0) -------------------
# Old code ordered work centres by their `sequence, code, name`
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js
index 4c39b24a..b7afdb61 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js
@@ -24,10 +24,110 @@ import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner";
+// =============================================================================
+// TimerChip — per-card live elapsed-in-stage chip (v19.0.24.10.0)
+// =============================================================================
+// Old design read state.tickEpoch from inside the parent's getCardTimer(),
+// which forced OWL to mark the WHOLE component dirty every 5 seconds —
+// 389 cards re-rendering all at once, even though only the chip text
+// changes. That's what caused the "drop, wait 5s, card jumps back" feel
+// on a busy board: a tick fired mid-drop and froze the main thread.
+//
+// Now each chip is a tiny isolated subcomponent. It owns its own ticker,
+// re-renders only itself, and has stable props from the parent. When a
+// card moves columns, OWL keeps the same TimerChip instance (matched by
+// t-key=card.id on the parent), so the interval keeps running across the
+// move — no remount, no flicker.
+class TimerChip extends Component {
+ static template = "fusion_plating_shopfloor.TimerChip";
+ static props = {
+ kind: { type: String, optional: true },
+ startedAt: { type: String, optional: true },
+ expectedMinutes: { type: Number, optional: true },
+ };
+
+ setup() {
+ this.state = useState({ now: Date.now() });
+ onMounted(() => {
+ // 5s tick is plenty — the displayed text changes at minute
+ // resolution after the first 60s anyway.
+ this._iv = setInterval(() => {
+ this.state.now = Date.now();
+ }, 5000);
+ });
+ onWillUnmount(() => {
+ if (this._iv) clearInterval(this._iv);
+ });
+ }
+
+ get display() {
+ const empty = { label: "", tone: "muted", critical: false, icon: "fa-clock-o" };
+ if (!this.props.kind || !this.props.startedAt) return empty;
+
+ const isoUtc = this.props.startedAt.replace(" ", "T") + "Z";
+ const startMs = Date.parse(isoUtc);
+ if (isNaN(startMs)) return empty;
+ const sec = Math.max(0, Math.floor((this.state.now - startMs) / 1000));
+
+ const fmt = (s) => {
+ if (s < 60) return s + "s";
+ const m = Math.floor(s / 60);
+ if (m < 60) return m + "m";
+ const h = Math.floor(m / 60);
+ const rem = m % 60;
+ if (h < 24) return rem ? `${h}h ${rem}m` : `${h}h`;
+ const d = Math.floor(h / 24);
+ const hr = h % 24;
+ return hr ? `${d}d ${hr}h` : `${d}d`;
+ };
+
+ if (this.props.kind === "running") {
+ const expSec = (this.props.expectedMinutes || 0) * 60;
+ let tone = "ok";
+ let critical = false;
+ if (expSec) {
+ if (sec > 1.5 * expSec) { tone = "danger"; critical = true; }
+ else if (sec > expSec) { tone = "warning"; }
+ }
+ return {
+ label: `Running ${fmt(sec)}` + (expSec ? ` / ${fmt(expSec)} planned` : ""),
+ tone,
+ critical,
+ icon: "fa-play-circle",
+ };
+ }
+ if (this.props.kind === "paused") {
+ let tone = "warning";
+ let critical = false;
+ if (sec > 24 * 3600) { tone = "danger"; critical = true; }
+ else if (sec > 8 * 3600) { tone = "danger"; }
+ return {
+ label: `Paused ${fmt(sec)}`,
+ tone,
+ critical,
+ icon: "fa-pause-circle",
+ };
+ }
+ if (this.props.kind === "queued") {
+ let tone = "muted";
+ let critical = false;
+ if (sec > 24 * 3600) { tone = "danger"; critical = true; }
+ else if (sec > 4 * 3600) { tone = "warning"; }
+ return {
+ label: `Queued ${fmt(sec)}`,
+ tone,
+ critical,
+ icon: "fa-hourglass-half",
+ };
+ }
+ return empty;
+ }
+}
+
export class PlantOverview extends Component {
static template = "fusion_plating_shopfloor.PlantOverview";
static props = ["*"];
- static components = { QrScanner };
+ static components = { QrScanner, TimerChip };
setup() {
this.notification = useService("notification");
@@ -42,19 +142,27 @@ export class PlantOverview extends Component {
});
this._refreshInterval = null;
- this._tickInterval = null;
- // tickEpoch is bumped every second so the OWL template re-renders
- // — we read it inside getCardTimer() so the ticker is reactive
- // without writing to every card on every second.
- this.state.tickEpoch = 0;
+ // Drag-drop coordination (v19.0.24.10.0):
+ // _movesInFlight → number of unresolved move RPCs right now
+ // _lastDropAt → ms timestamp of the most recent drop
+ // loadData() bails when either is non-zero (within a 30 s window
+ // of the last drop) so the periodic poll never clobbers an
+ // optimistic move with stale server state. This was THE main
+ // cause of the "card jumps back for 5 s and then re-moves"
+ // glitch users were seeing on a busy board.
+ this._movesInFlight = 0;
+ this._lastDropAt = 0;
+ // Timer chips manage their own tick (see TimerChip component).
+ // The parent no longer needs to wake up every second.
onMounted(async () => {
await this.loadData();
- // Auto-refresh every 30 seconds (data); timers tick every 1 s.
- this._refreshInterval = setInterval(() => this.loadData(), 30000);
- this._tickInterval = setInterval(() => {
- this.state.tickEpoch += 1;
- }, 1000);
+ // Server data refresh every 30 seconds (catches changes from
+ // other operators). Suppressed while a move is in flight or
+ // for 30 s after the last drop — see _shouldSkipRefresh().
+ this._refreshInterval = setInterval(() => {
+ if (!this._shouldSkipRefresh()) this.loadData();
+ }, 30000);
});
onWillUnmount(() => {
@@ -62,13 +170,17 @@ export class PlantOverview extends Component {
clearInterval(this._refreshInterval);
this._refreshInterval = null;
}
- if (this._tickInterval) {
- clearInterval(this._tickInterval);
- this._tickInterval = null;
- }
});
}
+ _shouldSkipRefresh() {
+ if (this._movesInFlight > 0) return true;
+ // 30 s grace after a drop so the optimistic position holds even
+ // if another browser tab triggers the poll first.
+ if (Date.now() - this._lastDropAt < 30000) return true;
+ return false;
+ }
+
// ----- Data loading ------------------------------------------------------
async loadData() {
@@ -150,12 +262,20 @@ export class PlantOverview extends Component {
}
onCardDragStart(card, col, ev) {
+ // Mark the kanban as actively dragging — CSS rule freezes all
+ // animations + transitions on descendants. Without this the
+ // browser was paint-locked fighting 27 chip pulses + transitions
+ // during the drop, causing the 5+ second visual freeze.
+ const root = document.querySelector(".o_fp_plant_overview");
+ if (root) root.classList.add("is-dragging");
+
this._draggedCard = {
id: card.id,
source_model: card.source_model || "fp.job.step",
source_wc_id: col.work_center_id,
el: ev.target,
};
+ this._dragStartedAt = performance.now();
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", String(card.id));
// Add ghost class to the dragged card after a tick (so the drag image isn't affected)
@@ -167,6 +287,9 @@ export class PlantOverview extends Component {
}
onCardDragEnd(ev) {
+ const root = document.querySelector(".o_fp_plant_overview");
+ if (root) root.classList.remove("is-dragging");
+
if (ev.target && ev.target.classList) {
ev.target.classList.remove("o_fp_dragging");
}
@@ -220,6 +343,14 @@ export class PlantOverview extends Component {
}
async onColDrop(col, ev) {
+ // Instrumentation (v19.0.24.11.0). Keeping these console.time
+ // markers permanent — they cost ~0.01ms each, make every freeze
+ // visible in DevTools, and let the user paste a real timing back
+ // when something feels slow. Look in Console for "[fp drop] …".
+ const _t0 = performance.now();
+ console.time("[fp drop] total");
+ console.time("[fp drop] phase1-setup");
+
ev.preventDefault();
const body = ev.currentTarget;
if (body) {
@@ -229,21 +360,28 @@ export class PlantOverview extends Component {
const dragged = this._draggedCard;
if (!dragged) {
+ console.timeEnd("[fp drop] phase1-setup");
+ console.timeEnd("[fp drop] total");
return;
}
// No-op if dropped on the same column
if (dragged.source_wc_id === col.work_center_id) {
this._draggedCard = null;
+ console.timeEnd("[fp drop] phase1-setup");
+ console.timeEnd("[fp drop] total");
return;
}
+ console.timeEnd("[fp drop] phase1-setup");
- // ---- Optimistic UI (v19.0.24.7.0) ---------------------------------
- // Old code awaited the move RPC and THEN called loadData() to repaint
- // the entire 400-card board — felt laggy because the user had to
- // wait for both the SQL update AND a full payload rebuild before the
- // card appeared in its new column. Now we move it in `state.columns`
- // immediately, fire the RPC in the background, and only roll back +
- // reload if the server rejects the move.
+ // ---- Optimistic UI (v19.0.24.9.0) ---------------------------------
+ // Old version used `cards.splice()` + `cards.push()` on a nested
+ // reactive array. OWL's proxy SHOULD track that, but in practice it
+ // dropped the source-column update on a fast drag → user had to
+ // hard-refresh to see the card in its new column. This version
+ // assigns NEW arrays back to .cards, which always triggers the
+ // setter and a re-render. Slightly more work per drop, fully
+ // reliable.
+ console.time("[fp drop] phase2-optimistic");
const sourceColIdx = this.state.columns.findIndex(
(c) => c.work_center_id === dragged.source_wc_id,
);
@@ -253,58 +391,113 @@ export class PlantOverview extends Component {
let movedCard = null;
let cardOriginalIdx = -1;
if (sourceColIdx >= 0 && targetColIdx >= 0) {
- const cards = this.state.columns[sourceColIdx].cards;
- cardOriginalIdx = cards.findIndex((c) => c.id === dragged.id);
+ const sourceCards = this.state.columns[sourceColIdx].cards;
+ cardOriginalIdx = sourceCards.findIndex((c) => c.id === dragged.id);
if (cardOriginalIdx >= 0) {
- movedCard = cards[cardOriginalIdx];
- cards.splice(cardOriginalIdx, 1);
- this.state.columns[targetColIdx].cards.push(movedCard);
+ // Snapshot the moved card BEFORE the splice so the
+ // rollback path doesn't lose the reference. Tag it as
+ // _optimistic so the template can dim it to ~65% while
+ // the server confirms. That gives the user immediate
+ // feedback that the drop landed, with a clear hint
+ // that confirmation is in flight.
+ movedCard = {
+ ...sourceCards[cardOriginalIdx],
+ _optimistic: true,
+ };
+ // New source array without the card
+ this.state.columns[sourceColIdx].cards = [
+ ...sourceCards.slice(0, cardOriginalIdx),
+ ...sourceCards.slice(cardOriginalIdx + 1),
+ ];
+ // New target array with the card on top — it just got
+ // moved, the supervisor's eye expects it there. Server
+ // sort will re-position on the next refresh.
+ this.state.columns[targetColIdx].cards = [
+ movedCard,
+ ...this.state.columns[targetColIdx].cards,
+ ];
}
}
this._draggedCard = null;
+ console.timeEnd("[fp drop] phase2-optimistic");
+ // Force a paint frame BEFORE awaiting the RPC. Without this,
+ // OWL's render is queued but the browser may not paint until
+ // after the await rpc resolves — which means the user sees the
+ // card "freeze" until the network roundtrip completes.
+ // requestAnimationFrame schedules the callback right before the
+ // next paint, so by the time we await, the card is on screen.
+ console.time("[fp drop] phase2b-paint");
+ await new Promise((resolve) => requestAnimationFrame(resolve));
+ console.timeEnd("[fp drop] phase2b-paint");
+
+ const rollback = () => {
+ if (!movedCard || sourceColIdx < 0 || targetColIdx < 0) return;
+ // Remove from target
+ const tgt = this.state.columns[targetColIdx].cards;
+ this.state.columns[targetColIdx].cards = tgt.filter(
+ (c) => c.id !== movedCard.id,
+ );
+ // Re-insert into source at original position
+ const src = this.state.columns[sourceColIdx].cards;
+ this.state.columns[sourceColIdx].cards = [
+ ...src.slice(0, cardOriginalIdx),
+ movedCard,
+ ...src.slice(cardOriginalIdx),
+ ];
+ };
+
+ // Block any 30s poll from clobbering this optimistic move
+ // before the server has committed it.
+ this._movesInFlight += 1;
+ this._lastDropAt = Date.now();
+
+ console.time("[fp drop] phase3-rpc");
try {
const result = await rpc("/fp/shopfloor/plant_overview/move_card", {
card_id: dragged.id,
source_model: dragged.source_model,
target_workcenter_id: col.work_center_id,
});
+ console.timeEnd("[fp drop] phase3-rpc");
if (result && result.ok) {
this.notification.add(
`Moved to ${col.work_center_name}`,
{ type: "success" },
);
- // Don't reload — optimistic move already updated the UI.
- // The 30 s auto-refresh will reconcile any drift.
+ // Server confirmed — clear the dim. Locate the card in
+ // its (now-target) column and rebuild the array WITHOUT
+ // the _optimistic flag so OWL repaints at full opacity.
+ if (movedCard && targetColIdx >= 0) {
+ const tgt = this.state.columns[targetColIdx].cards;
+ this.state.columns[targetColIdx].cards = tgt.map((c) =>
+ c.id === movedCard.id ? { ...c, _optimistic: false } : c,
+ );
+ }
} else {
- // Server said no — roll back the optimistic move.
this.notification.add(
result?.error || "Could not move card",
{ type: "warning" },
);
- if (movedCard && sourceColIdx >= 0 && targetColIdx >= 0) {
- const targetCards = this.state.columns[targetColIdx].cards;
- const movedIdx = targetCards.findIndex((c) => c.id === movedCard.id);
- if (movedIdx >= 0) targetCards.splice(movedIdx, 1);
- this.state.columns[sourceColIdx].cards.splice(
- cardOriginalIdx, 0, movedCard,
- );
- }
+ rollback();
}
} catch (err) {
- // Same rollback on network error.
+ console.timeEnd("[fp drop] phase3-rpc");
this.notification.add(
`Move failed: ${err.message || err}`,
{ type: "danger" },
);
- if (movedCard && sourceColIdx >= 0 && targetColIdx >= 0) {
- const targetCards = this.state.columns[targetColIdx].cards;
- const movedIdx = targetCards.findIndex((c) => c.id === movedCard.id);
- if (movedIdx >= 0) targetCards.splice(movedIdx, 1);
- this.state.columns[sourceColIdx].cards.splice(
- cardOriginalIdx, 0, movedCard,
- );
- }
+ rollback();
+ } finally {
+ this._movesInFlight -= 1;
+ // Refresh the drop timestamp so the 30 s grace window
+ // covers the post-RPC settlement period too.
+ this._lastDropAt = Date.now();
+ }
+ console.timeEnd("[fp drop] total");
+ const totalMs = (performance.now() - _t0).toFixed(0);
+ if (totalMs > 200) {
+ console.warn(`[fp drop] SLOW DROP: ${totalMs}ms — paste this in chat`);
}
}
@@ -365,84 +558,8 @@ export class PlantOverview extends Component {
}
}
- // ------ Per-step timer (v19.0.24.5.0) ------------------------------------
- //
- // Computes the live "Running 47m" / "Paused 3h" / "Queued 12m" chip text
- // plus a tone (ok/warning/danger/muted) and a `critical` flag that the
- // template binds to a pulse animation. The `state.tickEpoch` reference
- // makes this getter reactive — it re-evaluates every 1 s.
- //
- // Thresholds chosen to mirror the existing battle-test rules:
- // - in_progress 1.0×–1.5× expected → warning, >1.5× → danger + pulse (S7)
- // - paused >8 h → danger, >24 h → danger + pulse (S10)
- // - queued >4 h → warning, >24 h → danger + pulse
- //
- // Returns an object with .label, .tone, .critical, .icon.
- getCardTimer(card) {
- // Reactive tick — never remove this read; OWL uses it to know
- // when to re-evaluate this getter.
- const _ = this.state.tickEpoch;
- const empty = { label: "", tone: "muted", critical: false, icon: "fa-clock-o" };
- if (!card.timer_kind || !card.timer_started_at_iso) return empty;
-
- const isoUtc = card.timer_started_at_iso.replace(" ", "T") + "Z";
- const startMs = Date.parse(isoUtc);
- if (isNaN(startMs)) return empty;
- const sec = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
-
- const fmt = (s) => {
- if (s < 60) return s + "s";
- const m = Math.floor(s / 60);
- if (m < 60) return m + "m";
- const h = Math.floor(m / 60);
- const rem = m % 60;
- if (h < 24) return rem ? `${h}h ${rem}m` : `${h}h`;
- const d = Math.floor(h / 24);
- const hr = h % 24;
- return hr ? `${d}d ${hr}h` : `${d}d`;
- };
-
- if (card.timer_kind === "running") {
- const expSec = (card.timer_expected_minutes || 0) * 60;
- let tone = "ok";
- let critical = false;
- if (expSec) {
- if (sec > 1.5 * expSec) { tone = "danger"; critical = true; }
- else if (sec > expSec) { tone = "warning"; }
- }
- return {
- label: `Running ${fmt(sec)}` + (expSec ? ` / ${fmt(expSec)} planned` : ""),
- tone,
- critical,
- icon: "fa-play-circle",
- };
- }
- if (card.timer_kind === "paused") {
- let tone = "warning";
- let critical = false;
- if (sec > 24 * 3600) { tone = "danger"; critical = true; }
- else if (sec > 8 * 3600) { tone = "danger"; }
- return {
- label: `Paused ${fmt(sec)}`,
- tone,
- critical,
- icon: "fa-pause-circle",
- };
- }
- if (card.timer_kind === "queued") {
- let tone = "muted";
- let critical = false;
- if (sec > 24 * 3600) { tone = "danger"; critical = true; }
- else if (sec > 4 * 3600) { tone = "warning"; }
- return {
- label: `Queued ${fmt(sec)}`,
- tone,
- critical,
- icon: "fa-hourglass-half",
- };
- }
- return empty;
- }
+ // Per-step timer logic moved to TimerChip subcomponent (v19.0.24.10.0)
+ // so a tick re-renders ONE chip, not the whole 389-card board.
}
registry.category("actions").add("fp_plant_overview", PlantOverview);
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_overview.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_overview.scss
index 33cb5231..b5cb0a7e 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_overview.scss
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_overview.scss
@@ -599,14 +599,38 @@ $_fp-timer-warn-bg-alpha: 0.20;
}
}
-// Critical card halo — when ANY card carries a critical timer, give the
-// whole card a subtle red border-glow so the supervisor can spot which
-// card is the problem from across the room without scanning every chip.
-.o_fp_po_card:has(.o_fp_po_timer_critical) {
+// Critical card border (v19.0.24.11.0) — class-based, NOT `:has()`.
+// `:has()` re-evaluates on every layout pass; with 389 cards on screen
+// and the browser doing constant layout work during drag, that selector
+// was the actual reason drag-drop felt frozen for 5+ seconds. The
+// server now flags critical cards with `is_urgent=true` and the OWL
+// template adds `.o_fp_po_card_critical` directly — zero selector cost.
+.o_fp_po_card_critical {
box-shadow: $fp-elev-2,
0 0 0 2px rgba(220, 53, 69, 0.55),
0 0 18px rgba(220, 53, 69, 0.22);
- animation: fp-card-attention 2.2s $fp-ease-out infinite;
+}
+
+// While a drag is in progress, pause infinite keyframe animations on
+// the few cards that have them (chip pulses). We INTENTIONALLY do NOT
+// touch transitions here — the previous version used `* { transition:
+// none !important }` which forced the browser to recalculate styles
+// on every descendant (~12,000 elements at 389 cards) on every drop,
+// and that style-recalc *was* the bottleneck the user was feeling
+// (v19.0.24.12.0).
+.o_fp_plant_overview.is-dragging .o_fp_po_timer_critical,
+.o_fp_plant_overview.is-dragging .o_fp_po_card_urgency.o_fp_po_urg_pulse {
+ animation-play-state: paused !important;
+}
+
+// Visual hint while an optimistic move is awaiting server confirmation.
+// Card is already in its new column; this just dims it slightly so the
+// supervisor knows "this just moved, server is confirming." Class is
+// removed when the RPC settles. Transition is fast and only on opacity
+// so it doesn't trigger layout.
+.o_fp_po_card_optimistic {
+ opacity: 0.65;
+ transition: opacity 120ms ease-out;
}
@keyframes fp-timer-pulse {
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_overview.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_overview.xml
index f619e975..baebb93c 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_overview.xml
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_overview.xml
@@ -6,6 +6,18 @@
-->
+
+
+
+
+
+
+
+
+
+
@@ -84,7 +96,7 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+