This commit is contained in:
gsinghpal
2026-04-27 08:48:55 -04:00
parent 2a4909be25
commit f51976cb08
8 changed files with 874 additions and 37 deletions

View File

@@ -42,11 +42,19 @@ 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;
onMounted(async () => {
await this.loadData();
// Auto-refresh every 30 seconds
// 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);
});
onWillUnmount(() => {
@@ -54,6 +62,10 @@ export class PlantOverview extends Component {
clearInterval(this._refreshInterval);
this._refreshInterval = null;
}
if (this._tickInterval) {
clearInterval(this._tickInterval);
this._tickInterval = null;
}
});
}
@@ -225,6 +237,32 @@ export class PlantOverview extends Component {
return;
}
// ---- 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.
const sourceColIdx = this.state.columns.findIndex(
(c) => c.work_center_id === dragged.source_wc_id,
);
const targetColIdx = this.state.columns.findIndex(
(c) => c.work_center_id === col.work_center_id,
);
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);
if (cardOriginalIdx >= 0) {
movedCard = cards[cardOriginalIdx];
cards.splice(cardOriginalIdx, 1);
this.state.columns[targetColIdx].cards.push(movedCard);
}
}
this._draggedCard = null;
try {
const result = await rpc("/fp/shopfloor/plant_overview/move_card", {
card_id: dragged.id,
@@ -236,20 +274,38 @@ export class PlantOverview extends Component {
`Moved to ${col.work_center_name}`,
{ type: "success" },
);
await this.loadData();
// Don't reload — optimistic move already updated the UI.
// The 30 s auto-refresh will reconcile any drift.
} 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,
);
}
}
} catch (err) {
// Same rollback on network error.
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,
);
}
}
this._draggedCard = null;
}
// ----- Card actions ------------------------------------------------------
@@ -308,6 +364,85 @@ export class PlantOverview extends Component {
return "";
}
}
// ------ 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;
}
}
registry.category("actions").add("fp_plant_overview", PlantOverview);

View File

@@ -388,6 +388,260 @@
color: $fp-ink-mute;
margin-bottom: $fp-space-2;
}
// ---------- Urgency chip (v19.0.24.8.0) --------------------------------------
// Always visible on every card; explains WHY it's at this sort position.
// Tones map to existing semantic colors. Critical bands (hot/overdue/bake_risk)
// pulse via the same `fp-timer-pulse` keyframes already shipped for timer chips.
//
// Light/dark mode: warning text branches at compile time on $o-webclient-color-scheme
// (same pattern as the timer chip). Other tones rely on $fp-* tokens / --bs-*
// CSS vars that flip automatically with the bundle.
$_fp-urg-warn-text-hex: #856404;
$_fp-urg-warn-bg-alpha: 0.20;
@if $o-webclient-color-scheme == dark {
$_fp-urg-warn-text-hex: #ffda6a !global;
$_fp-urg-warn-bg-alpha: 0.28 !global;
}
.o_fp_po_card_urgency {
display: inline-flex;
align-items: center;
gap: $fp-space-1;
margin: $fp-space-1 0 $fp-space-1;
padding: 2px $fp-space-2;
font-size: $fp-text-xs;
font-weight: $fp-weight-bold;
letter-spacing: 0.03em;
border-radius: $fp-radius-pill;
text-transform: uppercase;
line-height: 1.2;
i { font-size: 11px; }
// Tones (mirror timer chip)
&.o_fp_po_urg_tone_muted {
background: $fp-card-soft;
color: $fp-ink-faint;
font-weight: $fp-weight-medium;
text-transform: none;
letter-spacing: normal;
}
&.o_fp_po_urg_tone_info {
background: rgba(13, 110, 253, 0.14);
color: var(--bs-primary, #0d6efd);
}
&.o_fp_po_urg_tone_warning {
background: rgba(255, 193, 7, $_fp-urg-warn-bg-alpha);
color: $_fp-urg-warn-text-hex;
}
&.o_fp_po_urg_tone_danger {
background: rgba(220, 53, 69, 0.16);
color: var(--bs-danger, #c52131);
}
// Pulse for critical (HOT / OVERDUE / BAKE / paused-stuck-24h)
&.o_fp_po_urg_pulse {
animation: fp-timer-pulse 1.4s $fp-ease-out infinite;
position: relative;
z-index: 1;
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: $fp-radius-pill;
border: 2px solid currentColor;
opacity: 0;
animation: fp-timer-halo 1.6s $fp-ease-out infinite;
pointer-events: none;
}
}
}
// HOT band gets the fattest treatment — solid red fill, white text.
// Overrides the danger tone above so this band can't fade into the
// other danger chips.
.o_fp_po_card_urgency.o_fp_po_urg_hot {
background: var(--bs-danger, #c52131);
color: #fff;
box-shadow: 0 1px 4px rgba(220, 53, 69, 0.35);
}
@media (prefers-reduced-motion: reduce) {
.o_fp_po_urg_pulse {
animation: none;
&::after { animation: none; opacity: 0.45; }
}
}
// ---------- Card part / coating lines (v19.0.24.6.0) -------------------------
// Replaces the always-identical "[FP-SERVICE] Plating Service" line with the
// part number + coating spec the operator actually cares about. Both lines
// rely on $fp-ink / $fp-ink-mute tokens so they flip cleanly between the
// light and dark bundles — no hard-coded hex.
.o_fp_po_card_part {
display: flex;
align-items: center;
gap: $fp-space-1;
font-size: $fp-text-sm;
font-weight: $fp-weight-semibold;
color: $fp-ink;
margin-bottom: 2px;
line-height: 1.3;
.o_fp_po_card_part_icon {
font-size: 11px;
color: $fp-ink-mute;
}
.o_fp_po_card_part_rev {
font-weight: $fp-weight-medium;
font-size: $fp-text-xs;
color: $fp-ink-mute;
margin-left: $fp-space-1;
}
}
.o_fp_po_card_coating {
display: flex;
align-items: center;
gap: $fp-space-1;
font-size: $fp-text-xs;
color: $fp-ink-soft;
margin-bottom: $fp-space-2;
line-height: 1.3;
.o_fp_po_card_coating_icon {
font-size: 10px;
color: $fp-ink-faint;
}
}
.o_fp_po_card_no_part {
display: flex;
align-items: center;
gap: $fp-space-1;
font-style: italic;
color: $fp-ink-faint;
margin-bottom: $fp-space-2;
}
// Step-ordinal badge — separator + total in mute tone (1-based "4/9").
.o_fp_po_card_step_total {
font-weight: $fp-weight-medium;
color: $fp-ink-faint;
margin-left: 1px;
}
// ---------- Per-step timer chip (v19.0.24.5.0) -------------------------------
// Live-ticking elapsed-in-stage label. JS getCardTimer() picks the tone
// (muted/ok/warning/danger) and a `critical` flag that toggles the pulse
// animation. Critical = step is overrun (>1.5× expected), paused >24h, or
// queued >24h — any of those conditions need supervisor attention NOW.
//
// Light/dark mode: warning text needs different hex per bundle so it
// stays legible against the translucent yellow tint. Other tones use
// $fp-* tokens or --bs-* CSS vars which Odoo flips automatically.
$_fp-timer-warn-text-hex: #856404; // dark brown — readable on light card
$_fp-timer-warn-bg-alpha: 0.20;
@if $o-webclient-color-scheme == dark {
$_fp-timer-warn-text-hex: #ffda6a !global; // light yellow on dark card
$_fp-timer-warn-bg-alpha: 0.28 !global; // a touch more saturation
}
.o_fp_po_card_timer {
display: inline-flex;
align-items: center;
gap: $fp-space-1;
margin: $fp-space-1 0 $fp-space-2;
padding: 2px $fp-space-2;
font-size: $fp-text-xs;
font-weight: $fp-weight-semibold;
border-radius: $fp-radius-pill;
i { font-size: 11px; }
// Tones — backgrounds use rgba() with a low alpha so the underlying
// card surface tints through; text uses the strong hue.
&.o_fp_po_timer_muted {
background: $fp-card-soft;
color: $fp-ink-mute;
}
&.o_fp_po_timer_ok {
background: rgba(25, 135, 84, 0.14);
color: var(--bs-success, #198754);
}
&.o_fp_po_timer_warning {
background: rgba(255, 193, 7, $_fp-timer-warn-bg-alpha);
color: $_fp-timer-warn-text-hex;
}
&.o_fp_po_timer_danger {
background: rgba(220, 53, 69, 0.16);
color: var(--bs-danger, #c52131);
}
// Critical attention-grabber. Two layers of motion so it's hard to
// ignore: (a) the chip itself pulses scale+glow, (b) a soft halo
// expands behind it like a sonar ping. Honours prefers-reduced-motion.
&.o_fp_po_timer_critical {
animation: fp-timer-pulse 1.4s $fp-ease-out infinite;
position: relative;
z-index: 1;
&::after {
content: "";
position: absolute;
inset: -2px;
border-radius: $fp-radius-pill;
border: 2px solid var(--bs-danger, #c52131);
opacity: 0;
animation: fp-timer-halo 1.6s $fp-ease-out infinite;
pointer-events: none;
}
}
}
// 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) {
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;
}
@keyframes fp-timer-pulse {
0%, 100% { transform: scale(1.0); }
50% { transform: scale(1.06); }
}
@keyframes fp-timer-halo {
0% { transform: scale(0.92); opacity: 0.0; }
35% { transform: scale(1.05); opacity: 0.55; }
100% { transform: scale(1.30); opacity: 0.0; }
}
@keyframes fp-card-attention {
0%, 100% {
box-shadow: $fp-elev-2,
0 0 0 2px rgba(220, 53, 69, 0.55),
0 0 14px rgba(220, 53, 69, 0.18);
}
50% {
box-shadow: $fp-elev-2,
0 0 0 2px rgba(220, 53, 69, 0.85),
0 0 28px rgba(220, 53, 69, 0.42);
}
}
@media (prefers-reduced-motion: reduce) {
.o_fp_po_timer_critical {
animation: none;
&::after { animation: none; opacity: 0.45; }
}
.o_fp_po_card:has(.o_fp_po_timer_critical) {
animation: none;
}
}
.o_fp_po_card_footer {
display: flex; justify-content: space-between; align-items: center;
margin-top: $fp-space-2;

View File

@@ -106,19 +106,61 @@
<div class="o_fp_po_card_title">
<strong t-esc="card.customer_name || 'Walk-In'"/>
</div>
<span class="o_fp_po_card_step_badge" t-if="card.step_number">
<t t-esc="card.step_number"/>
<!-- 1-based step ordinal: "4/9" -->
<!-- not "40" (v19.0.24.6.0) -->
<span class="o_fp_po_card_step_badge"
t-if="card.step_index">
<t t-esc="card.step_index"/>
<span class="o_fp_po_card_step_total"
t-if="card.job_step_count">
/<t t-esc="card.job_step_count"/>
</span>
</span>
</div>
<!-- Urgency chip (v19.0.24.8.0) — always -->
<!-- visible. Explains WHY the card is at -->
<!-- this position in the sort. Critical -->
<!-- bands (HOT, OVERDUE, MISSED BAKE) -->
<!-- pulse to grab attention. -->
<div t-if="card.urgency_band"
t-att-class="'o_fp_po_card_urgency o_fp_po_urg_' + card.urgency_band + ' o_fp_po_urg_tone_' + card.urgency_tone + (card.urgency_pulse ? ' o_fp_po_urg_pulse' : '')"
t-att-title="'Urgency score: ' + card.urgency_score">
<i t-att-class="'fa ' + card.urgency_icon"/>
<span class="o_fp_po_card_urgency_label"
t-esc="card.urgency_label"/>
</div>
<!-- SO / WO refs + product name -->
<div class="o_fp_po_card_refs">
<span t-if="card.so_name" t-esc="card.so_name"/>
<span t-if="card.so_name and card.wo_name"> | </span>
<span t-if="card.wo_name" t-esc="card.wo_name"/>
</div>
<div class="o_fp_po_card_product text-muted small" t-if="card.product_name">
<t t-esc="card.product_name"/>
<!-- Useful per-card detail (v19.0.24.6.0). -->
<!-- Line 1: part number + revision (what -->
<!-- the operator is holding). Line 2: -->
<!-- coating spec (what process they're -->
<!-- running). Falls back to product name -->
<!-- only if neither is set (legacy data). -->
<div class="o_fp_po_card_part"
t-if="card.part_number">
<i class="fa fa-tag o_fp_po_card_part_icon"/>
<strong t-esc="card.part_number"/>
<span class="o_fp_po_card_part_rev"
t-if="card.part_revision">
rev <t t-esc="card.part_revision"/>
</span>
</div>
<div class="o_fp_po_card_coating"
t-if="card.coating_label">
<i class="fa fa-flask o_fp_po_card_coating_icon"/>
<t t-esc="card.coating_label"/>
</div>
<div class="o_fp_po_card_no_part text-muted small"
t-if="!card.part_number and !card.coating_label">
<i class="fa fa-question-circle"/>
No part / coating set on job
</div>
<!-- Parts progress -->
@@ -138,6 +180,18 @@
<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>
<!-- Last activity -->
<div class="o_fp_po_card_last text-muted"
t-if="card.last_operator">