feat(fusion_plating_shopfloor): SignaturePad shared OWL service
Plan task P1.5. Modal canvas signature capture using HTML pointer events + Odoo Dialog service. Returns image/png dataURI via onSubmit callback; caller decides what to do with it (e.g. /fp/workspace/sign_off attaches to fp.job.step). Canvas stays light even in dark mode for signature legibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_gate_viz.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/gate_viz.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/gate_viz.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/signature_pad.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — SignaturePad (shared OWL service)
|
||||
//
|
||||
// Modal canvas signature capture. Returns dataURI via onSubmit; the caller
|
||||
// commits it (e.g. /fp/workspace/sign_off). Mounted via the dialog service:
|
||||
// this.dialog.add(FpSignaturePad, { title, contextLabel, onSubmit, onCancel })
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useRef, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
|
||||
export class FpSignaturePad extends Component {
|
||||
static template = "fusion_plating_shopfloor.SignaturePad";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
close: Function, // dialog service injects
|
||||
title: { type: String, optional: true },
|
||||
contextLabel: { type: String, optional: true },
|
||||
onSubmit: { type: Function, optional: false }, // (dataUri) => void
|
||||
onCancel: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.canvasRef = useRef("canvas");
|
||||
this.isDrawing = false;
|
||||
this.lastPoint = null;
|
||||
this.hasInk = false;
|
||||
|
||||
this._onDown = (ev) => {
|
||||
this.isDrawing = true;
|
||||
this.lastPoint = this._localPoint(ev);
|
||||
};
|
||||
this._onMove = (ev) => {
|
||||
if (!this.isDrawing) return;
|
||||
const p = this._localPoint(ev);
|
||||
const ctx = this.canvasRef.el.getContext("2d");
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.lastPoint.x, this.lastPoint.y);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
this.lastPoint = p;
|
||||
this.hasInk = true;
|
||||
};
|
||||
this._onUp = () => {
|
||||
this.isDrawing = false;
|
||||
this.lastPoint = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = this.canvasRef.el;
|
||||
// Match canvas pixel size to its CSS box so strokes don't stretch
|
||||
canvas.width = canvas.clientWidth;
|
||||
canvas.height = canvas.clientHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = "round";
|
||||
ctx.strokeStyle = "#000";
|
||||
|
||||
canvas.addEventListener("pointerdown", this._onDown);
|
||||
canvas.addEventListener("pointermove", this._onMove);
|
||||
canvas.addEventListener("pointerup", this._onUp);
|
||||
canvas.addEventListener("pointercancel", this._onUp);
|
||||
canvas.addEventListener("pointerleave", this._onUp);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
const canvas = this.canvasRef.el;
|
||||
if (!canvas) return;
|
||||
canvas.removeEventListener("pointerdown", this._onDown);
|
||||
canvas.removeEventListener("pointermove", this._onMove);
|
||||
canvas.removeEventListener("pointerup", this._onUp);
|
||||
canvas.removeEventListener("pointercancel", this._onUp);
|
||||
canvas.removeEventListener("pointerleave", this._onUp);
|
||||
});
|
||||
}
|
||||
|
||||
_localPoint(ev) {
|
||||
const r = this.canvasRef.el.getBoundingClientRect();
|
||||
return { x: ev.clientX - r.left, y: ev.clientY - r.top };
|
||||
}
|
||||
|
||||
onClear() {
|
||||
const canvas = this.canvasRef.el;
|
||||
canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
|
||||
this.hasInk = false;
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
if (!this.hasInk) return;
|
||||
const dataUri = this.canvasRef.el.toDataURL("image/png");
|
||||
this.props.onSubmit(dataUri);
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
if (this.props.onCancel) this.props.onCancel();
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// =============================================================================
|
||||
// SignaturePad — modal canvas signature capture
|
||||
// Canvas stays light even in dark mode (signature legibility).
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_sig-canvas-bg-hex: #ffffff;
|
||||
$_sig-canvas-border-hex: #d8dadd;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_sig-canvas-bg-hex: #f5f5f5 !global;
|
||||
$_sig-canvas-border-hex: #5a5a5e !global;
|
||||
}
|
||||
|
||||
.o_fp_sig_pad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.o_fp_sig_ctx {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.o_fp_sig_canvas {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: $_sig-canvas-bg-hex;
|
||||
border: 2px solid $_sig-canvas-border-hex;
|
||||
border-radius: 6px;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.o_fp_sig_hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #999);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.SignaturePad">
|
||||
<Dialog title="props.title or 'Signature required'" size="'md'">
|
||||
<div class="o_fp_sig_pad">
|
||||
<div class="o_fp_sig_ctx" t-if="props.contextLabel">
|
||||
<t t-esc="props.contextLabel"/>
|
||||
</div>
|
||||
<canvas class="o_fp_sig_canvas" t-ref="canvas"/>
|
||||
<div class="o_fp_sig_hint">Draw your signature above</div>
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-secondary" t-on-click="onClear">Clear</button>
|
||||
<button class="btn btn-link" t-on-click="onCancel">Cancel</button>
|
||||
<button class="btn btn-primary" t-on-click="onSubmit">Sign & Finish</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user