From a7d224899ab2b83b0d03f5618f8d8bfcfbf754dc Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 12 Apr 2026 14:29:58 -0400 Subject: [PATCH] fusion_plating: add process recipe system with OWL tree editor (v19.0.2.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fusion-plating/fusion_plating/__init__.py | 1 + fusion-plating/fusion_plating/__manifest__.py | 5 + .../fusion_plating/controllers/__init__.py | 5 + .../controllers/recipe_controller.py | 188 ++++++++ .../data/fp_demo_recipe_data.xml | 262 ++++++++++++ .../fusion_plating/models/__init__.py | 1 + .../fusion_plating/models/fp_process_node.py | 361 ++++++++++++++++ .../security/ir.model.access.csv | 6 + .../static/src/js/recipe_tree_editor.js | 403 ++++++++++++++++++ .../static/src/scss/recipe_tree_editor.scss | 393 +++++++++++++++++ .../static/src/xml/recipe_tree_editor.xml | 304 +++++++++++++ .../fusion_plating/views/fp_menu.xml | 6 + .../views/fp_process_node_views.xml | 179 ++++++++ 13 files changed, 2114 insertions(+) create mode 100644 fusion-plating/fusion_plating/controllers/__init__.py create mode 100644 fusion-plating/fusion_plating/controllers/recipe_controller.py create mode 100644 fusion-plating/fusion_plating/data/fp_demo_recipe_data.xml create mode 100644 fusion-plating/fusion_plating/models/fp_process_node.py create mode 100644 fusion-plating/fusion_plating/static/src/js/recipe_tree_editor.js create mode 100644 fusion-plating/fusion_plating/static/src/scss/recipe_tree_editor.scss create mode 100644 fusion-plating/fusion_plating/static/src/xml/recipe_tree_editor.xml create mode 100644 fusion-plating/fusion_plating/views/fp_process_node_views.xml diff --git a/fusion-plating/fusion_plating/__init__.py b/fusion-plating/fusion_plating/__init__.py index 3c90fa80..2ea9535e 100644 --- a/fusion-plating/fusion_plating/__init__.py +++ b/fusion-plating/fusion_plating/__init__.py @@ -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 diff --git a/fusion-plating/fusion_plating/__manifest__.py b/fusion-plating/fusion_plating/__manifest__.py index a9f2bcb6..f2378273 100644 --- a/fusion-plating/fusion_plating/__manifest__.py +++ b/fusion-plating/fusion_plating/__manifest__.py @@ -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, diff --git a/fusion-plating/fusion_plating/controllers/__init__.py b/fusion-plating/fusion_plating/controllers/__init__.py new file mode 100644 index 00000000..05a46f72 --- /dev/null +++ b/fusion-plating/fusion_plating/controllers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from . import recipe_controller diff --git a/fusion-plating/fusion_plating/controllers/recipe_controller.py b/fusion-plating/fusion_plating/controllers/recipe_controller.py new file mode 100644 index 00000000..8c762732 --- /dev/null +++ b/fusion-plating/fusion_plating/controllers/recipe_controller.py @@ -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)} diff --git a/fusion-plating/fusion_plating/data/fp_demo_recipe_data.xml b/fusion-plating/fusion_plating/data/fp_demo_recipe_data.xml new file mode 100644 index 00000000..44c6fb60 --- /dev/null +++ b/fusion-plating/fusion_plating/data/fp_demo_recipe_data.xml @@ -0,0 +1,262 @@ + + + + + + + + Electroless Nickel Plating — Steel Line + EN_STEEL + recipe + fa-flask + 10 + True + + + + + Blasting + operation + + fa-bullseye + 10 + 30 + True + + + Ready for Blast + step + + fa-clock-o + 10 + + + Blast + step + + fa-fire + 20 + True + + + + + Masking + operation + + fa-paint-brush + 20 + 45 + True + + + Ready for Masking + step + + 10 + + + Masking + step + + fa-paint-brush + 20 + True + + + + + Racking + operation + + fa-th + 30 + 20 + + + + + Steel Line + sub_process + + fa-industry + 40 + True + + + + + Cleaner + sub_process + + fa-shower + 10 + + + Soak Clean (S-3) + step + + 10 + 10 + False + + + Electroclean (S-3) + step + + 20 + 5 + False + + + Primary Rinse (S-4) + step + + 30 + False + + + + + Acid Dip (S-5) + operation + + fa-flask + 20 + 5 + False + + + + + Nickel Strike (S-7 / SP-5) + operation + + fa-bolt + 30 + 8 + False + True + + + + + E-Nickel Plate (Mid Phos) (S-9) + operation + + fa-diamond + 40 + 90 + False + True + + + Primary Rinse (S-11) + step + + 10 + False + + + Hot Rinse (S-13) + step + + 20 + False + + + + + Hot Water Porosity (A-15) + operation + + fa-tint + 50 + 15 + True + + + + + Dry + operation + + fa-sun-o + 60 + False + + + + + Oven Baking + operation + + fa-fire + 50 + 240 + False + True + + + + + De-Racking + operation + + fa-th + 60 + 15 + + + + + De-Masking + operation + + fa-eraser + 70 + 20 + + + + + Oven Bake (Post De-Rack) + operation + + fa-fire + 80 + 120 + False + + + + + Post Plate Inspection + operation + + fa-search + 90 + 30 + True + + + Ready for Post Plate Inspection + step + + 10 + + + Post Plate Inspection + step + + fa-check-circle + 20 + True + + + + diff --git a/fusion-plating/fusion_plating/models/__init__.py b/fusion-plating/fusion_plating/models/__init__.py index f02f3acd..c4a4eb43 100644 --- a/fusion-plating/fusion_plating/models/__init__.py +++ b/fusion-plating/fusion_plating/models/__init__.py @@ -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 diff --git a/fusion-plating/fusion_plating/models/fp_process_node.py b/fusion-plating/fusion_plating/models/fp_process_node.py new file mode 100644 index 00000000..2e852b46 --- /dev/null +++ b/fusion-plating/fusion_plating/models/fp_process_node.py @@ -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).', + ) diff --git a/fusion-plating/fusion_plating/security/ir.model.access.csv b/fusion-plating/fusion_plating/security/ir.model.access.csv index 728715a1..ebc7dcb7 100644 --- a/fusion-plating/fusion_plating/security/ir.model.access.csv +++ b/fusion-plating/fusion_plating/security/ir.model.access.csv @@ -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 diff --git a/fusion-plating/fusion_plating/static/src/js/recipe_tree_editor.js b/fusion-plating/fusion_plating/static/src/js/recipe_tree_editor.js new file mode 100644 index 00000000..36f1ddd4 --- /dev/null +++ b/fusion-plating/fusion_plating/static/src/js/recipe_tree_editor.js @@ -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); diff --git a/fusion-plating/fusion_plating/static/src/scss/recipe_tree_editor.scss b/fusion-plating/fusion_plating/static/src/scss/recipe_tree_editor.scss new file mode 100644 index 00000000..902819f6 --- /dev/null +++ b/fusion-plating/fusion_plating/static/src/scss/recipe_tree_editor.scss @@ -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; + } +} diff --git a/fusion-plating/fusion_plating/static/src/xml/recipe_tree_editor.xml b/fusion-plating/fusion_plating/static/src/xml/recipe_tree_editor.xml new file mode 100644 index 00000000..59d159e7 --- /dev/null +++ b/fusion-plating/fusion_plating/static/src/xml/recipe_tree_editor.xml @@ -0,0 +1,304 @@ + + + + + +
+ + +
+
+ +

+ + + + v + +

+
+
+ + + + + + +
+
+ + +
+ +

Loading recipe tree...

+
+ + +
+ +

No recipe selected.

+
+ + +
+ + +
+ + + + + +
+ + +
+ +
+
+ + Edit Node +
+ +
+
+
+ + +
+
+ + +
+
+ +
+ + + + +
+
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + operator input(s) +
+ +
+ + +
+
+
+
+
+
+
+ + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + +
+ + +
+ + +
+
+ + +
+
+
+ +
+ + + +
+
+
+ + +
+ + + + + + + +
+ + + diff --git a/fusion-plating/fusion_plating/views/fp_menu.xml b/fusion-plating/fusion_plating/views/fp_menu.xml index c5825a41..8a4f3876 100644 --- a/fusion-plating/fusion_plating/views/fp_menu.xml +++ b/fusion-plating/fusion_plating/views/fp_menu.xml @@ -19,6 +19,12 @@ parent="menu_fp_root" sequence="10"/> + + + + + + + + fusion.plating.process.node.tree + fusion.plating.process.node + + + + + + + + + + + + + + + + + + fusion.plating.process.node.form + fusion.plating.process.node + +
+
+
+ +
+ +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + fusion.plating.process.node.search + fusion.plating.process.node + + + + + + + + + + + + + + + + + + + + + + + Process Recipes + fusion.plating.process.node + list,form + [('node_type', '=', 'recipe')] + {'default_node_type': 'recipe', 'search_default_recipes_only': 1} + + +

+ Create your first process recipe +

+

+ Recipes define the step-by-step process for plating parts. + Each recipe is a reusable template with nested operations + and sub-processes. +

+
+
+ + + + Recipe Tree Editor + fp_recipe_tree_editor + + +