This commit is contained in:
gsinghpal
2026-04-20 01:16:12 -04:00
parent 8217bb0ff6
commit 54e56ed0e6
39 changed files with 5600 additions and 1131 deletions

View File

@@ -136,9 +136,16 @@ export class RecipeTreeEditor extends Component {
if (result && result.ok) {
this.state.recipe = result.recipe;
this.state.tree = result.tree;
// Auto-expand root node
if (result.tree) {
this.state.expandedNodes[result.tree.id] = true;
// Auto-expand every node on first load so the full
// hierarchy is visible. The horizontal bracket layout
// works best when everything is open by default;
// operators can still collapse individual branches.
if (result.tree && Object.keys(this.state.expandedNodes).length === 0) {
const expandAll = (n) => {
this.state.expandedNodes[n.id] = true;
for (const c of (n.children || [])) expandAll(c);
};
expandAll(result.tree);
}
// Refresh selected node data if panel is open
if (this.state.selectedNodeId) {

View File

@@ -3,41 +3,177 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Recipe Tree Editor — horizontal hierarchical layout (Steelhead-style).
Recursive template renders recipe → sub-process → operation → step
cards left→right with bracket connectors. Each card carries hover-
revealed Add / Delete buttons; a side panel slides in for editing
when a node is clicked.
-->
<templates xml:space="preserve">
<!-- ====================================================================
RECURSIVE NODE TEMPLATE
Expects: node, parentNode (or null), isFirst (bool)
==================================================================== -->
<t t-name="fusion_plating.RecipeTreeNode">
<div class="o_fp_re_node">
<!-- Card -->
<div t-att-class="'o_fp_re_card o_fp_re_type_' + node.node_type
+ (state.selectedNodeId === node.id ? ' o_fp_re_selected' : '')"
t-att-draggable="node.node_type !== 'recipe' ? 'true' : 'false'"
t-on-dragstart="(ev) => this.onNodeDragStart(node, parentNode, ev)"
t-on-dragend="(ev) => this.onNodeDragEnd(ev)"
t-on-dragover="(ev) => this.onNodeDragOver(node, ev)"
t-on-dragleave="(ev) => this.onNodeDragLeave(ev)"
t-on-drop="(ev) => this.onNodeDrop(node, parentNode, ev)"
t-on-click.stop="() => this.selectNode(node)">
<!-- Drag handle (left grip) -->
<i class="o_fp_re_handle fa fa-bars"
t-if="node.node_type !== 'recipe'"/>
<!-- Icon + name -->
<i t-attf-class="o_fp_re_icon fa #{ node.icon || 'fa-cog' }"/>
<div class="o_fp_re_card_body">
<div class="o_fp_re_title" t-esc="node.name"/>
<div class="o_fp_re_meta"
t-if="node.work_center or node.estimated_duration or node.input_count">
<span t-if="node.work_center">
<i class="fa fa-building me-1"/><t t-esc="node.work_center"/>
</span>
<span t-if="node.estimated_duration">
· <i class="fa fa-clock-o me-1"/><t t-esc="formatDuration(node.estimated_duration)"/>
</span>
<span t-if="node.input_count">
· <i class="fa fa-keyboard-o me-1"/><t t-esc="node.input_count"/>
</span>
</div>
</div>
<!-- Right side: type pill + capability icons + actions -->
<div class="o_fp_re_right">
<!-- Capability flags as small icons -->
<span class="o_fp_re_flags">
<i class="fa fa-check-square" t-if="node.requires_signoff" title="Requires sign-off"/>
<i class="fa fa-eye" t-if="node.customer_visible" title="Customer visible"/>
<i class="fa fa-magic" t-if="node.auto_complete" title="Auto-complete"/>
<i class="fa fa-bolt" t-if="!node.is_manual" title="Automated"/>
</span>
<!-- Type pill -->
<span t-attf-class="o_fp_re_type_pill o_fp_re_type_pill_#{ node.node_type }"
t-esc="getNodeTypeMeta(node.node_type).label"/>
<!-- Hover-revealed actions -->
<span class="o_fp_re_actions">
<button class="o_fp_re_btn o_fp_re_btn_add"
t-on-click.stop="() => this.startAddChild(node.id)"
title="Add child step">
<i class="fa fa-plus"/>
</button>
<button class="o_fp_re_btn o_fp_re_btn_del"
t-if="node.node_type !== 'recipe'"
t-on-click.stop="() => this.deleteNode(node.id)"
title="Delete">
<i class="fa fa-trash"/>
</button>
</span>
</div>
</div>
<!-- Children + inline-add form -->
<div class="o_fp_re_children"
t-if="(node.children and node.children.length and isExpanded(node.id)) or state.addingTo === node.id">
<t t-foreach="node.children || []" t-as="child" t-key="child.id">
<t t-call="fusion_plating.RecipeTreeNode">
<t t-set="node" t-value="child"/>
<t t-set="parentNode" t-value="node"/>
<t t-set="isFirst" t-value="false"/>
</t>
</t>
<!-- Inline add form (sits as the last child of `node`) -->
<div class="o_fp_re_node o_fp_re_add_form"
t-if="state.addingTo === node.id">
<div class="o_fp_re_card o_fp_re_card_add">
<i class="o_fp_re_icon fa fa-plus-circle"/>
<div class="o_fp_re_card_body">
<input type="text" class="o_fp_re_add_input"
placeholder="New step name..."
autofocus="autofocus"
t-att-value="state.newNodeName"
t-on-input="(ev) => { state.newNodeName = ev.target.value; }"
t-on-keydown="onAddNameKey"
t-on-click.stop=""/>
<div class="o_fp_re_add_row">
<select class="o_fp_re_add_select"
t-on-change="(ev) => { state.newNodeType = ev.target.value; }"
t-on-click.stop="">
<t t-foreach="getNodeTypeOptions()" t-as="opt" t-key="opt.value">
<option t-att-value="opt.value"
t-att-selected="state.newNodeType === opt.value"
t-esc="opt.label"/>
</t>
</select>
<button class="o_fp_re_btn o_fp_re_btn_confirm"
t-on-click.stop="confirmAdd">
<i class="fa fa-check"/>
</button>
<button class="o_fp_re_btn o_fp_re_btn_cancel"
t-on-click.stop="cancelAdd">
<i class="fa fa-times"/>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Collapsed indicator: small chip showing # of hidden children -->
<div class="o_fp_re_collapsed_chip"
t-if="node.children and node.children.length and !isExpanded(node.id)"
t-on-click.stop="() => this.toggleExpand(node.id)">
<i class="fa fa-plus-square-o me-1"/>
<t t-esc="node.children.length"/> hidden
</div>
</div>
</t>
<!-- ====================================================================
ROOT TEMPLATE
==================================================================== -->
<t t-name="fusion_plating.RecipeTreeEditor">
<div class="o_fp_recipe_editor">
<div class="o_fp_recipe_editor o_fp_re_v2">
<!-- ========== HEADER ========== -->
<div class="o_fp_recipe_header">
<div class="o_fp_recipe_header_left">
<button class="btn btn-link o_fp_recipe_back_btn"
t-on-click="onBackToList" title="Back to list">
<i class="fa fa-arrow-left me-1"/> Recipes
</button>
<h2 class="o_fp_recipe_title" t-if="state.recipe">
<i class="fa fa-flask me-2"/>
<t t-esc="state.recipe.name"/>
<span class="badge rounded-pill o_fp_recipe_version_badge ms-2"
t-if="state.recipe.version">
<div class="o_fp_re_header">
<button class="o_fp_re_back"
t-on-click="onBackToList"
title="Back to recipes">
<i class="fa fa-arrow-left me-2"/>Recipes
</button>
<div class="o_fp_re_header_title" t-if="state.recipe">
<h2 class="o_fp_re_h2 mb-0">
<i class="fa fa-flask me-2"/><t t-esc="state.recipe.name"/>
<span t-if="state.recipe.version" class="o_fp_re_ver">
v<t t-esc="state.recipe.version"/>
</span>
</h2>
<div class="o_fp_re_subtitle" t-if="state.recipe.process_type">
<i class="fa fa-tag me-1"/><t t-esc="state.recipe.process_type"/>
</div>
</div>
<div class="o_fp_recipe_header_right" t-if="state.recipe">
<span class="text-muted small me-3" t-if="state.recipe.process_type">
<i class="fa fa-tag me-1"/>
<t t-esc="state.recipe.process_type"/>
</span>
<button class="btn btn-sm btn-outline-secondary me-1"
t-on-click="onDuplicate" title="Duplicate recipe">
<i class="fa fa-copy me-1"/> Duplicate
<div class="o_fp_re_header_actions" t-if="state.recipe">
<button class="o_fp_re_btn_outline"
t-on-click="onDuplicate"
title="Duplicate recipe">
<i class="fa fa-copy me-1"/>Duplicate
</button>
<button class="btn btn-sm btn-outline-primary"
<button class="o_fp_re_btn_outline"
t-on-click="() => this.onOpenForm(state.recipe.id)"
title="Edit in form view">
<i class="fa fa-pencil me-1"/> Form View
<i class="fa fa-pencil me-1"/>Form
</button>
</div>
</div>
@@ -48,17 +184,17 @@
<p class="mt-2 text-muted">Loading recipe tree...</p>
</div>
<!-- ========== NO RECIPE ========== -->
<div class="text-center py-5" t-if="!state.loading and !_recipeId">
<i class="fa fa-exclamation-triangle fa-3x text-muted"/>
<p class="mt-3 text-muted">No recipe selected.</p>
<!-- ========== EMPTY ========== -->
<div class="o_fp_re_empty" t-if="!state.loading and !_recipeId">
<i class="fa fa-exclamation-triangle"/>
<div>No recipe selected.</div>
</div>
<!-- ========== TREE + PANEL LAYOUT ========== -->
<div class="o_fp_recipe_body" t-if="state.tree">
<!-- ========== BODY (canvas + side panel) ========== -->
<div class="o_fp_re_body" t-if="state.tree">
<!-- Tree area -->
<div class="o_fp_recipe_tree_area">
<!-- Tree canvas -->
<div class="o_fp_re_canvas">
<t t-call="fusion_plating.RecipeTreeNode">
<t t-set="node" t-value="state.tree"/>
<t t-set="parentNode" t-value="null"/>
@@ -67,26 +203,29 @@
</div>
<!-- Side panel -->
<div t-att-class="'o_fp_recipe_panel' + (state.showPanel ? ' o_fp_recipe_panel_open' : '')">
<div t-att-class="'o_fp_re_panel' + (state.showPanel ? ' o_fp_re_panel_open' : '')">
<t t-if="state.showPanel and state.selectedNode">
<div class="o_fp_recipe_panel_header">
<div class="o_fp_re_panel_head">
<h5>
<i t-att-class="'fa ' + (state.selectedNode.icon || 'fa-cog') + ' me-2'"/>
<i t-attf-class="fa #{ state.selectedNode.icon || 'fa-cog' } me-2"/>
Edit Node
</h5>
<button class="btn btn-sm btn-link" t-on-click="closePanel">
<button class="o_fp_re_btn o_fp_re_btn_cancel"
t-on-click="closePanel">
<i class="fa fa-times"/>
</button>
</div>
<div class="o_fp_recipe_panel_body">
<div class="mb-3">
<label class="form-label fw-bold">Name</label>
<div class="o_fp_re_panel_body">
<div class="o_fp_re_field">
<label>Name</label>
<input type="text" class="form-control"
t-att-value="state.selectedNode.name"
t-on-change="(ev) => { state.selectedNode.name = ev.target.value; }"/>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Type</label>
<div class="o_fp_re_field">
<label>Type</label>
<select class="form-select"
t-on-change="(ev) => { state.selectedNode.node_type = ev.target.value; }">
<option value="recipe"
@@ -99,53 +238,57 @@
t-att-selected="state.selectedNode.node_type === 'step'">Step</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Icon</label>
<div class="o_fp_recipe_icon_picker">
<div class="o_fp_re_field">
<label>Icon</label>
<div class="o_fp_re_icon_picker">
<t t-foreach="getIconOptions()" t-as="ic" t-key="ic.value">
<button t-att-class="'o_fp_recipe_icon_btn' + (state.selectedNode.icon === ic.value ? ' active' : '')"
<button t-att-class="'o_fp_re_icon_btn' + (state.selectedNode.icon === ic.value ? ' active' : '')"
t-on-click.stop="() => { state.selectedNode.icon = ic.value; }"
t-att-title="ic.label">
<i t-att-class="'fa ' + ic.value"/>
<i t-attf-class="fa #{ ic.value }"/>
</button>
</t>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Duration (min)</label>
<div class="o_fp_re_field">
<label>Estimated Duration (min)</label>
<input type="number" class="form-control" min="0" step="1"
t-att-value="state.selectedNode.estimated_duration || 0"
t-on-change="(ev) => { state.selectedNode.estimated_duration = parseFloat(ev.target.value) || 0; }"/>
</div>
<div class="mb-3">
<label class="form-label fw-bold d-block">Flags</label>
<div class="o_fp_re_field">
<label>Flags</label>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="fp_chk_manual"
<input type="checkbox" class="form-check-input" id="fp_re_chk_manual"
t-att-checked="state.selectedNode.is_manual"
t-on-change="(ev) => { state.selectedNode.is_manual = ev.target.checked; }"/>
<label class="form-check-label" for="fp_chk_manual">Manual operation</label>
<label class="form-check-label" for="fp_re_chk_manual">Manual operation</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="fp_chk_auto"
<input type="checkbox" class="form-check-input" id="fp_re_chk_auto"
t-att-checked="state.selectedNode.auto_complete"
t-on-change="(ev) => { state.selectedNode.auto_complete = ev.target.checked; }"/>
<label class="form-check-label" for="fp_chk_auto">Auto-complete</label>
<label class="form-check-label" for="fp_re_chk_auto">Auto-complete</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="fp_chk_signoff"
<input type="checkbox" class="form-check-input" id="fp_re_chk_signoff"
t-att-checked="state.selectedNode.requires_signoff"
t-on-change="(ev) => { state.selectedNode.requires_signoff = ev.target.checked; }"/>
<label class="form-check-label" for="fp_chk_signoff">Requires sign-off</label>
<label class="form-check-label" for="fp_re_chk_signoff">Requires sign-off</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="fp_chk_visible"
<input type="checkbox" class="form-check-input" id="fp_re_chk_visible"
t-att-checked="state.selectedNode.customer_visible"
t-on-change="(ev) => { state.selectedNode.customer_visible = ev.target.checked; }"/>
<label class="form-check-label" for="fp_chk_visible">Customer visible</label>
<label class="form-check-label" for="fp_re_chk_visible">Customer visible</label>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Opt In/Out</label>
<div class="o_fp_re_field">
<label>Opt In/Out</label>
<select class="form-select"
t-on-change="(ev) => { state.selectedNode.opt_in_out = ev.target.value; }">
<option value="disabled"
@@ -156,30 +299,16 @@
t-att-selected="state.selectedNode.opt_in_out === 'opt_out'">Opt-Out</option>
</select>
</div>
<!-- Info -->
<div class="text-muted small mb-2" t-if="state.selectedNode.work_center">
<i class="fa fa-building me-1"/>
<t t-esc="state.selectedNode.work_center"/>
</div>
<div class="text-muted small mb-2" t-if="state.selectedNode.process_type">
<i class="fa fa-tag me-1"/>
<t t-esc="state.selectedNode.process_type"/>
</div>
<div class="text-muted small mb-2"
t-if="state.selectedNode.input_count">
<i class="fa fa-keyboard-o me-1"/>
<t t-esc="state.selectedNode.input_count"/> operator input(s)
</div>
<!-- Tracking -->
<div class="o_fp_recipe_tracking mt-3 pt-3" t-if="state.selectedNode.create_date">
<div class="text-muted small mb-1">
<div class="o_fp_re_tracking" t-if="state.selectedNode.create_date">
<div>
<i class="fa fa-calendar-plus-o me-1"/>
Created <t t-esc="formatTimeAgo(state.selectedNode.create_date)"/>
<t t-if="state.selectedNode.create_uid_name">
by <strong t-esc="state.selectedNode.create_uid_name"/>
</t>
</div>
<div class="text-muted small" t-if="state.selectedNode.write_date">
<div t-if="state.selectedNode.write_date">
<i class="fa fa-pencil me-1"/>
Updated <t t-esc="formatTimeAgo(state.selectedNode.write_date)"/>
<t t-if="state.selectedNode.write_uid_name">
@@ -187,15 +316,15 @@
</t>
</div>
</div>
<!-- Actions -->
<div class="d-flex gap-2 mt-4">
<button class="btn btn-primary flex-fill"
<div class="o_fp_re_panel_actions">
<button class="o_fp_re_btn_save"
t-on-click="saveNode"
t-att-disabled="state.saving">
<i t-att-class="state.saving ? 'fa fa-spinner fa-spin me-1' : 'fa fa-check me-1'"/>
Save
</button>
<button class="btn btn-outline-secondary"
<button class="o_fp_re_btn_outline"
t-on-click="() => this.onOpenForm(state.selectedNode.id)"
title="Open full form">
<i class="fa fa-external-link"/>
@@ -208,127 +337,4 @@
</div>
</t>
<!-- ========== RECURSIVE NODE TEMPLATE ========== -->
<t t-name="fusion_plating.RecipeTreeNode">
<!-- Connector line (skip for root) -->
<div class="o_fp_recipe_connector" t-if="!isFirst"/>
<!-- Node card -->
<div t-att-class="'o_fp_recipe_node'
+ (state.selectedNodeId === node.id ? ' o_fp_recipe_node_selected' : '')
+ ' o_fp_recipe_node_' + node.node_type"
t-att-draggable="node.node_type !== 'recipe' ? 'true' : 'false'"
t-on-dragstart="(ev) => this.onNodeDragStart(node, parentNode, ev)"
t-on-dragend="(ev) => this.onNodeDragEnd(ev)"
t-on-dragover="(ev) => this.onNodeDragOver(node, ev)"
t-on-dragleave="(ev) => this.onNodeDragLeave(ev)"
t-on-drop="(ev) => this.onNodeDrop(node, parentNode, ev)"
t-on-click.stop="() => this.selectNode(node)">
<!-- Drag handle (non-root only) -->
<span class="o_fp_recipe_drag_handle" t-if="node.node_type !== 'recipe'">
<i class="fa fa-grip-vertical"/>
</span>
<!-- Node header row -->
<div class="o_fp_recipe_node_header">
<!-- Expand/collapse toggle -->
<button class="o_fp_recipe_toggle_btn"
t-if="node.children and node.children.length"
t-on-click.stop="() => this.toggleExpand(node.id)">
<i t-att-class="isExpanded(node.id) ? 'fa fa-chevron-down' : 'fa fa-chevron-right'"/>
</button>
<span class="o_fp_recipe_toggle_spacer" t-else=""/>
<!-- Icon -->
<i t-att-class="'o_fp_recipe_node_icon fa ' + (node.icon || 'fa-cog')"/>
<!-- Name -->
<span class="o_fp_recipe_node_name">
<t t-esc="node.name"/>
</span>
<!-- Type badge -->
<span t-att-class="'badge o_fp_recipe_node_badge ' + getNodeTypeMeta(node.node_type).badgeClass">
<t t-esc="getNodeTypeMeta(node.node_type).label"/>
</span>
</div>
<!-- Meta row: work centre, duration, capability icons -->
<div class="o_fp_recipe_node_meta">
<span class="o_fp_recipe_node_wc" t-if="node.work_center">
<i class="fa fa-building me-1"/>
<t t-esc="node.work_center"/>
</span>
<span class="o_fp_recipe_node_duration" t-if="node.estimated_duration">
<i class="fa fa-clock-o me-1"/>
<t t-esc="formatDuration(node.estimated_duration)"/>
</span>
<!-- Capability icons -->
<span class="o_fp_recipe_node_icons">
<i class="fa fa-hand-paper-o" t-if="node.is_manual" title="Manual"/>
<i class="fa fa-bolt" t-if="!node.is_manual" title="Automated"/>
<i class="fa fa-check-square" t-if="node.requires_signoff" title="Requires sign-off"/>
<i class="fa fa-eye" t-if="node.customer_visible" title="Customer visible"/>
<i class="fa fa-magic" t-if="node.auto_complete" title="Auto-complete"/>
</span>
</div>
<!-- Action buttons row -->
<div class="o_fp_recipe_node_actions">
<button class="btn btn-sm o_fp_recipe_add_btn"
t-on-click.stop="() => this.startAddChild(node.id)"
title="Add child step">
<i class="fa fa-plus me-1"/> Add Step
</button>
<button class="btn btn-sm o_fp_recipe_delete_btn"
t-if="node.node_type !== 'recipe'"
t-on-click.stop="() => this.deleteNode(node.id)"
title="Delete">
<i class="fa fa-trash"/>
</button>
</div>
</div>
<!-- Add child inline form -->
<div class="o_fp_recipe_add_form" t-if="state.addingTo === node.id">
<div class="o_fp_recipe_connector"/>
<div class="o_fp_recipe_add_card">
<input type="text" class="form-control form-control-sm mb-2"
placeholder="New step name..."
t-att-value="state.newNodeName"
t-on-input="(ev) => { state.newNodeName = ev.target.value; }"
t-on-keydown="onAddNameKey"/>
<div class="d-flex gap-2">
<select class="form-select form-select-sm flex-shrink-1"
style="max-width: 140px;"
t-on-change="(ev) => { state.newNodeType = ev.target.value; }">
<t t-foreach="getNodeTypeOptions()" t-as="opt" t-key="opt.value">
<option t-att-value="opt.value"
t-att-selected="state.newNodeType === opt.value"
t-esc="opt.label"/>
</t>
</select>
<button class="btn btn-sm btn-primary" t-on-click="confirmAdd">
<i class="fa fa-check"/>
</button>
<button class="btn btn-sm btn-outline-secondary" t-on-click="cancelAdd">
<i class="fa fa-times"/>
</button>
</div>
</div>
</div>
<!-- Children (recursive) -->
<div class="o_fp_recipe_children" t-if="node.children and node.children.length and isExpanded(node.id)">
<t t-foreach="node.children" t-as="child" t-key="child.id">
<t t-call="fusion_plating.RecipeTreeNode">
<t t-set="node" t-value="child"/>
<t t-set="parentNode" t-value="node"/>
<t t-set="isFirst" t-value="false"/>
</t>
</t>
</div>
</t>
</templates>