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:
@@ -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/scss/components/_signature_pad.scss',
|
||||||
'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml',
|
'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/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/qr_scanner.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
|
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user