diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py
index 36d6eac3..e53d7548 100644
--- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py
+++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
- 'version': '19.0.36.1.1',
+ 'version': '19.0.37.0.1',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
'description': """
@@ -109,6 +109,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/js/tablet_lock.js',
'fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml',
'fusion_plating_shopfloor/static/src/js/components/pin_setup.js',
+ # ---- Racking panel (multi-rack split, Phase 1 — 2026-06-03) ----
+ # Loaded before job_workspace.js (which imports RackingPanel).
+ 'fusion_plating_shopfloor/static/src/scss/components/_racking_panel.scss',
+ 'fusion_plating_shopfloor/static/src/xml/components/racking_panel.xml',
+ 'fusion_plating_shopfloor/static/src/js/components/racking_panel.js',
# ---- Job Workspace (Phase 1 — tablet redesign) ----
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py
index 17fe6476..fbbdbad4 100644
--- a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py
+++ b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py
@@ -10,3 +10,4 @@ from . import workspace_controller
from . import landing_controller
from . import tablet_controller
from . import plant_kanban
+from . import racking_controller
diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/racking_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/racking_controller.py
new file mode 100644
index 00000000..1028a651
--- /dev/null
+++ b/fusion_plating/fusion_plating_shopfloor/controllers/racking_controller.py
@@ -0,0 +1,91 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+#
+# Multi-rack splitting at Racking — Phase 1 controller. Endpoints run as the
+# technician (request.env.user); the rack-load + division logic lives on
+# fp.rack.load (core + fusion_plating_jobs extension).
+
+from odoo import http
+from odoo.http import request
+from odoo.exceptions import UserError
+
+
+class FpRackingController(http.Controller):
+
+ def _job(self, job_id):
+ return request.env['fp.job'].browse(int(job_id))
+
+ def _payload(self, job):
+ Load = request.env['fp.rack.load']
+ loads = Load._fp_job_loads(job)
+ total = Load._fp_racking_total(job)
+ return {
+ 'ok': True,
+ 'job_id': job.id,
+ 'wo_name': job.display_wo_name,
+ 'total': total,
+ 'unassigned': max(total - sum(loads.mapped('qty_total')), 0),
+ 'loads': [{
+ 'id': load.id,
+ 'name': load.name,
+ 'rack_id': load.rack_id.id or False,
+ 'rack_name': load.rack_id.name or '',
+ 'rack_capacity': load.rack_id.capacity or 0,
+ 'qty': load.qty_total,
+ 'over_capacity': bool(
+ load.rack_id and load.rack_id.capacity
+ and load.qty_total > load.rack_id.capacity),
+ 'moved': bool(load.current_step_id),
+ } for load in loads],
+ }
+
+ @http.route('/fp/racking/load', type='jsonrpc', auth='user')
+ def load(self, job_id):
+ job = self._job(job_id)
+ request.env['fp.rack.load']._fp_ensure_seeded(job)
+ return self._payload(job)
+
+ @http.route('/fp/racking/add_rack', type='jsonrpc', auth='user')
+ def add_rack(self, job_id):
+ job = self._job(job_id)
+ try:
+ request.env['fp.rack.load']._fp_add_rack(job)
+ except UserError as e:
+ return {'ok': False, 'error': str(e.args[0])}
+ return self._payload(job)
+
+ @http.route('/fp/racking/divide_equally', type='jsonrpc', auth='user')
+ def divide_equally(self, job_id):
+ job = self._job(job_id)
+ request.env['fp.rack.load']._fp_divide_equally(job)
+ return self._payload(job)
+
+ @http.route('/fp/racking/set_qty', type='jsonrpc', auth='user')
+ def set_qty(self, load_id, qty):
+ load = request.env['fp.rack.load'].browse(int(load_id))
+ job = load.line_ids[:1].job_id
+ try:
+ load._fp_set_qty(qty)
+ except UserError as e:
+ return {'ok': False, 'error': str(e.args[0])}
+ return self._payload(job)
+
+ @http.route('/fp/racking/remove_rack', type='jsonrpc', auth='user')
+ def remove_rack(self, load_id):
+ load = request.env['fp.rack.load'].browse(int(load_id))
+ job = load.line_ids[:1].job_id
+ try:
+ load._fp_remove_rack()
+ except UserError as e:
+ return {'ok': False, 'error': str(e.args[0])}
+ return self._payload(job)
+
+ @http.route('/fp/racking/assign_rack', type='jsonrpc', auth='user')
+ def assign_rack(self, load_id, rack_id):
+ load = request.env['fp.rack.load'].browse(int(load_id))
+ rack = request.env['fusion.plating.rack'].browse(int(rack_id))
+ load.rack_id = rack.id
+ if 'racking_state' in rack._fields:
+ rack.racking_state = 'loaded'
+ return self._payload(load.line_ids[:1].job_id)
diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py
index 6357b7af..08dfe0be 100644
--- a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py
+++ b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py
@@ -283,6 +283,14 @@ class FpWorkspaceController(http.Controller):
'is_manager': env.user.has_group(
'fusion_plating.group_fusion_plating_manager',
),
+ # Racking panel (multi-rack split) shows when the WO is at the
+ # racking step and it's the current actionable work. Detect by
+ # area_kind == 'racking' (corrected classification) — NOT
+ # _fp_is_racking_step(), which would also match mis-tagged
+ # De-Racking steps (kind='racking' in the data).
+ 'is_at_racking': bool(job.step_ids.filtered(
+ lambda s: s.area_kind == 'racking'
+ and s.state in ('ready', 'in_progress', 'paused'))),
}
# ======================================================================
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/components/racking_panel.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/components/racking_panel.js
new file mode 100644
index 00000000..bb8367c6
--- /dev/null
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/components/racking_panel.js
@@ -0,0 +1,53 @@
+/** @odoo-module **/
+// Racking panel — split a WO's parts across multiple racks (Phase 1).
+// Lives on the Job Workspace, shown when the WO is at the Racking step.
+import { Component, useState, onWillStart } from "@odoo/owl";
+import { rpc } from "@web/core/network/rpc";
+
+export class RackingPanel extends Component {
+ static template = "fusion_plating_shopfloor.RackingPanel";
+ static props = ["jobId"];
+
+ setup() {
+ this.state = useState({ data: null, error: "", busy: false });
+ onWillStart(() => this.reload());
+ }
+
+ _apply(d) {
+ if (d && d.ok) {
+ this.state.data = d;
+ this.state.error = "";
+ } else {
+ this.state.error = (d && d.error) || "Something went wrong.";
+ }
+ }
+
+ async _call(route, params) {
+ if (this.state.busy) {
+ return;
+ }
+ this.state.busy = true;
+ try {
+ this._apply(await rpc(route, params));
+ } finally {
+ this.state.busy = false;
+ }
+ }
+
+ reload() {
+ return this._call("/fp/racking/load", { job_id: this.props.jobId });
+ }
+ addRack() {
+ return this._call("/fp/racking/add_rack", { job_id: this.props.jobId });
+ }
+ divideEqually() {
+ return this._call("/fp/racking/divide_equally", { job_id: this.props.jobId });
+ }
+ setQty(load, ev) {
+ const qty = parseInt(ev.target.value, 10) || 0;
+ return this._call("/fp/racking/set_qty", { load_id: load.id, qty });
+ }
+ removeRack(load) {
+ return this._call("/fp/racking/remove_rack", { load_id: load.id });
+ }
+}
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
index e55595f1..b52bbae4 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
@@ -30,11 +30,12 @@ import { FpTabletLock } from "./tablet_lock";
import { FpRackPartsDialog } from "./rack_parts_dialog";
import { FpDamageDialog } from "./fp_damage_dialog";
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
+import { RackingPanel } from "./components/racking_panel";
export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"];
- static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog };
+ static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel };
setup() {
this.notification = useService("notification");
@@ -471,6 +472,17 @@ export class FpJobWorkspace extends Component {
async onReceivingBoxCountBlur(rcv, ev) {
const newVal = parseInt(ev.target.value, 10) || 0;
+ await this._saveReceivingBoxCount(rcv, newVal);
+ }
+
+ // +/- stepper on the Boxes-received field. Clamps at 0 and reuses the
+ // same persist path as the typed-in blur handler.
+ async onReceivingBoxStep(rcv, delta) {
+ const newVal = Math.max(0, (rcv.box_count_in || 0) + delta);
+ await this._saveReceivingBoxCount(rcv, newVal);
+ }
+
+ async _saveReceivingBoxCount(rcv, newVal) {
if (newVal === (rcv.box_count_in || 0)) return;
rcv.box_count_in = newVal;
try {
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_racking_panel.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_racking_panel.scss
new file mode 100644
index 00000000..74069ba8
--- /dev/null
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_racking_panel.scss
@@ -0,0 +1,70 @@
+// Racking panel (Job Workspace) — split a WO across racks. Self-contained
+// tokens with a compile-time dark-mode branch (Odoo 19 compiles this file
+// into both web.assets_backend and web.assets_web_dark).
+
+$o-webclient-color-scheme: bright !default;
+
+$_rkp-card-hex: #ffffff;
+$_rkp-border-hex: #d8dadd;
+$_rkp-text-hex: #1d1f1e;
+$_rkp-page-hex: #f3f4f6;
+
+@if $o-webclient-color-scheme == dark {
+ $_rkp-card-hex: #22262d !global;
+ $_rkp-border-hex: #3a3f47 !global;
+ $_rkp-text-hex: #e6e6e6 !global;
+ $_rkp-page-hex: #1a1d21 !global;
+}
+
+.o_fp_racking_panel {
+ background: $_rkp-card-hex;
+ border: 1px solid $_rkp-border-hex;
+ border-left: 4px solid #0071e3;
+ border-radius: 8px;
+ padding: 0.7rem 0.9rem;
+ margin-bottom: 0.7rem;
+ color: $_rkp-text-hex;
+
+ .o_fp_rkp_head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.5rem;
+ }
+ .o_fp_rkp_title { font-weight: 700; }
+ .o_fp_rkp_unassigned {
+ font-size: 0.85rem;
+ color: #1d6e2f;
+ &.has { color: #b06600; font-weight: 600; }
+ }
+ .o_fp_rkp_err {
+ color: #b00018;
+ font-size: 0.85rem;
+ margin-bottom: 0.4rem;
+ }
+ .o_fp_rkp_row {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.4rem 0;
+ border-bottom: 1px solid $_rkp-border-hex;
+ &:last-of-type { border-bottom: 0; }
+ &.over { border-left: 3px solid #ff9f0a; padding-left: 0.4rem; }
+ }
+ .o_fp_rkp_rk { flex: 1; font-weight: 600; }
+ .o_fp_rkp_qty {
+ width: 6rem;
+ text-align: center;
+ font-weight: 600;
+ background: $_rkp-page-hex;
+ }
+ .o_fp_rkp_cap { color: #888; font-size: 0.85rem; }
+ .o_fp_rkp_actions {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 0.6rem;
+ flex-wrap: wrap;
+ }
+}
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss
index 7fcad9a5..4fe22cf5 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss
@@ -219,19 +219,38 @@ $_ws-text-hex: #1d1d1f;
grid-template-columns: 1.7fr 1fr;
overflow: hidden;
- @media (max-width: 900px) { grid-template-columns: 1fr; }
+ // Single column on tablets/phones, and make MAIN itself the one scroll
+ // container — the work (steps/receiving) sits at the top, Notes stack
+ // below and scroll into view when needed. The old layout kept
+ // overflow:hidden with two nested auto-height scroll panes, which
+ // clipped the notes and broke scrolling on narrow screens.
+ @media (max-width: 900px) {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior: contain;
+ }
}
.o_fp_ws_steps {
padding: 0.7rem 1rem;
overflow-y: auto;
border-right: 1px solid $_ws-border-hex;
+
+ // MAIN owns the scroll on narrow screens; don't nest a second one.
+ @media (max-width: 900px) {
+ overflow-y: visible;
+ border-right: none;
+ }
}
.o_fp_ws_side {
padding: 0.7rem 1rem;
overflow-y: auto;
background: $_ws-page-hex;
+
+ @media (max-width: 900px) { overflow-y: visible; }
}
.o_fp_ws_empty {
@@ -385,6 +404,61 @@ $_ws-text-hex: #1d1d1f;
flex-wrap: wrap;
}
+// ===== Phone optimization (2026-06-02) ==============================
+// Shrink the fixed chrome (header + workflow bar + rail) so the operator
+// sees the actual work (receiving / step cards) without scrolling. Notes
+// sit below the work in the single scroll column — present, scroll for more.
+@media (max-width: 600px) {
+ .o_fp_ws_head {
+ padding: 0.45rem 0.7rem;
+ gap: 0.4rem 0.6rem;
+ }
+ .o_fp_ws_head_l, .o_fp_ws_head_r { gap: 0.35rem; }
+ .o_fp_ws_wo { font-size: 1.05rem; }
+ .o_fp_ws_cust, .o_fp_ws_part { font-size: 0.85rem; }
+ .o_fp_ws_back, .o_fp_ws_handoff { padding: 0.4rem 0.7rem; font-size: 0.85rem; }
+ .o_fp_ws_pill { padding: 0.2rem 0.5rem; font-size: 0.76rem; }
+
+ // Workflow bar: tighter + horizontally scrollable so every stage is
+ // reachable (it was clipped) while taking far less vertical room.
+ .o_fp_ws_bar { padding: 0.4rem 0.7rem; gap: 0.5rem; }
+ .o_fp_ws_bar_line {
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+ padding-bottom: 2px;
+ }
+ .o_fp_ws_dot_wrap { min-width: 56px; }
+ .o_fp_ws_dot_wrap .o_fp_ws_bar_dot { width: 14px; height: 14px; }
+ .o_fp_ws_dot_wrap .o_fp_ws_bar_label { font-size: 0.66rem; margin-top: 0.2rem; }
+ .o_fp_ws_next { white-space: nowrap; }
+
+ // Work + notes stack tighter; rail stays tappable but compact.
+ .o_fp_ws_steps, .o_fp_ws_side { padding: 0.5rem 0.6rem; }
+ .o_fp_ws_rail { padding: 0.45rem 0.6rem; gap: 0.4rem; }
+
+ // Receiving card part lines: stack vertically so the Received input and
+ // the Good/Damaged select wrap INSIDE the card instead of overflowing
+ // off the right edge. The part description now wraps across full width.
+ .o_fp_ws_rcv { padding: 0.7rem; }
+ .o_fp_ws_rcv_line {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.45rem;
+ }
+ .o_fp_ws_rcv_line_part { min-width: 0; overflow-wrap: anywhere; }
+ .o_fp_ws_rcv_line_qty {
+ flex-wrap: wrap;
+ gap: 0.5rem 0.75rem;
+ }
+ .o_fp_ws_rcv_line_qty label { flex: 1 1 8rem; }
+ .o_fp_ws_rcv_qty_input { width: auto; flex: 1 1 4rem; min-width: 3.5rem; }
+ .o_fp_ws_rcv_cond_select { width: auto; flex: 1 1 7rem; min-width: 6rem; }
+
+ // Shipping panel fields already wrap; keep them inside the card too.
+ .o_fp_ws_ship_fields label { min-width: 0; }
+}
+
// =============================================================================
// SHIPPING PANEL (tablet receiving+shipping 2026-05-29)
// =============================================================================
@@ -519,6 +593,45 @@ $_ws-text-hex: #1d1d1f;
text-align: center;
}
+// +/- stepper around the Boxes-received input (and any future numeric step
+// field). Big touch targets; the input grows to fill between the buttons.
+.o_fp_ws_stepper {
+ display: flex;
+ align-items: stretch;
+ gap: 0.4rem;
+ max-width: 18rem;
+
+ .o_fp_ws_rcv_box_input {
+ flex: 1 1 auto;
+ max-width: none; // override the standalone 12rem cap inside the stepper
+ margin: 0;
+ }
+}
+
+.o_fp_ws_stepper_btn {
+ flex: 0 0 auto;
+ width: 3rem;
+ min-height: 3rem; // comfortable touch target
+ border: 1px solid $_ws-border-hex;
+ border-radius: 8px;
+ background: linear-gradient(135deg, $_ws-card-hex 0%, $_ws-page-hex 100%);
+ color: $_ws-text-hex;
+ font-size: 1.2rem;
+ font-weight: 700;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: transform 0.1s ease, background 0.1s ease, box-shadow 0.1s ease;
+
+ &:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.12);
+ }
+ &:active { transform: scale(0.95); }
+}
+
.o_fp_ws_rcv_lines {
background: $_ws-page-hex;
border-radius: 6px;
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_kanban.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_kanban.scss
index 3020e352..a3c4eca3 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_kanban.scss
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_kanban.scss
@@ -3,11 +3,16 @@
.o_fp_plant_kanban {
padding: 8px;
background: $plant-bg;
- // Full viewport height + flex column so .board can grow to fill all
- // remaining vertical space. min-height: 100vh would let .board's
- // intrinsic height bubble up and put the horizontal scrollbar
- // mid-page; height + flex pins the scrollbar to the viewport bottom.
- height: 100vh;
+ // Fill the Odoo action area (below the navbar) and own the scroll
+ // internally — NOT 100vh. 100vh is taller than the available area by
+ // the navbar height, so the board bottom + its horizontal scrollbar
+ // overflowed off-screen and scrolling broke — badly on phones, where
+ // Odoo also re-lays-out at the md breakpoint and the scroll gets lost
+ // up the tree. height:100% + internal overflow is the same pattern
+ // job_workspace / manager_dashboard / .o_fp_tablet use. flex column so
+ // .board fills the remaining space under the sticky header.
+ height: 100%;
+ min-height: 0;
display: flex;
flex-direction: column;
color: $plant-text;
@@ -97,7 +102,26 @@
// 8 tiles — Work Orders, At My Station, Bakes Due, On Hold,
// Awaiting QC, Awaiting CoC, Ready to Ship, Overdue.
- .kpi-strip { display: grid; grid-template-columns: repeat(8, 1fr); gap: 8px; }
+ .kpi-strip {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ gap: 8px;
+ // 8 tiles can't fit a narrow row. Drop to 4-up on tablets, then on
+ // phones make it one horizontally-scrollable row so the header stays
+ // short and the board keeps the screen.
+ @media (max-width: 1180px) { grid-template-columns: repeat(4, 1fr); }
+ @media (max-width: 600px) {
+ grid-template-columns: none;
+ grid-auto-flow: column;
+ grid-auto-columns: 42%;
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+ scroll-snap-type: x proximity;
+ padding-bottom: 2px;
+ > * { scroll-snap-align: start; }
+ }
+ }
.search-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.search-input {
@@ -134,7 +158,23 @@
flex: 1 1 auto;
min-height: 0;
overflow-x: auto;
+ overflow-y: hidden; // columns scroll internally, not the board
+ overscroll-behavior-x: contain; // don't bounce the whole page sideways
+ -webkit-overflow-scrolling: touch;
padding-bottom: 4px; // room for the horizontal scrollbar
+ // Tablet: slightly narrower columns so more fit per swipe.
+ @media (max-width: 900px) {
+ grid-template-columns: repeat(9, minmax(260px, 1fr));
+ }
+ // Phone: one full-width stage per screen; swipe between stages.
+ // 86vw leaves a peek of the next column so it's clearly scrollable.
+ // Cards are width:100% so they never overflow the narrower column.
+ @media (max-width: 600px) {
+ grid-template-columns: repeat(9, 86vw);
+ gap: 10px;
+ scroll-snap-type: x proximity;
+ > .col { scroll-snap-align: start; }
+ }
}
// Each .col is now a proper bordered card that runs full board
// height — same visual treatment as Trello / Asana columns. The
@@ -214,3 +254,29 @@
}
}
}
+
+// ===== Responsive — phones / small screens (2026-06-02) ==============
+// Compact the header so the board keeps the screen, and make the toolbar
+// controls full-width + tappable (>=40px) on a phone.
+.o_fp_plant_kanban {
+ @media (max-width: 600px) {
+ padding: 6px;
+
+ .floor-header { padding: 8px; margin-bottom: 6px; gap: 6px; }
+ .floor-title { font-size: 15px; }
+
+ .floor-header-top { gap: 8px; }
+ .floor-controls { gap: 5px; width: 100%; }
+
+ // 3-segment mode toggle spans the row; each segment stays tappable.
+ .mode-toggle { flex: 1 1 100%; }
+ .mode-toggle .mode-btn { flex: 1 1 0; padding: 10px 6px; }
+
+ .station-picker,
+ .toolbar-btn { padding: 10px 12px; }
+
+ // Search fills its own row; filter chips wrap underneath.
+ .search-row { gap: 6px; }
+ .search-input { min-width: 0; flex: 1 1 100%; }
+ }
+}
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss
index 8b2c074b..e1f5456b 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss
@@ -328,3 +328,22 @@
&:hover { color: $lock-text; }
}
}
+
+// ===== Responsive — phones / small screens (2026-06-02) ==============
+// The lock screen is position:fixed + overflow-y:auto, so it already
+// scrolls; it just needs the 5-up operator grid + chrome to step down
+// on narrow screens. Ordered descending max-width so the smaller query
+// wins the cascade.
+@media (max-width: 900px) {
+ .o_fp_lock_tiles { grid-template-columns: repeat(4, 1fr); }
+}
+@media (max-width: 600px) {
+ .o_fp_tablet_lock { padding: 16px 12px; gap: 16px; }
+ .o_fp_lock_tiles { grid-template-columns: repeat(3, 1fr); gap: 10px; }
+ .o_fp_lock_clock { font-size: 34px; }
+ .o_fp_lock_logo_frame { width: 220px; height: 92px; }
+ .o_fp_lock_wizard { padding: 24px 20px; }
+}
+@media (max-width: 380px) {
+ .o_fp_lock_tiles { grid-template-columns: repeat(2, 1fr); }
+}
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/components/racking_panel.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/components/racking_panel.xml
new file mode 100644
index 00000000..fe00be87
--- /dev/null
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/components/racking_panel.xml
@@ -0,0 +1,43 @@
+
+