Compare commits

..

7 Commits

Author SHA1 Message Date
gsinghpal
6ca9a58a8c chore(fusion_plating_shopfloor): bump 19.0.30.1.0 for Phase 6.2 — lock screen
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
Frontend lock screen ships:
- tech_store + activity_tracker shared OWL services
- FpPinPad, FpIdleWarning, FpPinSetup components
- FpTabletLock outer wrapper
- Wired into Landing/Workspace/Manager + Hand-Off button in each header
- fp_tablet_pin_setup client action for Preferences self-service
2026-05-23 00:33:42 -04:00
gsinghpal
d86c120969 feat(fusion_plating_shopfloor): FpPinSetup client action for self-service PIN (P6.2.6)
Registers fp_tablet_pin_setup as an ir.actions.client tag. Triggered
from res.users preferences via action_open_tablet_pin_setup (added
to res_users.py in P6.1.1). Three-stage flow:

  loading → check if user has existing PIN via search_count
  old     → enter current PIN (skipped if first-time)
  new     → choose new PIN
  confirm → enter new PIN again
  done    → success toast + auto-close 1.5s later

Each stage reuses FpPinPad with a different onSubmit + title. On
mismatch / server error, resets to the first stage with a notification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:33:28 -04:00
gsinghpal
85609f99cd feat(fusion_plating_shopfloor): wire FpTabletLock + Hand-Off into Landing/Workspace/Manager (P6.2.5)
Three OWL client actions all wrap their root in <FpTabletLock>:

  ShopfloorLanding   wraps o_fp_landing
  JobWorkspace       wraps o_fp_ws
  ManagerDashboard   wraps o_fp_manager

Each adds FpTabletLock to static components, imports tech_store, and
gains a handOff() method that calls techStore.lock(). The Hand-Off
button (yellow, lock icon) lands next to the scan/QR controls in each
header — pressing it instantly returns the tablet to the tile grid
without waiting for the idle timer.

Component composition (per spec §6.5):
  FpTabletLock
    if isLocked → tile grid + FpPinPad
    else → existing client action (via <t t-slot="default"/>) + FpIdleWarning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:32:52 -04:00
gsinghpal
29821bd541 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>
2026-05-23 00:29:24 -04:00
gsinghpal
1fdafd34d1 feat(fusion_plating_shopfloor): FpIdleWarning overlay (P6.2.3)
Fixed-position yellow-border overlay + countdown toast shown during
the last N (default 30) seconds before auto-lock. Pure props-driven —
secondsRemaining is the only input; parent (FpTabletLock) decides
when to mount and unmount. Box-shadow pulse animation runs CSS-only
so OWL doesn't need to re-render every tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:28:30 -04:00
gsinghpal
9584953467 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>
2026-05-23 00:28:01 -04:00
gsinghpal
52097ca59b feat(fusion_plating_shopfloor): tech_store + activity_tracker OWL services (P6.2.1)
Two registry-level services:

tech_store    Shared reactive state holding currentTechId after a
              successful PIN unlock. Other components subscribe via
              useService("fp_shopfloor_tech_store") and read
              currentTechId to inject into action RPCs. setTech(id, name)
              on unlock; lock() on auto-lock / Hand-Off.

activity_tracker  Document-level event tracker for pointerdown / touchstart
              / keydown / visibilitychange. Mouse-move alone deliberately
              EXCLUDED — a tool resting on a tablet would otherwise keep
              the session alive indefinitely. Public API:
                bump(), getSecondsUntilLock(), getWarnThresholdSec()
              Reads thresholds from ir.config_parameter at start +
              every 5 min (so manager edits propagate within a shift).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:27:13 -04:00
20 changed files with 857 additions and 4 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.30.0.0',
'version': '19.0.30.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
@@ -82,6 +82,20 @@ 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',
'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',
'fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml',
'fusion_plating_shopfloor/static/src/js/components/pin_setup.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,18 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpIdleWarning (shared OWL service)
//
// Yellow-border overlay + countdown toast shown during the last
// (default 30) seconds before auto-lock. Any pointer/touch event on
// the document elsewhere resets the activity tracker, which causes
// this component's parent (FpTabletLock) to hide the warning.
// =============================================================================
import { Component } from "@odoo/owl";
export class FpIdleWarning extends Component {
static template = "fusion_plating_shopfloor.IdleWarning";
static props = {
secondsRemaining: { type: Number },
};
}

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,97 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpPinSetup (client action `fp_tablet_pin_setup`)
//
// Modal flow for setting OR changing the user's tablet PIN. Triggered
// from res.users preferences via action_open_tablet_pin_setup. Three
// stages: (1) old PIN (only if has_pin), (2) new PIN, (3) confirm new.
// =============================================================================
import { Component, useState, onMounted } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { user } from "@web/core/user";
import { FpPinPad } from "./pin_pad";
export class FpPinSetup extends Component {
static template = "fusion_plating_shopfloor.PinSetup";
static components = { FpPinPad };
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
stage: "loading", // 'loading' | 'old' | 'new' | 'confirm' | 'done'
newPin: "",
hasExistingPin: false,
});
onMounted(() => this._init());
}
async _init() {
// Cheap probe: search_count on the user's own record filtered
// by pin_set_date. Non-manager users can read their own set_date
// (not the hash). If the count is 1, they have a PIN; 0 = no PIN.
try {
const has = await rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_count",
args: [[
["id", "=", user.userId],
["x_fc_tablet_pin_set_date", "!=", false],
]],
kwargs: {},
});
this.state.hasExistingPin = has > 0;
} catch (e) {
this.state.hasExistingPin = false;
}
this.state.stage = this.state.hasExistingPin ? "old" : "new";
}
async onOldPinSubmit(pin) {
// Stash for the final call; set_pin verifies it server-side
this._oldPin = pin;
this.state.stage = "new";
return { ok: true };
}
async onNewPinSubmit(pin) {
this.state.newPin = pin;
this.state.stage = "confirm";
return { ok: true };
}
async onConfirmPinSubmit(pin) {
if (pin !== this.state.newPin) {
return { ok: false, error: "PINs don't match. Try again." };
}
const params = { new_pin: this.state.newPin };
if (this._oldPin) params.old_pin = this._oldPin;
const res = await rpc("/fp/tablet/set_pin", params);
if (res && res.ok) {
this.notification.add("Tablet PIN updated.", { type: "success" });
this.state.stage = "done";
setTimeout(() => this._close(), 1500);
return { ok: true };
}
// Reset back to start on hard error so user can retry cleanly
this.notification.add((res && res.error) || "Failed to set PIN", { type: "danger" });
this._oldPin = null;
this.state.newPin = "";
this.state.stage = this.state.hasExistingPin ? "old" : "new";
return { ok: false, error: (res && res.error) || "Failed" };
}
_close() {
this.action.doAction({ type: "ir.actions.act_window_close" });
}
onCancel() {
this._close();
}
}
registry.category("actions").add("fp_tablet_pin_setup", FpPinSetup);

View File

@@ -25,16 +25,18 @@ import { WorkflowChip } from "./components/workflow_chip";
import { GateViz } from "./components/gate_viz";
import { FpSignaturePad } from "./components/signature_pad";
import { FpHoldComposer } from "./components/hold_composer";
import { FpTabletLock } from "./tablet_lock";
export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer };
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock };
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.dialog = useService("dialog");
this.techStore = useService("fp_shopfloor_tech_store");
this.state = useState({
data: null,
@@ -76,6 +78,11 @@ export class FpJobWorkspace extends Component {
this.action.doAction({ type: "ir.actions.act_window_close" });
}
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
this.techStore.lock();
}
onJumpToBlocker({ model, id }) {
// If the predecessor is in this same workspace, just scroll to it
const inThisJob = (this.state.data.steps || []).find((s) => s.id === id);

View File

@@ -17,15 +17,17 @@ import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner";
import { FpTabletLock } from "./tablet_lock";
export class ManagerDashboard extends Component {
static template = "fusion_plating_shopfloor.ManagerDashboard";
static props = ["*"];
static components = { QrScanner };
static components = { QrScanner, FpTabletLock };
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.techStore = useService("fp_shopfloor_tech_store");
this.state = useState({
overview: null,
@@ -148,6 +150,11 @@ export class ManagerDashboard extends Component {
this.state.mode = this.state.mode === "quick" ? "detailed" : "quick";
}
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
this.techStore.lock();
}
toggleCard(jobId) {
this.state.expandedJobId = this.state.expandedJobId === jobId ? null : jobId;
}

View File

@@ -0,0 +1,73 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Activity Tracker (shared OWL service)
//
// Watches the document for pointer/touch/keydown/visibility events and
// tracks lastActiveAt. FpTabletLock reads getSecondsUntilLock() once per
// second to drive the idle warning + auto-lock transitions.
//
// Threshold reads from ir.config_parameter at service start; refreshes
// every 5 min in case the manager changed it.
// =============================================================================
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
const DEFAULT_IDLE_MIN = 5;
const DEFAULT_WARN_SEC = 30;
export const fpShopfloorActivityTracker = {
async start() {
let lastActiveAt = Date.now();
let idleThresholdMs = DEFAULT_IDLE_MIN * 60 * 1000;
let warnThresholdSec = DEFAULT_WARN_SEC;
async function refreshThreshold() {
try {
const minutes = await rpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fp.shopfloor.tablet_idle_lock_minutes", String(DEFAULT_IDLE_MIN)],
kwargs: {},
});
idleThresholdMs = (parseInt(minutes, 10) || DEFAULT_IDLE_MIN) * 60 * 1000;
const warn = await rpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fp.shopfloor.tablet_warn_seconds_before_lock", String(DEFAULT_WARN_SEC)],
kwargs: {},
});
warnThresholdSec = parseInt(warn, 10) || DEFAULT_WARN_SEC;
} catch (e) {
// keep defaults if RPC fails (e.g. no session yet)
}
}
await refreshThreshold();
setInterval(refreshThreshold, 5 * 60 * 1000);
// Activity = explicit user input. Mouse-move alone DOES NOT count
// because something brushing the screen (a stray glove, a tool
// resting on the tablet) could otherwise keep the session alive.
const bump = () => { lastActiveAt = Date.now(); };
document.addEventListener("pointerdown", bump, { capture: true });
document.addEventListener("touchstart", bump, { capture: true, passive: true });
document.addEventListener("keydown", bump, { capture: true });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") bump();
});
return {
bump,
getSecondsUntilLock() {
return Math.max(0, Math.floor((lastActiveAt + idleThresholdMs - Date.now()) / 1000));
},
getWarnThresholdSec() { return warnThresholdSec; },
getIdleThresholdMs() { return idleThresholdMs; },
getLastActiveAt() { return lastActiveAt; },
};
},
};
registry
.category("services")
.add("fp_shopfloor_activity", fpShopfloorActivityTracker);

View File

@@ -0,0 +1,42 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Tech Store (shared OWL service)
//
// Holds the "current tech of record" for the locked tablet. Set by
// FpTabletLock on successful PIN unlock; cleared on auto-lock / Hand-Off.
// Other components read currentTechId via useService("fp_shopfloor_tech_store")
// and pass it through fpRpc() so server actions credit the right user.
// =============================================================================
import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
export const fpShopfloorTechStore = {
start() {
const state = reactive({
currentTechId: null,
currentTechName: "",
lockedAt: null,
});
return {
get currentTechId() { return state.currentTechId; },
get currentTechName() { return state.currentTechName; },
get isLocked() { return !state.currentTechId; },
setTech(id, name) {
state.currentTechId = id;
state.currentTechName = name;
state.lockedAt = null;
},
lock() {
state.currentTechId = null;
state.currentTechName = "";
state.lockedAt = Date.now();
},
state, // exposed for OWL reactive subscriptions
};
},
};
registry
.category("services")
.add("fp_shopfloor_tech_store", fpShopfloorTechStore);

View File

@@ -23,6 +23,7 @@ import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner";
import { FpKanbanCard } from "./components/kanban_card";
import { FpTabletLock } from "./tablet_lock";
const LS_STATION_ID = "fp_landing_station_id";
const LS_MODE = "fp_landing_mode";
@@ -31,11 +32,12 @@ const REFRESH_MS = 15000;
export class FpShopfloorLanding extends Component {
static template = "fusion_plating_shopfloor.ShopfloorLanding";
static props = ["*"];
static components = { QrScanner, FpKanbanCard };
static components = { QrScanner, FpKanbanCard, FpTabletLock };
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.techStore = useService("fp_shopfloor_tech_store");
this.state = useState({
mode: localStorage.getItem(LS_MODE) || "all_plant",
@@ -120,6 +122,12 @@ export class FpShopfloorLanding extends Component {
this.refresh();
}
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
// Tech walking away: lock the tablet so the next operator must PIN in
this.techStore.lock();
}
// ---- Search ------------------------------------------------------------
onSearchInput(ev) {
this.state.search = ev.target.value;

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,35 @@
// =============================================================================
// FpIdleWarning — yellow-border countdown overlay before auto-lock
// =============================================================================
.o_fp_idle_warning_overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
box-shadow: inset 0 0 0 4px #ff9f0a;
animation: o_fp_idle_pulse 1s ease-in-out infinite alternate;
}
@keyframes o_fp_idle_pulse {
from { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 0.6); }
to { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 1); }
}
.o_fp_idle_warning_toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: #1d1d1f;
color: #ffd585;
padding: 0.6rem 1.2rem;
border-radius: 8px;
font-size: 0.9rem;
z-index: 9999;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
strong { color: #ffb84d; margin: 0 0.2rem; }
> i { margin-right: 0.4rem; }
}

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,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,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.IdleWarning">
<div class="o_fp_idle_warning_overlay">
<div class="o_fp_idle_warning_toast">
<i class="fa fa-clock-o"/>
Locking in <strong t-esc="props.secondsRemaining"/>s · tap anywhere to stay
</div>
</div>
</t>
</templates>

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>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.PinSetup">
<div class="o_fp_pin_setup">
<div t-if="state.stage === 'loading'" class="o_fp_pin_setup_loading">
<i class="fa fa-spinner fa-spin"/> Loading…
</div>
<FpPinPad t-if="state.stage === 'old'"
onSubmit.bind="onOldPinSubmit"
title="'Enter your current PIN'"
onCancel.bind="onCancel"/>
<FpPinPad t-if="state.stage === 'new'"
onSubmit.bind="onNewPinSubmit"
title="'Choose a new 4-digit PIN'"
onCancel.bind="onCancel"/>
<FpPinPad t-if="state.stage === 'confirm'"
onSubmit.bind="onConfirmPinSubmit"
title="'Confirm your new PIN'"
subtitle="'Enter it again to confirm'"
onCancel.bind="onCancel"/>
<div t-if="state.stage === 'done'" class="o_fp_pin_setup_done">
<i class="fa fa-check-circle text-success fa-3x"/>
<h3>PIN updated</h3>
</div>
</div>
</t>
</templates>

View File

@@ -2,6 +2,8 @@
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.JobWorkspace">
<FpTabletLock>
<t t-set-slot="default">
<div class="o_fp_ws">
<!-- Loading state -->
@@ -20,6 +22,12 @@
<button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
<i class="fa fa-arrow-left"/> Back
</button>
<!-- Phase 6.2 — Hand-Off: lock the tablet -->
<button class="btn btn-sm btn-warning ms-2"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off
</button>
<span class="o_fp_ws_wo"><t t-esc="state.data.job.display_wo_name"/></span>
<span class="o_fp_ws_dot"> · </span>
<span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span>
@@ -225,6 +233,8 @@
</t>
</div>
</t>
</FpTabletLock>
</t>
</templates>

View File

@@ -7,6 +7,8 @@
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ManagerDashboard">
<FpTabletLock>
<t t-set-slot="default">
<div class="o_fp_manager">
<!-- ============ Hero ============ -->
@@ -45,6 +47,12 @@
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
</button>
<QrScanner cssClass="'btn'"/>
<!-- Phase 6.2 — Hand-Off: lock the tablet -->
<button class="btn btn-warning"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off
</button>
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
t-on-click="toggleMode">
<t t-if="state.mode === 'quick'">Quick View</t>
@@ -583,6 +591,8 @@
<div>Loading manager data…</div>
</div>
</div>
</t>
</FpTabletLock>
</t>
</templates>

View File

@@ -2,6 +2,8 @@
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ShopfloorLanding">
<FpTabletLock>
<t t-set-slot="default">
<div class="o_fp_landing">
<!-- Loading state -->
@@ -62,6 +64,13 @@
</button>
<QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/>
<!-- Phase 6.2 — Hand-Off: lock the tablet for the next operator -->
<button class="btn btn-sm btn-warning"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off
</button>
<!-- Refresh indicator -->
<span class="o_fp_landing_refresh text-muted">
<i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/>
@@ -158,6 +167,8 @@
</t>
</div>
</t>
</FpTabletLock>
</t>
</templates>

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>