Compare commits
7 Commits
phase6_1-p
...
phase6_2-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ca9a58a8c | ||
|
|
d86c120969 | ||
|
|
85609f99cd | ||
|
|
29821bd541 | ||
|
|
1fdafd34d1 | ||
|
|
9584953467 | ||
|
|
52097ca59b |
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.30.0.0',
|
'version': '19.0.30.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||||
'first-piece inspection gates.',
|
'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/scss/components/_kanban_card.scss',
|
||||||
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
|
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
|
||||||
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
|
'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) ----
|
# ---- 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,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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -25,16 +25,18 @@ import { WorkflowChip } from "./components/workflow_chip";
|
|||||||
import { GateViz } from "./components/gate_viz";
|
import { GateViz } from "./components/gate_viz";
|
||||||
import { FpSignaturePad } from "./components/signature_pad";
|
import { FpSignaturePad } from "./components/signature_pad";
|
||||||
import { FpHoldComposer } from "./components/hold_composer";
|
import { FpHoldComposer } from "./components/hold_composer";
|
||||||
|
import { FpTabletLock } from "./tablet_lock";
|
||||||
|
|
||||||
export class FpJobWorkspace extends Component {
|
export class FpJobWorkspace extends Component {
|
||||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer };
|
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
this.action = useService("action");
|
this.action = useService("action");
|
||||||
this.dialog = useService("dialog");
|
this.dialog = useService("dialog");
|
||||||
|
this.techStore = useService("fp_shopfloor_tech_store");
|
||||||
|
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
data: null,
|
data: null,
|
||||||
@@ -76,6 +78,11 @@ export class FpJobWorkspace extends Component {
|
|||||||
this.action.doAction({ type: "ir.actions.act_window_close" });
|
this.action.doAction({ type: "ir.actions.act_window_close" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
|
||||||
|
handOff() {
|
||||||
|
this.techStore.lock();
|
||||||
|
}
|
||||||
|
|
||||||
onJumpToBlocker({ model, id }) {
|
onJumpToBlocker({ model, id }) {
|
||||||
// If the predecessor is in this same workspace, just scroll to it
|
// If the predecessor is in this same workspace, just scroll to it
|
||||||
const inThisJob = (this.state.data.steps || []).find((s) => s.id === id);
|
const inThisJob = (this.state.data.steps || []).find((s) => s.id === id);
|
||||||
|
|||||||
@@ -17,15 +17,17 @@ import { registry } from "@web/core/registry";
|
|||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
import { QrScanner } from "./qr_scanner";
|
import { QrScanner } from "./qr_scanner";
|
||||||
|
import { FpTabletLock } from "./tablet_lock";
|
||||||
|
|
||||||
export class ManagerDashboard extends Component {
|
export class ManagerDashboard extends Component {
|
||||||
static template = "fusion_plating_shopfloor.ManagerDashboard";
|
static template = "fusion_plating_shopfloor.ManagerDashboard";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
static components = { QrScanner };
|
static components = { QrScanner, FpTabletLock };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
this.action = useService("action");
|
this.action = useService("action");
|
||||||
|
this.techStore = useService("fp_shopfloor_tech_store");
|
||||||
|
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
overview: null,
|
overview: null,
|
||||||
@@ -148,6 +150,11 @@ export class ManagerDashboard extends Component {
|
|||||||
this.state.mode = this.state.mode === "quick" ? "detailed" : "quick";
|
this.state.mode = this.state.mode === "quick" ? "detailed" : "quick";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
|
||||||
|
handOff() {
|
||||||
|
this.techStore.lock();
|
||||||
|
}
|
||||||
|
|
||||||
toggleCard(jobId) {
|
toggleCard(jobId) {
|
||||||
this.state.expandedJobId = this.state.expandedJobId === jobId ? null : jobId;
|
this.state.expandedJobId = this.state.expandedJobId === jobId ? null : jobId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -23,6 +23,7 @@ import { rpc } from "@web/core/network/rpc";
|
|||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
import { QrScanner } from "./qr_scanner";
|
import { QrScanner } from "./qr_scanner";
|
||||||
import { FpKanbanCard } from "./components/kanban_card";
|
import { FpKanbanCard } from "./components/kanban_card";
|
||||||
|
import { FpTabletLock } from "./tablet_lock";
|
||||||
|
|
||||||
const LS_STATION_ID = "fp_landing_station_id";
|
const LS_STATION_ID = "fp_landing_station_id";
|
||||||
const LS_MODE = "fp_landing_mode";
|
const LS_MODE = "fp_landing_mode";
|
||||||
@@ -31,11 +32,12 @@ const REFRESH_MS = 15000;
|
|||||||
export class FpShopfloorLanding extends Component {
|
export class FpShopfloorLanding extends Component {
|
||||||
static template = "fusion_plating_shopfloor.ShopfloorLanding";
|
static template = "fusion_plating_shopfloor.ShopfloorLanding";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
static components = { QrScanner, FpKanbanCard };
|
static components = { QrScanner, FpKanbanCard, FpTabletLock };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
this.action = useService("action");
|
this.action = useService("action");
|
||||||
|
this.techStore = useService("fp_shopfloor_tech_store");
|
||||||
|
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
mode: localStorage.getItem(LS_MODE) || "all_plant",
|
mode: localStorage.getItem(LS_MODE) || "all_plant",
|
||||||
@@ -120,6 +122,12 @@ export class FpShopfloorLanding extends Component {
|
|||||||
this.refresh();
|
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 ------------------------------------------------------------
|
// ---- Search ------------------------------------------------------------
|
||||||
onSearchInput(ev) {
|
onSearchInput(ev) {
|
||||||
this.state.search = ev.target.value;
|
this.state.search = ev.target.value;
|
||||||
|
|||||||
@@ -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,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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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,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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
<t t-name="fusion_plating_shopfloor.JobWorkspace">
|
<t t-name="fusion_plating_shopfloor.JobWorkspace">
|
||||||
|
<FpTabletLock>
|
||||||
|
<t t-set-slot="default">
|
||||||
<div class="o_fp_ws">
|
<div class="o_fp_ws">
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
@@ -20,6 +22,12 @@
|
|||||||
<button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
|
<button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
|
||||||
<i class="fa fa-arrow-left"/> Back
|
<i class="fa fa-arrow-left"/> Back
|
||||||
</button>
|
</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_wo"><t t-esc="state.data.job.display_wo_name"/></span>
|
||||||
<span class="o_fp_ws_dot"> · </span>
|
<span class="o_fp_ws_dot"> · </span>
|
||||||
<span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span>
|
<span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span>
|
||||||
@@ -225,6 +233,8 @@
|
|||||||
|
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
</t>
|
||||||
|
</FpTabletLock>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
</templates>
|
</templates>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
<t t-name="fusion_plating_shopfloor.ManagerDashboard">
|
<t t-name="fusion_plating_shopfloor.ManagerDashboard">
|
||||||
|
<FpTabletLock>
|
||||||
|
<t t-set-slot="default">
|
||||||
<div class="o_fp_manager">
|
<div class="o_fp_manager">
|
||||||
|
|
||||||
<!-- ============ Hero ============ -->
|
<!-- ============ Hero ============ -->
|
||||||
@@ -45,6 +47,12 @@
|
|||||||
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
|
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
|
||||||
</button>
|
</button>
|
||||||
<QrScanner cssClass="'btn'"/>
|
<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' : '')"
|
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
|
||||||
t-on-click="toggleMode">
|
t-on-click="toggleMode">
|
||||||
<t t-if="state.mode === 'quick'">Quick View</t>
|
<t t-if="state.mode === 'quick'">Quick View</t>
|
||||||
@@ -583,6 +591,8 @@
|
|||||||
<div>Loading manager data…</div>
|
<div>Loading manager data…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</t>
|
||||||
|
</FpTabletLock>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
</templates>
|
</templates>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
<t t-name="fusion_plating_shopfloor.ShopfloorLanding">
|
<t t-name="fusion_plating_shopfloor.ShopfloorLanding">
|
||||||
|
<FpTabletLock>
|
||||||
|
<t t-set-slot="default">
|
||||||
<div class="o_fp_landing">
|
<div class="o_fp_landing">
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
@@ -62,6 +64,13 @@
|
|||||||
</button>
|
</button>
|
||||||
<QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/>
|
<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 -->
|
<!-- Refresh indicator -->
|
||||||
<span class="o_fp_landing_refresh text-muted">
|
<span class="o_fp_landing_refresh text-muted">
|
||||||
<i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/>
|
<i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/>
|
||||||
@@ -158,6 +167,8 @@
|
|||||||
|
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
</t>
|
||||||
|
</FpTabletLock>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
</templates>
|
</templates>
|
||||||
|
|||||||
@@ -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