feat(fusion_plating_shopfloor): HoldComposer shared OWL service

Plan task P1.6. Modal hold-creation form: reason picker, qty split,
optional photo (camera input on mobile), description, mark-for-scrap
toggle. Calls /fp/workspace/hold (added in P1.9). Reason list kept
client-side, keep in sync with fusion.plating.quality.hold.hold_reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-22 21:47:29 -04:00
parent 9d78bc4317
commit 8109b3ec76
4 changed files with 177 additions and 0 deletions

View File

@@ -74,6 +74,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'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/components/_hold_composer.scss',
'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml',
'fusion_plating_shopfloor/static/src/js/components/hold_composer.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,102 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — HoldComposer (shared OWL service)
//
// Modal form to create a fusion.plating.quality.hold with reason picker,
// qty split, optional photo, description, and mark-for-scrap toggle.
// Calls /fp/workspace/hold; caller passes onCreated(res) to refresh.
//
// Mounted via the dialog service:
// this.dialog.add(FpHoldComposer, {
// jobId, stepId?, defaultQty, partRef, onCreated,
// });
// =============================================================================
import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
// Hold reasons kept here so the picker doesn't need a server roundtrip.
// Keep in sync with the fusion.plating.quality.hold.hold_reason Selection.
const HOLD_REASONS = [
{ value: "dimensional", label: "Dimensional" },
{ value: "thickness", label: "Thickness fail" },
{ value: "plating_defect", label: "Plating defect" },
{ value: "contamination", label: "Contamination" },
{ value: "wrong_part", label: "Wrong part" },
{ value: "other", label: "Other" },
];
export class FpHoldComposer extends Component {
static template = "fusion_plating_shopfloor.HoldComposer";
static components = { Dialog };
static props = {
close: Function,
jobId: { type: Number, optional: false },
stepId: { type: [Number, Boolean], optional: true },
defaultQty: { type: Number, optional: true },
partRef: { type: String, optional: true },
onCreated: { type: Function, optional: true },
};
setup() {
this.notification = useService("notification");
this.reasons = HOLD_REASONS;
this.state = useState({
reason: "dimensional",
qty: this.props.defaultQty || 1,
description: "",
photoDataUri: null,
photoFilename: "",
markForScrap: false,
submitting: false,
});
}
onPhotoChange(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
// Strip "data:...;base64," prefix — backend expects raw base64
const dataUri = e.target.result;
const base64 = dataUri.split(",", 2)[1] || "";
this.state.photoDataUri = base64;
this.state.photoFilename = file.name;
};
reader.readAsDataURL(file);
}
async onSubmit() {
if (!this.state.qty || this.state.qty < 1) {
this.notification.add("Qty on hold must be at least 1", { type: "warning" });
return;
}
this.state.submitting = true;
try {
const res = await rpc("/fp/workspace/hold", {
job_id: this.props.jobId,
step_id: this.props.stepId || null,
part_ref: this.props.partRef || "",
reason: this.state.reason,
qty_on_hold: this.state.qty,
description: this.state.description || "",
mark_for_scrap: this.state.markForScrap,
photo_data: this.state.photoDataUri,
photo_filename: this.state.photoFilename,
});
if (res && res.ok) {
this.notification.add(`Hold ${res.hold_name} created.`, { type: "success" });
if (this.props.onCreated) this.props.onCreated(res);
this.props.close();
} else {
this.notification.add((res && res.error) || "Hold creation failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message || String(err), { type: "danger" });
} finally {
this.state.submitting = false;
}
}
}

View File

@@ -0,0 +1,21 @@
// =============================================================================
// HoldComposer — modal hold-create form
// =============================================================================
.o_fp_hc {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.o_fp_hc_row {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.o_fp_hc_row label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary, #666);
}

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.HoldComposer">
<Dialog title="'Place hold'" size="'md'">
<div class="o_fp_hc">
<div class="o_fp_hc_row">
<label>Reason</label>
<select class="form-select" t-model="state.reason">
<t t-foreach="reasons" t-as="r" t-key="r.value">
<option t-att-value="r.value"><t t-esc="r.label"/></option>
</t>
</select>
</div>
<div class="o_fp_hc_row">
<label>Qty on hold</label>
<input type="number" min="1" class="form-control"
t-model.number="state.qty"/>
</div>
<div class="o_fp_hc_row">
<label>Description</label>
<textarea class="form-control" rows="3" t-model="state.description"
placeholder="What happened?"/>
</div>
<div class="o_fp_hc_row">
<label>Photo (optional)</label>
<input type="file" accept="image/*" capture="environment"
class="form-control" t-on-change="onPhotoChange"/>
<small t-if="state.photoFilename" class="text-success">
<i class="fa fa-check"/> <t t-esc="state.photoFilename"/>
</small>
</div>
<div class="o_fp_hc_row">
<label class="form-check-label">
<input type="checkbox" class="form-check-input me-1"
t-model="state.markForScrap"/>
Mark for scrap
</label>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-link" t-on-click="() => this.props.close()">Cancel</button>
<button class="btn btn-warning" t-on-click="onSubmit"
t-att-disabled="state.submitting">
<i class="fa fa-exclamation-triangle me-1"/> Create Hold
</button>
</t>
</Dialog>
</t>
</templates>