changes
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user