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:
gsinghpal
2026-05-22 21:46:46 -04:00
parent 5c3c979f77
commit 9d78bc4317
4 changed files with 165 additions and 0 deletions

View File

@@ -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',

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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 &amp; Finish</button>
</t>
</Dialog>
</t>
</templates>