feat(shopfloor): rich Tablet Station dashboard + full shop-floor demo data
Tablet Station rebuilt as a live dashboard (not just a QR scanner):
* KPI strip — WOs Ready/Progress, Awaiting/Missed bakes,
First-Piece pending, Quality Holds (each tinted by state)
* Active WO banner with pulsing indicator when a WO is running
* My Queue panel (left) — priority-badged operator next-up list,
clickable rows that jump to the WO/bake/gate form
* Baths tile grid (right) — last-log status chips, MTO count,
hover jump to chemistry log
* Bake Windows list — inline Start/End/Open actions, colour-coded
by state (awaiting / in-progress / missed)
* First-Piece Gates — Pass/Fail buttons for pending inspections
* Quality Holds — Review jump when any open holds exist
* Station picker + scan drawer (collapsed by default)
* 30s auto-refresh, persists picked station in localStorage
New controller endpoints: /fp/shopfloor/tablet_overview,
/fp/shopfloor/pair_station, /fp/shopfloor/mark_gate.
Demo seeder (Phase 12.5) now populates:
* 5 shop-floor stations (Plating, Bake, Inspection, Shipping, Receiving)
* +3 bake windows (awaiting / in-progress / near-due)
* 4 first-piece gates (1 pending, 1 passed+released, 1 passed-holding, 1 failed)
* 2 quality holds on active MOs (one on_hold, one under_review)
All four Shop Floor menu pages (Plant Overview, Tablet Station, Bake
Windows, First-Piece Gates) now have meaningful content.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,14 +5,12 @@
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL component using `static template` + `static props = []`
|
||||
// (note: empty array, NOT empty object).
|
||||
// * RPC via standalone `rpc()` from @web/core/network/rpc — NOT useService.
|
||||
// * Registered under registry.category("actions") so the menu / record
|
||||
// action can launch it as a client action ("fp_shopfloor_tablet").
|
||||
// * Backend OWL component using `static template` + `static props = ["*"]`.
|
||||
// * RPC via standalone `rpc()` from @web/core/network/rpc.
|
||||
// * Registered under registry.category("actions") as "fp_shopfloor_tablet".
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, useRef } from "@odoo/owl";
|
||||
import { Component, useState, onMounted, onWillUnmount, useRef } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
@@ -23,155 +21,179 @@ export class ShopfloorTablet extends Component {
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.scanInput = useRef("scanInput");
|
||||
|
||||
this.state = useState({
|
||||
overview: null,
|
||||
stationId: null,
|
||||
scannedCode: "",
|
||||
station: null,
|
||||
currentTank: null,
|
||||
currentBath: null,
|
||||
currentJob: null,
|
||||
queueRows: [],
|
||||
message: "",
|
||||
messageType: "info", // info | success | warning | danger
|
||||
messageType: "info",
|
||||
loading: false,
|
||||
showScan: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this.refreshQueue();
|
||||
if (this.scanInput.el) {
|
||||
this.scanInput.el.focus();
|
||||
}
|
||||
const saved = parseInt(localStorage.getItem("fp_tablet_station_id") || "0") || null;
|
||||
if (saved) this.state.stationId = saved;
|
||||
await this.refresh();
|
||||
this._interval = setInterval(() => this.refresh(), 30000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._interval) clearInterval(this._interval);
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Helpers --------------------------------------------------------
|
||||
// ---------------------------------------------------------- Helpers
|
||||
setMessage(text, type = "info") {
|
||||
this.state.message = text;
|
||||
this.state.messageType = type;
|
||||
}
|
||||
|
||||
clearTargets() {
|
||||
this.state.currentTank = null;
|
||||
this.state.currentBath = null;
|
||||
this.state.currentJob = null;
|
||||
async refresh() {
|
||||
try {
|
||||
const payload = await rpc("/fp/shopfloor/tablet_overview", {
|
||||
station_id: this.state.stationId || null,
|
||||
});
|
||||
if (payload && payload.ok) {
|
||||
this.state.overview = payload;
|
||||
}
|
||||
} catch (err) {
|
||||
// silent — next tick will retry
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Station pairing
|
||||
async onPickStation(ev) {
|
||||
const id = parseInt(ev.target.value) || null;
|
||||
this.state.stationId = id;
|
||||
if (id) {
|
||||
localStorage.setItem("fp_tablet_station_id", String(id));
|
||||
try {
|
||||
await rpc("/fp/shopfloor/pair_station", { station_id: id });
|
||||
this.setMessage("Station paired.", "success");
|
||||
} catch (err) {
|
||||
this.setMessage(`Pair failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem("fp_tablet_station_id");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Scan drawer
|
||||
toggleScan() {
|
||||
this.state.showScan = !this.state.showScan;
|
||||
if (this.state.showScan) {
|
||||
setTimeout(() => this.scanInput.el && this.scanInput.el.focus(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- QR scan --------------------------------------------------------
|
||||
async onScan() {
|
||||
const code = (this.state.scannedCode || "").trim();
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
if (!code) return;
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const result = await rpc("/fp/shopfloor/scan", { qr_code: code });
|
||||
if (!result || !result.ok) {
|
||||
this.setMessage(
|
||||
(result && result.error) || "Unrecognised QR code",
|
||||
"danger",
|
||||
);
|
||||
this.setMessage((result && result.error) || "Unrecognised QR code", "danger");
|
||||
this.state.loading = false;
|
||||
return;
|
||||
}
|
||||
this.clearTargets();
|
||||
switch (result.model) {
|
||||
case "fusion.plating.tank":
|
||||
this.state.currentTank = result;
|
||||
this.setMessage(
|
||||
`Tank ${result.name} — ${result.queue_size} in queue`,
|
||||
"info",
|
||||
);
|
||||
break;
|
||||
case "fusion.plating.bath":
|
||||
this.state.currentBath = result;
|
||||
this.setMessage(`Bath ${result.name}`, "info");
|
||||
break;
|
||||
case "fusion.plating.bake.window":
|
||||
this.state.currentJob = result;
|
||||
this.setMessage(
|
||||
`Job ${result.name} — ${result.time_remaining || ""} remaining`,
|
||||
result.state === "missed_window" ? "danger" : "warning",
|
||||
);
|
||||
break;
|
||||
case "fusion.plating.shopfloor.station":
|
||||
this.state.station = result;
|
||||
this.setMessage(
|
||||
`Station paired: ${result.name}`,
|
||||
"success",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.setMessage(`Scanned ${result.model}`, "info");
|
||||
// If a station was scanned, pair it
|
||||
if (result.model === "fusion.plating.shopfloor.station") {
|
||||
this.state.stationId = result.id;
|
||||
localStorage.setItem("fp_tablet_station_id", String(result.id));
|
||||
this.setMessage(`Station paired: ${result.name}`, "success");
|
||||
} else {
|
||||
this.setMessage(`Scanned ${result.model} — ${result.name || ""}`, "info");
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Scan error: ${err.message || err}`, "danger");
|
||||
} finally {
|
||||
this.state.scannedCode = "";
|
||||
this.state.loading = false;
|
||||
if (this.scanInput.el) {
|
||||
this.scanInput.el.focus();
|
||||
}
|
||||
await this.refreshQueue();
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
onScanKey(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
this.onScan();
|
||||
}
|
||||
if (ev.key === "Enter") this.onScan();
|
||||
}
|
||||
|
||||
// ----- Bake controls --------------------------------------------------
|
||||
async onStartBake() {
|
||||
if (!this.state.currentJob) {
|
||||
return;
|
||||
}
|
||||
// ---------------------------------------------------------- Actions
|
||||
async openRecord(model, id) {
|
||||
if (!model || !id) return;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: model,
|
||||
res_id: id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
async onStartBake(bwId) {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/start_bake", {
|
||||
bake_window_id: this.state.currentJob.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage("Bake started", "success");
|
||||
this.state.currentJob.state = res.state;
|
||||
}
|
||||
await rpc("/fp/shopfloor/start_bake", { bake_window_id: bwId });
|
||||
this.setMessage("Bake started.", "success");
|
||||
} catch (err) {
|
||||
this.setMessage(`Start bake failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refreshQueue();
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async onEndBake() {
|
||||
if (!this.state.currentJob) {
|
||||
return;
|
||||
}
|
||||
async onEndBake(bwId) {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/end_bake", {
|
||||
bake_window_id: this.state.currentJob.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage(
|
||||
`Bake complete — ${res.bake_duration_hours.toFixed(2)} h`,
|
||||
"success",
|
||||
);
|
||||
this.state.currentJob.state = res.state;
|
||||
}
|
||||
await rpc("/fp/shopfloor/end_bake", { bake_window_id: bwId });
|
||||
this.setMessage("Bake complete.", "success");
|
||||
} catch (err) {
|
||||
this.setMessage(`End bake failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refreshQueue();
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
// ----- Queue ----------------------------------------------------------
|
||||
async refreshQueue() {
|
||||
async onGateResult(gateId, result) {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/queue", {});
|
||||
if (res && res.ok) {
|
||||
this.state.queueRows = res.rows || [];
|
||||
}
|
||||
await rpc("/fp/shopfloor/mark_gate", { gate_id: gateId, result });
|
||||
this.setMessage(`First-piece marked ${result.toUpperCase()}.`,
|
||||
result === "pass" ? "success" : "danger");
|
||||
} catch (err) {
|
||||
// Non-fatal: queue refresh shouldn't block scanning
|
||||
this.setMessage(`Gate update failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async onQueueItemClick(row) {
|
||||
if (row.source_model && row.source_id) {
|
||||
this.openRecord(row.source_model, row.source_id);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Utility
|
||||
stateBadge(state) {
|
||||
const map = {
|
||||
awaiting_bake: "warning",
|
||||
bake_in_progress: "info",
|
||||
baked: "success",
|
||||
missed_window: "danger",
|
||||
pending: "warning",
|
||||
pass: "success",
|
||||
fail: "danger",
|
||||
operational: "success",
|
||||
low: "warning",
|
||||
out_of_spec: "danger",
|
||||
on_hold: "danger",
|
||||
under_review: "warning",
|
||||
released: "success",
|
||||
scrapped: "muted",
|
||||
reworked: "info",
|
||||
ok: "success",
|
||||
warning: "warning",
|
||||
};
|
||||
return map[state] || "muted";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,192 +36,309 @@
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
font-size: 1.1rem;
|
||||
padding: 20px 24px;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
gap: 14px;
|
||||
|
||||
// ---------- Header -------------------------------------------------------
|
||||
.o_fp_tablet_header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_title {
|
||||
font-size: 1.6rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_station {
|
||||
.o_fp_tablet_subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_tablet_scan_row {
|
||||
.o_fp_tablet_chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 10px;
|
||||
background-color: color-mix(in srgb, var(--o-action) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--o-action) 35%, transparent);
|
||||
color: var(--o-action);
|
||||
border-radius: 999px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.o_fp_tablet_header_actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.o_fp_station_picker {
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
}
|
||||
.o_fp_scan_toggle {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.o_fp_tablet_message {
|
||||
padding: 14px 18px;
|
||||
// ---------- Scan drawer --------------------------------------------------
|
||||
.o_fp_scan_drawer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background-color: color-mix(in srgb, var(--o-action) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
border: 1px dashed color-mix(in srgb, var(--o-action) 40%, transparent);
|
||||
border-radius: 10px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&.o_fp_msg_info { @include fp-shop-tint(--bs-info); }
|
||||
// ---------- Flash message ------------------------------------------------
|
||||
.o_fp_tablet_message {
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
&.o_fp_msg_info { @include fp-shop-tint(--bs-info); }
|
||||
&.o_fp_msg_success { @include fp-shop-tint(--bs-success); }
|
||||
&.o_fp_msg_warning { @include fp-shop-tint(--bs-warning); }
|
||||
&.o_fp_msg_danger { @include fp-shop-tint(--bs-danger); }
|
||||
&.o_fp_msg_danger { @include fp-shop-tint(--bs-danger); }
|
||||
}
|
||||
|
||||
.o_fp_tablet_grid {
|
||||
// ---------- KPI strip ----------------------------------------------------
|
||||
.o_fp_kpi_strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.o_fp_kpi {
|
||||
position: relative;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
> .fa {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
font-size: 1.4rem;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.o_fp_kpi_value { font-size: 1.8rem; font-weight: 700; line-height: 1; }
|
||||
.o_fp_kpi_label { font-size: 0.85rem; color: var(--bs-secondary-color); text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
|
||||
&.o_fp_kpi_info { border-left: 4px solid var(--bs-info); }
|
||||
&.o_fp_kpi_success { border-left: 4px solid var(--bs-success); }
|
||||
&.o_fp_kpi_warning { border-left: 4px solid var(--bs-warning); }
|
||||
&.o_fp_kpi_danger {
|
||||
border-left: 4px solid var(--bs-danger);
|
||||
background-color: color-mix(in srgb, var(--bs-danger) 5%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
.o_fp_kpi_value { color: var(--bs-danger); }
|
||||
}
|
||||
&.o_fp_kpi_muted { border-left: 4px solid var(--bs-border-color); opacity: 0.8; }
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue {
|
||||
// ---------- Active WO banner --------------------------------------------
|
||||
.o_fp_active_wo {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--bs-success) 40%, var(--bs-border-color));
|
||||
background-color: color-mix(in srgb, var(--bs-success) 7%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
border-radius: 10px;
|
||||
}
|
||||
.o_fp_active_wo_left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.o_fp_active_wo_title { font-weight: 600; font-size: 1.05rem; }
|
||||
.o_fp_active_wo_meta { color: var(--bs-secondary-color); font-size: 0.9rem; }
|
||||
.o_fp_active_wo_pulse {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background-color: var(--bs-success);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--bs-success) 60%, transparent);
|
||||
animation: o_fp_pulse 1.4s infinite;
|
||||
}
|
||||
@keyframes o_fp_pulse {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--bs-success) 60%, transparent); }
|
||||
70% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--bs-success) 0%, transparent); }
|
||||
100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--bs-success) 0%, transparent); }
|
||||
}
|
||||
|
||||
// ---------- Dashboard layout --------------------------------------------
|
||||
.o_fp_tablet_dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.o_fp_tablet_dashboard { grid-template-columns: 1fr; }
|
||||
}
|
||||
.o_fp_right_col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
// ---------- Panel (reusable card) ---------------------------------------
|
||||
.o_fp_panel {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
|
||||
.o_fp_tablet_queue_title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--bs-border-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_item {
|
||||
background-color: color-mix(in srgb, var(--bs-body-color) 4%, transparent);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
|
||||
.o_fp_tablet_queue_label {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_desc {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
padding: 14px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Large card surface used for tank / bath info on the tablet
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_tablet_card {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
min-height: 140px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--o-action) 50%, var(--bs-border-color));
|
||||
box-shadow: 0 2px 10px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_meta {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Bake window card — colour shifts with state
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_bake_window_card {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-left-width: 6px;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
min-height: 160px;
|
||||
|
||||
.o_fp_tablet_card_label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.o_fp_tablet_card_value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.o_fp_tablet_card_meta {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.o_fp_tablet_card_actions {
|
||||
.o_fp_panel_head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--bs-border-color);
|
||||
h3 { font-size: 1.05rem; font-weight: 600; margin: 0; }
|
||||
}
|
||||
.o_fp_panel_count {
|
||||
background-color: color-mix(in srgb, var(--bs-body-color) 6%, transparent);
|
||||
color: var(--bs-body-color);
|
||||
border-radius: 999px;
|
||||
padding: 1px 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.o_fp_empty {
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
&[data-status="awaiting_bake"] {
|
||||
border-left-color: var(--bs-warning);
|
||||
background-color: color-mix(in srgb, var(--bs-warning) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
// ---------- My Queue list -----------------------------------------------
|
||||
.o_fp_queue_list {
|
||||
list-style: none;
|
||||
margin: 0; padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
&[data-status="bake_in_progress"] {
|
||||
border-left-color: var(--bs-info, var(--o-action));
|
||||
background-color: color-mix(in srgb, var(--bs-info, var(--o-action)) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
.o_fp_queue_row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 20px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--o-action) 7%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
border-color: color-mix(in srgb, var(--o-action) 40%, var(--bs-border-color));
|
||||
}
|
||||
}
|
||||
&[data-status="baked"] {
|
||||
border-left-color: var(--bs-success);
|
||||
background-color: color-mix(in srgb, var(--bs-success) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
.o_fp_queue_label { font-weight: 600; }
|
||||
.o_fp_queue_desc { font-size: 0.88rem; color: var(--bs-secondary-color); }
|
||||
.o_fp_queue_pri {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
&[data-priority="high"] { background-color: color-mix(in srgb, var(--bs-danger) 15%, transparent); color: var(--bs-danger); }
|
||||
&[data-priority="med"] { background-color: color-mix(in srgb, var(--bs-warning) 15%, transparent); color: var(--bs-warning); }
|
||||
&[data-priority="low"] { background-color: color-mix(in srgb, var(--bs-body-color) 8%, transparent); color: var(--bs-secondary-color); }
|
||||
}
|
||||
&[data-status="missed_window"],
|
||||
&[data-status="scrapped"] {
|
||||
border-left-color: var(--bs-danger);
|
||||
background-color: color-mix(in srgb, var(--bs-danger) 8%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
|
||||
// ---------- Bath tiles --------------------------------------------------
|
||||
.o_fp_tile_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.o_fp_tile {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background-color 120ms ease;
|
||||
|
||||
&:hover { border-color: color-mix(in srgb, var(--o-action) 40%, var(--bs-border-color)); }
|
||||
&[data-tone="danger"] { border-left: 4px solid var(--bs-danger); }
|
||||
&[data-tone="warning"] { border-left: 4px solid var(--bs-warning); }
|
||||
&[data-tone="success"] { border-left: 4px solid var(--bs-success); }
|
||||
&[data-tone="info"] { border-left: 4px solid var(--bs-info); }
|
||||
}
|
||||
.o_fp_tile_title { font-weight: 600; margin-bottom: 2px; }
|
||||
.o_fp_tile_meta { font-size: 0.85rem; color: var(--bs-secondary-color); margin-bottom: 6px; }
|
||||
.o_fp_tile_chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
|
||||
// ---------- Chips -------------------------------------------------------
|
||||
.o_fp_chip {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
|
||||
&.o_fp_chip_info { @include fp-shop-tint(--bs-info); }
|
||||
&.o_fp_chip_success { @include fp-shop-tint(--bs-success); }
|
||||
&.o_fp_chip_warning { @include fp-shop-tint(--bs-warning); }
|
||||
&.o_fp_chip_danger { @include fp-shop-tint(--bs-danger); }
|
||||
&.o_fp_chip_muted {
|
||||
background-color: color-mix(in srgb, var(--bs-body-color) 6%, transparent);
|
||||
color: var(--bs-secondary-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Bake / Gate / Hold rows -------------------------------------
|
||||
.o_fp_bake_list {
|
||||
list-style: none;
|
||||
margin: 0; padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.o_fp_bake_row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
|
||||
&[data-state="awaiting_bake"], &[data-state="pending"] {
|
||||
border-left: 4px solid var(--bs-warning);
|
||||
}
|
||||
&[data-state="bake_in_progress"], &[data-state="under_review"] {
|
||||
border-left: 4px solid var(--bs-info);
|
||||
}
|
||||
&[data-state="missed_window"], &[data-state="fail"], &[data-state="on_hold"] {
|
||||
border-left: 4px solid var(--bs-danger);
|
||||
background-color: color-mix(in srgb, var(--bs-danger) 4%, transparent);
|
||||
}
|
||||
&[data-state="baked"], &[data-state="pass"] {
|
||||
border-left: 4px solid var(--bs-success);
|
||||
}
|
||||
}
|
||||
.o_fp_bake_name { font-weight: 600; }
|
||||
.o_fp_bake_meta { font-size: 0.85rem; }
|
||||
.o_fp_bake_actions { display: flex; gap: 6px; }
|
||||
|
||||
// ---------- Footer ------------------------------------------------------
|
||||
.o_fp_tablet_footer {
|
||||
text-align: right;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--bs-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,11 +348,11 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_scan_input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 56px;
|
||||
padding: 12px 18px;
|
||||
font-size: 1.3rem;
|
||||
min-height: 44px;
|
||||
padding: 8px 14px;
|
||||
font-size: 1.05rem;
|
||||
border: 2px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
|
||||
@@ -244,10 +361,7 @@
|
||||
border-color: var(--o-action);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--o-action) 25%, transparent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
&::placeholder { color: var(--bs-secondary-color); }
|
||||
}
|
||||
|
||||
|
||||
@@ -255,26 +369,19 @@
|
||||
// Big touch-friendly action button
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_big_button {
|
||||
min-height: 56px;
|
||||
min-width: 120px;
|
||||
padding: 12px 24px;
|
||||
font-size: 1.1rem;
|
||||
min-height: 44px;
|
||||
min-width: 100px;
|
||||
padding: 8px 18px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--o-action);
|
||||
background-color: var(--o-action);
|
||||
color: var(--o-we-text-on-action, #fff);
|
||||
cursor: pointer;
|
||||
transition: filter 120ms ease, transform 80ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&:hover:not(:disabled) { filter: brightness(1.05); }
|
||||
&:active:not(:disabled) { transform: translateY(1px); }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
@@ -3,111 +3,326 @@
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Tablet Station dashboard — KPI strip, My Queue, Active WO,
|
||||
Baths, Bake Windows, First-Piece Gates, Quality Holds.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ShopfloorTablet">
|
||||
<div class="o_fp_tablet">
|
||||
|
||||
<!-- ===== Header — title, station picker, scan toggle ===== -->
|
||||
<div class="o_fp_tablet_header">
|
||||
<div class="o_fp_tablet_title">Fusion Plating — Shop Floor</div>
|
||||
<div class="o_fp_tablet_station" t-if="state.station">
|
||||
Station: <strong t-esc="state.station.name"/>
|
||||
<div>
|
||||
<div class="o_fp_tablet_title">
|
||||
<i class="fa fa-tablet me-2"/>Shop Floor Tablet
|
||||
</div>
|
||||
<div class="o_fp_tablet_subtitle" t-if="state.overview">
|
||||
<span t-esc="state.overview.user_name"/>
|
||||
<t t-if="state.overview.station">
|
||||
<span class="o_fp_tablet_chip ms-2">
|
||||
<i class="fa fa-desktop me-1"/>
|
||||
<span t-esc="state.overview.station.name"/>
|
||||
<span t-if="state.overview.station.work_center" class="text-muted">
|
||||
— <span t-esc="state.overview.station.work_center"/>
|
||||
</span>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_tablet_header_actions">
|
||||
<select class="form-select o_fp_station_picker"
|
||||
t-on-change="onPickStation"
|
||||
t-if="state.overview">
|
||||
<option value="">— Pick station —</option>
|
||||
<t t-foreach="state.overview.stations" t-as="s" t-key="s.id">
|
||||
<option t-att-value="s.id"
|
||||
t-att-selected="state.stationId === s.id">
|
||||
<t t-esc="s.name"/>
|
||||
<t t-if="s.work_center"> · <t t-esc="s.work_center"/></t>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<button class="btn btn-outline-primary o_fp_scan_toggle"
|
||||
t-on-click="toggleScan">
|
||||
<i class="fa fa-qrcode me-1"/>Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_scan_row">
|
||||
<input
|
||||
type="text"
|
||||
class="o_fp_scan_input"
|
||||
placeholder="Scan QR code"
|
||||
t-ref="scanInput"
|
||||
t-model="state.scannedCode"
|
||||
t-on-keydown="onScanKey"
|
||||
/>
|
||||
<button class="o_fp_big_button" t-on-click="onScan" t-att-disabled="state.loading">
|
||||
<!-- ===== Optional scan drawer ===== -->
|
||||
<div t-if="state.showScan" class="o_fp_scan_drawer">
|
||||
<input type="text"
|
||||
class="o_fp_scan_input"
|
||||
placeholder="Scan QR code (FP-STATION:…, FP-TANK:…, FP-BATH:…, FP-WO:…)"
|
||||
t-ref="scanInput"
|
||||
t-model="state.scannedCode"
|
||||
t-on-keydown="onScanKey"/>
|
||||
<button class="o_fp_big_button"
|
||||
t-on-click="onScan"
|
||||
t-att-disabled="state.loading">
|
||||
Scan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div t-if="state.message" t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
|
||||
<!-- ===== Flash message ===== -->
|
||||
<div t-if="state.message"
|
||||
t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
|
||||
<span t-esc="state.message"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_grid">
|
||||
<div class="o_fp_tablet_card" t-if="state.currentTank">
|
||||
<div class="o_fp_tablet_card_label">Tank</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentTank.name"/>
|
||||
<!-- ===== KPI strip ===== -->
|
||||
<div class="o_fp_kpi_strip" t-if="state.overview">
|
||||
<t t-foreach="state.overview.kpis" t-as="k" t-key="k.label">
|
||||
<div t-att-class="'o_fp_kpi o_fp_kpi_' + k.tone">
|
||||
<i t-att-class="'fa ' + k.icon"/>
|
||||
<div class="o_fp_kpi_value"><t t-esc="k.value"/></div>
|
||||
<div class="o_fp_kpi_label"><t t-esc="k.label"/></div>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentTank.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta" t-if="state.currentTank.current_bath_name">
|
||||
Bath: <t t-esc="state.currentTank.current_bath_name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
Queue: <t t-esc="state.currentTank.queue_size"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- ===== Active WO banner (only when one is running) ===== -->
|
||||
<div class="o_fp_active_wo"
|
||||
t-if="state.overview and state.overview.active_wo">
|
||||
<div class="o_fp_active_wo_left">
|
||||
<span class="o_fp_active_wo_pulse"/>
|
||||
<div>
|
||||
<div class="o_fp_active_wo_title">
|
||||
<i class="fa fa-play-circle me-1"/>
|
||||
Active Work Order: <strong t-esc="state.overview.active_wo.name"/>
|
||||
</div>
|
||||
<div class="o_fp_active_wo_meta">
|
||||
MO <t t-esc="state.overview.active_wo.mo_name"/>
|
||||
· <t t-esc="state.overview.active_wo.product_name"/>
|
||||
· Qty <t t-esc="state.overview.active_wo.qty_done"/>/<t t-esc="state.overview.active_wo.qty_total"/>
|
||||
<span t-if="state.overview.active_wo.workcenter" class="ms-2">
|
||||
@ <t t-esc="state.overview.active_wo.workcenter"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
t-on-click="() => openRecord('mrp.workorder', state.overview.active_wo.id)">
|
||||
Open WO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_card" t-if="state.currentBath">
|
||||
<div class="o_fp_tablet_card_label">Bath</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentBath.name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentBath.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta" t-if="state.currentBath.tank_name">
|
||||
Tank: <t t-esc="state.currentBath.tank_name"/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ===== Main grid ===== -->
|
||||
<div class="o_fp_tablet_dashboard" t-if="state.overview">
|
||||
|
||||
<div class="o_fp_bake_window_card"
|
||||
t-if="state.currentJob"
|
||||
t-att-data-status="state.currentJob.state">
|
||||
<div class="o_fp_tablet_card_label">Bake Job</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentJob.name"/>
|
||||
<!-- === LEFT column: My Queue (wide) === -->
|
||||
<section class="o_fp_panel o_fp_panel_queue">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-list-ol me-2"/>My Queue</h3>
|
||||
<span class="o_fp_panel_count">
|
||||
<t t-esc="state.overview.my_queue.length"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentJob.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
Remaining: <t t-esc="state.currentJob.time_remaining"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_actions">
|
||||
<button class="o_fp_big_button"
|
||||
t-if="state.currentJob.state === 'awaiting_bake'"
|
||||
t-on-click="onStartBake">
|
||||
Start Bake
|
||||
</button>
|
||||
<button class="o_fp_big_button"
|
||||
t-if="state.currentJob.state === 'bake_in_progress'"
|
||||
t-on-click="onEndBake">
|
||||
End Bake
|
||||
</button>
|
||||
<div t-if="!state.overview.my_queue.length"
|
||||
class="o_fp_empty">
|
||||
<i class="fa fa-check-circle text-success me-2"/>
|
||||
All caught up.
|
||||
</div>
|
||||
<ul class="o_fp_queue_list" t-if="state.overview.my_queue.length">
|
||||
<t t-foreach="state.overview.my_queue" t-as="row" t-key="row.id">
|
||||
<li class="o_fp_queue_row"
|
||||
t-on-click="() => this.onQueueItemClick(row)">
|
||||
<div class="o_fp_queue_pri" t-att-data-priority="row.priority >= 90 ? 'high' : (row.priority >= 70 ? 'med' : 'low')">
|
||||
<t t-if="row.priority >= 90">HI</t>
|
||||
<t t-elif="row.priority >= 70">M</t>
|
||||
<t t-else="">·</t>
|
||||
</div>
|
||||
<div class="o_fp_queue_body">
|
||||
<div class="o_fp_queue_label"><t t-esc="row.label"/></div>
|
||||
<div class="o_fp_queue_desc"><t t-esc="row.description"/></div>
|
||||
</div>
|
||||
<i class="fa fa-chevron-right text-muted"/>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- === RIGHT column: Baths + Bakes + Gates + Holds === -->
|
||||
<div class="o_fp_right_col">
|
||||
|
||||
<!-- Baths -->
|
||||
<section class="o_fp_panel">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-flask me-2"/>Baths</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.baths.length"/></span>
|
||||
</div>
|
||||
<div t-if="!state.overview.baths.length" class="o_fp_empty">
|
||||
No baths configured.
|
||||
</div>
|
||||
<div class="o_fp_tile_grid" t-if="state.overview.baths.length">
|
||||
<t t-foreach="state.overview.baths" t-as="b" t-key="b.id">
|
||||
<div class="o_fp_tile"
|
||||
t-att-data-tone="stateBadge(b.last_log_status || b.state)"
|
||||
t-on-click="() => this.openRecord('fusion.plating.bath', b.id)">
|
||||
<div class="o_fp_tile_title"><t t-esc="b.name"/></div>
|
||||
<div class="o_fp_tile_meta">
|
||||
Tank <t t-esc="b.tank || '—'"/>
|
||||
</div>
|
||||
<div class="o_fp_tile_chips">
|
||||
<span t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(b.state)">
|
||||
<t t-esc="b.state"/>
|
||||
</span>
|
||||
<span t-if="b.last_log_status"
|
||||
t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(b.last_log_status)">
|
||||
log: <t t-esc="b.last_log_status"/>
|
||||
</span>
|
||||
<span t-if="b.mto" class="o_fp_chip o_fp_chip_muted">
|
||||
MTO <t t-esc="b.mto"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bake Windows -->
|
||||
<section class="o_fp_panel">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-fire me-2"/>Bake Windows</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.bake_windows.length"/></span>
|
||||
</div>
|
||||
<div t-if="!state.overview.bake_windows.length" class="o_fp_empty">
|
||||
No bakes pending.
|
||||
</div>
|
||||
<ul class="o_fp_bake_list" t-if="state.overview.bake_windows.length">
|
||||
<t t-foreach="state.overview.bake_windows" t-as="bw" t-key="bw.id">
|
||||
<li class="o_fp_bake_row" t-att-data-state="bw.state">
|
||||
<div class="o_fp_bake_main">
|
||||
<div class="o_fp_bake_name">
|
||||
<strong t-esc="bw.name"/>
|
||||
<span class="text-muted ms-1">— <t t-esc="bw.part_ref"/></span>
|
||||
</div>
|
||||
<div class="o_fp_bake_meta text-muted">
|
||||
<t t-esc="bw.customer"/>
|
||||
· Qty <t t-esc="bw.quantity"/>
|
||||
· Lot <t t-esc="bw.lot_ref"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_bake_time">
|
||||
<span t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(bw.state)">
|
||||
<t t-esc="bw.remaining || bw.state"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_bake_actions">
|
||||
<button t-if="bw.state === 'awaiting_bake'"
|
||||
class="btn btn-sm btn-warning"
|
||||
t-on-click="() => this.onStartBake(bw.id)">
|
||||
Start
|
||||
</button>
|
||||
<button t-if="bw.state === 'bake_in_progress'"
|
||||
class="btn btn-sm btn-success"
|
||||
t-on-click="() => this.onEndBake(bw.id)">
|
||||
End
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
t-on-click="() => this.openRecord('fusion.plating.bake.window', bw.id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- First-Piece Gates -->
|
||||
<section class="o_fp_panel">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-flag-checkered me-2"/>First-Piece Gates</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.gates.length"/></span>
|
||||
</div>
|
||||
<div t-if="!state.overview.gates.length" class="o_fp_empty">
|
||||
No pending first-piece inspections.
|
||||
</div>
|
||||
<ul class="o_fp_bake_list" t-if="state.overview.gates.length">
|
||||
<t t-foreach="state.overview.gates" t-as="g" t-key="g.id">
|
||||
<li class="o_fp_bake_row" t-att-data-state="g.result">
|
||||
<div class="o_fp_bake_main">
|
||||
<div class="o_fp_bake_name">
|
||||
<strong t-esc="g.name"/>
|
||||
<span class="text-muted ms-1">— <t t-esc="g.part_ref"/></span>
|
||||
</div>
|
||||
<div class="o_fp_bake_meta text-muted">
|
||||
<t t-esc="g.customer"/>
|
||||
<t t-if="g.bath"> · Bath <t t-esc="g.bath"/></t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_bake_time">
|
||||
<span t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(g.result)">
|
||||
<t t-esc="g.result"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_bake_actions">
|
||||
<button t-if="g.result === 'pending'"
|
||||
class="btn btn-sm btn-success"
|
||||
t-on-click="() => this.onGateResult(g.id, 'pass')">
|
||||
Pass
|
||||
</button>
|
||||
<button t-if="g.result === 'pending'"
|
||||
class="btn btn-sm btn-danger"
|
||||
t-on-click="() => this.onGateResult(g.id, 'fail')">
|
||||
Fail
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
t-on-click="() => this.openRecord('fusion.plating.first.piece.gate', g.id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Quality Holds -->
|
||||
<section class="o_fp_panel" t-if="state.overview.holds.length">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3 class="text-danger"><i class="fa fa-pause-circle me-2"/>Quality Holds</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.holds.length"/></span>
|
||||
</div>
|
||||
<ul class="o_fp_bake_list">
|
||||
<t t-foreach="state.overview.holds" t-as="h" t-key="h.id">
|
||||
<li class="o_fp_bake_row" t-att-data-state="h.state">
|
||||
<div class="o_fp_bake_main">
|
||||
<div class="o_fp_bake_name">
|
||||
<strong t-esc="h.name"/>
|
||||
<span class="text-muted ms-1">— <t t-esc="h.part_ref"/></span>
|
||||
</div>
|
||||
<div class="o_fp_bake_meta text-muted">
|
||||
Qty <t t-esc="h.qty"/>
|
||||
· <t t-esc="h.reason"/>
|
||||
<t t-if="h.operator"> · <t t-esc="h.operator"/></t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_bake_actions">
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
t-on-click="() => this.openRecord('fusion.plating.quality.hold', h.id)">
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_queue">
|
||||
<div class="o_fp_tablet_queue_title">Next Up</div>
|
||||
<div t-if="!state.queueRows.length" class="text-muted">
|
||||
Queue is empty.
|
||||
</div>
|
||||
<ul class="o_fp_tablet_queue_list" t-if="state.queueRows.length">
|
||||
<t t-foreach="state.queueRows" t-as="row" t-key="row.id">
|
||||
<li class="o_fp_tablet_queue_item">
|
||||
<div class="o_fp_tablet_queue_label">
|
||||
<strong t-esc="row.label"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_queue_desc text-muted">
|
||||
<t t-esc="row.description"/>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
<!-- ===== Loading / initial state ===== -->
|
||||
<div t-if="!state.overview" class="o_fp_empty">
|
||||
<i class="fa fa-spinner fa-spin me-2"/>Loading shop-floor data…
|
||||
</div>
|
||||
|
||||
<!-- ===== Footer — server time ===== -->
|
||||
<div class="o_fp_tablet_footer text-muted" t-if="state.overview">
|
||||
<small>
|
||||
Auto-refresh every 30 s · Last sync
|
||||
<t t-esc="state.overview.server_time"/>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
Reference in New Issue
Block a user