feat(jobs,shopfloor): smart buttons + QR scanner + NFC tank pages
Three connected operator-workflow features for entech.
A. fp.job smart buttons — count fields and action methods for sale
order, steps, deliveries, invoices, payments, quality holds,
certificates, time logs, and portal job. Each is an oe_stat_button
that drills into the matching records, mirroring the sale.order
pattern. Cross-module models are runtime-detected so the form
stays clean when bridge modules are uninstalled.
B. Reusable QR scanner OWL component (`<QrScanner/>`) wired into the
Manager Desk, Tablet Station, Plant Overview, and Process Tree
headers. Click → modal with rear-camera stream (getUserMedia) +
BarcodeDetector live decode → opens the matching fp.job form via
the action service. Falls back to a manual URL paste box on
browsers without BarcodeDetector. Works on iOS 17+ Safari and
Android Chrome. Width uses `min(420px, 92vw)` wrapped in #{} so
dart-sass passes it through verbatim instead of trying to compute
incompatible units at compile time.
C. /fp/tank/<id> public-but-auth-required tank status page for NFC
taps. Renders the tank's current step (in-progress / paused),
queued ready steps, and most recent bath chemistry log (lines
table) on a mobile-first page. URL-based so it works on iOS Safari
without the Web NFC API — the operator taps the NFC tag, the URL
opens in the default browser, the page auto-renders. New
web.assets_frontend bundle entry pulls in the design tokens +
tank_status.scss.
Manifest version bumps: jobs 19.0.5.0.0, shopfloor 19.0.16.0.0.
Tests: 44 pass (3 new smart-button assertions added).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,10 +16,12 @@ import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
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";
|
||||
|
||||
export class ManagerDashboard extends Component {
|
||||
static template = "fusion_plating_shopfloor.ManagerDashboard";
|
||||
static props = ["*"];
|
||||
static components = { QrScanner };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
|
||||
@@ -22,10 +22,12 @@ import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
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";
|
||||
|
||||
export class PlantOverview extends Component {
|
||||
static template = "fusion_plating_shopfloor.PlantOverview";
|
||||
static props = ["*"];
|
||||
static components = { QrScanner };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
|
||||
@@ -24,10 +24,12 @@ import { Component, useState, onMounted } from "@odoo/owl";
|
||||
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";
|
||||
|
||||
export class ProcessTree extends Component {
|
||||
static template = "fusion_plating_shopfloor.ProcessTree";
|
||||
static props = ["*"];
|
||||
static components = { QrScanner };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Reusable QR Scanner OWL Component
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// Renders a single button. On click, opens a modal that streams the rear
|
||||
// camera into a <video> element and uses the browser's BarcodeDetector
|
||||
// API to decode QR codes in real time. When a code is detected, parses
|
||||
// it as a URL, extracts /fp/job/<id> (or /fp/wo/<id> as a legacy alias),
|
||||
// and opens the matching fp.job form via the action service.
|
||||
//
|
||||
// Falls back to a paste-the-URL textbox if BarcodeDetector or
|
||||
// getUserMedia is unavailable (e.g. on insecure origins, older Safari).
|
||||
//
|
||||
// BarcodeDetector is supported on:
|
||||
// * Android Chrome (since 2019)
|
||||
// * iOS Safari 17+ (2023)
|
||||
// * Desktop Chrome / Edge
|
||||
//
|
||||
// Used by Manager Desk, Tablet Station, Plant Overview, and Process Tree
|
||||
// headers — see each component's `static components = { QrScanner }`.
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, useRef, onWillUnmount } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class QrScanner extends Component {
|
||||
static template = "fusion_plating_shopfloor.QrScanner";
|
||||
static props = {
|
||||
label: { type: String, optional: true },
|
||||
cssClass: { type: String, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
label: "Scan",
|
||||
cssClass: "btn btn-secondary",
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
this.notification = useService("notification");
|
||||
this.videoRef = useRef("video");
|
||||
this.state = useState({
|
||||
open: false,
|
||||
error: null,
|
||||
manualUrl: "",
|
||||
supportsBarcode: typeof BarcodeDetector !== "undefined",
|
||||
});
|
||||
this.stream = null;
|
||||
this.decodeLoopActive = false;
|
||||
|
||||
onWillUnmount(() => this._stopCamera());
|
||||
}
|
||||
|
||||
async open() {
|
||||
this.state.open = true;
|
||||
this.state.error = null;
|
||||
await this._startCamera();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.state.open = false;
|
||||
this._stopCamera();
|
||||
}
|
||||
|
||||
async _startCamera() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
this.state.error = "Camera access not available. Use the URL input below.";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: "environment" } },
|
||||
audio: false,
|
||||
});
|
||||
// Wait one paint tick so the t-ref resolves to the <video>
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const v = this.videoRef.el;
|
||||
if (v) {
|
||||
v.srcObject = this.stream;
|
||||
await v.play();
|
||||
}
|
||||
if (this.state.supportsBarcode) {
|
||||
this._decodeLoop();
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.error = "Couldn't access camera: " + (e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
_stopCamera() {
|
||||
this.decodeLoopActive = false;
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach((t) => t.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
async _decodeLoop() {
|
||||
if (!this.state.supportsBarcode) return;
|
||||
const detector = new BarcodeDetector({ formats: ["qr_code"] });
|
||||
this.decodeLoopActive = true;
|
||||
const v = this.videoRef.el;
|
||||
if (!v) return;
|
||||
const tick = async () => {
|
||||
if (!this.decodeLoopActive || !this.state.open) return;
|
||||
try {
|
||||
if (v.readyState >= 2) {
|
||||
const codes = await detector.detect(v);
|
||||
if (codes.length > 0) {
|
||||
this._handleCode(codes[0].rawValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Decode errors are noisy and recoverable — try again
|
||||
// next frame. Real failures (camera revoked, etc.)
|
||||
// surface via _startCamera's catch.
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
onManualSubmit() {
|
||||
if (this.state.manualUrl) {
|
||||
this._handleCode(this.state.manualUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a decoded value (URL or raw id-bearing string) and route to
|
||||
* the matching fp.job form. Accepts /fp/job/<id> and /fp/wo/<id>
|
||||
* (legacy alias from older mrp.workorder stickers — both now point
|
||||
* at fp.job).
|
||||
*/
|
||||
_handleCode(rawValue) {
|
||||
const m = (rawValue || "").match(/\/fp\/(?:job|wo)\/(\d+)/);
|
||||
if (!m) {
|
||||
this.state.error =
|
||||
"QR doesn't look like a job sticker. Got: " +
|
||||
(rawValue || "").slice(0, 80);
|
||||
this.state.manualUrl = "";
|
||||
return;
|
||||
}
|
||||
const jobId = parseInt(m[1], 10);
|
||||
this._stopCamera();
|
||||
this.state.open = false;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.job",
|
||||
res_id: jobId,
|
||||
view_mode: "form",
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
this.notification.add("Opened job " + jobId, { type: "success" });
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,12 @@ import { Component, useState, onMounted, onWillUnmount, useRef } from "@odoo/owl
|
||||
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";
|
||||
|
||||
export class ShopfloorTablet extends Component {
|
||||
static template = "fusion_plating_shopfloor.ShopfloorTablet";
|
||||
static props = ["*"];
|
||||
static components = { QrScanner };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
|
||||
@@ -71,6 +71,12 @@ $pt-line-width : 2px;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
.o_fp_pt_header_actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $fp-space-2;
|
||||
}
|
||||
.o_fp_pt_back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Reusable QR Scanner Modal
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// Mobile-first modal that overlays the page. The video element fills
|
||||
// the body with a fixed aspect ratio so the layout doesn't jump as
|
||||
// the camera initialises.
|
||||
//
|
||||
// All surfaces resolve from the shop-floor design tokens
|
||||
// (_fp_shopfloor_tokens.scss) so light + dark modes both work without
|
||||
// extra rules.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_qr_modal_backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.o_fp_qr_modal {
|
||||
background: $fp-card;
|
||||
color: $fp-ink;
|
||||
border-radius: $fp-radius-lg;
|
||||
box-shadow: $fp-elev-3;
|
||||
// Wrap min() in #{...} so dart-sass doesn't try to compute it at
|
||||
// compile time (it can't combine 420px and 92vw — the clamp/min
|
||||
// functions are CSS-runtime, not SCSS). Pass through verbatim.
|
||||
width: #{"min(420px, 92vw)"};
|
||||
max-width: 92vw;
|
||||
overflow: hidden;
|
||||
font-family: $fp-font-stack;
|
||||
}
|
||||
|
||||
.o_fp_qr_modal_head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $fp-space-3 $fp-space-4;
|
||||
border-bottom: 1px solid $fp-border;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: $fp-text-lg;
|
||||
color: $fp-ink;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_qr_modal_body {
|
||||
padding: $fp-space-4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $fp-space-3;
|
||||
}
|
||||
|
||||
.o_fp_qr_video {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: #000;
|
||||
border-radius: $fp-radius-md;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.o_fp_qr_error,
|
||||
.o_fp_qr_warn {
|
||||
padding: $fp-space-2 $fp-space-3;
|
||||
border-radius: $fp-radius-sm;
|
||||
background: $fp-card-soft;
|
||||
color: $fp-ink-soft;
|
||||
font-size: $fp-text-sm;
|
||||
}
|
||||
|
||||
.o_fp_qr_error {
|
||||
border-left: 3px solid $fp-bad;
|
||||
}
|
||||
|
||||
.o_fp_qr_manual {
|
||||
border-top: 1px solid $fp-border;
|
||||
padding-top: $fp-space-3;
|
||||
|
||||
label {
|
||||
font-size: $fp-text-sm;
|
||||
color: $fp-ink-mute;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background: $fp-card-soft;
|
||||
border: 1px solid $fp-border;
|
||||
color: $fp-ink;
|
||||
|
||||
&:focus {
|
||||
@include fp-focus-ring;
|
||||
border-color: $fp-accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Tank Status (NFC tap-to-view)
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// Mobile-first stylesheet for /fp/tank/<id>. Renders inside
|
||||
// web.frontend_layout. Uses the shop-floor design tokens so light +
|
||||
// dark themes both work without an extra rule set.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_tank_status {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: $fp-space-4;
|
||||
color: $fp-ink;
|
||||
font-family: $fp-font-stack;
|
||||
background: $fp-page;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.o_fp_tank_head {
|
||||
text-align: center;
|
||||
margin-bottom: $fp-space-5;
|
||||
|
||||
h1 {
|
||||
font-size: $fp-text-2xl;
|
||||
margin: 0 0 $fp-space-2;
|
||||
color: $fp-ink;
|
||||
|
||||
i {
|
||||
color: $fp-accent;
|
||||
margin-right: $fp-space-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_tank_meta {
|
||||
color: $fp-ink-mute;
|
||||
font-size: $fp-text-sm;
|
||||
|
||||
span + span::before {
|
||||
content: " · ";
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: $fp-ink-soft;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_tank_section {
|
||||
background: $fp-card-soft;
|
||||
border-radius: $fp-radius-md;
|
||||
padding: $fp-space-4;
|
||||
margin-bottom: $fp-space-4;
|
||||
|
||||
h2 {
|
||||
font-size: $fp-text-md;
|
||||
margin: 0 0 $fp-space-3;
|
||||
color: $fp-ink-soft;
|
||||
|
||||
i {
|
||||
margin-right: $fp-space-2;
|
||||
color: $fp-accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_tank_card {
|
||||
background: $fp-card;
|
||||
border: 1px solid $fp-border;
|
||||
border-radius: $fp-radius-sm;
|
||||
padding: $fp-space-3 $fp-space-4;
|
||||
box-shadow: $fp-elev-1;
|
||||
margin-bottom: $fp-space-2;
|
||||
color: $fp-ink;
|
||||
}
|
||||
|
||||
.o_fp_tank_card_compact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.o_fp_tank_card_title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $fp-space-2;
|
||||
gap: $fp-space-2;
|
||||
}
|
||||
|
||||
.o_fp_tank_card_meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $fp-space-2;
|
||||
font-size: $fp-text-sm;
|
||||
color: $fp-ink-soft;
|
||||
|
||||
span strong {
|
||||
color: $fp-ink-mute;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_tank_card_sub {
|
||||
color: $fp-ink-mute;
|
||||
font-size: $fp-text-sm;
|
||||
}
|
||||
|
||||
.o_fp_tank_empty {
|
||||
color: $fp-ink-mute;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: $fp-space-3 0;
|
||||
}
|
||||
|
||||
// State / status pills — use the same translucent-tint pattern as the
|
||||
// other shop-floor surfaces so they read at a glance on a phone.
|
||||
.o_fp_state_badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: $fp-radius-pill;
|
||||
font-size: $fp-text-xs;
|
||||
text-transform: uppercase;
|
||||
font-weight: $fp-weight-semibold;
|
||||
letter-spacing: 0.5px;
|
||||
background: color-mix(in srgb, #{$fp-ink-soft} 14%, transparent);
|
||||
color: $fp-ink-soft;
|
||||
|
||||
&[data-state="in_progress"] {
|
||||
background: color-mix(in srgb, #{$fp-accent} 18%, transparent);
|
||||
color: $fp-accent;
|
||||
}
|
||||
|
||||
&[data-state="paused"] {
|
||||
background: color-mix(in srgb, #{$fp-warn} 18%, transparent);
|
||||
color: $fp-warn;
|
||||
}
|
||||
|
||||
&[data-state="ok"] {
|
||||
background: color-mix(in srgb, #{$fp-ok} 18%, transparent);
|
||||
color: $fp-ok;
|
||||
}
|
||||
|
||||
&[data-state="warning"] {
|
||||
background: color-mix(in srgb, #{$fp-warn} 18%, transparent);
|
||||
color: $fp-warn;
|
||||
}
|
||||
|
||||
&[data-state="out_of_spec"] {
|
||||
background: color-mix(in srgb, #{$fp-bad} 18%, transparent);
|
||||
color: $fp-bad;
|
||||
}
|
||||
}
|
||||
|
||||
// Bath chemistry grid — one cell per parameter reading.
|
||||
.o_fp_tank_chem_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: $fp-space-2;
|
||||
margin-top: $fp-space-3;
|
||||
border-top: 1px solid $fp-border;
|
||||
padding-top: $fp-space-3;
|
||||
}
|
||||
|
||||
.o_fp_tank_chem_cell {
|
||||
background: $fp-card-soft;
|
||||
border-radius: $fp-radius-sm;
|
||||
padding: $fp-space-2 $fp-space-3;
|
||||
text-align: center;
|
||||
|
||||
&[data-status="ok"] {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, #{$fp-ok} 40%, transparent);
|
||||
}
|
||||
|
||||
&[data-status="warning"] {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, #{$fp-warn} 40%, transparent);
|
||||
}
|
||||
|
||||
&[data-status="out_of_spec"] {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, #{$fp-bad} 50%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_tank_chem_label {
|
||||
font-size: $fp-text-xs;
|
||||
color: $fp-ink-mute;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.o_fp_tank_chem_value {
|
||||
font-size: $fp-text-lg;
|
||||
font-weight: $fp-weight-semibold;
|
||||
color: $fp-ink;
|
||||
margin-top: 2px;
|
||||
|
||||
small {
|
||||
font-size: $fp-text-xs;
|
||||
font-weight: $fp-weight-medium;
|
||||
color: $fp-ink-mute;
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_tank_chem_range {
|
||||
font-size: $fp-text-xs;
|
||||
color: $fp-ink-faint;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.o_fp_tank_foot {
|
||||
text-align: center;
|
||||
color: $fp-ink-faint;
|
||||
font-size: $fp-text-xs;
|
||||
margin-top: $fp-space-6;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
t-att-disabled="state.isFetching">
|
||||
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
|
||||
</button>
|
||||
<QrScanner cssClass="'btn'"/>
|
||||
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
|
||||
t-on-click="toggleMode">
|
||||
<t t-if="state.mode === 'quick'">Quick View</t>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
title="Refresh">
|
||||
<i t-att-class="state.loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"/>
|
||||
</button>
|
||||
<QrScanner cssClass="'btn btn-outline-secondary'"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -106,6 +106,9 @@
|
||||
<span t-if="state.recipe"> · <i class="fa fa-flask me-1"/><t t-esc="state.recipe"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_pt_header_actions">
|
||||
<QrScanner cssClass="'btn btn-outline-secondary'"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== LOADING ========== -->
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
Fusion Plating — Reusable QR Scanner template
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_plating_shopfloor.QrScanner">
|
||||
<button t-att-class="props.cssClass + ' o_fp_qr_btn'"
|
||||
t-on-click="() => this.open()">
|
||||
<i class="fa fa-qrcode me-1"/>
|
||||
<t t-esc="props.label"/>
|
||||
</button>
|
||||
<div t-if="state.open" class="o_fp_qr_modal_backdrop"
|
||||
t-on-click="close">
|
||||
<div class="o_fp_qr_modal" t-on-click.stop="">
|
||||
<div class="o_fp_qr_modal_head">
|
||||
<h3>Scan job QR</h3>
|
||||
<button class="btn btn-sm btn-light" t-on-click="close">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="o_fp_qr_modal_body">
|
||||
<div t-if="!state.supportsBarcode and !state.error"
|
||||
class="o_fp_qr_warn">
|
||||
Live decoding isn't supported on this browser.
|
||||
Paste the URL below.
|
||||
</div>
|
||||
<video t-if="state.supportsBarcode" t-ref="video"
|
||||
class="o_fp_qr_video" muted="true" playsinline="true"/>
|
||||
<div t-if="state.error" class="o_fp_qr_error">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<span t-esc="state.error"/>
|
||||
</div>
|
||||
<div class="o_fp_qr_manual">
|
||||
<label class="form-label">Or paste sticker URL</label>
|
||||
<input class="form-control" t-model="state.manualUrl"
|
||||
placeholder="https://entech/.../fp/job/123"
|
||||
t-on-keyup="(e) => e.key === 'Enter' && this.onManualSubmit()"/>
|
||||
<button class="btn btn-primary mt-2"
|
||||
t-on-click="() => this.onManualSubmit()">Open</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -42,8 +42,9 @@
|
||||
</t>
|
||||
</select>
|
||||
<button class="o_fp_scan_toggle" t-on-click="toggleScan">
|
||||
<i class="fa fa-qrcode me-1"/>Scan
|
||||
<i class="fa fa-qrcode me-1"/>Code
|
||||
</button>
|
||||
<QrScanner cssClass="'o_fp_scan_toggle'" label="'Camera'"/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user