feat(fusion_plating_shopfloor): mobile responsiveness, boxes stepper, racking panel
- Plant Kanban + Job Workspace made phone-responsive: height:100% + single internal scroll (was 100vh, broke mobile scroll), compact header/workflow bar, receiving part-line stacking so fields don't overflow, responsive lock-screen tile grid. - +/- stepper on the receiving "Boxes received" field. - Multi-rack Racking panel (Phase 1): split a WO's parts across racks (+Add Rack / Divide Equally / manual qty + Unassigned counter) on the Job Workspace, shown only when the WO is at the Racking step (area_kind based, excludes De-Racking). New /fp/racking/* controller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -10,3 +10,4 @@ from . import workspace_controller
|
||||
from . import landing_controller
|
||||
from . import tablet_controller
|
||||
from . import plant_kanban
|
||||
from . import racking_controller
|
||||
|
||||
@@ -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)
|
||||
@@ -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'))),
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.RackingPanel">
|
||||
<div class="o_fp_racking_panel" t-if="state.data">
|
||||
<div class="o_fp_rkp_head">
|
||||
<span class="o_fp_rkp_title">🧰 Racking — split across racks</span>
|
||||
<span t-att-class="'o_fp_rkp_unassigned' + (state.data.unassigned ? ' has' : '')">
|
||||
Unassigned: <t t-esc="state.data.unassigned"/> / <t t-esc="state.data.total"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div t-if="state.error" class="o_fp_rkp_err" t-esc="state.error"/>
|
||||
|
||||
<t t-foreach="state.data.loads" t-as="load" t-key="load.id">
|
||||
<div t-att-class="'o_fp_rkp_row' + (load.over_capacity ? ' over' : '')">
|
||||
<span class="o_fp_rkp_rk">
|
||||
Rack <t t-esc="load_index + 1"/><t t-if="load.rack_name">: <t t-esc="load.rack_name"/></t>
|
||||
</span>
|
||||
<input type="number" inputmode="numeric"
|
||||
class="form-control o_fp_rkp_qty"
|
||||
t-att-value="load.qty"
|
||||
t-att-disabled="load.moved ? 'disabled' : false"
|
||||
t-on-change="(ev) => this.setQty(load, ev)"/>
|
||||
<span t-if="load.rack_capacity" class="o_fp_rkp_cap">
|
||||
/ <t t-esc="load.rack_capacity"/>
|
||||
</span>
|
||||
<button class="btn btn-sm btn-light o_fp_rkp_x"
|
||||
t-att-disabled="load.moved ? 'disabled' : false"
|
||||
t-on-click="() => this.removeRack(load)">✕</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="o_fp_rkp_actions">
|
||||
<button class="btn btn-primary" t-att-disabled="state.busy ? 'disabled' : false"
|
||||
t-on-click="addRack">+ Add Rack</button>
|
||||
<button class="btn btn-light" t-att-disabled="state.busy ? 'disabled' : false"
|
||||
t-on-click="divideEqually">Divide Equally</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -80,6 +80,11 @@
|
||||
<!-- STEP LIST -->
|
||||
<div class="o_fp_ws_steps">
|
||||
|
||||
<!-- Racking: split a WO's parts across multiple racks.
|
||||
Shown only when the WO is at the Racking step. -->
|
||||
<RackingPanel t-if="state.data.is_at_racking"
|
||||
jobId="state.jobId"/>
|
||||
|
||||
<!-- PRE-RECIPE: RECEIVING CARDS (Spec C1+C2 2026-05-24)
|
||||
Renders one card per fp.receiving in state
|
||||
draft/counted on the linked SO. Card disappears
|
||||
@@ -100,11 +105,25 @@
|
||||
<t t-if="rcv.state === 'draft'">
|
||||
<div class="o_fp_ws_rcv_field">
|
||||
<label>Boxes received</label>
|
||||
<input type="number"
|
||||
class="form-control o_fp_ws_rcv_box_input"
|
||||
inputmode="numeric"
|
||||
t-att-value="rcv.box_count_in || ''"
|
||||
t-on-blur="(ev) => this.onReceivingBoxCountBlur(rcv, ev)"/>
|
||||
<div class="o_fp_ws_stepper">
|
||||
<button type="button"
|
||||
class="o_fp_ws_stepper_btn"
|
||||
aria-label="Decrease boxes received"
|
||||
t-on-click="() => this.onReceivingBoxStep(rcv, -1)">
|
||||
<i class="fa fa-minus"/>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="form-control o_fp_ws_rcv_box_input"
|
||||
inputmode="numeric"
|
||||
t-att-value="rcv.box_count_in || ''"
|
||||
t-on-blur="(ev) => this.onReceivingBoxCountBlur(rcv, ev)"/>
|
||||
<button type="button"
|
||||
class="o_fp_ws_stepper_btn"
|
||||
aria-label="Increase boxes received"
|
||||
t-on-click="() => this.onReceivingBoxStep(rcv, 1)">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="rcv.lines.length" class="o_fp_ws_rcv_lines">
|
||||
|
||||
Reference in New Issue
Block a user