folder rename

This commit is contained in:
gsinghpal
2026-04-16 20:53:53 -04:00
parent 3f3ddcbab4
commit 7c7ef06057
634 changed files with 0 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,488 @@
/** @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" },
];
// ---- Icon picker options (curated for plating / manufacturing) -----------
const ICON_OPTIONS = [
{ value: "fa-flask", label: "Flask / Chemistry" },
{ value: "fa-industry", label: "Industry / Line" },
{ value: "fa-sitemap", label: "Sitemap / Process" },
{ value: "fa-wrench", label: "Wrench / Operation" },
{ value: "fa-cog", label: "Gear / General" },
{ value: "fa-cogs", label: "Gears / System" },
{ value: "fa-paint-brush", label: "Paint / Masking" },
{ value: "fa-eraser", label: "Eraser / De-Masking" },
{ value: "fa-th", label: "Grid / Racking" },
{ value: "fa-fire", label: "Fire / Bake" },
{ value: "fa-bolt", label: "Bolt / Electric" },
{ value: "fa-diamond", label: "Diamond / Plating" },
{ value: "fa-tint", label: "Tint / Rinse" },
{ value: "fa-shower", label: "Shower / Clean" },
{ value: "fa-bullseye", label: "Target / Blast" },
{ value: "fa-search", label: "Search / Inspect" },
{ value: "fa-check-circle", label: "Check / Approve" },
{ value: "fa-clock-o", label: "Clock / Wait" },
{ value: "fa-sun-o", label: "Sun / Dry" },
{ value: "fa-thermometer-half", label: "Temp / Heat" },
{ value: "fa-eye", label: "Eye / Visual" },
{ value: "fa-hand-paper-o", label: "Hand / Manual" },
{ value: "fa-cube", label: "Cube / Part" },
{ value: "fa-shield", label: "Shield / Protect" },
];
// ---- Auto-icon: guess the best icon from the node name ------------------
const ICON_KEYWORDS = [
{ pattern: /mask/i, icon: "fa-paint-brush" },
{ pattern: /de-?mask|unmask/i, icon: "fa-eraser" },
{ pattern: /rack/i, icon: "fa-th" },
{ pattern: /de-?rack|unrack/i, icon: "fa-th" },
{ pattern: /blast/i, icon: "fa-bullseye" },
{ pattern: /bake|oven/i, icon: "fa-fire" },
{ pattern: /clean|soak|wash/i, icon: "fa-shower" },
{ pattern: /rinse/i, icon: "fa-tint" },
{ pattern: /dry/i, icon: "fa-sun-o" },
{ pattern: /nickel|plate|plat/i, icon: "fa-diamond" },
{ pattern: /strike|electro/i, icon: "fa-bolt" },
{ pattern: /acid|dip|etch/i, icon: "fa-flask" },
{ pattern: /inspect|check|test/i, icon: "fa-search" },
{ pattern: /ready|wait|queue/i, icon: "fa-clock-o" },
{ pattern: /line|process/i, icon: "fa-industry" },
{ pattern: /heat|temp/i, icon: "fa-thermometer-half" },
{ pattern: /porosity/i, icon: "fa-tint" },
];
function guessIcon(name) {
if (!name) return "fa-cog";
for (const rule of ICON_KEYWORDS) {
if (rule.pattern.test(name)) return rule.icon;
}
return "fa-cog";
}
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,
vals: { icon: guessIcon(name) },
});
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;
}
getIconOptions() {
return ICON_OPTIONS;
}
formatTimeAgo(isoStr) {
if (!isoStr) return "";
const date = new Date(isoStr);
const now = new Date();
let diff = Math.floor((now - date) / 1000); // seconds
if (diff < 0) diff = 0;
const parts = [];
const weeks = Math.floor(diff / 604800);
diff %= 604800;
const days = Math.floor(diff / 86400);
diff %= 86400;
const hours = Math.floor(diff / 3600);
diff %= 3600;
const minutes = Math.floor(diff / 60);
const seconds = diff % 60;
if (weeks) parts.push(`${weeks}w`);
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
if (minutes) parts.push(`${minutes}m`);
parts.push(`${seconds}s`);
return parts.join(" ") + " ago";
}
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);

View File

@@ -0,0 +1,173 @@
// =============================================================================
// Fusion Plating — backend styles
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// THEME AWARENESS
// ---------------
// This file NEVER hardcodes backgrounds or text colours. All surface colours
// come from Odoo / Bootstrap CSS custom properties so the component renders
// correctly in BOTH light and dark mode without any duplication:
//
// background: var(--bs-body-bg) // main surface
// surface: var(--o-view-background-color) // view canvas
// foreground: var(--bs-body-color) // main text
// muted text: var(--bs-secondary-color)
// border: var(--bs-border-color)
// primary: var(--o-action) // Odoo action/brand
//
// Semantic status colours (green / amber / red) use `color-mix()` against the
// Bootstrap theme token so a green badge is darker on light mode and brighter
// on dark mode automatically — one rule, two looks.
//
// We never target `.o_dark`, `html.dark`, or `@media (prefers-color-scheme)`
// to override colours. If you find yourself needing that, it's a smell — use
// a variable instead.
// =============================================================================
// -----------------------------------------------------------------------------
// Local helpers
// -----------------------------------------------------------------------------
// `color-mix()` lets us tint a semantic colour against the surface, so the
// result adapts to light or dark backgrounds automatically.
@mixin fp-tint($color-var, $amount: 12%) {
background-color: color-mix(in srgb, var(#{$color-var}) #{$amount}, transparent);
color: var(#{$color-var});
border: 1px solid color-mix(in srgb, var(#{$color-var}) 35%, transparent);
}
// -----------------------------------------------------------------------------
// Generic card surface used in kanban views (facility, tank, bath)
// -----------------------------------------------------------------------------
.o_fp_card {
background-color: var(--o-view-background-color, var(--bs-body-bg));
color: var(--bs-body-color);
border: 1px solid var(--bs-border-color);
border-radius: 10px;
padding: 12px 14px;
transition: border-color 120ms ease, box-shadow 120ms ease;
&:hover {
border-color: color-mix(in srgb, var(--o-action) 50%, var(--bs-border-color));
box-shadow: 0 2px 8px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
}
.o_fp_card_title {
color: var(--bs-body-color);
font-size: 1rem;
line-height: 1.2;
}
.o_fp_card_stats {
color: var(--bs-body-color);
.text-muted,
.text-muted * {
color: var(--bs-secondary-color) !important;
}
}
}
// -----------------------------------------------------------------------------
// Tank kanban — state badge theming
// -----------------------------------------------------------------------------
.o_fp_tank_kanban {
.o_fp_tank_card {
// Let the left-border carry the state — subtle, theme-aware.
border-left-width: 4px;
&[data-state="empty"],
&[data-state="out_of_service"] {
border-left-color: var(--bs-secondary-color);
}
&[data-state="filled"] {
border-left-color: var(--bs-info, var(--o-action));
}
&[data-state="in_use"] {
border-left-color: var(--bs-success);
}
&[data-state="draining"],
&[data-state="maintenance"] {
border-left-color: var(--bs-warning);
}
}
.o_fp_badge {
padding: 2px 8px;
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.02em;
border-radius: 999px;
&[data-state="empty"],
&[data-state="out_of_service"] {
@include fp-tint(--bs-secondary-color);
}
&[data-state="filled"] {
@include fp-tint(--bs-info);
}
&[data-state="in_use"] {
@include fp-tint(--bs-success);
}
&[data-state="draining"],
&[data-state="maintenance"] {
@include fp-tint(--bs-warning);
}
}
}
// -----------------------------------------------------------------------------
// Bath kanban — chemistry health dot
// -----------------------------------------------------------------------------
.o_fp_bath_kanban {
.o_fp_bath_card {
// A single left-border tint conveys chemistry health without colouring
// the entire card.
border-left-width: 4px;
border-left-color: var(--bs-success);
&[data-log-status="warning"] {
border-left-color: var(--bs-warning);
}
&[data-log-status="out_of_spec"] {
border-left-color: var(--bs-danger);
}
}
.o_fp_health_dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--bs-success);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-success) 25%, transparent);
&[data-status="warning"] {
background-color: var(--bs-warning);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-warning) 25%, transparent);
}
&[data-status="out_of_spec"] {
background-color: var(--bs-danger);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-danger) 25%, transparent);
}
}
}
// -----------------------------------------------------------------------------
// Facility kanban — stat strip spacing
// -----------------------------------------------------------------------------
.o_fp_facility_kanban {
.o_fp_card_stats {
padding-top: 8px;
border-top: 1px dashed var(--bs-border-color);
}
}

View File

@@ -0,0 +1,433 @@
// =============================================================================
// 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;
}
}
// ---- Tracking section -------------------------------------------------------
.o_fp_recipe_tracking {
border-top: 1px solid $border-color;
}
// ---- Icon picker ------------------------------------------------------------
.o_fp_recipe_icon_picker {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.o_fp_recipe_icon_btn {
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid $border-color;
border-radius: 6px;
background: transparent;
color: var(--bs-secondary-color);
font-size: 0.9rem;
cursor: pointer;
transition: border-color 0.12s, background-color 0.12s;
&:hover {
border-color: var(--o-action, var(--bs-primary));
color: var(--bs-body-color);
}
&.active {
background: var(--o-action, var(--bs-primary));
border-color: var(--o-action, var(--bs-primary));
color: #fff;
}
}
// ---- 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;
}
}

View File

@@ -0,0 +1,334 @@
<?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</label>
<div class="o_fp_recipe_icon_picker">
<t t-foreach="getIconOptions()" t-as="ic" t-key="ic.value">
<button t-att-class="'o_fp_recipe_icon_btn' + (state.selectedNode.icon === ic.value ? ' active' : '')"
t-on-click.stop="() => { state.selectedNode.icon = ic.value; }"
t-att-title="ic.label">
<i t-att-class="'fa ' + ic.value"/>
</button>
</t>
</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>
<div class="mb-3">
<label class="form-label fw-bold">Opt In/Out</label>
<select class="form-select"
t-on-change="(ev) => { state.selectedNode.opt_in_out = ev.target.value; }">
<option value="disabled"
t-att-selected="state.selectedNode.opt_in_out === 'disabled'">Disabled</option>
<option value="opt_in"
t-att-selected="state.selectedNode.opt_in_out === 'opt_in'">Opt-In</option>
<option value="opt_out"
t-att-selected="state.selectedNode.opt_in_out === 'opt_out'">Opt-Out</option>
</select>
</div>
<!-- Info -->
<div class="text-muted small mb-2" t-if="state.selectedNode.work_center">
<i class="fa fa-building me-1"/>
<t t-esc="state.selectedNode.work_center"/>
</div>
<div class="text-muted small mb-2" t-if="state.selectedNode.process_type">
<i class="fa fa-tag me-1"/>
<t t-esc="state.selectedNode.process_type"/>
</div>
<div class="text-muted small mb-2"
t-if="state.selectedNode.input_count">
<i class="fa fa-keyboard-o me-1"/>
<t t-esc="state.selectedNode.input_count"/> operator input(s)
</div>
<!-- Tracking -->
<div class="o_fp_recipe_tracking mt-3 pt-3" t-if="state.selectedNode.create_date">
<div class="text-muted small mb-1">
<i class="fa fa-calendar-plus-o me-1"/>
Created <t t-esc="formatTimeAgo(state.selectedNode.create_date)"/>
<t t-if="state.selectedNode.create_uid_name">
by <strong t-esc="state.selectedNode.create_uid_name"/>
</t>
</div>
<div class="text-muted small" t-if="state.selectedNode.write_date">
<i class="fa fa-pencil me-1"/>
Updated <t t-esc="formatTimeAgo(state.selectedNode.write_date)"/>
<t t-if="state.selectedNode.write_uid_name">
by <strong t-esc="state.selectedNode.write_uid_name"/>
</t>
</div>
</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>