changes
This commit is contained in:
@@ -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',
|
||||
],
|
||||
|
||||
@@ -6,42 +6,50 @@
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ===== LOGISTICS (top-level under the core Plating app) ===== -->
|
||||
<!-- ===== LOGISTICS now lives under "Shipping & Receiving" ===== -->
|
||||
<!-- (v19.0.3.2.0) Logistics top-level was retired so the same -->
|
||||
<!-- dock workflow — receiving in, shipping out — sits under one -->
|
||||
<!-- menu root. The receiving module owns the new combined root. -->
|
||||
<!-- Sequences 40+ keep these BELOW the inbound items (10/20/30). -->
|
||||
<!-- -->
|
||||
<!-- The legacy `menu_fp_logistics` xmlid is kept (parented to -->
|
||||
<!-- Configuration so it doesn't show on the main bar) so any -->
|
||||
<!-- external reference / bookmark / view-action still resolves. -->
|
||||
<menuitem id="menu_fp_logistics"
|
||||
name="Logistics"
|
||||
parent="fusion_plating.menu_fp_root"
|
||||
sequence="50"
|
||||
groups="fusion_plating.group_fusion_plating_operator"/>
|
||||
name="Logistics (legacy)"
|
||||
parent="fusion_plating.menu_fp_config"
|
||||
sequence="999"
|
||||
active="False"/>
|
||||
|
||||
<menuitem id="menu_fp_pickup_requests"
|
||||
name="Pickup Requests"
|
||||
parent="menu_fp_logistics"
|
||||
parent="fusion_plating_receiving.menu_fp_receiving_root"
|
||||
action="action_fp_pickup_request"
|
||||
sequence="10"/>
|
||||
sequence="40"/>
|
||||
|
||||
<menuitem id="menu_fp_deliveries"
|
||||
name="Deliveries"
|
||||
parent="menu_fp_logistics"
|
||||
parent="fusion_plating_receiving.menu_fp_receiving_root"
|
||||
action="action_fp_delivery"
|
||||
sequence="20"/>
|
||||
sequence="50"/>
|
||||
|
||||
<menuitem id="menu_fp_routes"
|
||||
name="Routes"
|
||||
parent="menu_fp_logistics"
|
||||
parent="fusion_plating_receiving.menu_fp_receiving_root"
|
||||
action="action_fp_route"
|
||||
sequence="30"/>
|
||||
sequence="60"/>
|
||||
|
||||
<menuitem id="menu_fp_chain_of_custody"
|
||||
name="Chain of Custody"
|
||||
parent="menu_fp_logistics"
|
||||
parent="fusion_plating_receiving.menu_fp_receiving_root"
|
||||
action="action_fp_chain_of_custody"
|
||||
sequence="40"/>
|
||||
sequence="70"/>
|
||||
|
||||
<menuitem id="menu_fp_proof_of_delivery"
|
||||
name="Proof of Delivery"
|
||||
parent="menu_fp_logistics"
|
||||
parent="fusion_plating_receiving.menu_fp_receiving_root"
|
||||
action="action_fp_proof_of_delivery"
|
||||
sequence="50"/>
|
||||
sequence="80"/>
|
||||
|
||||
<!-- ===== VEHICLES under Configuration ===== -->
|
||||
<menuitem id="menu_fp_vehicles"
|
||||
|
||||
@@ -21,13 +21,17 @@
|
||||
<field name="domain">[('state', '=', 'discrepancy')]</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== RECEIVING & INSPECTION submenu ===== -->
|
||||
<!-- ===== SHIPPING & RECEIVING — combined menu (v) ===== -->
|
||||
<!-- Renamed from "Receiving & Inspection" so the same dock workflow -->
|
||||
<!-- — parts coming in AND parts going out — lives in one place. -->
|
||||
<!-- Logistics module reparents its 5 menu items under this root. -->
|
||||
<menuitem id="menu_fp_receiving_root"
|
||||
name="Receiving & Inspection"
|
||||
name="Shipping & Receiving"
|
||||
parent="fusion_plating.menu_fp_root"
|
||||
sequence="15"
|
||||
groups="group_fp_receiving"/>
|
||||
|
||||
<!-- Inbound (sequences 10–30) -->
|
||||
<menuitem id="menu_fp_receiving_all"
|
||||
name="All Receiving"
|
||||
parent="menu_fp_receiving_root"
|
||||
@@ -45,5 +49,7 @@
|
||||
parent="menu_fp_receiving_root"
|
||||
action="action_fp_receiving_discrepancy"
|
||||
sequence="30"/>
|
||||
<!-- Outbound items (40–80) added by fusion_plating_logistics -->
|
||||
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,18 @@
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- Per-card timer chip (v19.0.24.10.0). Subcomponent so each chip -->
|
||||
<!-- has its own reactive ticker — a 5s tick re-renders only that -->
|
||||
<!-- chip instead of the entire 389-card board. -->
|
||||
<t t-name="fusion_plating_shopfloor.TimerChip">
|
||||
<div t-if="display.label"
|
||||
t-att-class="'o_fp_po_card_timer o_fp_po_timer_' + display.tone + (display.critical ? ' o_fp_po_timer_critical' : '')"
|
||||
t-att-title="props.kind === 'running' ? 'Time in this step' : (props.kind === 'paused' ? 'Time since paused' : 'Time queued in this stage')">
|
||||
<i t-att-class="'fa ' + display.icon"/>
|
||||
<span class="o_fp_po_timer_label" t-esc="display.label"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.PlantOverview">
|
||||
<div class="o_fp_plant_overview">
|
||||
|
||||
@@ -84,7 +96,7 @@
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="col.cards" t-as="card" t-key="card.id">
|
||||
<div t-att-class="'o_fp_po_card ' + getStateClass(card.state) + (card.priority === '2' ? ' o_fp_po_card_hot' : card.priority === '1' ? ' o_fp_po_card_urgent' : '')"
|
||||
<div t-att-class="'o_fp_po_card ' + getStateClass(card.state) + (card.priority === '2' ? ' o_fp_po_card_hot' : card.priority === '1' ? ' o_fp_po_card_urgent' : '') + (card.is_urgent ? ' o_fp_po_card_critical' : '') + (card._optimistic ? ' o_fp_po_card_optimistic' : '')"
|
||||
draggable="true"
|
||||
t-att-data-card-id="card.id"
|
||||
t-att-data-source-model="card.source_model"
|
||||
@@ -180,17 +192,14 @@
|
||||
<t t-esc="card.step_display"/>
|
||||
</div>
|
||||
|
||||
<!-- Per-step timer (v19.0.24.5.0). -->
|
||||
<!-- Live-ticking elapsed in this stage, -->
|
||||
<!-- color-coded by tone, with a critical -->
|
||||
<!-- pulse animation on overrun / stuck. -->
|
||||
<t t-set="t" t-value="getCardTimer(card)"/>
|
||||
<div t-if="t.label"
|
||||
t-att-class="'o_fp_po_card_timer o_fp_po_timer_' + t.tone + (t.critical ? ' o_fp_po_timer_critical' : '')"
|
||||
t-att-title="card.timer_kind === 'running' ? 'Time in this step' : (card.timer_kind === 'paused' ? 'Time since paused' : 'Time queued in this stage')">
|
||||
<i t-att-class="'fa ' + t.icon"/>
|
||||
<span class="o_fp_po_timer_label" t-esc="t.label"/>
|
||||
</div>
|
||||
<!-- Per-step timer (v19.0.24.10.0) -->
|
||||
<!-- TimerChip subcomponent — owns its -->
|
||||
<!-- own tick so a refresh re-renders one -->
|
||||
<!-- chip, not the whole board. -->
|
||||
<TimerChip
|
||||
kind="card.timer_kind || ''"
|
||||
startedAt="card.timer_started_at_iso || ''"
|
||||
expectedMinutes="card.timer_expected_minutes || 0"/>
|
||||
|
||||
<!-- Last activity -->
|
||||
<div class="o_fp_po_card_last text-muted"
|
||||
|
||||
Reference in New Issue
Block a user