feat(record-inputs): tap-to-adjust steppers + inputmode keypad hint

Adds [-] / [+] buttons around every numeric input in the Record Inputs
dialog (single-value, dual-entry, and pass_fail+range branches). Tap
to increment / decrement by the recipe-author-derived step size
(stepFor() already computes this from target_min/target_max precision,
falling back to input-type defaults).

- Decrement clamps at 0 (typical qty/time/temp on a plating floor
  doesn't go negative; if needed, operator can still tap the input
  and type a negative value)
- Increment uses _stepRound() to avoid floating-point fuzz on decimals
- Center-aligned monospace-ish input between the buttons for clarity
- inputmode='decimal' (or 'numeric' for time fields) hint so when the
  operator does tap the input, the iPad shows a number keypad instead
  of the full keyboard

Touches single-value, dual-entry (min/max), and pass_fail+range. Other
multi-field widgets (multi-point thickness, bath chemistry panel) still
use plain inputs — separate request if they need steppers too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 19:43:00 -04:00
parent 8d4c85cc52
commit 7dab5fb9c6
4 changed files with 201 additions and 34 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.28.0',
'version': '19.0.10.29.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

@@ -236,6 +236,52 @@ export class FpRecordInputsDialog extends Component {
return "any";
}
// Stepper helpers — give the operator a tap-to-increment / -decrement
// pair next to each numeric input so they don't have to open the
// keyboard for small adjustments. Field is one of: value_number,
// value_min, value_max. Increment uses stepFor() so taps move in the
// same decimal magnitude the recipe spec was written in. Clamps at 0
// (typical qty/time/temp on a plating shop floor doesn't go negative;
// if a recipe needs negatives, the operator can still type the value
// by tapping the input).
_stepDelta(row) {
const s = this.stepFor(row);
if (s === "any") return 1;
const n = parseFloat(s);
return isNaN(n) || n <= 0 ? 1 : n;
}
_stepRound(n, delta) {
// Avoid floating-point fuzz (0.1+0.2=0.30000004). Round to the
// delta's decimal precision.
const decimals = (String(delta).split(".")[1] || "").length;
if (!decimals) return Math.round(n);
const factor = Math.pow(10, decimals);
return Math.round(n * factor) / factor;
}
onIncrement(row, field) {
const cur = parseFloat(row[field]) || 0;
const delta = this._stepDelta(row);
row[field] = this._stepRound(cur + delta, delta);
}
onDecrement(row, field) {
const cur = parseFloat(row[field]) || 0;
const delta = this._stepDelta(row);
row[field] = Math.max(0, this._stepRound(cur - delta, delta));
}
inputModeFor(row) {
// Tablet keyboard hint — show numeric keypad instead of full
// keyboard when the operator does tap the input. 'decimal' is
// safer than 'numeric' because it includes the decimal point
// (needed for pH, thickness, temperature).
const t = row.input_type || "";
if (t === "time_seconds" || t === "time_hms") return "numeric";
return "decimal";
}
_fpCountDecimals(n) {
if (n === null || n === undefined || n === "" || n === 0) return 0;
const s = String(n);

View File

@@ -801,3 +801,49 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
color: $rid-required;
}
}
// Numeric stepper (tap-to-increment / tap-to-decrement around the input).
// Operator can still tap the input to open the keypad (inputmode="decimal"
// gives them the number keypad on iPad).
.o_fp_ri_stepper {
display: flex;
align-items: stretch;
gap: 0;
max-width: 16rem;
}
.o_fp_ri_stepper_btn {
flex: 0 0 auto;
width: 3.2rem;
border: 1px solid #cdd0d4;
background: #f5f6f8;
color: #1d1d1f;
font-size: 1.3rem;
cursor: pointer;
transition: background 80ms;
display: flex;
align-items: center;
justify-content: center;
&:hover, &:active { background: #e5e7eb; }
&.o_fp_ri_stepper_minus {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
border-right: 0;
}
&.o_fp_ri_stepper_plus {
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
border-left: 0;
}
}
.o_fp_ri_stepper_input {
flex: 1 1 auto;
border-radius: 0;
text-align: center;
font-size: 1.2rem;
font-weight: 600;
min-width: 4rem;
}

View File

@@ -120,11 +120,26 @@
<!-- Numeric — single value (no range defined) -->
<div t-if="isNumeric(row) and !hasRangeEntry(row)"
class="o_fp_ri_numeric">
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
t-att-step="stepFor(row)"
t-model.number="row.value_number"
t-att-placeholder="row.target_min or '0.00'"/>
<div class="o_fp_ri_stepper">
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
t-on-click="() => this.onDecrement(row, 'value_number')"
aria-label="Decrease">
<i class="fa fa-minus"/>
</button>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
t-att-inputmode="inputModeFor(row)"
t-att-step="stepFor(row)"
t-model.number="row.value_number"
t-att-placeholder="row.target_min or '0.00'"/>
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
t-on-click="() => this.onIncrement(row, 'value_number')"
aria-label="Increase">
<i class="fa fa-plus"/>
</button>
</div>
<t t-set="hint" t-value="rangeHint(row)"/>
<span t-if="hint"
class="o_fp_ri_range_hint"
@@ -138,22 +153,52 @@
Constrained to numeric so it doesn't duplicate
the pass_fail+range branch above. -->
<div t-if="isNumeric(row) and hasRangeEntry(row)" class="o_fp_ri_dual">
<label class="o_fp_ri_dual_field">
<div class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
<label class="o_fp_ri_dual_field">
<div class="o_fp_ri_stepper">
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
t-on-click="() => this.onDecrement(row, 'value_min')"
aria-label="Decrease">
<i class="fa fa-minus"/>
</button>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
t-att-inputmode="inputModeFor(row)"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
t-on-click="() => this.onIncrement(row, 'value_min')"
aria-label="Increase">
<i class="fa fa-plus"/>
</button>
</div>
</div>
<div class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
<div class="o_fp_ri_stepper">
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
t-on-click="() => this.onDecrement(row, 'value_max')"
aria-label="Decrease">
<i class="fa fa-minus"/>
</button>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
t-att-inputmode="inputModeFor(row)"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
t-on-click="() => this.onIncrement(row, 'value_max')"
aria-label="Increase">
<i class="fa fa-plus"/>
</button>
</div>
</div>
<t t-set="dhint" t-value="dualRangeHint(row)"/>
<span t-if="dhint"
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
@@ -169,22 +214,52 @@
prompt (e.g. Bore inspection: 0.005-0.007 in). -->
<t t-if="isPassFail(row) and hasRangeEntry(row)">
<div class="o_fp_ri_dual">
<label class="o_fp_ri_dual_field">
<div class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
<label class="o_fp_ri_dual_field">
<div class="o_fp_ri_stepper">
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
t-on-click="() => this.onDecrement(row, 'value_min')"
aria-label="Decrease">
<i class="fa fa-minus"/>
</button>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
t-att-inputmode="inputModeFor(row)"
t-att-step="stepFor(row)"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
t-on-click="() => this.onIncrement(row, 'value_min')"
aria-label="Increase">
<i class="fa fa-plus"/>
</button>
</div>
</div>
<div class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
<div class="o_fp_ri_stepper">
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
t-on-click="() => this.onDecrement(row, 'value_max')"
aria-label="Decrease">
<i class="fa fa-minus"/>
</button>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
t-att-inputmode="inputModeFor(row)"
t-att-step="stepFor(row)"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
<button type="button"
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
t-on-click="() => this.onIncrement(row, 'value_max')"
aria-label="Increase">
<i class="fa fa-plus"/>
</button>
</div>
</div>
<t t-set="dhint" t-value="dualRangeHint(row)"/>
<span t-if="dhint"
class="o_fp_ri_range_hint o_fp_ri_dual_hint"