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:
gsinghpal
2026-04-27 21:11:49 -04:00
parent a521b7c37b
commit 6d046f2881
2 changed files with 290 additions and 0 deletions

View File

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

View File

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