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/scss/components/_idle_warning.scss',
|
||||||
'fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml',
|
'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/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) ----
|
# ---- Job Workspace (Phase 1 — tablet redesign) ----
|
||||||
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
|
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
|
||||||
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
|
'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