diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py index ca7016ca..75e9e7fe 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/shopfloor_controller.py @@ -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 # ---------------------------------------------------------------------- diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js index 9738e790..34dc897c 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js @@ -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"; } } diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss index 607f772e..13351080 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss @@ -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; } } diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml index 1009671d..82ab5866 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml @@ -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. -->
+ +
-
Fusion Plating — Shop Floor
-
- Station: +
+
+ Shop Floor Tablet +
+
+ + + + + + + — + + + +
+
+
+ +
-
- -
-
+ +
-
-
-
Tank
-
- + +
+ +
+ +
+
-
- State: -
-
- Bath: -
-
- Queue: + +
+ + +
+
+ +
+
+ + Active Work Order: +
+
+ MO + · + · Qty / + + @ + +
+ +
-
-
Bath
-
- -
-
- State: -
-
- Tank: -
-
+ +
-
-
Bake Job
-
- + +
+
+

My Queue

+ + +
-
- State: -
-
- Remaining: -
-
- - +
+ + All caught up.
+
    + +
  • +
    + HI + M + · +
    +
    +
    +
    +
    + +
  • +
    +
+
+ + +
+ + +
+
+

Baths

+ +
+
+ No baths configured. +
+
+ +
+
+
+ Tank +
+
+ + + + + log: + + + MTO + +
+
+
+
+
+ + +
+
+

Bake Windows

+ +
+
+ No bakes pending. +
+
    + +
  • +
    +
    + + +
    +
    + + · Qty + · Lot +
    +
    +
    + + + +
    +
    + + + +
    +
  • +
    +
+
+ + +
+
+

First-Piece Gates

+ +
+
+ No pending first-piece inspections. +
+
    + +
  • +
    +
    + + +
    +
    + + · Bath +
    +
    +
    + + + +
    +
    + + + +
    +
  • +
    +
+
+ + +
+
+

Quality Holds

+ +
+
    + +
  • +
    +
    + + +
    +
    + Qty + · + · +
    +
    +
    + +
    +
  • +
    +
+
-
-
Next Up
-
- Queue is empty. -
-
    - -
  • -
    - -
    -
    - -
    -
  • -
    -
+ +
+ Loading shop-floor data… +
+ + +
diff --git a/fusion_plating/scripts/fp_demo_seed.py b/fusion_plating/scripts/fp_demo_seed.py index 934ee330..7dc5328f 100644 --- a/fusion_plating/scripts/fp_demo_seed.py +++ b/fusion_plating/scripts/fp_demo_seed.py @@ -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': '

Thickness 1.95 mils — within tolerance. Lot released pending planner signoff.

', + }) + 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': '

Thickness 0.8 mils — below spec (min 1.2). Rework required.

', + }) + 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("")