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

@@ -313,6 +313,248 @@ class FpShopfloorController(http.Controller):
'state': hold.state,
}
# ----------------------------------------------------------------------
# Tablet Overview — one-shot dashboard payload
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/tablet_overview', type='jsonrpc', auth='user')
def tablet_overview(self, station_id=None, facility_id=None):
"""Return a rich dashboard snapshot for the Tablet Station page.
Shape:
{
station: {...} or None,
facility: {id, name} or None,
kpis: [{label, value, tone}], # top strip
my_queue: [...], # top 8 queue rows
active_wo: {...} or None, # the one WO currently in progress
baths: [...], # chemistry quick view (top 6)
bake_windows: [...], # top 6 awaiting/in-progress, soonest first
gates: [...], # pending first-piece gates
holds: [...], # open quality holds
stations: [...], # all stations, for the picker
}
"""
env = request.env
user = env.user
# -- Resolve station / facility -----------------------------------
station = None
if station_id:
stn = env['fusion.plating.shopfloor.station'].browse(int(station_id))
if stn.exists():
station = stn
if not facility_id:
facility_id = stn.facility_id.id
fac = None
if facility_id:
fac = env['fusion.plating.facility'].browse(int(facility_id))
# -- KPI counts ---------------------------------------------------
BakeWindow = env['fusion.plating.bake.window']
Gate = env['fusion.plating.first.piece.gate']
Hold = env['fusion.plating.quality.hold']
MrpWO = env.get('mrp.workorder')
def _dom(dom):
return dom + ([('facility_id', '=', fac.id)] if fac else [])
wos_ready = wos_progress = 0
if MrpWO is not None:
wo_base = []
if fac:
wo_base = [('workcenter_id.x_fc_facility_id', '=', fac.id)]
wos_ready = MrpWO.search_count(wo_base + [('state', '=', 'ready')])
wos_progress = MrpWO.search_count(wo_base + [('state', '=', 'progress')])
awaiting = BakeWindow.search_count(_dom([('state', '=', 'awaiting_bake')]))
in_progress_bakes = BakeWindow.search_count(_dom([('state', '=', 'bake_in_progress')]))
missed = BakeWindow.search_count(_dom([('state', '=', 'missed_window')]))
pending_gates = Gate.search_count(_dom([('result', '=', 'pending')]))
open_holds = Hold.search_count([('state', 'in', ('on_hold', 'under_review'))])
kpis = [
{'label': 'WOs Ready', 'value': wos_ready, 'tone': 'info', 'icon': 'fa-hourglass-half'},
{'label': 'WOs In Progress', 'value': wos_progress, 'tone': 'success', 'icon': 'fa-cogs'},
{'label': 'Awaiting Bake', 'value': awaiting, 'tone': 'warning', 'icon': 'fa-fire'},
{'label': 'Missed Windows', 'value': missed, 'tone': 'danger' if missed else 'muted', 'icon': 'fa-exclamation-triangle'},
{'label': 'First-Piece', 'value': pending_gates, 'tone': 'info', 'icon': 'fa-flag-checkered'},
{'label': 'Quality Holds', 'value': open_holds, 'tone': 'danger' if open_holds else 'muted', 'icon': 'fa-pause-circle'},
]
# -- My Queue (top 8) --------------------------------------------
queue_rows = env['fusion.plating.operator.queue'].build_for_user(
user_id=user.id, facility_id=fac.id if fac else None,
)
my_queue = [
{
'id': r.id,
'label': r.label,
'description': r.description,
'priority': r.priority,
'due_at': fields.Datetime.to_string(r.due_at) if r.due_at else '',
'source_model': r.source_model,
'source_id': r.source_id,
}
for r in queue_rows[:8]
]
# -- Active WO for this user -------------------------------------
active_wo = None
if MrpWO is not None:
wo = MrpWO.search([('state', '=', 'progress')], limit=1)
if wo:
prod = wo.production_id
active_wo = {
'id': wo.id,
'name': wo.display_name or wo.name,
'workcenter': wo.workcenter_id.name or '',
'mo_name': prod.name or '',
'product_name': prod.product_id.display_name if prod.product_id else '',
'qty_done': int(getattr(prod, 'qty_produced', None) or 0),
'qty_total': int(prod.product_qty or 0),
'duration': wo.duration or 0,
'step_display': getattr(wo, 'x_fc_step_display', '') or '',
'customer': prod.origin or '',
}
# -- Baths chemistry quick view ----------------------------------
bath_domain = [('state', 'in', ('operational', 'low', 'out_of_spec'))]
if fac:
bath_domain.append(('facility_id', '=', fac.id))
baths = env['fusion.plating.bath'].search(bath_domain, order='last_log_date desc, id', limit=6)
baths_data = [
{
'id': b.id,
'name': b.display_name or b.name,
'tank': b.tank_id.name or '',
'state': b.state or '',
'last_log_date': fields.Datetime.to_string(b.last_log_date) if b.last_log_date else '',
'last_log_status': b.last_log_status or '',
'mto': round(b.mto_count or 0, 2),
}
for b in baths
]
# -- Bake windows (top 6 awaiting/in-progress, soonest first) -----
bw_domain = _dom([('state', 'in', ('awaiting_bake', 'bake_in_progress', 'missed_window'))])
bws = BakeWindow.search(bw_domain, order='bake_required_by asc', limit=6)
bw_data = [
{
'id': bw.id,
'name': bw.name,
'part_ref': bw.part_ref or '',
'lot_ref': bw.lot_ref or '',
'customer': bw.customer_ref or '',
'state': bw.state,
'remaining': bw.time_remaining_display or '',
'required_by': fields.Datetime.to_string(bw.bake_required_by) if bw.bake_required_by else '',
'quantity': bw.quantity or 0,
}
for bw in bws
]
# -- First-piece gates (top 6 pending or recently failed) ---------
gate_domain = _dom([('result', 'in', ('pending', 'fail'))])
gates = Gate.search(gate_domain, order='first_piece_produced desc', limit=6)
gates_data = [
{
'id': g.id,
'name': g.name,
'part_ref': g.part_ref or '',
'customer': g.customer_ref or '',
'bath': g.bath_id.name or '',
'result': g.result,
'first_piece': fields.Datetime.to_string(g.first_piece_produced) if g.first_piece_produced else '',
'inspector': g.inspector_id.name or '',
}
for g in gates
]
# -- Quality holds (top 6 open) -----------------------------------
hold_domain = [('state', 'in', ('on_hold', 'under_review'))]
holds = Hold.search(hold_domain, order='create_date desc', limit=6)
holds_data = [
{
'id': h.id,
'name': h.name,
'part_ref': h.part_ref or '',
'qty': h.qty_on_hold or 0,
'reason': dict(h._fields['hold_reason'].selection).get(h.hold_reason, h.hold_reason or ''),
'state': h.state,
'operator': h.operator_id.name or '',
}
for h in holds
]
# -- All stations for picker --------------------------------------
station_list = env['fusion.plating.shopfloor.station'].search([], order='facility_id, name')
stations = [
{
'id': s.id,
'name': s.name,
'code': s.code or '',
'facility': s.facility_id.name or '',
'work_center': s.work_center_id.name or '',
'current_operator': s.current_operator_id.name or '',
}
for s in station_list
]
return {
'ok': True,
'station': {
'id': station.id,
'name': station.name,
'code': station.code or '',
'facility': station.facility_id.name or '',
'work_center': station.work_center_id.name or '',
} if station else None,
'facility': {
'id': fac.id,
'name': fac.name,
} if fac else None,
'user_name': user.name,
'kpis': kpis,
'my_queue': my_queue,
'active_wo': active_wo,
'baths': baths_data,
'bake_windows': bw_data,
'gates': gates_data,
'holds': holds_data,
'stations': stations,
'server_time': fields.Datetime.to_string(fields.Datetime.now()),
}
# ----------------------------------------------------------------------
# Pair a station (bumps current_operator_id + last_ping)
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/pair_station', type='jsonrpc', auth='user')
def pair_station(self, station_id):
stn = request.env['fusion.plating.shopfloor.station'].browse(int(station_id))
if not stn.exists():
return {'ok': False, 'error': 'Station not found.'}
stn.write({
'current_operator_id': request.env.user.id,
'last_ping': fields.Datetime.now(),
})
return {'ok': True}
# ----------------------------------------------------------------------
# Mark a first-piece gate result from the tablet
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
def mark_gate(self, gate_id, result):
gate = request.env['fusion.plating.first.piece.gate'].browse(int(gate_id))
if not gate.exists():
return {'ok': False, 'error': 'Gate not found.'}
if result == 'pass':
gate.action_mark_pass()
elif result == 'fail':
gate.action_mark_fail()
else:
return {'ok': False, 'error': f'Unknown result {result}'}
return {'ok': True, 'state': gate.result}
# ----------------------------------------------------------------------
# Operator queue snapshot
# ----------------------------------------------------------------------

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>

View File

@@ -993,6 +993,143 @@ else:
LOG(f" Already has {bw_count} bake windows — skipping")
# ============================================================
# Phase 12.5: Shop-floor stations + first-piece gates + variety
# Feeds the Tablet Station + First-Piece Gates + bake Kanban demos.
# ============================================================
LOG("Phase 12.5: Shop-floor stations + first-piece gates")
Station = env['fusion.plating.shopfloor.station']
stn_count = Station.search_count([])
if stn_count < 5:
station_defs = [
('Plating Room Tablet 1', 'TAB-PL-01', 'tablet', 'Plating'),
('Bake Oven Tablet', 'TAB-BK-01', 'tablet', 'Bake Oven'),
('Inspection Kiosk', 'TAB-QA-01', 'kiosk', 'Inspection'),
('Shipping Desktop', 'TAB-SH-01', 'desktop', 'Shipping'),
('Receiving Mobile', 'TAB-RC-01', 'mobile', 'Receiving'),
]
for sname, scode, stype, wc_name in station_defs:
if Station.search_count([('code', '=', scode)]):
continue
fp_wc = env['fusion.plating.work.center'].search(
[('name', '=', wc_name)], limit=1,
) if wc_name else False
Station.create({
'name': sname,
'code': scode,
'station_type': stype,
'facility_id': facility.id,
'work_center_id': fp_wc.id if fp_wc else False,
'last_ping': datetime.now() - timedelta(minutes=random.randint(0, 45)),
})
LOG(f" 5 shop-floor stations created")
else:
LOG(f" Already has {stn_count} stations — skipping")
# More bake windows — add a couple active ones for realism
if env['fusion.plating.bake.window'].search_count([]) < 6:
env['fusion.plating.bake.window'].create({
'bath_id': bath_en.id,
'part_ref': 'HW-TOR-5501', 'lot_ref': 'LOT-BW-HW-01',
'customer_ref': 'Honeywell Toronto',
'quantity': 120, 'window_hours': 4.0,
'plate_exit_time': datetime.now() - timedelta(hours=2, minutes=15),
})
env['fusion.plating.bake.window'].create({
'bath_id': bath_en.id,
'part_ref': 'AP-WGL-7200', 'lot_ref': 'LOT-BW-AP-01',
'customer_ref': 'Amphenol Canada',
'quantity': 300, 'window_hours': 4.0,
'plate_exit_time': datetime.now() - timedelta(hours=3, minutes=30),
'bake_start_time': datetime.now() - timedelta(minutes=40),
'state': 'bake_in_progress',
})
env['fusion.plating.bake.window'].create({
'bath_id': bath_en.id,
'part_ref': 'CY-STR-240', 'lot_ref': 'LOT-BW-CY-02',
'customer_ref': 'Cyclone Manufacturing',
'quantity': 60, 'window_hours': 4.0,
'plate_exit_time': datetime.now() - timedelta(minutes=45),
})
LOG(" +3 additional bake windows (awaiting / in-progress)")
# First-piece inspection gates — seed 4 variants
Gate = env['fusion.plating.first.piece.gate']
if Gate.search_count([]) < 4:
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'HW-TOR-5501',
'customer_ref': 'Honeywell Toronto',
'routing_first_run': True,
'first_piece_produced': datetime.now() - timedelta(minutes=35),
'result': 'pending',
})
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'AP-WGL-7200',
'customer_ref': 'Amphenol Canada',
'routing_first_run': False,
'first_piece_produced': datetime.now() - timedelta(hours=2),
'first_piece_inspected': datetime.now() - timedelta(hours=1, minutes=40),
'inspector_id': env.user.id,
'result': 'pass',
'rest_of_lot_released': True,
})
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'MG-WG-8801',
'customer_ref': 'Magellan Aerospace',
'routing_first_run': True,
'first_piece_produced': datetime.now() - timedelta(hours=4),
'first_piece_inspected': datetime.now() - timedelta(hours=3, minutes=30),
'inspector_id': env.user.id,
'result': 'pass',
'rest_of_lot_released': False, # passed but awaiting release
'notes': '<p>Thickness 1.95 mils — within tolerance. Lot released pending planner signoff.</p>',
})
Gate.create({
'bath_id': bath_en.id,
'part_ref': 'CY-STR-240',
'customer_ref': 'Cyclone Manufacturing',
'routing_first_run': True,
'first_piece_produced': datetime.now() - timedelta(hours=6),
'first_piece_inspected': datetime.now() - timedelta(hours=5, minutes=30),
'inspector_id': env.user.id,
'result': 'fail',
'notes': '<p>Thickness 0.8 mils — below spec (min 1.2). Rework required.</p>',
})
LOG(" 4 first-piece gates: 1 pending / 1 passed+released / 1 passed-holding / 1 failed")
else:
LOG(f" Already has {Gate.search_count([])} first-piece gates — skipping")
# Quality holds on active MOs — gives the Shop Floor quality-holds panel content
Hold = env['fusion.plating.quality.hold']
if Hold.search_count([]) < 2:
active_mos = env['mrp.production'].search(
[('state', 'in', ('progress', 'confirmed'))], limit=3,
)
hold_reasons = ['out_of_spec', 'damaged', 'contamination']
for i, mo in enumerate(active_mos[:2]):
wo = mo.workorder_ids[:1]
Hold.create({
'part_ref': mo.product_id.default_code or mo.product_id.name,
'qty_on_hold': random.randint(3, 8),
'qty_original': int(mo.product_qty or 10),
'hold_reason': hold_reasons[i % len(hold_reasons)],
'description': f'Demo hold — flagged during in-process inspection on {mo.name}.',
'production_id': mo.id,
'workorder_id': wo.id if wo else False,
'portal_job_id': mo.x_fc_portal_job_id.id if mo.x_fc_portal_job_id else False,
'facility_id': facility.id,
'operator_id': env.user.id,
'state': 'on_hold' if i == 0 else 'under_review',
})
LOG(f" {Hold.search_count([])} quality holds seeded")
else:
LOG(f" Already has {Hold.search_count([])} quality holds — skipping")
# ============================================================
# Phase 13: Quote configurator + win/loss variety
# ============================================================
@@ -1236,6 +1373,9 @@ LOG(f" Payments: {env['account.payment'].search_count([('payment_type', '
LOG(f" Bath logs: {env['fusion.plating.bath.log'].search_count([])}")
LOG(f" Replenishments: {env['fusion.plating.bath.replenishment.suggestion'].search_count([])}")
LOG(f" Bake windows: {env['fusion.plating.bake.window'].search_count([])}")
LOG(f" Stations: {env['fusion.plating.shopfloor.station'].search_count([])}")
LOG(f" First-piece: {env['fusion.plating.first.piece.gate'].search_count([])}")
LOG(f" Quality holds: {env['fusion.plating.quality.hold'].search_count([])}")
LOG(f" Racks: {env['fusion.plating.rack'].search_count([])}")
LOG(f" Operator certs: {env['fp.operator.certification'].search_count([])}")
LOG("")