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:
gsinghpal
2026-05-23 00:28:01 -04:00
parent 52097ca59b
commit 9584953467
4 changed files with 204 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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