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:
gsinghpal
2026-06-03 08:37:11 -04:00
parent ae256b4480
commit 5424c785d9
12 changed files with 514 additions and 14 deletions

View File

@@ -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',

View File

@@ -10,3 +10,4 @@ from . import workspace_controller
from . import landing_controller
from . import tablet_controller
from . import plant_kanban
from . import racking_controller

View File

@@ -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)

View File

@@ -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'))),
}
# ======================================================================

View File

@@ -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 });
}
}

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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%; }
}
}

View File

@@ -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); }
}

View File

@@ -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>

View File

@@ -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">