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:
gsinghpal
2026-05-23 00:29:24 -04:00
parent 1fdafd34d1
commit 29821bd541
4 changed files with 284 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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