changes
This commit is contained in:
@@ -4,9 +4,9 @@
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Thin wrapper around the existing recipe tree editor. Gives a part
|
||||
// its own composed process tree by cloning a shared template, then
|
||||
// hands off to the fp_recipe_tree_editor action for edits.
|
||||
// Sub 9 — multi-variant Composer. Each part can carry several recipe trees
|
||||
// (e.g. "Standard ENP", "Selective Masking", "Rework"). One is the default;
|
||||
// estimators may pick a non-default variant on a per-order basis.
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL: static template + static props = ["*"]
|
||||
@@ -27,8 +27,6 @@ export class FpPartProcessComposer extends Component {
|
||||
this.action = useService("action");
|
||||
this.notification = useService("notification");
|
||||
|
||||
// Pull part_id out of the client action's params (set by
|
||||
// fp.part.catalog.action_open_part_composer on the server).
|
||||
const params = (this.props.action && this.props.action.params) || {};
|
||||
this.partId = params.part_id || null;
|
||||
|
||||
@@ -38,9 +36,11 @@ export class FpPartProcessComposer extends Component {
|
||||
part: null,
|
||||
hasTree: false,
|
||||
rootId: null,
|
||||
variants: [],
|
||||
templates: [],
|
||||
selectedTemplateId: null,
|
||||
loadingTemplate: false,
|
||||
newVariantLabel: "",
|
||||
busy: false,
|
||||
});
|
||||
|
||||
onMounted(() => this.refresh());
|
||||
@@ -67,10 +67,9 @@ export class FpPartProcessComposer extends Component {
|
||||
this.state.part = stateRes.part;
|
||||
this.state.hasTree = stateRes.has_tree;
|
||||
this.state.rootId = stateRes.root_id || null;
|
||||
this.state.variants = stateRes.variants || [];
|
||||
this.state.templates = tplRes.templates || [];
|
||||
|
||||
// Default the dropdown selection to the first template so the
|
||||
// user can click Load immediately.
|
||||
if (this.state.templates.length > 0 && !this.state.selectedTemplateId) {
|
||||
this.state.selectedTemplateId = this.state.templates[0].id;
|
||||
}
|
||||
@@ -87,46 +86,110 @@ export class FpPartProcessComposer extends Component {
|
||||
this.state.selectedTemplateId = parseInt(ev.target.value, 10) || null;
|
||||
}
|
||||
|
||||
async onLoadTemplate() {
|
||||
if (!this.state.selectedTemplateId) return;
|
||||
const confirmReplace = this.state.hasTree
|
||||
? window.confirm("This will replace the current process tree for this part. Continue?")
|
||||
: true;
|
||||
if (!confirmReplace) return;
|
||||
onNewLabelInput(ev) {
|
||||
this.state.newVariantLabel = ev.target.value || "";
|
||||
}
|
||||
|
||||
this.state.loadingTemplate = true;
|
||||
try {
|
||||
async onAddVariantFromTemplate() {
|
||||
if (!this.state.selectedTemplateId) {
|
||||
this.notification.add("Pick a template first.", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
const label = (this.state.newVariantLabel || "").trim()
|
||||
|| (this.state.templates.find(t => t.id === this.state.selectedTemplateId)?.name)
|
||||
|| "Variant";
|
||||
await this._busy(async () => {
|
||||
const res = await rpc("/fp/part/composer/load_template", {
|
||||
part_id: this.partId,
|
||||
template_id: this.state.selectedTemplateId,
|
||||
variant_label: label,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.error || "Load failed.");
|
||||
if (!res.ok) throw new Error(res.error || "Add variant failed.");
|
||||
this.notification.add(
|
||||
`Template loaded — ${res.node_count} nodes cloned into this part's tree.`,
|
||||
{ type: "success" }
|
||||
`Variant "${label}" added (${res.node_count} nodes).`,
|
||||
{ type: "success" },
|
||||
);
|
||||
this.state.newVariantLabel = "";
|
||||
await this.refresh();
|
||||
// Hand off directly to the tree editor so the user can
|
||||
// immediately start customising.
|
||||
this.openRecipeEditor(res.root_id);
|
||||
});
|
||||
}
|
||||
|
||||
async onDuplicateVariant(variantId) {
|
||||
const src = this.state.variants.find(v => v.id === variantId);
|
||||
const proposed = window.prompt(
|
||||
"Name for the duplicated variant:",
|
||||
(src?.label || "Variant") + " (copy)",
|
||||
);
|
||||
if (!proposed) return;
|
||||
await this._busy(async () => {
|
||||
const res = await rpc("/fp/part/composer/duplicate_variant", {
|
||||
part_id: this.partId,
|
||||
source_variant_id: variantId,
|
||||
variant_label: proposed,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.error || "Duplicate failed.");
|
||||
this.notification.add(`Variant "${proposed}" created.`, { type: "success" });
|
||||
await this.refresh();
|
||||
this.openRecipeEditor(res.root_id);
|
||||
});
|
||||
}
|
||||
|
||||
async onRenameVariant(variantId) {
|
||||
const v = this.state.variants.find(x => x.id === variantId);
|
||||
const proposed = window.prompt("New label:", v?.label || "");
|
||||
if (!proposed) return;
|
||||
await this._busy(async () => {
|
||||
const res = await rpc("/fp/part/composer/rename_variant", {
|
||||
part_id: this.partId,
|
||||
variant_id: variantId,
|
||||
variant_label: proposed,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.error || "Rename failed.");
|
||||
await this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
async onSetDefaultVariant(variantId) {
|
||||
await this._busy(async () => {
|
||||
const res = await rpc("/fp/part/composer/set_default_variant", {
|
||||
part_id: this.partId,
|
||||
variant_id: variantId,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.error || "Set default failed.");
|
||||
this.notification.add("Default variant updated.", { type: "success" });
|
||||
await this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
async onDeleteVariant(variantId) {
|
||||
const v = this.state.variants.find(x => x.id === variantId);
|
||||
if (!window.confirm(`Delete variant "${v?.label || ""}"? This removes its tree.`)) return;
|
||||
await this._busy(async () => {
|
||||
const res = await rpc("/fp/part/composer/delete_variant", {
|
||||
part_id: this.partId,
|
||||
variant_id: variantId,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.error || "Delete failed.");
|
||||
this.notification.add("Variant deleted.", { type: "success" });
|
||||
await this.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
async _busy(fn) {
|
||||
this.state.busy = true;
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
`Load failed: ${err.message || err}`,
|
||||
{ type: "danger" }
|
||||
);
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
} finally {
|
||||
this.state.loadingTemplate = false;
|
||||
this.state.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
openRecipeEditor(rootId) {
|
||||
const id = rootId || this.state.rootId;
|
||||
if (!id) return;
|
||||
// The existing fp_recipe_tree_editor reads recipe_id from
|
||||
// this.props.action?.context — pass it via `context`, not `params`.
|
||||
// Label the editor as "Process Editor …" so it doesn't collide with
|
||||
// "Process Composer …" in the breadcrumb stack; the two pages are
|
||||
// distinct roles and should read differently in the trail.
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_recipe_tree_editor",
|
||||
@@ -137,10 +200,6 @@ export class FpPartProcessComposer extends Component {
|
||||
}
|
||||
|
||||
backToPart() {
|
||||
// clearBreadcrumbs: "Back" is semantically a RETURN, not a forward
|
||||
// navigation — reset the stack to just the part form so repeated
|
||||
// round-trips (part → composer → editor → back) don't accumulate
|
||||
// duplicate entries.
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.part.catalog",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
OWL template for the part-scoped Process Composer client action.
|
||||
Sub 9 — multi-variant Composer.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
@@ -36,53 +37,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_part_composer_loader">
|
||||
<label>Load Existing Process:</label>
|
||||
<select class="form-select" t-on-change="onSelectTemplate">
|
||||
<t t-foreach="state.templates" t-as="tpl" t-key="tpl.id">
|
||||
<option t-att-value="tpl.id"
|
||||
t-att-selected="tpl.id == state.selectedTemplateId">
|
||||
<t t-esc="tpl.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="onLoadTemplate"
|
||||
t-att-disabled="state.loadingTemplate or !state.selectedTemplateId">
|
||||
<t t-if="state.loadingTemplate">
|
||||
<i class="fa fa-spinner fa-spin"/>
|
||||
<span> Loading…</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-if="state.hasTree">Replace with Selected</t>
|
||||
<t t-else="">Load</t>
|
||||
</t>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_part_composer_tree">
|
||||
<t t-if="state.hasTree">
|
||||
<div class="o_fp_part_composer_hint">
|
||||
<p>This part has a composed process tree. Click below to open the
|
||||
full tree editor where you can add, remove, reorder, and configure
|
||||
the process nodes.</p>
|
||||
<button class="btn btn-primary o_fp_part_composer_editor_btn"
|
||||
t-on-click="() => this.openRecipeEditor()">
|
||||
<i class="fa fa-sitemap"/>
|
||||
<span>Open Process Editor</span>
|
||||
</button>
|
||||
<div class="o_fp_part_composer_variants mt-3">
|
||||
<h4>Process Variants</h4>
|
||||
<p class="text-muted small">
|
||||
Add as many variants as you need (e.g. "Standard", "Selective Masking", "Rework").
|
||||
One variant is the default; order lines may pick another at entry time.
|
||||
</p>
|
||||
<t t-if="state.variants.length === 0">
|
||||
<div class="o_fp_part_composer_empty">
|
||||
<i class="fa fa-cogs fa-2x"/>
|
||||
<p>No variants yet. Pick a template below and add the first one.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_fp_part_composer_empty">
|
||||
<i class="fa fa-cogs fa-3x"/>
|
||||
<p>No process composed yet.</p>
|
||||
<p class="text-muted">
|
||||
Pick a template above and click <strong>Load</strong> to get started.
|
||||
</p>
|
||||
</div>
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Default</th>
|
||||
<th>Label</th>
|
||||
<th>Recipe Name</th>
|
||||
<th class="text-end">Nodes</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.variants" t-as="v" t-key="v.id">
|
||||
<tr>
|
||||
<td>
|
||||
<t t-if="v.is_default">
|
||||
<span class="badge bg-success">Default</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<button class="btn btn-link btn-sm p-0"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.onSetDefaultVariant(v.id)">
|
||||
Set Default
|
||||
</button>
|
||||
</t>
|
||||
</td>
|
||||
<td>
|
||||
<strong t-esc="v.label"/>
|
||||
</td>
|
||||
<td class="text-muted" t-esc="v.name"/>
|
||||
<td class="text-end" t-esc="v.node_count"/>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-primary me-1"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.openRecipeEditor(v.id)">
|
||||
<i class="fa fa-pencil"/> Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary me-1"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.onDuplicateVariant(v.id)">
|
||||
<i class="fa fa-copy"/> Duplicate
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary me-1"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.onRenameVariant(v.id)">
|
||||
<i class="fa fa-i-cursor"/> Rename
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.onDeleteVariant(v.id)">
|
||||
<i class="fa fa-trash"/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_part_composer_loader mt-4">
|
||||
<h4>Add Variant from Template</h4>
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<label class="me-2">Template:</label>
|
||||
<select class="form-select" style="max-width: 280px;"
|
||||
t-on-change="onSelectTemplate">
|
||||
<t t-foreach="state.templates" t-as="tpl" t-key="tpl.id">
|
||||
<option t-att-value="tpl.id"
|
||||
t-att-selected="tpl.id == state.selectedTemplateId">
|
||||
<t t-esc="tpl.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<input class="form-control" style="max-width: 240px;"
|
||||
placeholder="Variant label (e.g. Standard ENP)"
|
||||
t-att-value="state.newVariantLabel"
|
||||
t-on-input="onNewLabelInput"/>
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="onAddVariantFromTemplate"
|
||||
t-att-disabled="state.busy or !state.selectedTemplateId">
|
||||
<i class="fa fa-plus"/> Add Variant
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-muted small mt-1">
|
||||
Leave the label blank to use the template name. The first variant added becomes the default automatically.
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
Reference in New Issue
Block a user