feat(fusion_plating_shopfloor): FpTabletLock outer wrapper component (P6.2.4)
Top-level wrapper that renders lock screen (tile grid + PIN pad) when
no tech is signed in, and renders <t t-slot="default"/> otherwise.
Drives the auto-lock countdown via the activity_tracker service +
sends a /fp/tablet/ping heartbeat every 60s while a tech is signed in.
Tiles fetch from /fp/tablet/tiles using the localStorage station id
(set by ShopfloorLanding on QR pair / station picker selection).
State machine for the lock screen body:
loadingTiles → tiles list → tile tapped → PinPad → unlock RPC
↑
onPinCancel → back to tiles
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/idle_warning.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/tablet_lock.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,135 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpTabletLock (top-level wrapper)
|
||||
//
|
||||
// Mounted by Landing / Workspace / Manager Dashboard as their outermost
|
||||
// element. Renders the lock screen (tile grid + PIN pad) when no tech
|
||||
// is signed in; renders <t t-slot="default"/> (the wrapped client
|
||||
// action) otherwise. Also drives the auto-lock countdown + idle warning.
|
||||
//
|
||||
// Usage in a parent template:
|
||||
//
|
||||
// <FpTabletLock>
|
||||
// <t t-set-slot="default">
|
||||
// <div class="o_fp_landing"> ...your existing tree... </div>
|
||||
// </t>
|
||||
// </FpTabletLock>
|
||||
//
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { FpPinPad } from "./components/pin_pad";
|
||||
import { FpIdleWarning } from "./components/idle_warning";
|
||||
|
||||
export class FpTabletLock extends Component {
|
||||
static template = "fusion_plating_shopfloor.TabletLock";
|
||||
static components = { FpPinPad, FpIdleWarning };
|
||||
static props = {
|
||||
slots: { type: Object, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.techStore = useService("fp_shopfloor_tech_store");
|
||||
this.activity = useService("fp_shopfloor_activity");
|
||||
this.notification = useService("notification");
|
||||
|
||||
this.state = useState({
|
||||
tiles: [],
|
||||
selectedTileUserId: null,
|
||||
idleSecondsRemaining: null,
|
||||
loadingTiles: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this._loadTiles();
|
||||
this._tick = setInterval(() => this._checkIdle(), 1000);
|
||||
// Heartbeat ping every 60s — for forensic visibility
|
||||
this._ping = setInterval(() => {
|
||||
if (this.techStore.currentTechId) {
|
||||
rpc("/fp/tablet/ping", { current_tech_id: this.techStore.currentTechId })
|
||||
.catch(() => {});
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._tick) clearInterval(this._tick);
|
||||
if (this._ping) clearInterval(this._ping);
|
||||
});
|
||||
}
|
||||
|
||||
get isLocked() {
|
||||
return this.techStore.isLocked;
|
||||
}
|
||||
|
||||
async _loadTiles() {
|
||||
this.state.loadingTiles = true;
|
||||
try {
|
||||
const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null;
|
||||
const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
|
||||
if (res && res.ok) {
|
||||
this.state.tiles = res.tiles;
|
||||
}
|
||||
} catch (err) {
|
||||
// Quiet fail — tile grid stays empty; user gets prompted
|
||||
} finally {
|
||||
this.state.loadingTiles = false;
|
||||
}
|
||||
}
|
||||
|
||||
_checkIdle() {
|
||||
if (!this.techStore.currentTechId) {
|
||||
this.state.idleSecondsRemaining = null;
|
||||
return;
|
||||
}
|
||||
const remaining = this.activity.getSecondsUntilLock();
|
||||
const warnThreshold = this.activity.getWarnThresholdSec();
|
||||
if (remaining <= 0) {
|
||||
this.handOff();
|
||||
} else if (remaining <= warnThreshold) {
|
||||
this.state.idleSecondsRemaining = remaining;
|
||||
} else if (this.state.idleSecondsRemaining !== null) {
|
||||
this.state.idleSecondsRemaining = null;
|
||||
}
|
||||
}
|
||||
|
||||
onTileClick(userId) {
|
||||
this.state.selectedTileUserId = userId;
|
||||
}
|
||||
|
||||
_selectedTileName() {
|
||||
const tile = this.state.tiles.find(t => t.user_id === this.state.selectedTileUserId);
|
||||
return tile ? tile.name : "";
|
||||
}
|
||||
|
||||
async unlock(pin) {
|
||||
try {
|
||||
const res = await rpc("/fp/tablet/unlock", {
|
||||
user_id: this.state.selectedTileUserId,
|
||||
pin,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.techStore.setTech(res.current_tech_id, res.current_tech_name);
|
||||
this.activity.bump();
|
||||
this.state.selectedTileUserId = null;
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: (res && res.error) || "Unlock failed" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
onPinCancel() {
|
||||
this.state.selectedTileUserId = null;
|
||||
}
|
||||
|
||||
handOff() {
|
||||
this.techStore.lock();
|
||||
this.state.selectedTileUserId = null;
|
||||
this.state.idleSecondsRemaining = null;
|
||||
this._loadTiles();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// =============================================================================
|
||||
// FpTabletLock — lock screen with tile grid + PIN pad overlay
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_lock-bg-hex: #f3f4f6;
|
||||
$_lock-card-hex: #ffffff;
|
||||
$_lock-border-hex: #d8dadd;
|
||||
$_lock-ink-hex: #1d1d1f;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_lock-bg-hex: #1a1d21 !global;
|
||||
$_lock-card-hex: #22262d !global;
|
||||
$_lock-border-hex: #424245 !global;
|
||||
$_lock-ink-hex: #f5f5f7 !global;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: $_lock-bg-hex;
|
||||
color: $_lock-ink-hex;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
z-index: 9000;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_header {
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_loading, .o_fp_tablet_lock_empty {
|
||||
margin: 2rem auto;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile {
|
||||
background: $_lock-card-hex;
|
||||
border: 2px solid $_lock-border-hex;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: border-color 0.1s ease, transform 0.05s ease;
|
||||
|
||||
&:hover { border-color: #0071e3; }
|
||||
&:active { transform: scale(0.98); }
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_name {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_clocked {
|
||||
color: #34c759;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_nopin {
|
||||
color: #ff9f0a;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_pinwrap {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.TabletLock">
|
||||
<t t-if="isLocked">
|
||||
<div class="o_fp_tablet_lock">
|
||||
<div class="o_fp_tablet_lock_header">
|
||||
<h1><i class="fa fa-lock"/> Tap your name to unlock</h1>
|
||||
</div>
|
||||
<div t-if="state.loadingTiles" class="o_fp_tablet_lock_loading">
|
||||
<i class="fa fa-spinner fa-spin"/> Loading…
|
||||
</div>
|
||||
<div t-elif="!state.selectedTileUserId" class="o_fp_tablet_lock_tiles">
|
||||
<t t-if="!state.tiles.length">
|
||||
<div class="o_fp_tablet_lock_empty">
|
||||
No operators configured.
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="state.tiles" t-as="tile" t-key="tile.user_id">
|
||||
<button class="o_fp_tablet_lock_tile"
|
||||
t-on-click="() => this.onTileClick(tile.user_id)">
|
||||
<img class="o_fp_tablet_lock_tile_avatar"
|
||||
t-att-src="tile.avatar_url"
|
||||
t-att-alt="tile.name"/>
|
||||
<div class="o_fp_tablet_lock_tile_name" t-esc="tile.name"/>
|
||||
<span t-if="tile.is_clocked_in" class="o_fp_tablet_lock_tile_clocked">
|
||||
● Clocked in
|
||||
</span>
|
||||
<span t-if="!tile.has_pin" class="o_fp_tablet_lock_tile_nopin">
|
||||
PIN required
|
||||
</span>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
<div t-else="" class="o_fp_tablet_lock_pinwrap">
|
||||
<FpPinPad onSubmit.bind="unlock"
|
||||
title="_selectedTileName()"
|
||||
subtitle="'Enter your 4-digit PIN'"
|
||||
onCancel.bind="onPinCancel"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-slot="default"/>
|
||||
<FpIdleWarning t-if="state.idleSecondsRemaining !== null"
|
||||
secondsRemaining="state.idleSecondsRemaining"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user