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:
gsinghpal
2026-04-17 07:43:10 -04:00
parent 3b5b5cbf7c
commit e07002d550
5 changed files with 1079 additions and 353 deletions

View File

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

View File

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

View File

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