feat(configurator): Sub 3 Phase B — part-scoped Process Composer client action + part form Compose button
- Add fp_part_composer_controller with 3 JSON-RPC endpoints:
/fp/part/composer/state, /fp/part/composer/templates,
/fp/part/composer/load_template (deep-clones a shared template
into a part-owned tree inside a cr.savepoint, sets
fp.part.catalog.default_process_id atomically)
- _clone_subtree copies name/sequence/opt_in_out/treatment_uom plus
description/notes/icon/color/timing/behaviour/work_center/process_type
and stamps part_catalog_id + cloned_from_id on every node
- Add fp_part_process_composer OWL client action (JS + XML + SCSS):
picks template from dropdown, clones, hands off to existing
fp_recipe_tree_editor via context={recipe_id, part_id}
- Add Process tab on part form with readonly default_process_id
field and Compose button calling action_open_part_composer
- Register new assets in web.assets_backend, bump configurator
version to 19.0.11.0.0
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Part-Scoped Process Composer (OWL client action)
|
||||
// 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.
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL: static template + static props = ["*"]
|
||||
// * RPC: standalone rpc() from @web/core/network/rpc
|
||||
// * Registered under registry.category("actions") → "fp_part_process_composer"
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class FpPartProcessComposer extends Component {
|
||||
static template = "fusion_plating_configurator.FpPartProcessComposer";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
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;
|
||||
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
error: null,
|
||||
part: null,
|
||||
hasTree: false,
|
||||
rootId: null,
|
||||
templates: [],
|
||||
selectedTemplateId: null,
|
||||
loadingTemplate: false,
|
||||
});
|
||||
|
||||
onMounted(() => this.refresh());
|
||||
}
|
||||
|
||||
// ---- Data loading -------------------------------------------------------
|
||||
|
||||
async refresh() {
|
||||
if (!this.partId) {
|
||||
this.state.error = "No part specified.";
|
||||
this.state.loading = false;
|
||||
return;
|
||||
}
|
||||
this.state.loading = true;
|
||||
this.state.error = null;
|
||||
try {
|
||||
const [stateRes, tplRes] = await Promise.all([
|
||||
rpc("/fp/part/composer/state", { part_id: this.partId }),
|
||||
rpc("/fp/part/composer/templates", {}),
|
||||
]);
|
||||
if (!stateRes.ok) throw new Error(stateRes.error || "Failed to load part state.");
|
||||
if (!tplRes.ok) throw new Error(tplRes.error || "Failed to load templates.");
|
||||
|
||||
this.state.part = stateRes.part;
|
||||
this.state.hasTree = stateRes.has_tree;
|
||||
this.state.rootId = stateRes.root_id || null;
|
||||
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;
|
||||
}
|
||||
} catch (err) {
|
||||
this.state.error = err.message || String(err);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Handlers -----------------------------------------------------------
|
||||
|
||||
onSelectTemplate(ev) {
|
||||
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;
|
||||
|
||||
this.state.loadingTemplate = true;
|
||||
try {
|
||||
const res = await rpc("/fp/part/composer/load_template", {
|
||||
part_id: this.partId,
|
||||
template_id: this.state.selectedTemplateId,
|
||||
});
|
||||
if (!res.ok) throw new Error(res.error || "Load failed.");
|
||||
this.notification.add(
|
||||
`Template loaded — ${res.node_count} nodes cloned into this part's tree.`,
|
||||
{ type: "success" }
|
||||
);
|
||||
await this.refresh();
|
||||
// Hand off directly to the tree editor so the user can
|
||||
// immediately start customising.
|
||||
this.openRecipeEditor(res.root_id);
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
`Load failed: ${err.message || err}`,
|
||||
{ type: "danger" }
|
||||
);
|
||||
} finally {
|
||||
this.state.loadingTemplate = 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`.
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_recipe_tree_editor",
|
||||
name: `Process Composer — ${(this.state.part && this.state.part.display) || ""}`,
|
||||
context: { recipe_id: id, part_id: this.partId },
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
backToPart() {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.part.catalog",
|
||||
res_id: this.partId,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_part_process_composer", FpPartProcessComposer);
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
// Part of the Fusion Plating product family.
|
||||
//
|
||||
// Sub 3 — Process Composer styles.
|
||||
|
||||
.o_fp_part_composer {
|
||||
padding: 16px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
|
||||
&_state {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--bs-secondary-color, #666);
|
||||
|
||||
.fa {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&_error {
|
||||
color: var(--bs-danger, #c00);
|
||||
}
|
||||
|
||||
&_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--bs-border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
&_title {
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&_loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: var(--bs-tertiary-bg, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
select {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
&_tree {
|
||||
min-height: 300px;
|
||||
padding: 24px;
|
||||
background: var(--bs-body-bg, #ffffff);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--bs-border-color, #d8dadd);
|
||||
}
|
||||
|
||||
&_hint,
|
||||
&_empty {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
|
||||
.fa {
|
||||
color: var(--bs-secondary-color, #999);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--bs-secondary-color, #666);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
OWL template for the part-scoped Process Composer client action.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_configurator.FpPartProcessComposer">
|
||||
<div class="o_fp_part_composer">
|
||||
<t t-if="state.loading">
|
||||
<div class="o_fp_part_composer_state">
|
||||
<i class="fa fa-spinner fa-spin"/>
|
||||
<span> Loading…</span>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="state.error">
|
||||
<div class="o_fp_part_composer_state o_fp_part_composer_error">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<span t-esc="state.error"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="state.part">
|
||||
<div class="o_fp_part_composer_header">
|
||||
<button class="btn btn-secondary" t-on-click="backToPart">
|
||||
<i class="fa fa-arrow-left"/>
|
||||
<span> Back to Part</span>
|
||||
</button>
|
||||
<div class="o_fp_part_composer_title">
|
||||
<h2>Process Composer — <t t-esc="state.part.display"/></h2>
|
||||
<small class="text-muted" t-if="state.part.customer">
|
||||
Customer: <t t-esc="state.part.customer"/>
|
||||
</small>
|
||||
</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"
|
||||
t-on-click="() => this.openRecipeEditor()">
|
||||
<i class="fa fa-edit"/>
|
||||
<span> Open Tree Editor</span>
|
||||
</button>
|
||||
</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>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user