feat(shopfloor): tablet_lock branches on tablet_session_mode

When ir.config_parameter[fp.shopfloor.tablet_session_mode]='session_swap',
PIN submit calls /fp/tablet/unlock_session and reloads the page; the
new session manager service kicks in on next mount. handOff() calls
lockBack('manual') which destroys the tech session server-side and
re-auths as kiosk.

Legacy mode unchanged — same /fp/tablet/unlock + techStore flow.

The feature flag, kiosk_uid, and current_uid arrive via the existing
/fp/tablet/tiles bootstrap response (Task D0).

Adds a tablet_lock-owned Hand-Off button visible only in session_swap
mode (in legacy mode wrapper components own their own buttons that hit
techStore.lock(); session_swap renders our own button so the manual
hand-off goes through lockBack() + page reload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 13:17:53 -04:00
parent b41d9629e1
commit d8456fb9a3
2 changed files with 69 additions and 0 deletions

View File

@@ -34,6 +34,9 @@ export class FpTabletLock extends Component {
this.techStore = useService("fp_shopfloor_tech_store");
this.activity = useService("fp_shopfloor_activity");
this.notification = useService("notification");
// Phase D: idle + ceiling timer for session_swap mode. Started
// once tiles bootstrap shows we're already on a tech session.
this.tabletSessionManager = useService("fp_tablet_session_manager");
this.state = useState({
tiles: [],
@@ -46,6 +49,10 @@ export class FpTabletLock extends Component {
clockText: this._formatTime(new Date()),
dateText: this._formatDate(new Date()),
company: null,
// Phase D — feature flag + kiosk identity from bootstrap
sessionMode: "legacy", // 'legacy' or 'session_swap'
kioskUid: null,
currentUid: null,
});
onMounted(async () => {
@@ -65,16 +72,33 @@ export class FpTabletLock extends Component {
this.state.clockText = this._formatTime(now);
this.state.dateText = this._formatDate(now);
}, 60000);
// Session-swap mode: if we're already on a TECH session (uid
// != kiosk), start the idle/ceiling timer immediately. This
// handles the case where the page was reloaded after
// unlock_session minted the tech's session.
if (this.state.sessionMode === "session_swap"
&& this.state.currentUid
&& this.state.currentUid !== this.state.kioskUid) {
this.tabletSessionManager.beginSession();
}
});
onWillUnmount(() => {
if (this._tick) clearInterval(this._tick);
if (this._ping) clearInterval(this._ping);
if (this._clockInterval) clearInterval(this._clockInterval);
this.tabletSessionManager.endSession();
});
}
get isLocked() {
// SESSION-SWAP MODE: the BROWSER session itself tells us whether
// a tech is unlocked — current_uid != kiosk_uid means unlocked.
// LEGACY MODE: defer to the techStore client-side flag.
if (this.state.sessionMode === "session_swap") {
return !this.state.currentUid
|| this.state.currentUid === this.state.kioskUid;
}
return this.techStore.isLocked;
}
@@ -85,6 +109,11 @@ export class FpTabletLock extends Component {
const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
if (res && res.ok) {
this.state.company = res.company || null;
// Phase D — capture session_mode + kiosk/current uids so
// unlock() / isLocked / handOff can branch on mode.
this.state.sessionMode = res.tablet_session_mode || "legacy";
this.state.kioskUid = res.kiosk_uid || null;
this.state.currentUid = res.current_uid || null;
// Decorate each tile with an animation-delay (50ms staggered,
// capped at 300ms so the screen doesn't take 3s to settle on
// shops with 20+ operators).
@@ -127,6 +156,25 @@ export class FpTabletLock extends Component {
async unlock(pin) {
try {
// SESSION-SWAP MODE: call the new endpoint, then reload the
// page so the browser re-bootstraps under the tech's session.
if (this.state.sessionMode === "session_swap") {
const res = await rpc("/fp/tablet/unlock_session", {
user_id: this.state.selectedTileUserId,
pin,
});
if (res && res.ok) {
// Cookie has swapped. Reload so OWL/services re-init
// under the new (tech) session. The session manager
// (Task D1) picks up on the next page load.
window.location.reload();
// Return a pending state so the caller doesn't try to
// navigate while we're tearing down.
return { ok: true, reloading: true };
}
return { ok: false, error: (res && res.error) || "Unlock failed" };
}
// LEGACY MODE: existing /fp/tablet/unlock path
const res = await rpc("/fp/tablet/unlock", {
user_id: this.state.selectedTileUserId,
pin,
@@ -148,6 +196,13 @@ export class FpTabletLock extends Component {
}
handOff() {
// SESSION-SWAP MODE: the server destroys the tech session, then
// we reload to re-bootstrap as the kiosk.
if (this.state.sessionMode === "session_swap") {
this.tabletSessionManager.lockBack("manual");
return;
}
// LEGACY MODE: client-side state flip only.
this.techStore.lock();
this.state.selectedTileUserId = null;
this.state.idleSecondsRemaining = null;

View File

@@ -75,6 +75,20 @@
<t t-slot="default"/>
<FpIdleWarning t-if="state.idleSecondsRemaining !== null"
secondsRemaining="state.idleSecondsRemaining"/>
<!-- Phase D: floating Hand-Off button for session_swap mode.
In legacy mode the wrapper components (landing/workspace/
manager) own their own Hand-Off buttons that hit
techStore.lock(). In session_swap mode those become
no-ops (techStore isn't the source of truth anymore),
so we render a tablet_lock-owned button that calls
our own handOff() which routes through the session
manager + page reload. -->
<button t-if="state.sessionMode === 'session_swap'"
class="o_fp_lock_handoff_btn btn btn-warning"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off
</button>
</t>
</t>