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

View File

@@ -1,16 +1,17 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Process Tree View (OWL backend client action)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
// Fusion Plating — Process Tree (horizontal hierarchical view)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Visual routing-step tree for a single manufacturing order showing progress
// bars per work order.
// Renders the MO's recipe (recipe → sub_process → operation → state) as a
// horizontal bracket tree. Cards render dark, identical card style across
// all depths; connector lines are drawn from CSS so the layout stays in
// pure flexbox.
//
// Odoo 19 conventions:
// * Backend OWL component: `static template` + `static props = ["*"]`
// * RPC via standalone `rpc()` from @web/core/network/rpc
// * Registered under registry.category("actions") → "fp_process_tree"
// Action context:
// production_id — required; the MO whose recipe to render
// back_workorder_id — optional; if set, the back button returns to
// that WO instead of Plant Overview
// =============================================================================
import { Component, useState, onMounted } from "@odoo/owl";
@@ -30,9 +31,12 @@ export class ProcessTree extends Component {
productionName: "",
productName: "",
moState: "",
nodes: [],
customer: "",
soName: "",
productQty: 0,
recipe: "",
root: null,
loading: false,
collapsed: {}, // node id → boolean
});
onMounted(async () => {
@@ -40,20 +44,19 @@ export class ProcessTree extends Component {
});
}
// ----- Data loading ------------------------------------------------------
// ---- Action context -----------------------------------------------------
get productionId() {
// Client action may receive production_id via action context or params
const ctx = this.props.action && this.props.action.context;
if (ctx && ctx.production_id) {
return ctx.production_id;
}
const params = this.props.action && this.props.action.params;
if (params && params.production_id) {
return params.production_id;
}
return null;
get _ctx() {
const a = this.props.action || {};
return { ...(a.context || {}), ...(a.params || {}) };
}
get productionId() { return this._ctx.production_id || null; }
get backWorkorderId() { return this._ctx.back_workorder_id || null; }
get backLabel() {
return this.backWorkorderId ? "Back to Work Order" : "Plant Overview";
}
// ---- Data ---------------------------------------------------------------
async loadTree() {
const prodId = this.productionId;
@@ -66,14 +69,18 @@ export class ProcessTree extends Component {
}
this.state.loading = true;
try {
const result = await rpc("/fp/shopfloor/process_tree", {
const r = await rpc("/fp/shopfloor/process_tree", {
production_id: prodId,
});
if (result) {
this.state.productionName = result.production_name || "";
this.state.productName = result.product_name || "";
this.state.moState = result.state || "";
this.state.nodes = result.nodes || [];
if (r) {
this.state.productionName = r.production_name || "";
this.state.productName = r.product_name || "";
this.state.moState = r.state || "";
this.state.customer = r.customer || "";
this.state.soName = r.so_name || "";
this.state.productQty = r.product_qty || 0;
this.state.recipe = r.recipe || "";
this.state.root = r.root || null;
}
} catch (err) {
this.notification.add(
@@ -85,20 +92,10 @@ export class ProcessTree extends Component {
}
}
// ----- Collapse / expand -------------------------------------------------
toggleNode(nodeId) {
this.state.collapsed[nodeId] = !this.state.collapsed[nodeId];
}
isCollapsed(nodeId) {
return !!this.state.collapsed[nodeId];
}
// ----- Navigation --------------------------------------------------------
// ---- Navigation ---------------------------------------------------------
onNodeClick(node) {
if (!node.workorder_id) {
if (!node || !node.workorder_id) {
return;
}
this.action.doAction({
@@ -110,54 +107,68 @@ export class ProcessTree extends Component {
});
}
onBackToOverview() {
onBack() {
const woId = this.backWorkorderId;
if (woId) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "mrp.workorder",
res_id: parseInt(woId, 10),
views: [[false, "form"]],
target: "current",
});
return;
}
this.action.doAction("fusion_plating_shopfloor.action_fp_plant_overview");
}
// ----- Helpers -----------------------------------------------------------
// ---- Helpers ------------------------------------------------------------
getProgressPct(node) {
if (!node.qty_total || node.qty_total === 0) {
return 0;
/** Return the css class chain for a node card (state + node_type). */
getCardClass(node) {
const parts = ["o_fp_pt_card"];
parts.push(`o_fp_pt_type_${node.node_type || "unknown"}`);
if (node.state) {
parts.push(`o_fp_pt_state_${node.state}`);
}
return Math.round((node.qty_done / node.qty_total) * 100);
if (node.workorder_id) {
parts.push("o_fp_pt_clickable");
}
if (this.isHighlight(node)) {
parts.push("o_fp_pt_highlight");
}
return parts.join(" ");
}
getProgressClass(node) {
const pct = this.getProgressPct(node);
if (pct >= 100) {
return "o_fp_tree_progress_done";
}
if (pct > 0) {
return "o_fp_tree_progress_active";
}
return "o_fp_tree_progress_empty";
/** A node should pulse-highlight if it is the live position of the MO. */
isHighlight(node) {
return node.state === "ready"
|| node.state === "progress"
|| node.state === "waiting";
}
getNodeStateLabel(state) {
const map = {
pending: "Pending",
waiting: "Waiting",
ready: "Ready",
progress: "In Progress",
done: "Done",
cancel: "Cancelled",
getKindBadge(node) {
if (!node.wo_kind) return null;
return {
cls: `o_fp_pt_kind o_fp_pt_kind_${node.wo_kind}`,
label: node.wo_kind_label || node.wo_kind,
};
return map[state] || state || "—";
}
getNodeStateClass(state) {
switch (state) {
case "done":
return "o_fp_tree_state_done";
case "progress":
return "o_fp_tree_state_progress";
case "ready":
return "o_fp_tree_state_ready";
case "cancel":
return "o_fp_tree_state_cancel";
default:
return "o_fp_tree_state_pending";
qtyLabel(node) {
if (!node.qty_total) return "";
return `${node.qty_done}/${node.qty_total}`;
}
nodeIcon(node) {
if (node.icon) return node.icon;
switch (node.node_type) {
case "recipe": return "fa-cubes";
case "sub_process": return "fa-folder";
case "operation": return "fa-cog";
case "step": return "fa-circle-o";
case "state": return "fa-circle";
default: return "fa-square";
}
}
}

View File

@@ -1,298 +1,398 @@
// =============================================================================
// Fusion Plating — Process Tree View
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
// Fusion Plating — Process Tree (horizontal hierarchical, v3, 2026-04)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// THEME AWARENESS
// ---------------
// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
// the tree view renders correctly in BOTH light and dark mode.
// Hierarchical bracket tree:
//
// background: var(--bs-body-bg)
// surface: var(--o-view-background-color)
// foreground: var(--bs-body-color)
// muted text: var(--bs-secondary-color)
// border: var(--bs-border-color)
// primary: var(--o-action)
// [Recipe]──┬──[Sub-Process]──┬──[Operation]──┬──[Ready for X]
// │ │ └──[X]
// │ └──[Operation]
// ├──[Operation]
// └──[Operation]
//
// Each .o_fp_pt_node is `display: flex` with:
// - the card on the left
// - .o_fp_pt_children on the right (column of recursed children)
// Connectors are drawn entirely from CSS pseudo-elements:
// - vertical bus column on each child via ::after
// - horizontal stub from bus column to card via ::before
// - first/last children trim the vertical line so it stops at the card
// centre.
// =============================================================================
.o_fp_process_tree {
@media (hover: none) {
.o_fp_process_tree [class*="o_fp_pt_"]:hover {
transform: none !important;
box-shadow: inherit !important;
}
}
// --- Connector geometry -------------------------------------------------------
// Tweaking these recalculates the whole bracket-tree layout.
$pt-card-h : 44px; // nominal card height (cards may be taller
// when meta line wraps; centre stays at h/2)
$pt-row-gap : 12px; // vertical gap between sibling children
$pt-indent : 36px; // horizontal gap from parent → children
$pt-stub : 28px; // horizontal connector segment length
$pt-line-color : #6b7280; // connector colour
$pt-line-width : 2px;
.o_fp_process_tree.o_fp_pt_v3 {
font-family: $fp-font-stack;
background-color: $fp-page;
color: $fp-ink;
height: 100%;
overflow: auto; // both axes — wide trees scroll horizontally
-webkit-overflow-scrolling: touch;
padding: $fp-space-4 $fp-space-5;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--o-view-background-color, var(--bs-body-bg));
padding: 0;
}
gap: $fp-space-3;
// ---- Header -----------------------------------------------------------------
@media (max-width: 600px) { padding: $fp-space-3; gap: $fp-space-3; }
.o_fp_pt_header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 16px 24px;
background: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
box-shadow: 0 1px 3px color-mix(in srgb, var(--bs-body-color) 6%, transparent);
.o_fp_pt_header_left {
// -------------------------------------------------------------------------
// Header (compact strip)
// -------------------------------------------------------------------------
.o_fp_pt_header {
display: flex;
align-items: center;
gap: $fp-space-3;
flex-wrap: wrap;
padding: $fp-space-3 $fp-space-4;
background-color: $fp-card;
border-radius: $fp-radius-md;
box-shadow: $fp-elev-1;
position: sticky;
top: 0;
z-index: 5;
}
.o_fp_pt_back {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: $fp-radius-pill;
background-color: $fp-card-soft;
color: $fp-ink-soft;
font-weight: $fp-weight-medium;
font-size: $fp-text-sm;
border: 1px solid #{$fp-border};
cursor: pointer;
transition: background-color $fp-dur $fp-ease,
border-color $fp-dur $fp-ease,
color $fp-dur $fp-ease;
@include fp-hover-only {
&:hover {
background-color: color-mix(in srgb, #{$fp-accent} 8%, $fp-card);
border-color: color-mix(in srgb, #{$fp-accent} 45%, #{$fp-border});
color: $fp-ink;
}
}
}
.o_fp_pt_title_block { flex: 1 1 auto; min-width: 0; }
.o_fp_pt_title {
font-size: 1.2rem;
font-weight: 700;
color: var(--bs-body-color);
font-size: $fp-text-md;
font-weight: $fp-weight-bold;
margin: 0;
color: $fp-ink;
display: inline-flex; align-items: center; gap: 4px;
.o_fp_pt_mo_name { color: $fp-ink-soft; font-weight: $fp-weight-semibold; }
}
.o_fp_pt_subtitle {
font-size: 0.85rem;
display: block;
}
}
// ---- Tree container ---------------------------------------------------------
.o_fp_pt_tree {
padding: 24px;
padding-left: 48px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
// ---- Node wrapper -----------------------------------------------------------
.o_fp_pt_node_wrapper {
position: relative;
margin-bottom: 0;
}
// ---- Connector line (vertical line between nodes) ---------------------------
.o_fp_pt_connector {
width: 3px;
height: 20px;
background: var(--bs-border-color);
margin-left: 28px;
}
// ---- Node box ---------------------------------------------------------------
.o_fp_pt_node {
background: var(--bs-secondary-bg);
color: var(--bs-body-color);
border-radius: 10px;
padding: 14px 18px;
max-width: 440px;
cursor: pointer;
transition: box-shadow 0.15s, transform 0.1s;
position: relative;
&:hover {
box-shadow: 0 3px 12px color-mix(in srgb, var(--bs-body-color) 15%, transparent);
transform: translateX(2px);
margin-top: 2px;
font-size: $fp-text-xs;
color: $fp-ink-mute;
display: flex; flex-wrap: wrap; align-items: center; gap: 2px;
.fa { margin-right: 2px; opacity: 0.7; }
}
// State colour accents (left border)
&.o_fp_tree_state_done {
border-left: 5px solid var(--bs-success);
}
&.o_fp_tree_state_progress {
border-left: 5px solid var(--bs-warning);
}
&.o_fp_tree_state_ready {
border-left: 5px solid var(--bs-primary);
}
&.o_fp_tree_state_cancel {
border-left: 5px solid var(--bs-secondary);
opacity: 0.6;
}
&.o_fp_tree_state_pending {
border-left: 5px solid var(--bs-border-color);
}
}
.o_fp_pt_node_header {
display: flex;
align-items: center;
justify-content: space-between;
}
.o_fp_pt_node_name {
font-size: 1rem;
}
.o_fp_pt_node_seq {
color: var(--bs-secondary-color);
font-weight: 400;
margin-right: 4px;
}
.o_fp_pt_toggle_btn {
background: none;
border: none;
color: var(--bs-secondary-color);
cursor: pointer;
padding: 2px 6px;
font-size: 0.85rem;
&:hover {
color: var(--bs-body-color);
}
}
.o_fp_pt_node_wc {
font-size: 0.8rem;
color: var(--bs-secondary-color) !important;
margin-top: 2px;
}
// ---- State badges inside tree -----------------------------------------------
.o_fp_pt_node_state {
.badge {
font-size: 0.7rem;
font-weight: 600;
padding: 3px 8px;
// -------------------------------------------------------------------------
// Empty / loading
// -------------------------------------------------------------------------
.o_fp_pt_empty {
text-align: center;
padding: $fp-space-7 $fp-space-5;
color: $fp-ink-mute;
background-color: $fp-card;
border-radius: $fp-radius-md;
box-shadow: $fp-elev-1;
font-size: $fp-text-sm;
max-width: 520px;
> .fa { font-size: 1.75rem; margin-bottom: $fp-space-2; opacity: 0.6; }
}
.o_fp_tree_state_done {
background: var(--bs-success) !important;
color: #fff !important;
}
.o_fp_tree_state_progress {
background: var(--bs-warning) !important;
color: #fff !important;
}
.o_fp_tree_state_ready {
background: var(--bs-primary) !important;
color: #fff !important;
}
.o_fp_tree_state_cancel {
background: var(--bs-secondary) !important;
color: #fff !important;
}
.o_fp_tree_state_pending {
background: var(--bs-tertiary-bg) !important;
color: var(--bs-secondary-color) !important;
}
}
// ---- Progress bar -----------------------------------------------------------
.o_fp_pt_bar {
height: 8px;
background: var(--bs-tertiary-bg);
border-radius: 4px;
overflow: hidden;
&.o_fp_pt_bar_sm {
height: 6px;
// -------------------------------------------------------------------------
// Tree canvas — horizontally scrollable
// -------------------------------------------------------------------------
.o_fp_pt_canvas {
padding: $fp-space-3 0;
min-width: max-content; // let cards push the canvas wider for scroll
}
.o_fp_pt_bar_fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
&.o_fp_tree_progress_active .o_fp_pt_bar_fill {
background: var(--bs-warning);
}
&.o_fp_tree_progress_done .o_fp_pt_bar_fill {
background: var(--bs-success);
}
&.o_fp_tree_progress_empty .o_fp_pt_bar_fill {
background: var(--bs-secondary);
}
}
.o_fp_pt_bar_label {
font-size: 0.75rem;
color: var(--bs-secondary-color);
margin-top: 2px;
display: inline-block;
}
.o_fp_pt_node_duration {
font-size: 0.75rem;
color: var(--bs-secondary-color) !important;
}
// ---- Children (sub-state nodes) ---------------------------------------------
.o_fp_pt_children {
margin-left: 48px;
padding-top: 4px;
}
.o_fp_pt_child_connector {
width: 3px;
height: 12px;
background: var(--bs-border-color);
margin-left: 20px;
}
.o_fp_pt_child_node {
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
border-radius: 8px;
padding: 10px 14px;
max-width: 360px;
margin-bottom: 0;
&.o_fp_tree_state_progress {
border-left: 4px solid var(--bs-warning);
}
&.o_fp_tree_state_ready {
border-left: 4px solid var(--bs-primary);
}
&.o_fp_tree_state_done {
border-left: 4px solid var(--bs-success);
}
&.o_fp_tree_state_pending {
border-left: 4px solid var(--bs-secondary);
}
}
.o_fp_pt_child_name {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 4px;
}
.o_fp_pt_child_progress {
display: flex;
align-items: center;
gap: 8px;
.o_fp_pt_bar {
flex: 1;
}
}
// ---- Responsive -------------------------------------------------------------
@media (max-width: 768px) {
.o_fp_pt_tree {
padding: 16px;
padding-left: 24px;
}
// -------------------------------------------------------------------------
// Recursive node — flex row of [card | children-column]
// -------------------------------------------------------------------------
.o_fp_pt_node {
max-width: 100%;
display: flex;
align-items: flex-start;
position: relative;
}
.o_fp_pt_child_node {
max-width: 100%;
// -------------------------------------------------------------------------
// Card (Steelhead-style: dark fill, rounded, fixed-ish width per row)
// -------------------------------------------------------------------------
.o_fp_pt_card {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 200px;
max-width: 320px;
min-height: $pt-card-h;
padding: 8px 12px;
background-color: #2b2f36; // dark slate, matches Steelhead look
color: #f1f3f5;
border-radius: $fp-radius-sm;
box-shadow: $fp-elev-1;
font-size: $fp-text-sm;
line-height: 1.25;
flex: 0 0 auto;
position: relative;
z-index: 1; // sit above connector lines
transition: transform $fp-dur-fast $fp-ease,
box-shadow $fp-dur $fp-ease,
background-color $fp-dur $fp-ease;
&.o_fp_pt_clickable {
cursor: pointer;
@include fp-hover-only {
&:hover {
transform: translateY(-1px);
box-shadow: $fp-elev-2;
background-color: #34394221;
background-color: #353a42;
}
}
}
// ---- Card type tints (subtle) -------------------------------------
&.o_fp_pt_type_recipe {
background-color: #1f2329;
font-weight: $fp-weight-bold;
}
&.o_fp_pt_type_sub_process {
background-color: #262a31;
font-weight: $fp-weight-semibold;
}
&.o_fp_pt_type_state {
background-color: #3a3f47;
font-size: $fp-text-xs;
min-height: 36px;
min-width: 160px;
}
&.o_fp_pt_type_step {
background-color: #353a42;
font-size: $fp-text-xs;
min-height: 36px;
}
// ---- Live state highlight ----------------------------------------
&.o_fp_pt_state_progress,
&.o_fp_pt_highlight.o_fp_pt_state_progress {
background-color: #c0392b; // warm red — active step
color: #fff;
box-shadow: 0 0 0 1px rgba(192, 57, 43, .6),
0 4px 14px rgba(192, 57, 43, .35);
}
&.o_fp_pt_highlight.o_fp_pt_state_ready,
&.o_fp_pt_state_ready.o_fp_pt_type_state {
background-color: #c0392b; // ready-to-pickup also red
color: #fff;
box-shadow: 0 0 0 1px rgba(192, 57, 43, .6),
0 4px 14px rgba(192, 57, 43, .35);
}
&.o_fp_pt_state_done.o_fp_pt_type_state {
background-color: #1e8449; // green for completed slice
color: #fff;
}
&.o_fp_pt_state_cancel { opacity: 0.55; }
}
.o_fp_pt_card_icon {
flex: 0 0 auto;
width: 18px;
text-align: center;
opacity: 0.85;
font-size: 0.95em;
}
.o_fp_pt_card_body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.o_fp_pt_card_title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.o_fp_pt_card_meta {
font-size: 0.72rem;
opacity: 0.75;
display: flex;
flex-wrap: wrap;
gap: 2px 6px;
.fa { opacity: 0.8; }
}
.o_fp_pt_card_right {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
}
.o_fp_pt_qty {
font-size: 0.72rem;
font-weight: $fp-weight-bold;
padding: 1px 8px;
border-radius: $fp-radius-pill;
background-color: rgba(255, 255, 255, 0.18);
color: #fff;
}
.o_fp_pt_card_open {
opacity: 0.55;
font-size: 0.85em;
}
// -------------------------------------------------------------------------
// Kind badge inside cards
// -------------------------------------------------------------------------
.o_fp_pt_kind {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: $fp-radius-pill;
font-size: 0.65rem;
font-weight: $fp-weight-bold;
line-height: 1.3;
white-space: nowrap;
&.o_fp_pt_kind_wet { background-color: rgba(13, 110, 253, .25); color: #6ea8fe; }
&.o_fp_pt_kind_bake { background-color: rgba(220, 53, 69, .25); color: #f1aeb5; }
&.o_fp_pt_kind_mask { background-color: rgba(255, 193, 7, .25); color: #ffd866; }
&.o_fp_pt_kind_rack { background-color: rgba(108, 117, 125, .35); color: #d0d4d9; }
&.o_fp_pt_kind_inspect { background-color: rgba(25, 135, 84, .28); color: #75d4a4; }
&.o_fp_pt_kind_other { background-color: rgba(255, 255, 255, .12); color: #c8ccd2; }
}
// -------------------------------------------------------------------------
// Children column (recursed nodes laid out vertically to the right)
//
// The ::before pseudo draws the horizontal connector that bridges the
// parent card's right edge → the bus column at left: 0 of this
// container. Without it the children look orphaned even though the
// bus column + per-child stubs are present.
// -------------------------------------------------------------------------
.o_fp_pt_children {
margin-left: 24px;
display: flex;
flex-direction: column;
gap: $pt-row-gap;
margin-left: $pt-indent;
position: relative;
&::before {
content: "";
position: absolute;
left: -#{$pt-indent};
top: calc(#{$pt-card-h} / 2); // parent-card vertical centre
width: $pt-indent;
height: $pt-line-width;
background-color: $pt-line-color;
z-index: 0;
}
}
// -------------------------------------------------------------------------
// Connector lines (bracket style, drawn from CSS only)
//
// Each child .o_fp_pt_node owns its own connector segments:
// ::before → horizontal stub from the bus column → card centre
// ::after → vertical bus segment for this row
//
// First/last/single children trim the vertical so the bracket stops
// exactly at the card centre.
// -------------------------------------------------------------------------
.o_fp_pt_children > .o_fp_pt_node {
position: relative;
padding-left: $pt-stub; // room for the horizontal stub
// -- horizontal stub from bus column → card --------------------------
&::before {
content: "";
position: absolute;
left: 0;
top: calc(#{$pt-card-h} / 2); // align with card vertical centre
width: $pt-stub;
height: $pt-line-width;
background-color: $pt-line-color;
z-index: 0;
}
// -- vertical bus segment (default: full row, top → bottom) ----------
&::after {
content: "";
position: absolute;
left: 0;
top: calc(-#{$pt-row-gap} / 2); // bridge gap to sibling above
bottom: calc(-#{$pt-row-gap} / 2); // bridge gap to sibling below
width: $pt-line-width;
background-color: $pt-line-color;
z-index: 0;
}
// First child — vertical only from card centre → bottom of row
&:first-child::after {
top: calc(#{$pt-card-h} / 2);
}
// Last child — vertical only from top of row → card centre
&:last-child::after {
bottom: calc(100% - (#{$pt-card-h} / 2));
}
// Only child — vertical only at the card centre point (just enough
// to render the elbow connecting to the parent stub)
&:first-child:last-child::after {
top: calc(#{$pt-card-h} / 2);
bottom: calc(100% - (#{$pt-card-h} / 2));
}
}
// -------------------------------------------------------------------------
// Pulse on live (in-progress / ready) cards
// -------------------------------------------------------------------------
@keyframes o_fp_pt_pulse {
0%, 100% { box-shadow: 0 0 0 1px rgba(192, 57, 43, .55),
0 4px 14px rgba(192, 57, 43, .35); }
50% { box-shadow: 0 0 0 4px rgba(192, 57, 43, .25),
0 4px 18px rgba(192, 57, 43, .45); }
}
.o_fp_pt_card.o_fp_pt_state_progress,
.o_fp_pt_card.o_fp_pt_highlight.o_fp_pt_state_ready {
animation: o_fp_pt_pulse 2.4s ease-in-out infinite;
}
}

View File

@@ -3,148 +3,133 @@
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Process Tree — horizontal hierarchical view.
Recursive template renders the recipe → sub-process → operation → step
hierarchy with bracket connectors between cards. Active step pulses.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ProcessTree">
<div class="o_fp_process_tree">
<!-- =====================================================================
RECURSIVE NODE TEMPLATE
Expects a `node` set in the t-call context.
===================================================================== -->
<t t-name="fusion_plating_shopfloor.ProcessNode">
<div class="o_fp_pt_node">
<!-- ========== HEADER ========== -->
<div class="o_fp_pt_header">
<div class="o_fp_pt_header_left">
<button class="btn btn-outline-secondary btn-sm me-3"
t-on-click="onBackToOverview"
title="Back to Plant Overview">
<i class="fa fa-arrow-left me-1"/> Overview
</button>
<div class="o_fp_pt_title_block">
<h3 class="o_fp_pt_title mb-0">
<i class="fa fa-sitemap me-2"/>
Process Tree
</h3>
<span class="o_fp_pt_subtitle text-muted" t-if="state.productionName">
<t t-esc="state.productionName"/>
<t t-if="state.productName">
<t t-esc="state.productName"/>
</t>
<!-- The card itself -->
<div t-att-class="getCardClass(node)"
t-on-click="() => this.onNodeClick(node)">
<i t-attf-class="o_fp_pt_card_icon fa #{ nodeIcon(node) }"/>
<div class="o_fp_pt_card_body">
<div class="o_fp_pt_card_title" t-esc="node.name"/>
<div class="o_fp_pt_card_meta"
t-if="node.assigned_user_name or node.bath or node.tank or node.oven or node.rack or node.masking_material or node.duration_display or node.duration_expected_display">
<span t-if="node.assigned_user_name">
<i class="fa fa-user me-1"/><t t-esc="node.assigned_user_name"/>
</span>
<span t-if="node.bath">
· <i class="fa fa-flask me-1"/><t t-esc="node.bath"/>
</span>
<span t-if="node.tank">
· <i class="fa fa-tint me-1"/><t t-esc="node.tank"/>
</span>
<span t-if="node.oven">
· <i class="fa fa-fire me-1"/><t t-esc="node.oven"/>
</span>
<span t-if="node.rack">
· <i class="fa fa-th me-1"/><t t-esc="node.rack"/>
</span>
<span t-if="node.masking_material">
· <i class="fa fa-tag me-1"/><t t-esc="node.masking_material"/>
</span>
<span t-if="node.duration_display">
· <i class="fa fa-clock-o me-1"/><t t-esc="node.duration_display"/>
</span>
<span t-elif="node.duration_expected_display">
· <i class="fa fa-hourglass-half me-1"/><t t-esc="node.duration_expected_display"/>
</span>
</div>
</div>
<div class="o_fp_pt_header_right" t-if="state.moState">
<span class="badge bg-secondary">
MO: <t t-esc="state.moState"/>
</span>
<!-- Right-side: kind badge / qty / open icon -->
<div class="o_fp_pt_card_right">
<span t-if="node.wo_kind"
t-attf-class="o_fp_pt_kind o_fp_pt_kind_#{ node.wo_kind }"
t-esc="node.wo_kind_label || node.wo_kind"/>
<span class="o_fp_pt_qty"
t-if="node.qty_total"
t-esc="qtyLabel(node)"/>
<i class="o_fp_pt_card_open fa fa-external-link"
t-if="node.workorder_id"/>
</div>
</div>
<!-- Children — recurse -->
<div class="o_fp_pt_children" t-if="node.children and node.children.length">
<t t-foreach="node.children" t-as="child" t-key="child.id">
<t t-call="fusion_plating_shopfloor.ProcessNode">
<t t-set="node" t-value="child"/>
</t>
</t>
</div>
</div>
</t>
<!-- =====================================================================
ROOT TEMPLATE
===================================================================== -->
<t t-name="fusion_plating_shopfloor.ProcessTree">
<div class="o_fp_process_tree o_fp_pt_v3">
<!-- ========== HEADER ========== -->
<div class="o_fp_pt_header">
<button class="o_fp_pt_back"
t-on-click="onBack"
t-att-title="backLabel">
<i class="fa fa-arrow-left me-2"/>
<t t-esc="backLabel"/>
</button>
<div class="o_fp_pt_title_block">
<h2 class="o_fp_pt_title mb-0">
<i class="fa fa-sitemap me-2"/>Process
<span t-if="state.productionName" class="o_fp_pt_mo_name">
· <t t-esc="state.productionName"/>
</span>
</h2>
<div class="o_fp_pt_subtitle">
<span t-if="state.soName"><t t-esc="state.soName"/></span>
<span t-if="state.customer"> · <i class="fa fa-user me-1"/><t t-esc="state.customer"/></span>
<span t-if="state.productName"> · <t t-esc="state.productName"/></span>
<span t-if="state.productQty"> · Qty <t t-esc="state.productQty"/></span>
<span t-if="state.recipe"> · <i class="fa fa-flask me-1"/><t t-esc="state.recipe"/></span>
</div>
</div>
</div>
<!-- ========== LOADING ========== -->
<div class="o_fp_pt_loading text-center py-5" t-if="state.loading">
<div class="o_fp_pt_loading text-center py-4" t-if="state.loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2 text-muted">Loading process tree...</p>
<p class="mt-2 text-muted small">Loading process...</p>
</div>
<!-- ========== NO PRODUCTION ID ========== -->
<div class="o_fp_pt_empty text-center py-5"
<!-- ========== EMPTY ========== -->
<div class="o_fp_pt_empty"
t-if="!state.loading and !productionId">
<i class="fa fa-exclamation-triangle fa-3x text-warning"/>
<p class="mt-3">No manufacturing order selected.
Open this view from a production order to see its routing tree.</p>
<i class="fa fa-exclamation-triangle"/>
<div>No manufacturing order selected.</div>
</div>
<!-- ========== EMPTY TREE ========== -->
<div class="o_fp_pt_empty text-center py-5"
t-if="!state.loading and productionId and !state.nodes.length">
<i class="fa fa-sitemap fa-3x text-muted"/>
<p class="mt-3 text-muted">No routing steps found for this order.</p>
<div class="o_fp_pt_empty"
t-if="!state.loading and productionId and !state.root">
<i class="fa fa-sitemap"/>
<div>No process steps for this order.</div>
</div>
<!-- ========== TREE ========== -->
<div class="o_fp_pt_tree" t-if="state.nodes.length">
<t t-foreach="state.nodes" t-as="node" t-key="node.id">
<div class="o_fp_pt_node_wrapper">
<!-- Connecting line (not on first node) -->
<div class="o_fp_pt_connector" t-if="!node_first"/>
<!-- Node box -->
<div t-att-class="'o_fp_pt_node ' + getNodeStateClass(node.state)"
t-on-click="() => this.onNodeClick(node)">
<div class="o_fp_pt_node_header">
<div class="o_fp_pt_node_name">
<span class="o_fp_pt_node_seq"
t-if="node.sequence">
<t t-esc="node.sequence"/>.
</span>
<strong t-esc="node.name"/>
</div>
<button class="o_fp_pt_toggle_btn"
t-if="node.children and node.children.length"
t-on-click.stop="() => this.toggleNode(node.id)"
title="Expand / collapse">
<i t-att-class="isCollapsed(node.id) ? 'fa fa-chevron-right' : 'fa fa-chevron-down'"/>
</button>
</div>
<!-- Work centre -->
<div class="o_fp_pt_node_wc text-muted"
t-if="node.work_center_name">
<i class="fa fa-cog me-1"/>
<t t-esc="node.work_center_name"/>
</div>
<!-- State badge -->
<div class="o_fp_pt_node_state mt-1">
<span t-att-class="'badge ' + getNodeStateClass(node.state)">
<t t-esc="getNodeStateLabel(node.state)"/>
</span>
</div>
<!-- Progress bar -->
<div class="o_fp_pt_node_progress mt-2"
t-if="node.qty_total">
<div t-att-class="'o_fp_pt_bar ' + getProgressClass(node)">
<div class="o_fp_pt_bar_fill"
t-att-style="'width:' + getProgressPct(node) + '%'"/>
</div>
<span class="o_fp_pt_bar_label">
<t t-esc="node.qty_done"/>/<t t-esc="node.qty_total"/>
(<t t-esc="getProgressPct(node)"/>%)
</span>
</div>
<!-- Duration -->
<div class="o_fp_pt_node_duration text-muted mt-1"
t-if="node.duration_display">
<i class="fa fa-clock-o me-1"/>
<t t-esc="node.duration_display"/>
</div>
</div>
<!-- Child nodes (sub-states: Ready for X, X-ing) -->
<div class="o_fp_pt_children"
t-if="node.children and node.children.length and !isCollapsed(node.id)">
<t t-foreach="node.children" t-as="child" t-key="child.id">
<div class="o_fp_pt_child_connector"/>
<div t-att-class="'o_fp_pt_child_node ' + getNodeStateClass(child.state)">
<div class="o_fp_pt_child_name">
<t t-esc="child.name"/>
</div>
<div class="o_fp_pt_child_progress"
t-if="child.qty_total">
<div t-att-class="'o_fp_pt_bar o_fp_pt_bar_sm ' + getProgressClass(child)">
<div class="o_fp_pt_bar_fill"
t-att-style="'width:' + getProgressPct(child) + '%'"/>
</div>
<span class="o_fp_pt_bar_label">
<t t-esc="child.qty_done"/>/<t t-esc="child.qty_total"/>
</span>
</div>
</div>
</t>
</div>
</div>
<div class="o_fp_pt_canvas" t-if="state.root">
<t t-call="fusion_plating_shopfloor.ProcessNode">
<t t-set="node" t-value="state.root"/>
</t>
</div>