From 66cfe5f97f696e190d881cbac32683877cdfe90f Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Mon, 27 Apr 2026 09:41:46 -0400 Subject: [PATCH] changes --- .../fusion_plating_logistics/__manifest__.py | 3 +- .../views/fp_menu.xml | 38 +- .../views/fp_receiving_menu.xml | 10 +- .../fusion_plating_shopfloor/__manifest__.py | 2 +- .../controllers/shopfloor_controller.py | 22 ++ .../static/src/js/plant_overview.js | 367 ++++++++++++------ .../static/src/scss/plant_overview.scss | 34 +- .../static/src/xml/plant_overview.xml | 33 +- 8 files changed, 348 insertions(+), 161 deletions(-) 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"/> [('state', '=', 'discrepancy')] - + + + + + + + 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 @@
-
- - - - - -
- - -
+ + + + +