fusion_plating: add process recipe system with OWL tree editor (v19.0.2.0.0)
New model fusion.plating.process.node with _parent_store hierarchy for defining reusable plating recipes. Node types: recipe, sub_process, operation, step. Includes companion model for operator input definitions. Full OWL tree editor (client action) with: - Hierarchical tree with connector lines and type-coloured borders - Click-to-edit side panel with save - Add/delete child nodes inline - Drag & drop reorder and reparent - Theme-aware SCSS (light + dark mode) - Demo data: Electroless Nickel Plating — Steel Line (30+ nodes) Backend: 7 JSON-RPC endpoints for tree CRUD, reorder, move, duplicate. Security: 3-tier ACL (operator read / supervisor write / manager full). Menu: Process Recipes under Plating > Operations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,4 +3,5 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
|
||||
@@ -89,15 +89,20 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'views/fp_bath_log_views.xml',
|
||||
'views/fp_facility_views.xml',
|
||||
'views/fp_bath_views.xml',
|
||||
'views/fp_process_node_views.xml',
|
||||
'views/fp_menu.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_plating/static/src/scss/fusion_plating.scss',
|
||||
'fusion_plating/static/src/scss/recipe_tree_editor.scss',
|
||||
'fusion_plating/static/src/xml/recipe_tree_editor.xml',
|
||||
'fusion_plating/static/src/js/recipe_tree_editor.js',
|
||||
],
|
||||
},
|
||||
'demo': [
|
||||
'data/fp_demo_data.xml',
|
||||
'data/fp_demo_recipe_data.xml',
|
||||
],
|
||||
'images': ['static/description/icon.png'],
|
||||
'installable': True,
|
||||
|
||||
5
fusion-plating/fusion_plating/controllers/__init__.py
Normal file
5
fusion-plating/fusion_plating/controllers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import recipe_controller
|
||||
188
fusion-plating/fusion_plating/controllers/recipe_controller.py
Normal file
188
fusion-plating/fusion_plating/controllers/recipe_controller.py
Normal file
@@ -0,0 +1,188 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpRecipeController(http.Controller):
|
||||
"""JSON-RPC endpoints for the process recipe tree editor."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Read — full tree
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/tree', type='jsonrpc', auth='user')
|
||||
def get_tree(self, recipe_id):
|
||||
"""Return the full nested tree for a recipe."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
recipe = Node.browse(int(recipe_id))
|
||||
if not recipe.exists():
|
||||
return {'ok': False, 'error': f'Recipe {recipe_id} not found.'}
|
||||
return {
|
||||
'ok': True,
|
||||
'recipe': {
|
||||
'id': recipe.id,
|
||||
'name': recipe.name,
|
||||
'code': recipe.code or '',
|
||||
'version': recipe.version,
|
||||
'process_type': recipe.process_type_id.name if recipe.process_type_id else '',
|
||||
},
|
||||
'tree': recipe.get_tree_data(),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create node
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/node/create', type='jsonrpc', auth='user')
|
||||
def create_node(self, parent_id, name, node_type='operation', vals=None):
|
||||
"""Create a new child node under parent_id."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
parent = Node.browse(int(parent_id))
|
||||
if not parent.exists():
|
||||
return {'ok': False, 'error': 'Parent node not found.'}
|
||||
|
||||
# Determine next sequence
|
||||
max_seq = max((c.sequence for c in parent.child_ids), default=0)
|
||||
data = {
|
||||
'name': name,
|
||||
'node_type': node_type,
|
||||
'parent_id': parent.id,
|
||||
'sequence': max_seq + 10,
|
||||
}
|
||||
if vals:
|
||||
data.update(vals)
|
||||
|
||||
try:
|
||||
new_node = Node.create(data)
|
||||
_logger.info('Recipe: created node %s (%s) under %s by uid %s',
|
||||
new_node.id, name, parent.id, request.env.uid)
|
||||
return {'ok': True, 'node_id': new_node.id}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe create_node failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Update node
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/node/write', type='jsonrpc', auth='user')
|
||||
def write_node(self, node_id, vals):
|
||||
"""Update fields on an existing node."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
node = Node.browse(int(node_id))
|
||||
if not node.exists():
|
||||
return {'ok': False, 'error': 'Node not found.'}
|
||||
|
||||
# Filter to allowed fields only
|
||||
allowed = {
|
||||
'name', 'code', 'node_type', 'icon', 'color',
|
||||
'process_type_id', 'work_center_id',
|
||||
'description', 'notes',
|
||||
'estimated_duration',
|
||||
'auto_complete', 'customer_visible', 'is_manual',
|
||||
'requires_signoff', 'sequence', 'version',
|
||||
}
|
||||
safe_vals = {k: v for k, v in vals.items() if k in allowed}
|
||||
if not safe_vals:
|
||||
return {'ok': False, 'error': 'No valid fields to update.'}
|
||||
|
||||
try:
|
||||
node.write(safe_vals)
|
||||
return {'ok': True}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe write_node failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Delete node
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/node/unlink', type='jsonrpc', auth='user')
|
||||
def unlink_node(self, node_id):
|
||||
"""Delete a node and all its children (cascade)."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
node = Node.browse(int(node_id))
|
||||
if not node.exists():
|
||||
return {'ok': False, 'error': 'Node not found.'}
|
||||
if node.node_type == 'recipe':
|
||||
return {'ok': False, 'error': 'Cannot delete a recipe root from the tree editor. Use the list view.'}
|
||||
|
||||
try:
|
||||
name = node.name
|
||||
node.unlink()
|
||||
_logger.info('Recipe: deleted node %s (%s) by uid %s',
|
||||
node_id, name, request.env.uid)
|
||||
return {'ok': True}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe unlink_node failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reorder siblings
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/node/reorder', type='jsonrpc', auth='user')
|
||||
def reorder_nodes(self, node_ids):
|
||||
"""Bulk-update sequence for an ordered list of sibling node IDs."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
try:
|
||||
for idx, nid in enumerate(node_ids):
|
||||
Node.browse(int(nid)).write({'sequence': (idx + 1) * 10})
|
||||
return {'ok': True}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe reorder failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Move node to new parent
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/node/move', type='jsonrpc', auth='user')
|
||||
def move_node(self, node_id, new_parent_id):
|
||||
"""Move a node to a new parent (drag between sub-trees)."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
node = Node.browse(int(node_id))
|
||||
parent = Node.browse(int(new_parent_id))
|
||||
if not node.exists() or not parent.exists():
|
||||
return {'ok': False, 'error': 'Node or parent not found.'}
|
||||
|
||||
# Prevent moving a recipe root
|
||||
if node.node_type == 'recipe':
|
||||
return {'ok': False, 'error': 'Cannot move a recipe root.'}
|
||||
|
||||
# Prevent making a node its own descendant
|
||||
if f'/{node.id}/' in (parent.parent_path or ''):
|
||||
return {'ok': False, 'error': 'Cannot move a node under its own descendant.'}
|
||||
|
||||
try:
|
||||
max_seq = max((c.sequence for c in parent.child_ids), default=0)
|
||||
node.write({
|
||||
'parent_id': parent.id,
|
||||
'sequence': max_seq + 10,
|
||||
})
|
||||
return {'ok': True}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe move_node failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Duplicate recipe
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/recipe/duplicate', type='jsonrpc', auth='user')
|
||||
def duplicate_recipe(self, recipe_id):
|
||||
"""Deep-copy an entire recipe tree."""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
recipe = Node.browse(int(recipe_id))
|
||||
if not recipe.exists():
|
||||
return {'ok': False, 'error': 'Recipe not found.'}
|
||||
if recipe.node_type != 'recipe':
|
||||
return {'ok': False, 'error': 'Can only duplicate recipe roots.'}
|
||||
|
||||
try:
|
||||
new_recipe = recipe.copy()
|
||||
return {'ok': True, 'recipe_id': new_recipe.id}
|
||||
except Exception as exc:
|
||||
_logger.exception('Recipe duplicate failed')
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
262
fusion-plating/fusion_plating/data/fp_demo_recipe_data.xml
Normal file
262
fusion-plating/fusion_plating/data/fp_demo_recipe_data.xml
Normal file
@@ -0,0 +1,262 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Demo recipe: Electroless Nickel Plating — Steel Line
|
||||
-->
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- ===== ROOT: Electroless Nickel Plating — Steel Line ===== -->
|
||||
<record id="demo_recipe_en_steel" model="fusion.plating.process.node">
|
||||
<field name="name">Electroless Nickel Plating — Steel Line</field>
|
||||
<field name="code">EN_STEEL</field>
|
||||
<field name="node_type">recipe</field>
|
||||
<field name="icon">fa-flask</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="customer_visible">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 1. Blasting -->
|
||||
<record id="demo_node_blasting" model="fusion.plating.process.node">
|
||||
<field name="name">Blasting</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-bullseye</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="estimated_duration">30</field>
|
||||
<field name="is_manual">True</field>
|
||||
</record>
|
||||
<record id="demo_step_ready_blast" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for Blast</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_blasting"/>
|
||||
<field name="icon">fa-clock-o</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="demo_step_blast" model="fusion.plating.process.node">
|
||||
<field name="name">Blast</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_blasting"/>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 2. Masking -->
|
||||
<record id="demo_node_masking" model="fusion.plating.process.node">
|
||||
<field name="name">Masking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-paint-brush</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="estimated_duration">45</field>
|
||||
<field name="is_manual">True</field>
|
||||
</record>
|
||||
<record id="demo_step_ready_mask" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for Masking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_masking"/>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="demo_step_mask" model="fusion.plating.process.node">
|
||||
<field name="name">Masking</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_masking"/>
|
||||
<field name="icon">fa-paint-brush</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 3. Racking -->
|
||||
<record id="demo_node_racking" model="fusion.plating.process.node">
|
||||
<field name="name">Racking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-th</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="estimated_duration">20</field>
|
||||
</record>
|
||||
|
||||
<!-- 4. Steel Line (sub-process with many children) -->
|
||||
<record id="demo_node_steel_line" model="fusion.plating.process.node">
|
||||
<field name="name">Steel Line</field>
|
||||
<field name="node_type">sub_process</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-industry</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="auto_complete">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 4a. Cleaner (sub-process inside Steel Line) -->
|
||||
<record id="demo_node_cleaner" model="fusion.plating.process.node">
|
||||
<field name="name">Cleaner</field>
|
||||
<field name="node_type">sub_process</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-shower</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="demo_step_soak_clean" model="fusion.plating.process.node">
|
||||
<field name="name">Soak Clean (S-3)</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_cleaner"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="estimated_duration">10</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
<record id="demo_step_electroclean" model="fusion.plating.process.node">
|
||||
<field name="name">Electroclean (S-3)</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_cleaner"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="estimated_duration">5</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
<record id="demo_step_primary_rinse_1" model="fusion.plating.process.node">
|
||||
<field name="name">Primary Rinse (S-4)</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_cleaner"/>
|
||||
<field name="sequence">30</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
|
||||
<!-- 4b. Acid Dip -->
|
||||
<record id="demo_node_acid_dip" model="fusion.plating.process.node">
|
||||
<field name="name">Acid Dip (S-5)</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-flask</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="estimated_duration">5</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
|
||||
<!-- 4c. Nickel Strike -->
|
||||
<record id="demo_node_nickel_strike" model="fusion.plating.process.node">
|
||||
<field name="name">Nickel Strike (S-7 / SP-5)</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-bolt</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="estimated_duration">8</field>
|
||||
<field name="is_manual">False</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 4d. E-Nickel Plate (Mid Phos) -->
|
||||
<record id="demo_node_en_plate" model="fusion.plating.process.node">
|
||||
<field name="name">E-Nickel Plate (Mid Phos) (S-9)</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-diamond</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="estimated_duration">90</field>
|
||||
<field name="is_manual">False</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
<record id="demo_step_rinse_after_plate" model="fusion.plating.process.node">
|
||||
<field name="name">Primary Rinse (S-11)</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_en_plate"/>
|
||||
<field name="sequence">10</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
<record id="demo_step_hot_rinse" model="fusion.plating.process.node">
|
||||
<field name="name">Hot Rinse (S-13)</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_en_plate"/>
|
||||
<field name="sequence">20</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
|
||||
<!-- 4e. Hot Water Porosity -->
|
||||
<record id="demo_node_porosity" model="fusion.plating.process.node">
|
||||
<field name="name">Hot Water Porosity (A-15)</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-tint</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="estimated_duration">15</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 4f. Dry -->
|
||||
<record id="demo_node_dry" model="fusion.plating.process.node">
|
||||
<field name="name">Dry</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_node_steel_line"/>
|
||||
<field name="icon">fa-sun-o</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
|
||||
<!-- 5. Oven Baking -->
|
||||
<record id="demo_node_oven_bake" model="fusion.plating.process.node">
|
||||
<field name="name">Oven Baking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="estimated_duration">240</field>
|
||||
<field name="is_manual">False</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
<!-- 6. De-racking -->
|
||||
<record id="demo_node_derack" model="fusion.plating.process.node">
|
||||
<field name="name">De-Racking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-th</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="estimated_duration">15</field>
|
||||
</record>
|
||||
|
||||
<!-- 7. De-Masking -->
|
||||
<record id="demo_node_demask" model="fusion.plating.process.node">
|
||||
<field name="name">De-Masking</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-eraser</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="estimated_duration">20</field>
|
||||
</record>
|
||||
|
||||
<!-- 8. Oven Bake (Post De-Rack) -->
|
||||
<record id="demo_node_post_bake" model="fusion.plating.process.node">
|
||||
<field name="name">Oven Bake (Post De-Rack)</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="sequence">80</field>
|
||||
<field name="estimated_duration">120</field>
|
||||
<field name="is_manual">False</field>
|
||||
</record>
|
||||
|
||||
<!-- 9. Post Plate Inspection -->
|
||||
<record id="demo_node_inspection" model="fusion.plating.process.node">
|
||||
<field name="name">Post Plate Inspection</field>
|
||||
<field name="node_type">operation</field>
|
||||
<field name="parent_id" ref="demo_recipe_en_steel"/>
|
||||
<field name="icon">fa-search</field>
|
||||
<field name="sequence">90</field>
|
||||
<field name="estimated_duration">30</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
<record id="demo_step_ready_inspect" model="fusion.plating.process.node">
|
||||
<field name="name">Ready for Post Plate Inspection</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_inspection"/>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="demo_step_inspect" model="fusion.plating.process.node">
|
||||
<field name="name">Post Plate Inspection</field>
|
||||
<field name="node_type">step</field>
|
||||
<field name="parent_id" ref="demo_node_inspection"/>
|
||||
<field name="icon">fa-check-circle</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="requires_signoff">True</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -12,4 +12,5 @@ from . import fp_bath
|
||||
from . import fp_bath_log
|
||||
from . import fp_bath_log_line
|
||||
from . import fp_bath_parameter
|
||||
from . import fp_process_node
|
||||
from . import res_company
|
||||
|
||||
361
fusion-plating/fusion_plating/models/fp_process_node.py
Normal file
361
fusion-plating/fusion_plating/models/fp_process_node.py
Normal file
@@ -0,0 +1,361 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class FpProcessNode(models.Model):
|
||||
"""A node in the process recipe tree.
|
||||
|
||||
Recipes are hierarchical templates that define how to plate a part.
|
||||
They are reusable across production orders and serve as the single
|
||||
source of truth for the shop's plating processes.
|
||||
|
||||
Node types
|
||||
----------
|
||||
* recipe — top-level root (e.g. "Electroless Nickel — Steel Line")
|
||||
* sub_process — a group of operations (e.g. "Steel Line", "Cleaner")
|
||||
* operation — a single production step (e.g. "Acid Dip", "Nickel Strike")
|
||||
* step — a sub-step within an operation (e.g. "Ready for Blast", "Blast")
|
||||
|
||||
Hierarchy uses Odoo's _parent_store for efficient tree queries.
|
||||
"""
|
||||
_name = 'fusion.plating.process.node'
|
||||
_description = 'Fusion Plating — Process Node'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_parent_store = True
|
||||
_parent_name = 'parent_id'
|
||||
_order = 'parent_path, sequence, id'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
# ---- Identity & hierarchy ------------------------------------------------
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
code = fields.Char(
|
||||
string='Code',
|
||||
help='Optional short code (e.g. EN_STEEL).',
|
||||
tracking=True,
|
||||
)
|
||||
node_type = fields.Selection(
|
||||
[
|
||||
('recipe', 'Recipe'),
|
||||
('sub_process', 'Sub-Process'),
|
||||
('operation', 'Operation'),
|
||||
('step', 'Step'),
|
||||
],
|
||||
string='Type',
|
||||
required=True,
|
||||
default='operation',
|
||||
tracking=True,
|
||||
)
|
||||
parent_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Parent',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
parent_path = fields.Char(
|
||||
index=True,
|
||||
unaccent=False,
|
||||
)
|
||||
child_ids = fields.One2many(
|
||||
'fusion.plating.process.node',
|
||||
'parent_id',
|
||||
string='Child Steps',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
depth = fields.Integer(
|
||||
string='Depth',
|
||||
compute='_compute_depth',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ---- Process references --------------------------------------------------
|
||||
|
||||
process_type_id = fields.Many2one(
|
||||
'fusion.plating.process.type',
|
||||
string='Process Type',
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
work_center_id = fields.Many2one(
|
||||
'fusion.plating.work.center',
|
||||
string='Work Centre',
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Content & metadata --------------------------------------------------
|
||||
|
||||
description = fields.Html(
|
||||
string='Description',
|
||||
help='Rich text instructions for this step.',
|
||||
)
|
||||
notes = fields.Text(
|
||||
string='Internal Notes',
|
||||
help='Internal notes (not shown to customers).',
|
||||
)
|
||||
icon = fields.Char(
|
||||
string='Icon',
|
||||
default='fa-cog',
|
||||
help='Font Awesome icon class.',
|
||||
)
|
||||
color = fields.Integer(
|
||||
string='Colour',
|
||||
default=0,
|
||||
)
|
||||
|
||||
# ---- Timing --------------------------------------------------------------
|
||||
|
||||
estimated_duration = fields.Float(
|
||||
string='Estimated Duration (min)',
|
||||
help='Expected time in minutes.',
|
||||
)
|
||||
|
||||
# ---- Behaviour flags -----------------------------------------------------
|
||||
|
||||
auto_complete = fields.Boolean(
|
||||
string='Auto-Complete',
|
||||
default=False,
|
||||
help='Automatically marks done when all children complete.',
|
||||
)
|
||||
customer_visible = fields.Boolean(
|
||||
string='Customer Visible',
|
||||
default=True,
|
||||
help='Whether to show this step name to customers.',
|
||||
)
|
||||
is_manual = fields.Boolean(
|
||||
string='Manual Operation',
|
||||
default=True,
|
||||
help='Unchecked = automated (e.g. timed immersion).',
|
||||
)
|
||||
requires_signoff = fields.Boolean(
|
||||
string='Requires Sign-Off',
|
||||
default=False,
|
||||
help='Quality hold point — requires operator sign-off.',
|
||||
)
|
||||
|
||||
# ---- Lifecycle -----------------------------------------------------------
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
version = fields.Integer(
|
||||
string='Version',
|
||||
default=1,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Computed fields -----------------------------------------------------
|
||||
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
recursive=True,
|
||||
)
|
||||
child_count = fields.Integer(
|
||||
string='Children',
|
||||
compute='_compute_child_count',
|
||||
)
|
||||
recipe_root_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe Root',
|
||||
compute='_compute_recipe_root_id',
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ---- Operator inputs (one2many) ------------------------------------------
|
||||
|
||||
input_ids = fields.One2many(
|
||||
'fusion.plating.process.node.input',
|
||||
'node_id',
|
||||
string='Operator Inputs',
|
||||
)
|
||||
|
||||
# ---- SQL constraints -----------------------------------------------------
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_process_node_code_uniq',
|
||||
'unique(code)',
|
||||
'Recipe node code must be unique.'),
|
||||
]
|
||||
|
||||
# ---- Computes ------------------------------------------------------------
|
||||
|
||||
@api.depends('name', 'code', 'parent_id.display_name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
if rec.parent_id and rec.node_type != 'recipe':
|
||||
rec.display_name = f'{rec.parent_id.display_name} / {rec.name}'
|
||||
else:
|
||||
rec.display_name = rec.name or ''
|
||||
|
||||
@api.depends('parent_path')
|
||||
def _compute_depth(self):
|
||||
for rec in self:
|
||||
rec.depth = (rec.parent_path or '').count('/') - 1
|
||||
|
||||
@api.depends('child_ids')
|
||||
def _compute_child_count(self):
|
||||
for rec in self:
|
||||
rec.child_count = len(rec.child_ids)
|
||||
|
||||
@api.depends('parent_path')
|
||||
def _compute_recipe_root_id(self):
|
||||
for rec in self:
|
||||
if rec.parent_path:
|
||||
root_id = int(rec.parent_path.split('/')[0])
|
||||
rec.recipe_root_id = root_id
|
||||
else:
|
||||
rec.recipe_root_id = rec.id
|
||||
|
||||
# ---- Constraints ---------------------------------------------------------
|
||||
|
||||
@api.constrains('parent_id')
|
||||
def _check_recursion_constraint(self):
|
||||
if not self._check_recursion():
|
||||
raise ValidationError(
|
||||
_('A process node cannot be its own ancestor.'))
|
||||
|
||||
# ---- Tree data for OWL component -----------------------------------------
|
||||
|
||||
def get_tree_data(self):
|
||||
"""Return full nested dict for the OWL recipe tree editor.
|
||||
|
||||
Called via the controller. Returns the tree rooted at `self`,
|
||||
recursively including all descendants.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self._node_to_dict()
|
||||
|
||||
def _node_to_dict(self, max_depth=10):
|
||||
"""Recursively convert this node + children to a dict."""
|
||||
if max_depth <= 0:
|
||||
return None
|
||||
children = []
|
||||
for child in self.child_ids.sorted('sequence'):
|
||||
child_dict = child._node_to_dict(max_depth=max_depth - 1)
|
||||
if child_dict:
|
||||
children.append(child_dict)
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name or '',
|
||||
'code': self.code or '',
|
||||
'node_type': self.node_type,
|
||||
'sequence': self.sequence,
|
||||
'depth': self.depth,
|
||||
'icon': self.icon or 'fa-cog',
|
||||
'color': self.color,
|
||||
'process_type': self.process_type_id.name if self.process_type_id else '',
|
||||
'process_type_id': self.process_type_id.id if self.process_type_id else False,
|
||||
'work_center': self.work_center_id.name if self.work_center_id else '',
|
||||
'work_center_id': self.work_center_id.id if self.work_center_id else False,
|
||||
'description': self.description or '',
|
||||
'notes': self.notes or '',
|
||||
'estimated_duration': self.estimated_duration,
|
||||
'auto_complete': self.auto_complete,
|
||||
'customer_visible': self.customer_visible,
|
||||
'is_manual': self.is_manual,
|
||||
'requires_signoff': self.requires_signoff,
|
||||
'version': self.version,
|
||||
'child_count': len(children),
|
||||
'input_count': len(self.input_ids),
|
||||
'children': children,
|
||||
}
|
||||
|
||||
# ---- Actions -------------------------------------------------------------
|
||||
|
||||
def action_open_tree_editor(self):
|
||||
"""Open the OWL recipe tree editor for this recipe."""
|
||||
self.ensure_one()
|
||||
root = self if self.node_type == 'recipe' else self.recipe_root_id
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_recipe_tree_editor',
|
||||
'name': f'Recipe — {root.name}',
|
||||
'context': {'recipe_id': root.id},
|
||||
}
|
||||
|
||||
# ---- Copy (deep-duplicate) -----------------------------------------------
|
||||
|
||||
def copy(self, default=None):
|
||||
"""Deep-copy: duplicates the node and all descendants."""
|
||||
default = dict(default or {})
|
||||
if self.node_type == 'recipe':
|
||||
default.setdefault('name', _('%s (Copy)', self.name))
|
||||
default.setdefault('code', f'{self.code}_copy' if self.code else False)
|
||||
new_node = super().copy(default)
|
||||
for child in self.child_ids.sorted('sequence'):
|
||||
child.copy({'parent_id': new_node.id})
|
||||
return new_node
|
||||
|
||||
|
||||
class FpProcessNodeInput(models.Model):
|
||||
"""An operator input definition attached to a process node.
|
||||
|
||||
These define what the operator needs to record when executing this
|
||||
step — temperature readings, visual inspections, timing, etc.
|
||||
"""
|
||||
_name = 'fusion.plating.process.node.input'
|
||||
_description = 'Fusion Plating — Process Node Input'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
help='E.g. "Temperature Reading", "Visual Inspection".',
|
||||
)
|
||||
node_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Process Node',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
input_type = fields.Selection(
|
||||
[
|
||||
('text', 'Text'),
|
||||
('number', 'Number'),
|
||||
('boolean', 'Yes / No'),
|
||||
('selection', 'Selection'),
|
||||
('photo', 'Photo'),
|
||||
],
|
||||
string='Input Type',
|
||||
required=True,
|
||||
default='text',
|
||||
)
|
||||
required = fields.Boolean(
|
||||
string='Required',
|
||||
default=False,
|
||||
)
|
||||
hint = fields.Char(
|
||||
string='Hint',
|
||||
help='Placeholder text shown to the operator.',
|
||||
)
|
||||
selection_options = fields.Text(
|
||||
string='Options',
|
||||
help='Comma-separated list of options (for Selection type).',
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
uom = fields.Char(
|
||||
string='Unit',
|
||||
help='Unit label (e.g. °C, min, psi).',
|
||||
)
|
||||
@@ -26,3 +26,9 @@ access_fp_bath_log_manager,fp.bath.log.manager,model_fusion_plating_bath_log,gro
|
||||
access_fp_bath_log_line_operator,fp.bath.log.line.operator,model_fusion_plating_bath_log_line,group_fusion_plating_operator,1,1,1,0
|
||||
access_fp_bath_log_line_supervisor,fp.bath.log.line.supervisor,model_fusion_plating_bath_log_line,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_bath_log_line_manager,fp.bath.log.line.manager,model_fusion_plating_bath_log_line,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_process_node_operator,fp.process.node.operator,model_fusion_plating_process_node,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_process_node_supervisor,fp.process.node.supervisor,model_fusion_plating_process_node,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_process_node_manager,fp.process.node.manager,model_fusion_plating_process_node,group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_process_node_input_operator,fp.process.node.input.operator,model_fusion_plating_process_node_input,group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_process_node_input_supervisor,fp.process.node.input.supervisor,model_fusion_plating_process_node_input,group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_process_node_input_manager,fp.process.node.input.manager,model_fusion_plating_process_node_input,group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,403 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Recipe Tree Editor (OWL backend client action)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Professional tree editor for process recipes. Renders the full
|
||||
// node hierarchy with connector lines, expand/collapse, click-to-edit
|
||||
// side panel, add/delete operations, and drag-and-drop reorder.
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL: static template + static props = ["*"]
|
||||
// * RPC: standalone rpc() from @web/core/network/rpc
|
||||
// * Registered under registry.category("actions") → "fp_recipe_tree_editor"
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, useRef, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
// ---- Node type metadata ---------------------------------------------------
|
||||
const NODE_TYPES = {
|
||||
recipe: { label: "Recipe", icon: "fa-flask", badgeClass: "o_fp_recipe_badge_recipe" },
|
||||
sub_process: { label: "Sub-Process", icon: "fa-sitemap", badgeClass: "o_fp_recipe_badge_sub" },
|
||||
operation: { label: "Operation", icon: "fa-wrench", badgeClass: "o_fp_recipe_badge_op" },
|
||||
step: { label: "Step", icon: "fa-dot-circle-o", badgeClass: "o_fp_recipe_badge_step" },
|
||||
};
|
||||
|
||||
const NODE_TYPE_OPTIONS = [
|
||||
{ value: "sub_process", label: "Sub-Process" },
|
||||
{ value: "operation", label: "Operation" },
|
||||
{ value: "step", label: "Step" },
|
||||
];
|
||||
|
||||
export class RecipeTreeEditor extends Component {
|
||||
static template = "fusion_plating.RecipeTreeEditor";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.dialog = useService("dialog");
|
||||
|
||||
this.state = useState({
|
||||
recipe: null,
|
||||
tree: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
selectedNodeId: null,
|
||||
selectedNode: null,
|
||||
expandedNodes: {},
|
||||
showPanel: false,
|
||||
// Add-node form
|
||||
addingTo: null, // parent node id when "add" dialog is open
|
||||
newNodeName: "",
|
||||
newNodeType: "operation",
|
||||
});
|
||||
|
||||
this._recipeId = null;
|
||||
|
||||
onMounted(async () => {
|
||||
const ctx = this.props.action?.context || {};
|
||||
this._recipeId = ctx.recipe_id || null;
|
||||
if (this._recipeId) {
|
||||
await this.loadTree();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Data loading -------------------------------------------------------
|
||||
|
||||
async loadTree() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const result = await rpc("/fp/recipe/tree", {
|
||||
recipe_id: this._recipeId,
|
||||
});
|
||||
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;
|
||||
}
|
||||
// Refresh selected node data if panel is open
|
||||
if (this.state.selectedNodeId) {
|
||||
this.state.selectedNode = this._findNode(
|
||||
result.tree, this.state.selectedNodeId
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.notification.add(
|
||||
result?.error || "Failed to load recipe.",
|
||||
{ type: "danger" }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(`Load failed: ${err.message || err}`, { type: "danger" });
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tree traversal helpers ---------------------------------------------
|
||||
|
||||
_findNode(node, id) {
|
||||
if (!node) return null;
|
||||
if (node.id === id) return node;
|
||||
for (const child of (node.children || [])) {
|
||||
const found = this._findNode(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Expand / collapse --------------------------------------------------
|
||||
|
||||
isExpanded(nodeId) {
|
||||
return !!this.state.expandedNodes[nodeId];
|
||||
}
|
||||
|
||||
toggleExpand(nodeId) {
|
||||
this.state.expandedNodes[nodeId] = !this.state.expandedNodes[nodeId];
|
||||
}
|
||||
|
||||
// ---- Node selection (side panel) ----------------------------------------
|
||||
|
||||
selectNode(node) {
|
||||
if (this.state.selectedNodeId === node.id) {
|
||||
// Toggle panel off
|
||||
this.state.selectedNodeId = null;
|
||||
this.state.selectedNode = null;
|
||||
this.state.showPanel = false;
|
||||
} else {
|
||||
this.state.selectedNodeId = node.id;
|
||||
this.state.selectedNode = { ...node };
|
||||
this.state.showPanel = true;
|
||||
}
|
||||
}
|
||||
|
||||
closePanel() {
|
||||
this.state.selectedNodeId = null;
|
||||
this.state.selectedNode = null;
|
||||
this.state.showPanel = false;
|
||||
}
|
||||
|
||||
// ---- Node editing (panel save) ------------------------------------------
|
||||
|
||||
async saveNode() {
|
||||
const node = this.state.selectedNode;
|
||||
if (!node) return;
|
||||
this.state.saving = true;
|
||||
try {
|
||||
const vals = {
|
||||
name: node.name,
|
||||
icon: node.icon,
|
||||
node_type: node.node_type,
|
||||
estimated_duration: node.estimated_duration || 0,
|
||||
auto_complete: node.auto_complete,
|
||||
customer_visible: node.customer_visible,
|
||||
is_manual: node.is_manual,
|
||||
requires_signoff: node.requires_signoff,
|
||||
};
|
||||
const result = await rpc("/fp/recipe/node/write", {
|
||||
node_id: node.id,
|
||||
vals,
|
||||
});
|
||||
if (result && result.ok) {
|
||||
this.notification.add("Saved", { type: "success" });
|
||||
await this.loadTree();
|
||||
} else {
|
||||
this.notification.add(result?.error || "Save failed.", { type: "warning" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(`Save failed: ${err.message || err}`, { type: "danger" });
|
||||
} finally {
|
||||
this.state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Add child node -----------------------------------------------------
|
||||
|
||||
startAddChild(parentId) {
|
||||
this.state.addingTo = parentId;
|
||||
this.state.newNodeName = "";
|
||||
this.state.newNodeType = "operation";
|
||||
// Auto-expand parent
|
||||
this.state.expandedNodes[parentId] = true;
|
||||
}
|
||||
|
||||
cancelAdd() {
|
||||
this.state.addingTo = null;
|
||||
}
|
||||
|
||||
async confirmAdd() {
|
||||
const name = (this.state.newNodeName || "").trim();
|
||||
if (!name) {
|
||||
this.notification.add("Name is required.", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
this.state.saving = true;
|
||||
try {
|
||||
const result = await rpc("/fp/recipe/node/create", {
|
||||
parent_id: this.state.addingTo,
|
||||
name: name,
|
||||
node_type: this.state.newNodeType,
|
||||
});
|
||||
if (result && result.ok) {
|
||||
this.notification.add(`Added "${name}"`, { type: "success" });
|
||||
this.state.addingTo = null;
|
||||
await this.loadTree();
|
||||
} else {
|
||||
this.notification.add(result?.error || "Add failed.", { type: "warning" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(`Add failed: ${err.message || err}`, { type: "danger" });
|
||||
} finally {
|
||||
this.state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onAddNameKey(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
this.confirmAdd();
|
||||
} else if (ev.key === "Escape") {
|
||||
this.cancelAdd();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Delete node --------------------------------------------------------
|
||||
|
||||
async deleteNode(nodeId) {
|
||||
const node = this._findNode(this.state.tree, nodeId);
|
||||
if (!node) return;
|
||||
if (node.node_type === "recipe") {
|
||||
this.notification.add("Cannot delete the recipe root.", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
const childWarning = node.child_count > 0
|
||||
? ` and its ${node.child_count} child step(s)`
|
||||
: "";
|
||||
if (!confirm(`Delete "${node.name}"${childWarning}?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await rpc("/fp/recipe/node/unlink", { node_id: nodeId });
|
||||
if (result && result.ok) {
|
||||
this.notification.add(`Deleted "${node.name}"`, { type: "success" });
|
||||
if (this.state.selectedNodeId === nodeId) {
|
||||
this.closePanel();
|
||||
}
|
||||
await this.loadTree();
|
||||
} else {
|
||||
this.notification.add(result?.error || "Delete failed.", { type: "warning" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(`Delete failed: ${err.message || err}`, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Drag & drop reorder ------------------------------------------------
|
||||
|
||||
onNodeDragStart(node, parentNode, ev) {
|
||||
if (node.node_type === "recipe") {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
this._draggedNode = {
|
||||
id: node.id,
|
||||
parentId: parentNode ? parentNode.id : null,
|
||||
};
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
ev.dataTransfer.setData("text/plain", String(node.id));
|
||||
requestAnimationFrame(() => {
|
||||
ev.target.classList.add("o_fp_recipe_drag_ghost");
|
||||
});
|
||||
}
|
||||
|
||||
onNodeDragEnd(ev) {
|
||||
this._draggedNode = null;
|
||||
ev.target.classList.remove("o_fp_recipe_drag_ghost");
|
||||
document.querySelectorAll(".o_fp_recipe_drop_target").forEach(el => {
|
||||
el.classList.remove("o_fp_recipe_drop_target");
|
||||
});
|
||||
}
|
||||
|
||||
onNodeDragOver(node, ev) {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = "move";
|
||||
ev.currentTarget.classList.add("o_fp_recipe_drop_target");
|
||||
}
|
||||
|
||||
onNodeDragLeave(ev) {
|
||||
if (!ev.currentTarget.contains(ev.relatedTarget)) {
|
||||
ev.currentTarget.classList.remove("o_fp_recipe_drop_target");
|
||||
}
|
||||
}
|
||||
|
||||
async onNodeDrop(targetNode, parentNode, ev) {
|
||||
ev.preventDefault();
|
||||
ev.currentTarget.classList.remove("o_fp_recipe_drop_target");
|
||||
const dragged = this._draggedNode;
|
||||
if (!dragged || dragged.id === targetNode.id) return;
|
||||
|
||||
// If dropping on a node with children, move into it
|
||||
// If dropping on a sibling, reorder within parent
|
||||
const targetParentId = parentNode ? parentNode.id : null;
|
||||
|
||||
if (dragged.parentId === targetParentId) {
|
||||
// Reorder within same parent — swap positions
|
||||
const siblings = parentNode
|
||||
? (parentNode.children || [])
|
||||
: [this.state.tree];
|
||||
const ids = siblings.map(c => c.id);
|
||||
const fromIdx = ids.indexOf(dragged.id);
|
||||
const toIdx = ids.indexOf(targetNode.id);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
ids.splice(fromIdx, 1);
|
||||
ids.splice(toIdx, 0, dragged.id);
|
||||
|
||||
try {
|
||||
const result = await rpc("/fp/recipe/node/reorder", { node_ids: ids });
|
||||
if (result && result.ok) {
|
||||
await this.loadTree();
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(`Reorder failed: ${err.message}`, { type: "danger" });
|
||||
}
|
||||
} else {
|
||||
// Move to new parent
|
||||
try {
|
||||
const result = await rpc("/fp/recipe/node/move", {
|
||||
node_id: dragged.id,
|
||||
new_parent_id: targetNode.id,
|
||||
});
|
||||
if (result && result.ok) {
|
||||
this.state.expandedNodes[targetNode.id] = true;
|
||||
await this.loadTree();
|
||||
} else {
|
||||
this.notification.add(result?.error || "Move failed.", { type: "warning" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(`Move failed: ${err.message}`, { type: "danger" });
|
||||
}
|
||||
}
|
||||
this._draggedNode = null;
|
||||
}
|
||||
|
||||
// ---- Navigation ---------------------------------------------------------
|
||||
|
||||
onBackToList() {
|
||||
this.action.doAction("fusion_plating.action_fp_process_recipe");
|
||||
}
|
||||
|
||||
onOpenForm(nodeId) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fusion.plating.process.node",
|
||||
res_id: nodeId,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
async onDuplicate() {
|
||||
if (!this._recipeId) return;
|
||||
try {
|
||||
const result = await rpc("/fp/recipe/duplicate", {
|
||||
recipe_id: this._recipeId,
|
||||
});
|
||||
if (result && result.ok) {
|
||||
this.notification.add("Recipe duplicated.", { type: "success" });
|
||||
this._recipeId = result.recipe_id;
|
||||
await this.loadTree();
|
||||
} else {
|
||||
this.notification.add(result?.error || "Duplicate failed.", { type: "warning" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(`Duplicate failed: ${err.message}`, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ------------------------------------------------------------
|
||||
|
||||
getNodeTypeMeta(type) {
|
||||
return NODE_TYPES[type] || NODE_TYPES.operation;
|
||||
}
|
||||
|
||||
getNodeTypeOptions() {
|
||||
return NODE_TYPE_OPTIONS;
|
||||
}
|
||||
|
||||
formatDuration(minutes) {
|
||||
if (!minutes) return "";
|
||||
if (minutes < 60) return `${Math.round(minutes)}m`;
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = Math.round(minutes % 60);
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_recipe_tree_editor", RecipeTreeEditor);
|
||||
@@ -0,0 +1,393 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Recipe Tree Editor
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// THEME AWARENESS
|
||||
// ---------------
|
||||
// All colours from CSS custom properties + SCSS $border-color.
|
||||
// Works in both light and dark mode.
|
||||
// =============================================================================
|
||||
|
||||
// ---- Root container ---------------------------------------------------------
|
||||
|
||||
.o_fp_recipe_editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--o-view-background-color, var(--bs-body-bg));
|
||||
}
|
||||
|
||||
// ---- Header -----------------------------------------------------------------
|
||||
|
||||
.o_fp_recipe_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: var(--bs-body-bg);
|
||||
border-bottom: 1px solid $border-color;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.o_fp_recipe_header_left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.o_fp_recipe_back_btn {
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.o_fp_recipe_title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_recipe_version_badge {
|
||||
background: var(--bs-secondary-color);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.o_fp_recipe_header_right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Body (tree + panel) layout ---------------------------------------------
|
||||
|
||||
.o_fp_recipe_body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o_fp_recipe_tree_area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 24px 24px 40px;
|
||||
}
|
||||
|
||||
// ---- Side panel -------------------------------------------------------------
|
||||
|
||||
.o_fp_recipe_panel {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
border-left: 1px solid $border-color;
|
||||
background: var(--bs-body-bg);
|
||||
|
||||
&.o_fp_recipe_panel_open {
|
||||
width: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.o_fp_recipe_panel_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
h5 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_recipe_panel_body {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Connector lines --------------------------------------------------------
|
||||
|
||||
.o_fp_recipe_connector {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
background: $border-color;
|
||||
margin-left: 22px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
// ---- Node card --------------------------------------------------------------
|
||||
|
||||
.o_fp_recipe_node {
|
||||
position: relative;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: $border-color;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
max-width: 520px;
|
||||
cursor: pointer;
|
||||
background: var(--bs-body-bg);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
border-color: var(--o-action, var(--bs-primary));
|
||||
}
|
||||
|
||||
&.o_fp_recipe_node_selected {
|
||||
border-color: var(--o-action, var(--bs-primary));
|
||||
box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb, 13, 110, 253), 0.2);
|
||||
}
|
||||
|
||||
// Node type left accent
|
||||
&.o_fp_recipe_node_recipe {
|
||||
border-left: 5px solid var(--bs-primary);
|
||||
}
|
||||
&.o_fp_recipe_node_sub_process {
|
||||
border-left: 5px solid var(--bs-info);
|
||||
}
|
||||
&.o_fp_recipe_node_operation {
|
||||
border-left: 5px solid var(--bs-success);
|
||||
}
|
||||
&.o_fp_recipe_node_step {
|
||||
border-left: 5px solid var(--bs-secondary);
|
||||
}
|
||||
|
||||
// Drag states
|
||||
&.o_fp_recipe_drag_ghost {
|
||||
opacity: 0.35;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
&.o_fp_recipe_drop_target {
|
||||
border-color: var(--o-action, var(--bs-primary));
|
||||
background: color-mix(in srgb, var(--o-action, var(--bs-primary)) 6%, var(--bs-body-bg));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Drag handle ------------------------------------------------------------
|
||||
|
||||
.o_fp_recipe_drag_handle {
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--bs-secondary-color);
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
font-size: 0.85rem;
|
||||
|
||||
.o_fp_recipe_node:hover & {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Node header row --------------------------------------------------------
|
||||
|
||||
.o_fp_recipe_node_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.o_fp_recipe_toggle_btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--bs-secondary-color);
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
|
||||
&:hover {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_recipe_toggle_spacer {
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.o_fp_recipe_node_icon {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.9rem;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.o_fp_recipe_node_name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--bs-body-color);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.o_fp_recipe_node_badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.o_fp_recipe_badge_recipe {
|
||||
background: var(--bs-primary);
|
||||
color: #fff;
|
||||
}
|
||||
&.o_fp_recipe_badge_sub {
|
||||
background: var(--bs-info);
|
||||
color: #fff;
|
||||
}
|
||||
&.o_fp_recipe_badge_op {
|
||||
background: var(--bs-success);
|
||||
color: #fff;
|
||||
}
|
||||
&.o_fp_recipe_badge_step {
|
||||
background: var(--bs-secondary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Node meta row ----------------------------------------------------------
|
||||
|
||||
.o_fp_recipe_node_meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--bs-secondary-color);
|
||||
padding-left: 28px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.o_fp_recipe_node_wc,
|
||||
.o_fp_recipe_node_duration {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.o_fp_recipe_node_icons {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--bs-secondary-color);
|
||||
|
||||
i {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Node action buttons ----------------------------------------------------
|
||||
|
||||
.o_fp_recipe_node_actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding-left: 28px;
|
||||
margin-top: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
.o_fp_recipe_node:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.o_fp_recipe_add_btn {
|
||||
font-size: 0.72rem;
|
||||
color: var(--bs-success);
|
||||
border: 1px solid var(--bs-success);
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
background: var(--bs-success);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_recipe_delete_btn {
|
||||
font-size: 0.72rem;
|
||||
color: var(--bs-danger);
|
||||
border: 1px solid transparent;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bs-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Add child form ---------------------------------------------------------
|
||||
|
||||
.o_fp_recipe_add_form {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.o_fp_recipe_add_card {
|
||||
border: 1px dashed var(--bs-success);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
max-width: 520px;
|
||||
background: color-mix(in srgb, var(--bs-success) 4%, var(--bs-body-bg));
|
||||
}
|
||||
|
||||
// ---- Children container (indentation) ---------------------------------------
|
||||
|
||||
.o_fp_recipe_children {
|
||||
margin-left: 32px;
|
||||
padding-top: 0;
|
||||
position: relative;
|
||||
|
||||
// Vertical guide line
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 0;
|
||||
bottom: 16px;
|
||||
width: 2px;
|
||||
background: $border-color;
|
||||
border-radius: 1px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Responsive -------------------------------------------------------------
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.o_fp_recipe_tree_area {
|
||||
padding: 16px 12px 16px 24px;
|
||||
}
|
||||
|
||||
.o_fp_recipe_node {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_recipe_panel.o_fp_recipe_panel_open {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.o_fp_recipe_children {
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
<?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.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating.RecipeTreeEditor">
|
||||
<div class="o_fp_recipe_editor">
|
||||
|
||||
<!-- ========== 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">
|
||||
v<t t-esc="state.recipe.version"/>
|
||||
</span>
|
||||
</h2>
|
||||
</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
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
t-on-click="() => this.onOpenForm(state.recipe.id)"
|
||||
title="Edit in form view">
|
||||
<i class="fa fa-pencil me-1"/> Form View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== LOADING ========== -->
|
||||
<div class="text-center py-5" t-if="state.loading and !state.tree">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- ========== TREE + PANEL LAYOUT ========== -->
|
||||
<div class="o_fp_recipe_body" t-if="state.tree">
|
||||
|
||||
<!-- Tree area -->
|
||||
<div class="o_fp_recipe_tree_area">
|
||||
<t t-call="fusion_plating.RecipeTreeNode">
|
||||
<t t-set="node" t-value="state.tree"/>
|
||||
<t t-set="parentNode" t-value="null"/>
|
||||
<t t-set="isFirst" t-value="true"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Side panel -->
|
||||
<div t-att-class="'o_fp_recipe_panel' + (state.showPanel ? ' o_fp_recipe_panel_open' : '')">
|
||||
<t t-if="state.showPanel and state.selectedNode">
|
||||
<div class="o_fp_recipe_panel_header">
|
||||
<h5>
|
||||
<i t-att-class="'fa ' + (state.selectedNode.icon || 'fa-cog') + ' me-2'"/>
|
||||
Edit Node
|
||||
</h5>
|
||||
<button class="btn btn-sm btn-link" 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>
|
||||
<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>
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => { state.selectedNode.node_type = ev.target.value; }">
|
||||
<option value="recipe"
|
||||
t-att-selected="state.selectedNode.node_type === 'recipe'">Recipe</option>
|
||||
<option value="sub_process"
|
||||
t-att-selected="state.selectedNode.node_type === 'sub_process'">Sub-Process</option>
|
||||
<option value="operation"
|
||||
t-att-selected="state.selectedNode.node_type === 'operation'">Operation</option>
|
||||
<option value="step"
|
||||
t-att-selected="state.selectedNode.node_type === 'step'">Step</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Icon (FA class)</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<i t-att-class="'fa ' + (state.selectedNode.icon || 'fa-cog')"/>
|
||||
</span>
|
||||
<input type="text" class="form-control"
|
||||
t-att-value="state.selectedNode.icon"
|
||||
t-on-change="(ev) => { state.selectedNode.icon = ev.target.value; }"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">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="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="fp_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>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="fp_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>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="fp_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>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="fp_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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Info -->
|
||||
<div class="text-muted small mb-3" 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-3" 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-3"
|
||||
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>
|
||||
<!-- Actions -->
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button class="btn btn-primary flex-fill"
|
||||
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"
|
||||
t-on-click="() => this.onOpenForm(state.selectedNode.id)"
|
||||
title="Open full form">
|
||||
<i class="fa fa-external-link"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@@ -19,6 +19,12 @@
|
||||
parent="menu_fp_root"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_fp_process_recipes"
|
||||
name="Process Recipes"
|
||||
parent="menu_fp_operations"
|
||||
action="action_fp_process_recipe"
|
||||
sequence="5"/>
|
||||
|
||||
<menuitem id="menu_fp_baths"
|
||||
name="Baths"
|
||||
parent="menu_fp_operations"
|
||||
|
||||
179
fusion-plating/fusion_plating/views/fp_process_node_views.xml
Normal file
179
fusion-plating/fusion_plating/views/fp_process_node_views.xml
Normal file
@@ -0,0 +1,179 @@
|
||||
<?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.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ===== TREE (LIST) VIEW — Recipes only ===== -->
|
||||
<record id="view_fp_process_node_tree" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.process.node.tree</field>
|
||||
<field name="model">fusion.plating.process.node</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Process Recipes" default_order="sequence, name">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="code" optional="show"/>
|
||||
<field name="name"/>
|
||||
<field name="node_type" widget="badge"
|
||||
decoration-info="node_type == 'recipe'"
|
||||
decoration-success="node_type == 'operation'"
|
||||
decoration-warning="node_type == 'sub_process'"
|
||||
decoration-muted="node_type == 'step'"/>
|
||||
<field name="process_type_id" optional="show"/>
|
||||
<field name="work_center_id" optional="show"/>
|
||||
<field name="child_count" string="Steps"/>
|
||||
<field name="version" optional="hide"/>
|
||||
<field name="active" column_invisible="True"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== FORM VIEW ===== -->
|
||||
<record id="view_fp_process_node_form" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.process.node.form</field>
|
||||
<field name="model">fusion.plating.process.node</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Process Node">
|
||||
<header>
|
||||
<button name="action_open_tree_editor" type="object"
|
||||
string="Open Tree Editor" class="btn-primary"
|
||||
icon="fa-sitemap"
|
||||
invisible="node_type != 'recipe'"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_open_tree_editor" type="object"
|
||||
class="oe_stat_button" icon="fa-sitemap"
|
||||
invisible="node_type != 'recipe'">
|
||||
<field name="child_count" widget="statinfo"
|
||||
string="Steps"/>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived"
|
||||
bg_color="text-bg-danger"
|
||||
invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Node name..."/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Classification">
|
||||
<field name="code"/>
|
||||
<field name="node_type"/>
|
||||
<field name="process_type_id"/>
|
||||
<field name="work_center_id"/>
|
||||
<field name="parent_id"/>
|
||||
<field name="icon"/>
|
||||
</group>
|
||||
<group string="Behaviour">
|
||||
<field name="sequence"/>
|
||||
<field name="estimated_duration"/>
|
||||
<field name="auto_complete"/>
|
||||
<field name="customer_visible"/>
|
||||
<field name="is_manual"/>
|
||||
<field name="requires_signoff"/>
|
||||
<field name="version"/>
|
||||
<field name="active" invisible="True"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Description" name="description">
|
||||
<field name="description" widget="html"/>
|
||||
</page>
|
||||
<page string="Operator Inputs" name="inputs">
|
||||
<field name="input_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="input_type"/>
|
||||
<field name="required"/>
|
||||
<field name="hint"/>
|
||||
<field name="uom"/>
|
||||
<field name="selection_options"
|
||||
invisible="input_type != 'selection'"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Child Steps" name="children">
|
||||
<field name="child_ids">
|
||||
<list default_order="sequence, name">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="node_type" widget="badge"/>
|
||||
<field name="work_center_id"/>
|
||||
<field name="estimated_duration"/>
|
||||
<field name="child_count" string="Sub-Steps"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes" placeholder="Internal notes..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== SEARCH VIEW ===== -->
|
||||
<record id="view_fp_process_node_search" model="ir.ui.view">
|
||||
<field name="name">fusion.plating.process.node.search</field>
|
||||
<field name="model">fusion.plating.process.node</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Process Nodes">
|
||||
<field name="name" string="Name"
|
||||
filter_domain="['|', ('name', 'ilike', self), ('code', 'ilike', self)]"/>
|
||||
<field name="process_type_id"/>
|
||||
<field name="work_center_id"/>
|
||||
<separator/>
|
||||
<filter name="recipes_only" string="Recipes"
|
||||
domain="[('node_type', '=', 'recipe')]"/>
|
||||
<filter name="sub_processes" string="Sub-Processes"
|
||||
domain="[('node_type', '=', 'sub_process')]"/>
|
||||
<filter name="operations" string="Operations"
|
||||
domain="[('node_type', '=', 'operation')]"/>
|
||||
<separator/>
|
||||
<filter name="archived" string="Archived"
|
||||
domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter name="group_type" string="Type"
|
||||
context="{'group_by': 'node_type'}"/>
|
||||
<filter name="group_process" string="Process Type"
|
||||
context="{'group_by': 'process_type_id'}"/>
|
||||
<filter name="group_wc" string="Work Centre"
|
||||
context="{'group_by': 'work_center_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== WINDOW ACTION — Recipe list ===== -->
|
||||
<record id="action_fp_process_recipe" model="ir.actions.act_window">
|
||||
<field name="name">Process Recipes</field>
|
||||
<field name="res_model">fusion.plating.process.node</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('node_type', '=', 'recipe')]</field>
|
||||
<field name="context">{'default_node_type': 'recipe', 'search_default_recipes_only': 1}</field>
|
||||
<field name="search_view_id" ref="view_fp_process_node_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first process recipe
|
||||
</p>
|
||||
<p>
|
||||
Recipes define the step-by-step process for plating parts.
|
||||
Each recipe is a reusable template with nested operations
|
||||
and sub-processes.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== CLIENT ACTION — OWL Tree Editor ===== -->
|
||||
<record id="action_fp_recipe_tree_editor" model="ir.actions.client">
|
||||
<field name="name">Recipe Tree Editor</field>
|
||||
<field name="tag">fp_recipe_tree_editor</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user