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:
@@ -313,6 +313,248 @@ class FpShopfloorController(http.Controller):
|
|||||||
'state': hold.state,
|
'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
|
# Operator queue snapshot
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -5,14 +5,12 @@
|
|||||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
//
|
//
|
||||||
// Odoo 19 conventions:
|
// Odoo 19 conventions:
|
||||||
// * Backend OWL component using `static template` + `static props = []`
|
// * Backend OWL component using `static template` + `static props = ["*"]`.
|
||||||
// (note: empty array, NOT empty object).
|
// * RPC via standalone `rpc()` from @web/core/network/rpc.
|
||||||
// * RPC via standalone `rpc()` from @web/core/network/rpc — NOT useService.
|
// * Registered under registry.category("actions") as "fp_shopfloor_tablet".
|
||||||
// * Registered under registry.category("actions") so the menu / record
|
|
||||||
// action can launch it as a client action ("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 { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
@@ -23,155 +21,179 @@ export class ShopfloorTablet extends Component {
|
|||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
|
this.action = useService("action");
|
||||||
this.scanInput = useRef("scanInput");
|
this.scanInput = useRef("scanInput");
|
||||||
|
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
|
overview: null,
|
||||||
|
stationId: null,
|
||||||
scannedCode: "",
|
scannedCode: "",
|
||||||
station: null,
|
|
||||||
currentTank: null,
|
|
||||||
currentBath: null,
|
|
||||||
currentJob: null,
|
|
||||||
queueRows: [],
|
|
||||||
message: "",
|
message: "",
|
||||||
messageType: "info", // info | success | warning | danger
|
messageType: "info",
|
||||||
loading: false,
|
loading: false,
|
||||||
|
showScan: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await this.refreshQueue();
|
const saved = parseInt(localStorage.getItem("fp_tablet_station_id") || "0") || null;
|
||||||
if (this.scanInput.el) {
|
if (saved) this.state.stationId = saved;
|
||||||
this.scanInput.el.focus();
|
await this.refresh();
|
||||||
}
|
this._interval = setInterval(() => this.refresh(), 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onWillUnmount(() => {
|
||||||
|
if (this._interval) clearInterval(this._interval);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Helpers --------------------------------------------------------
|
// ---------------------------------------------------------- Helpers
|
||||||
setMessage(text, type = "info") {
|
setMessage(text, type = "info") {
|
||||||
this.state.message = text;
|
this.state.message = text;
|
||||||
this.state.messageType = type;
|
this.state.messageType = type;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTargets() {
|
async refresh() {
|
||||||
this.state.currentTank = null;
|
try {
|
||||||
this.state.currentBath = null;
|
const payload = await rpc("/fp/shopfloor/tablet_overview", {
|
||||||
this.state.currentJob = null;
|
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() {
|
async onScan() {
|
||||||
const code = (this.state.scannedCode || "").trim();
|
const code = (this.state.scannedCode || "").trim();
|
||||||
if (!code) {
|
if (!code) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.state.loading = true;
|
this.state.loading = true;
|
||||||
try {
|
try {
|
||||||
const result = await rpc("/fp/shopfloor/scan", { qr_code: code });
|
const result = await rpc("/fp/shopfloor/scan", { qr_code: code });
|
||||||
if (!result || !result.ok) {
|
if (!result || !result.ok) {
|
||||||
this.setMessage(
|
this.setMessage((result && result.error) || "Unrecognised QR code", "danger");
|
||||||
(result && result.error) || "Unrecognised QR code",
|
|
||||||
"danger",
|
|
||||||
);
|
|
||||||
this.state.loading = false;
|
this.state.loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.clearTargets();
|
// If a station was scanned, pair it
|
||||||
switch (result.model) {
|
if (result.model === "fusion.plating.shopfloor.station") {
|
||||||
case "fusion.plating.tank":
|
this.state.stationId = result.id;
|
||||||
this.state.currentTank = result;
|
localStorage.setItem("fp_tablet_station_id", String(result.id));
|
||||||
this.setMessage(
|
this.setMessage(`Station paired: ${result.name}`, "success");
|
||||||
`Tank ${result.name} — ${result.queue_size} in queue`,
|
} else {
|
||||||
"info",
|
this.setMessage(`Scanned ${result.model} — ${result.name || ""}`, "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");
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setMessage(`Scan error: ${err.message || err}`, "danger");
|
this.setMessage(`Scan error: ${err.message || err}`, "danger");
|
||||||
} finally {
|
} finally {
|
||||||
this.state.scannedCode = "";
|
this.state.scannedCode = "";
|
||||||
this.state.loading = false;
|
this.state.loading = false;
|
||||||
if (this.scanInput.el) {
|
await this.refresh();
|
||||||
this.scanInput.el.focus();
|
|
||||||
}
|
|
||||||
await this.refreshQueue();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onScanKey(ev) {
|
onScanKey(ev) {
|
||||||
if (ev.key === "Enter") {
|
if (ev.key === "Enter") this.onScan();
|
||||||
this.onScan();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Bake controls --------------------------------------------------
|
// ---------------------------------------------------------- Actions
|
||||||
async onStartBake() {
|
async openRecord(model, id) {
|
||||||
if (!this.state.currentJob) {
|
if (!model || !id) return;
|
||||||
return;
|
this.action.doAction({
|
||||||
}
|
type: "ir.actions.act_window",
|
||||||
|
res_model: model,
|
||||||
|
res_id: id,
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onStartBake(bwId) {
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/shopfloor/start_bake", {
|
await rpc("/fp/shopfloor/start_bake", { bake_window_id: bwId });
|
||||||
bake_window_id: this.state.currentJob.id,
|
this.setMessage("Bake started.", "success");
|
||||||
});
|
|
||||||
if (res && res.ok) {
|
|
||||||
this.setMessage("Bake started", "success");
|
|
||||||
this.state.currentJob.state = res.state;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setMessage(`Start bake failed: ${err.message || err}`, "danger");
|
this.setMessage(`Start bake failed: ${err.message || err}`, "danger");
|
||||||
}
|
}
|
||||||
await this.refreshQueue();
|
await this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onEndBake() {
|
async onEndBake(bwId) {
|
||||||
if (!this.state.currentJob) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/shopfloor/end_bake", {
|
await rpc("/fp/shopfloor/end_bake", { bake_window_id: bwId });
|
||||||
bake_window_id: this.state.currentJob.id,
|
this.setMessage("Bake complete.", "success");
|
||||||
});
|
|
||||||
if (res && res.ok) {
|
|
||||||
this.setMessage(
|
|
||||||
`Bake complete — ${res.bake_duration_hours.toFixed(2)} h`,
|
|
||||||
"success",
|
|
||||||
);
|
|
||||||
this.state.currentJob.state = res.state;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setMessage(`End bake failed: ${err.message || err}`, "danger");
|
this.setMessage(`End bake failed: ${err.message || err}`, "danger");
|
||||||
}
|
}
|
||||||
await this.refreshQueue();
|
await this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Queue ----------------------------------------------------------
|
async onGateResult(gateId, result) {
|
||||||
async refreshQueue() {
|
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/shopfloor/queue", {});
|
await rpc("/fp/shopfloor/mark_gate", { gate_id: gateId, result });
|
||||||
if (res && res.ok) {
|
this.setMessage(`First-piece marked ${result.toUpperCase()}.`,
|
||||||
this.state.queueRows = res.rows || [];
|
result === "pass" ? "success" : "danger");
|
||||||
}
|
|
||||||
} catch (err) {
|
} 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));
|
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||||
color: var(--bs-body-color);
|
color: var(--bs-body-color);
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 24px;
|
padding: 20px 24px;
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 14px;
|
||||||
|
|
||||||
|
// ---------- Header -------------------------------------------------------
|
||||||
.o_fp_tablet_header {
|
.o_fp_tablet_header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 1px solid var(--bs-border-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_tablet_title {
|
.o_fp_tablet_title {
|
||||||
font-size: 1.6rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--bs-body-color);
|
|
||||||
}
|
}
|
||||||
|
.o_fp_tablet_subtitle {
|
||||||
.o_fp_tablet_station {
|
font-size: 0.95rem;
|
||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
|
.o_fp_tablet_chip {
|
||||||
.o_fp_tablet_scan_row {
|
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;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
|
}
|
||||||
|
.o_fp_station_picker {
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.o_fp_scan_toggle {
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_tablet_message {
|
// ---------- Scan drawer --------------------------------------------------
|
||||||
padding: 14px 18px;
|
.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;
|
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_success { @include fp-shop-tint(--bs-success); }
|
||||||
&.o_fp_msg_warning { @include fp-shop-tint(--bs-warning); }
|
&.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;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
gap: 16px;
|
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));
|
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||||
border: 1px solid var(--bs-border-color);
|
border: 1px solid var(--bs-border-color);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px 18px;
|
padding: 14px 16px;
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
.o_fp_panel_head {
|
||||||
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// 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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
justify-content: space-between;
|
||||||
margin-top: 12px;
|
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"] {
|
// ---------- My Queue list -----------------------------------------------
|
||||||
border-left-color: var(--bs-warning);
|
.o_fp_queue_list {
|
||||||
background-color: color-mix(in srgb, var(--bs-warning) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
list-style: none;
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
&[data-status="bake_in_progress"] {
|
.o_fp_queue_row {
|
||||||
border-left-color: var(--bs-info, var(--o-action));
|
display: grid;
|
||||||
background-color: color-mix(in srgb, var(--bs-info, var(--o-action)) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
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"] {
|
.o_fp_queue_label { font-weight: 600; }
|
||||||
border-left-color: var(--bs-success);
|
.o_fp_queue_desc { font-size: 0.88rem; color: var(--bs-secondary-color); }
|
||||||
background-color: color-mix(in srgb, var(--bs-success) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
.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"] {
|
// ---------- Bath tiles --------------------------------------------------
|
||||||
border-left-color: var(--bs-danger);
|
.o_fp_tile_grid {
|
||||||
background-color: color-mix(in srgb, var(--bs-danger) 8%, var(--o-view-background-color, var(--bs-body-bg)));
|
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 {
|
.o_fp_scan_input {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 56px;
|
min-height: 44px;
|
||||||
padding: 12px 18px;
|
padding: 8px 14px;
|
||||||
font-size: 1.3rem;
|
font-size: 1.05rem;
|
||||||
border: 2px solid var(--bs-border-color);
|
border: 2px solid var(--bs-border-color);
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
background-color: var(--bs-body-bg);
|
background-color: var(--bs-body-bg);
|
||||||
color: var(--bs-body-color);
|
color: var(--bs-body-color);
|
||||||
|
|
||||||
@@ -244,10 +361,7 @@
|
|||||||
border-color: var(--o-action);
|
border-color: var(--o-action);
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--o-action) 25%, transparent);
|
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
|
// Big touch-friendly action button
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
.o_fp_big_button {
|
.o_fp_big_button {
|
||||||
min-height: 56px;
|
min-height: 44px;
|
||||||
min-width: 120px;
|
min-width: 100px;
|
||||||
padding: 12px 24px;
|
padding: 8px 18px;
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--o-action);
|
border: 1px solid var(--o-action);
|
||||||
background-color: var(--o-action);
|
background-color: var(--o-action);
|
||||||
color: var(--o-we-text-on-action, #fff);
|
color: var(--o-we-text-on-action, #fff);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: filter 120ms ease, transform 80ms ease;
|
transition: filter 120ms ease, transform 80ms ease;
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) { filter: brightness(1.05); }
|
||||||
filter: brightness(1.05);
|
&:active:not(:disabled) { transform: translateY(1px); }
|
||||||
}
|
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
&:active:not(:disabled) {
|
|
||||||
transform: translateY(1px);
|
|
||||||
}
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,111 +3,326 @@
|
|||||||
Copyright 2026 Nexa Systems Inc.
|
Copyright 2026 Nexa Systems Inc.
|
||||||
License OPL-1 (Odoo Proprietary License v1.0)
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
Part of the Fusion Plating product family.
|
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">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
<t t-name="fusion_plating_shopfloor.ShopfloorTablet">
|
<t t-name="fusion_plating_shopfloor.ShopfloorTablet">
|
||||||
<div class="o_fp_tablet">
|
<div class="o_fp_tablet">
|
||||||
|
|
||||||
|
<!-- ===== Header — title, station picker, scan toggle ===== -->
|
||||||
<div class="o_fp_tablet_header">
|
<div class="o_fp_tablet_header">
|
||||||
<div class="o_fp_tablet_title">Fusion Plating — Shop Floor</div>
|
<div>
|
||||||
<div class="o_fp_tablet_station" t-if="state.station">
|
<div class="o_fp_tablet_title">
|
||||||
Station: <strong t-esc="state.station.name"/>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="o_fp_tablet_scan_row">
|
<!-- ===== Optional scan drawer ===== -->
|
||||||
<input
|
<div t-if="state.showScan" class="o_fp_scan_drawer">
|
||||||
type="text"
|
<input type="text"
|
||||||
class="o_fp_scan_input"
|
class="o_fp_scan_input"
|
||||||
placeholder="Scan QR code"
|
placeholder="Scan QR code (FP-STATION:…, FP-TANK:…, FP-BATH:…, FP-WO:…)"
|
||||||
t-ref="scanInput"
|
t-ref="scanInput"
|
||||||
t-model="state.scannedCode"
|
t-model="state.scannedCode"
|
||||||
t-on-keydown="onScanKey"
|
t-on-keydown="onScanKey"/>
|
||||||
/>
|
<button class="o_fp_big_button"
|
||||||
<button class="o_fp_big_button" t-on-click="onScan" t-att-disabled="state.loading">
|
t-on-click="onScan"
|
||||||
|
t-att-disabled="state.loading">
|
||||||
Scan
|
Scan
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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"/>
|
<span t-esc="state.message"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="o_fp_tablet_grid">
|
<!-- ===== KPI strip ===== -->
|
||||||
<div class="o_fp_tablet_card" t-if="state.currentTank">
|
<div class="o_fp_kpi_strip" t-if="state.overview">
|
||||||
<div class="o_fp_tablet_card_label">Tank</div>
|
<t t-foreach="state.overview.kpis" t-as="k" t-key="k.label">
|
||||||
<div class="o_fp_tablet_card_value">
|
<div t-att-class="'o_fp_kpi o_fp_kpi_' + k.tone">
|
||||||
<t t-esc="state.currentTank.name"/>
|
<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>
|
||||||
<div class="o_fp_tablet_card_meta">
|
</t>
|
||||||
State: <t t-esc="state.currentTank.state"/>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="o_fp_tablet_card_meta" t-if="state.currentTank.current_bath_name">
|
<!-- ===== Active WO banner (only when one is running) ===== -->
|
||||||
Bath: <t t-esc="state.currentTank.current_bath_name"/>
|
<div class="o_fp_active_wo"
|
||||||
</div>
|
t-if="state.overview and state.overview.active_wo">
|
||||||
<div class="o_fp_tablet_card_meta">
|
<div class="o_fp_active_wo_left">
|
||||||
Queue: <t t-esc="state.currentTank.queue_size"/>
|
<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>
|
||||||
</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">
|
<!-- ===== Main grid ===== -->
|
||||||
<div class="o_fp_tablet_card_label">Bath</div>
|
<div class="o_fp_tablet_dashboard" t-if="state.overview">
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="o_fp_bake_window_card"
|
<!-- === LEFT column: My Queue (wide) === -->
|
||||||
t-if="state.currentJob"
|
<section class="o_fp_panel o_fp_panel_queue">
|
||||||
t-att-data-status="state.currentJob.state">
|
<div class="o_fp_panel_head">
|
||||||
<div class="o_fp_tablet_card_label">Bake Job</div>
|
<h3><i class="fa fa-list-ol me-2"/>My Queue</h3>
|
||||||
<div class="o_fp_tablet_card_value">
|
<span class="o_fp_panel_count">
|
||||||
<t t-esc="state.currentJob.name"/>
|
<t t-esc="state.overview.my_queue.length"/>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_tablet_card_meta">
|
<div t-if="!state.overview.my_queue.length"
|
||||||
State: <t t-esc="state.currentJob.state"/>
|
class="o_fp_empty">
|
||||||
</div>
|
<i class="fa fa-check-circle text-success me-2"/>
|
||||||
<div class="o_fp_tablet_card_meta">
|
All caught up.
|
||||||
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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="o_fp_tablet_queue">
|
<!-- ===== Loading / initial state ===== -->
|
||||||
<div class="o_fp_tablet_queue_title">Next Up</div>
|
<div t-if="!state.overview" class="o_fp_empty">
|
||||||
<div t-if="!state.queueRows.length" class="text-muted">
|
<i class="fa fa-spinner fa-spin me-2"/>Loading shop-floor data…
|
||||||
Queue is empty.
|
</div>
|
||||||
</div>
|
|
||||||
<ul class="o_fp_tablet_queue_list" t-if="state.queueRows.length">
|
<!-- ===== Footer — server time ===== -->
|
||||||
<t t-foreach="state.queueRows" t-as="row" t-key="row.id">
|
<div class="o_fp_tablet_footer text-muted" t-if="state.overview">
|
||||||
<li class="o_fp_tablet_queue_item">
|
<small>
|
||||||
<div class="o_fp_tablet_queue_label">
|
Auto-refresh every 30 s · Last sync
|
||||||
<strong t-esc="row.label"/>
|
<t t-esc="state.overview.server_time"/>
|
||||||
</div>
|
</small>
|
||||||
<div class="o_fp_tablet_queue_desc text-muted">
|
|
||||||
<t t-esc="row.description"/>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</t>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
@@ -993,6 +993,143 @@ else:
|
|||||||
LOG(f" Already has {bw_count} bake windows — skipping")
|
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
|
# 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" Bath logs: {env['fusion.plating.bath.log'].search_count([])}")
|
||||||
LOG(f" Replenishments: {env['fusion.plating.bath.replenishment.suggestion'].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" 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" Racks: {env['fusion.plating.rack'].search_count([])}")
|
||||||
LOG(f" Operator certs: {env['fp.operator.certification'].search_count([])}")
|
LOG(f" Operator certs: {env['fp.operator.certification'].search_count([])}")
|
||||||
LOG("")
|
LOG("")
|
||||||
|
|||||||
Reference in New Issue
Block a user