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