This commit is contained in:
gsinghpal
2026-04-27 09:41:46 -04:00
parent f51976cb08
commit 66cfe5f97f
8 changed files with 348 additions and 161 deletions

View File

@@ -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',
],

View File

@@ -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"

View File

@@ -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 &amp; Inspection"
name="Shipping &amp; Receiving"
parent="fusion_plating.menu_fp_root"
sequence="15"
groups="group_fp_receiving"/>
<!-- Inbound (sequences 1030) -->
<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 (4080) added by fusion_plating_logistics -->
</odoo>

View File

@@ -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.',

View File

@@ -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 56 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`

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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"