feat(sub12b): OWL Move Parts dialog (Task 11)
Mirrors Steelhead screens 1-3, 14-15. Loads preview on mount,
re-checks hard-blockers on commit. MOVE (n) button disabled when
hard-blocked OR required prompt blank — improvement over Steelhead's
silent disabled state (we show a tooltip listing reasons).
Inline 'Resolve' button next to each blocker. For rack-required,
fires a window CustomEvent ('fp-resolve-rack') the parent tablet
catches to open the Rack Parts sub-dialog.
Typed input rendering by input_type — text/number/checkbox/select/
datetime, plus support for time_hms and signature/photo (text input
for now; full upload widget in Sub 12c).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
|||||||
|
/** @odoo-module */
|
||||||
|
/*
|
||||||
|
* Sub 12b — Move Parts dialog (OWL).
|
||||||
|
*
|
||||||
|
* Mirror of Steelhead screens 1-3, 14-15. Loads preview on mount,
|
||||||
|
* re-checks hard-blockers on commit. MOVE (n) button disabled when
|
||||||
|
* hard-blocked OR required prompt blank — improvement over Steelhead's
|
||||||
|
* silent disabled state (we show a tooltip listing blockers).
|
||||||
|
*
|
||||||
|
* Inline 'Resolve' button next to each blocker with a resolve_action.
|
||||||
|
* For rack-required, fires a window CustomEvent ('fp-resolve-rack')
|
||||||
|
* the parent tablet catches to open the Rack Parts sub-dialog.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, onWillStart, 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";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
|
||||||
|
export class FpMovePartsDialog extends Component {
|
||||||
|
static template = "fusion_plating_shopfloor.FpMovePartsDialog";
|
||||||
|
static components = { Dialog };
|
||||||
|
static props = ["fromStepId", "toStepId", "onCommit?", "close"];
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.notification = useService("notification");
|
||||||
|
this.state = useState({
|
||||||
|
loading: true,
|
||||||
|
qty: 0,
|
||||||
|
qtyAvailable: 0,
|
||||||
|
fromStep: { tank_options: [] },
|
||||||
|
toStep: { tank_options: [] },
|
||||||
|
transferType: "step",
|
||||||
|
toTankId: false,
|
||||||
|
toLocation: "global",
|
||||||
|
customerWoCount: 0,
|
||||||
|
transitionPrompts: [],
|
||||||
|
promptValues: {},
|
||||||
|
blockers: [],
|
||||||
|
committing: false,
|
||||||
|
});
|
||||||
|
onWillStart(async () => {
|
||||||
|
await this.loadPreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPreview() {
|
||||||
|
this.state.loading = true;
|
||||||
|
const data = await rpc("/fp/tablet/move_parts/preview", {
|
||||||
|
from_step_id: this.props.fromStepId,
|
||||||
|
to_step_id: this.props.toStepId,
|
||||||
|
});
|
||||||
|
if (!data.ok) {
|
||||||
|
this.notification.add(data.error || _t("Preview failed"),
|
||||||
|
{ type: "danger" });
|
||||||
|
this.props.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.qtyAvailable = data.qty_available;
|
||||||
|
this.state.qty = data.qty_available;
|
||||||
|
this.state.fromStep = data.from_step;
|
||||||
|
this.state.toStep = data.to_step;
|
||||||
|
this.state.transitionPrompts = data.transition_prompts;
|
||||||
|
this.state.blockers = data.blockers;
|
||||||
|
const opts = data.to_step.tank_options || [];
|
||||||
|
this.state.toTankId = opts.length ? opts[0].id : false;
|
||||||
|
this.state.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hardBlocked() {
|
||||||
|
return this.state.blockers.some((b) => b.severity === "hard");
|
||||||
|
}
|
||||||
|
|
||||||
|
get requiredPromptsBlank() {
|
||||||
|
return this.state.transitionPrompts.some((p) => {
|
||||||
|
return p.required && !this.state.promptValues[p.id];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get blockerTooltip() {
|
||||||
|
const reasons = [];
|
||||||
|
if (this.hardBlocked) {
|
||||||
|
reasons.push(...this.state.blockers
|
||||||
|
.filter((b) => b.severity === "hard")
|
||||||
|
.map((b) => b.message));
|
||||||
|
}
|
||||||
|
if (this.requiredPromptsBlank) {
|
||||||
|
reasons.push(_t("Required compliance prompt(s) blank."));
|
||||||
|
}
|
||||||
|
if (this.state.qty <= 0) {
|
||||||
|
reasons.push(_t("Part Count must be > 0."));
|
||||||
|
}
|
||||||
|
if (this.state.qty > this.state.qtyAvailable) {
|
||||||
|
reasons.push(_t("Part Count exceeds available."));
|
||||||
|
}
|
||||||
|
return reasons.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
get canCommit() {
|
||||||
|
return !this.state.committing &&
|
||||||
|
!this.hardBlocked &&
|
||||||
|
!this.requiredPromptsBlank &&
|
||||||
|
this.state.qty > 0 &&
|
||||||
|
this.state.qty <= this.state.qtyAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onCommit() {
|
||||||
|
if (!this.canCommit) return;
|
||||||
|
this.state.committing = true;
|
||||||
|
const result = await rpc("/fp/tablet/move_parts/commit", {
|
||||||
|
from_step_id: this.props.fromStepId,
|
||||||
|
to_step_id: this.props.toStepId,
|
||||||
|
qty: this.state.qty,
|
||||||
|
transfer_type: this.state.transferType,
|
||||||
|
to_tank_id: this.state.toTankId || false,
|
||||||
|
to_location: this.state.toLocation,
|
||||||
|
customer_wo_count: this.state.customerWoCount,
|
||||||
|
prompt_values: this.state.promptValues,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Move %s committed", result.move_name),
|
||||||
|
{ type: "success" },
|
||||||
|
);
|
||||||
|
if (this.props.onCommit) {
|
||||||
|
this.props.onCommit(result);
|
||||||
|
}
|
||||||
|
this.props.close();
|
||||||
|
} else {
|
||||||
|
this.notification.add(
|
||||||
|
result.error || _t("Move failed"),
|
||||||
|
{ type: "danger" });
|
||||||
|
this.state.committing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onResolveBlocker(blocker) {
|
||||||
|
if (blocker.resolve_action === "open_rack_parts_dialog") {
|
||||||
|
const ev = new CustomEvent("fp-resolve-rack", {
|
||||||
|
detail: {
|
||||||
|
fromStepId: this.props.fromStepId,
|
||||||
|
qty: this.state.qty,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.dispatchEvent(ev);
|
||||||
|
this.props.close();
|
||||||
|
} else {
|
||||||
|
this.notification.add(
|
||||||
|
blocker.message + " " + _t("(Open the related step manually.)"),
|
||||||
|
{ type: "warning" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_plating_shopfloor.FpMovePartsDialog">
|
||||||
|
<Dialog title.translate="Move Parts" size="'lg'">
|
||||||
|
<div class="o_fp_move_dialog" t-if="!state.loading">
|
||||||
|
|
||||||
|
<div class="o_fp_move_field">
|
||||||
|
<label>Part Count</label>
|
||||||
|
<input type="number" t-model.number="state.qty"
|
||||||
|
t-att-min="1" t-att-max="state.qtyAvailable"/>
|
||||||
|
<span class="text-muted">Available: <t t-esc="state.qtyAvailable"/></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_move_field">
|
||||||
|
<label>From Node</label>
|
||||||
|
<span t-esc="state.fromStep.name"/>
|
||||||
|
<span/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_move_field" t-if="state.fromStep.tank_name">
|
||||||
|
<label>From Station</label>
|
||||||
|
<span t-esc="state.fromStep.tank_name"/>
|
||||||
|
<span/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_move_field">
|
||||||
|
<label>Transfer Type</label>
|
||||||
|
<select t-model="state.transferType">
|
||||||
|
<option value="step">Step</option>
|
||||||
|
<option value="hold">Hold</option>
|
||||||
|
<option value="scrap">Scrap</option>
|
||||||
|
<option value="rework">Rework</option>
|
||||||
|
<option value="split">Split</option>
|
||||||
|
<option value="return">Return</option>
|
||||||
|
</select>
|
||||||
|
<span/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_move_field">
|
||||||
|
<label>To Node</label>
|
||||||
|
<span t-esc="state.toStep.name"/>
|
||||||
|
<span/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_move_field"
|
||||||
|
t-if="state.toStep.tank_options and state.toStep.tank_options.length > 1">
|
||||||
|
<label>To Station</label>
|
||||||
|
<select t-model.number="state.toTankId">
|
||||||
|
<t t-foreach="state.toStep.tank_options"
|
||||||
|
t-as="tk" t-key="tk.id">
|
||||||
|
<option t-att-value="tk.id"><t t-esc="tk.name"/></option>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
<span/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_move_field">
|
||||||
|
<label>To Location</label>
|
||||||
|
<select t-model="state.toLocation">
|
||||||
|
<option value="global">Global</option>
|
||||||
|
<option value="quarantine">Quarantine</option>
|
||||||
|
<option value="staging_a">Staging A</option>
|
||||||
|
<option value="staging_b">Staging B</option>
|
||||||
|
<option value="shipping_dock">Shipping Dock</option>
|
||||||
|
<option value="scrap_bin">Scrap Bin</option>
|
||||||
|
</select>
|
||||||
|
<span/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_compliance_prompts"
|
||||||
|
t-if="state.transitionPrompts.length">
|
||||||
|
<h5>Compliance Prompts</h5>
|
||||||
|
<t t-foreach="state.transitionPrompts" t-as="p" t-key="p.id">
|
||||||
|
<div class="o_fp_move_field">
|
||||||
|
<label>
|
||||||
|
<t t-esc="p.name"/>
|
||||||
|
<span t-if="p.required" class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
<input t-if="['text','customer_wo','time_hms','location_picker','signature','photo'].includes(p.input_type)"
|
||||||
|
type="text" t-model="state.promptValues[p.id]"/>
|
||||||
|
<input t-elif="['number','temperature','thickness','time_seconds'].includes(p.input_type)"
|
||||||
|
type="number" t-model.number="state.promptValues[p.id]"/>
|
||||||
|
<input t-elif="p.input_type === 'boolean' or p.input_type === 'pass_fail'"
|
||||||
|
type="checkbox" t-model="state.promptValues[p.id]"/>
|
||||||
|
<input t-elif="p.input_type === 'date'"
|
||||||
|
type="datetime-local" t-model="state.promptValues[p.id]"/>
|
||||||
|
<select t-elif="p.input_type === 'selection'"
|
||||||
|
t-model="state.promptValues[p.id]">
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
<t t-foreach="p.selection_options.split(',')"
|
||||||
|
t-as="opt" t-key="opt_index">
|
||||||
|
<option t-att-value="opt.trim()"><t t-esc="opt.trim()"/></option>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
<span class="text-muted" t-if="p.hint"><t t-esc="p.hint"/></span>
|
||||||
|
<span t-else=""/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_blockers" t-if="state.blockers.length">
|
||||||
|
<h5>Blockers</h5>
|
||||||
|
<t t-foreach="state.blockers" t-as="b" t-key="b_index">
|
||||||
|
<div class="o_fp_blocker_row"
|
||||||
|
t-att-class="b.severity === 'hard' ? 'o_fp_blocker_hard' : 'o_fp_blocker_soft'">
|
||||||
|
<span class="o_fp_blocker_icon">⚠</span>
|
||||||
|
<span class="o_fp_blocker_msg" t-esc="b.message"/>
|
||||||
|
<button t-if="b.resolve_action"
|
||||||
|
class="btn btn-sm btn-warning"
|
||||||
|
t-on-click="() => this.onResolveBlocker(b)">
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="state.loading">Loading…</div>
|
||||||
|
|
||||||
|
<t t-set-slot="footer">
|
||||||
|
<button class="btn btn-secondary" t-on-click="props.close">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
t-att-disabled="!canCommit"
|
||||||
|
t-att-title="blockerTooltip"
|
||||||
|
t-on-click="onCommit">
|
||||||
|
MOVE (<t t-esc="state.qty"/>)
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
</Dialog>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
Reference in New Issue
Block a user