feat(fusion_plating_shopfloor): FpPinPad numeric keypad component (P6.2.2)
Reusable 4-digit PIN pad. Auto-submits on the 4th digit via the
onSubmit prop. On wrong PIN, shake animation + dots clear + error
banner (caller controls the message via the returned {ok:false, error}).
Used by FpTabletLock (unlock flow) and FpPinSetup (set/change flow).
Dark-mode SCSS branch follows the same $o-webclient-color-scheme
pattern as the rest of the shopfloor components.
Also registers tech_store + activity_tracker services in the asset
bundle (assets/web.assets_backend) before the pin_pad files, since
the pin_pad/tablet_lock components consume them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
|
||||
# ---- Phase 6.2 tablet PIN gate ----
|
||||
'fusion_plating_shopfloor/static/src/js/services/tech_store.js',
|
||||
'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/pin_pad.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',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpPinPad (shared OWL service)
|
||||
//
|
||||
// Numeric 4-digit PIN pad. Auto-submits on the 4th digit via onSubmit
|
||||
// callback. Used by FpTabletLock unlock flow AND FpPinSetup change flow.
|
||||
//
|
||||
// Props:
|
||||
// onSubmit : (pin: string) => Promise<{ok: boolean, error?: string}>
|
||||
// title : optional header text
|
||||
// subtitle : optional smaller text
|
||||
// onCancel : optional cancel callback (e.g. close modal)
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
export class FpPinPad extends Component {
|
||||
static template = "fusion_plating_shopfloor.PinPad";
|
||||
static props = {
|
||||
onSubmit: { type: Function },
|
||||
title: { type: String, optional: true },
|
||||
subtitle: { type: String, optional: true },
|
||||
onCancel: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
pin: "",
|
||||
submitting: false,
|
||||
error: "",
|
||||
shake: false,
|
||||
});
|
||||
}
|
||||
|
||||
async _press(digit) {
|
||||
if (this.state.submitting) return;
|
||||
if (this.state.pin.length >= 4) return;
|
||||
this.state.pin = this.state.pin + digit;
|
||||
this.state.error = "";
|
||||
if (this.state.pin.length === 4) {
|
||||
await this._submit();
|
||||
}
|
||||
}
|
||||
|
||||
_clear() {
|
||||
this.state.pin = "";
|
||||
this.state.error = "";
|
||||
}
|
||||
|
||||
async _submit() {
|
||||
this.state.submitting = true;
|
||||
try {
|
||||
const result = await this.props.onSubmit(this.state.pin);
|
||||
if (result && !result.ok) {
|
||||
this.state.error = result.error || "Incorrect PIN";
|
||||
this.state.shake = true;
|
||||
setTimeout(() => { this.state.shake = false; }, 400);
|
||||
this.state.pin = "";
|
||||
}
|
||||
} catch (err) {
|
||||
this.state.error = err.message || String(err);
|
||||
this.state.pin = "";
|
||||
} finally {
|
||||
this.state.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
get dots() {
|
||||
// Render 4 dot slots: filled if typed, empty otherwise
|
||||
return [0, 1, 2, 3].map((i) => this.state.pin.length > i);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// =============================================================================
|
||||
// FpPinPad — numeric keypad for tablet lock screen + PIN setup
|
||||
// Dark-mode aware via $o-webclient-color-scheme branch.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_pin-bg-hex: #ffffff;
|
||||
$_pin-key-bg-hex: #f3f4f6;
|
||||
$_pin-key-hover-hex: #e5e7eb;
|
||||
$_pin-border-hex: #d8dadd;
|
||||
$_pin-dot-hex: #d8dadd;
|
||||
$_pin-dot-fill-hex: #1d1d1f;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_pin-bg-hex: #22262d !global;
|
||||
$_pin-key-bg-hex: #2d3138 !global;
|
||||
$_pin-key-hover-hex: #3a3f48 !global;
|
||||
$_pin-border-hex: #424245 !global;
|
||||
$_pin-dot-fill-hex: #f5f5f7 !global;
|
||||
}
|
||||
|
||||
.o_fp_pin_pad {
|
||||
background: $_pin-bg-hex;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; }
|
||||
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #666); text-align: center; }
|
||||
|
||||
.o_fp_pin_dots {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.o_fp_pin_dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: $_pin-dot-hex;
|
||||
transition: background 0.1s ease;
|
||||
&.filled { background: $_pin-dot-fill-hex; }
|
||||
}
|
||||
|
||||
.o_fp_pin_error {
|
||||
color: #ff3b30;
|
||||
font-size: 0.85rem;
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
|
||||
.o_fp_pin_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_pin_key {
|
||||
background: $_pin-key-bg-hex;
|
||||
border: 1px solid $_pin-border-hex;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease, transform 0.05s ease;
|
||||
|
||||
&:hover { background: $_pin-key-hover-hex; }
|
||||
&:active { transform: scale(0.97); }
|
||||
&:disabled { opacity: 0.5; cursor: wait; }
|
||||
}
|
||||
|
||||
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--text-secondary, #666); }
|
||||
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--text-secondary, #666); }
|
||||
|
||||
@keyframes o_fp_pin_shake_kf {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-8px); }
|
||||
50% { transform: translateX(8px); }
|
||||
75% { transform: translateX(-4px); }
|
||||
}
|
||||
.o_fp_pin_shake { animation: o_fp_pin_shake_kf 0.4s ease; }
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.PinPad">
|
||||
<div t-att-class="'o_fp_pin_pad' + (state.shake ? ' o_fp_pin_shake' : '')">
|
||||
<div t-if="props.title" class="o_fp_pin_title" t-esc="props.title"/>
|
||||
<div t-if="props.subtitle" class="o_fp_pin_subtitle" t-esc="props.subtitle"/>
|
||||
|
||||
<div class="o_fp_pin_dots">
|
||||
<t t-foreach="dots" t-as="filled" t-key="filled_index">
|
||||
<span t-att-class="'o_fp_pin_dot' + (filled ? ' filled' : '')"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div t-if="state.error" class="o_fp_pin_error" t-esc="state.error"/>
|
||||
|
||||
<div class="o_fp_pin_grid">
|
||||
<t t-foreach="[1,2,3,4,5,6,7,8,9]" t-as="d" t-key="d">
|
||||
<button class="o_fp_pin_key"
|
||||
t-on-click="() => this._press(String(d))"
|
||||
t-att-disabled="state.submitting">
|
||||
<t t-esc="d"/>
|
||||
</button>
|
||||
</t>
|
||||
<button class="o_fp_pin_key o_fp_pin_key_clear"
|
||||
t-on-click="_clear">Clear</button>
|
||||
<button class="o_fp_pin_key"
|
||||
t-on-click="() => this._press('0')"
|
||||
t-att-disabled="state.submitting">0</button>
|
||||
<button t-if="props.onCancel"
|
||||
class="o_fp_pin_key o_fp_pin_key_cancel"
|
||||
t-on-click="() => this.props.onCancel()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user