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

@@ -1021,87 +1021,286 @@ class FpShopfloorController(http.Controller):
def process_tree(self, production_id):
"""Return routing tree for a manufacturing order.
Each node is an operation/work-order step. Children represent
sub-states (ready vs active) within that step.
"""
MrpWO = request.env.get('mrp.workorder')
if MrpWO is None:
return {
'production_name': '',
'product_name': '',
'state': '',
'nodes': [],
}
Walks the MO's recipe tree (fusion.plating.process.node) and returns
a recursive nested structure:
recipe → sub_process → operation → step
For each `operation` node we look up the matching mrp.workorder by
name within this MO, then attach the WO state, qty progress, kind,
equipment, and a synthetic state-child ("Ready for X" or "In X")
so the operator sees the live position in the flow.
MrpProduction = request.env['mrp.production']
If the MO has no recipe assigned we fall back to a flat list of
WOs as a single tier of operation nodes under a synthetic root.
"""
env = request.env
MrpWO = env.get('mrp.workorder')
MrpProduction = env['mrp.production']
production = MrpProduction.browse(int(production_id))
if not production.exists():
raise UserError(f"Manufacturing order {production_id} not found")
work_orders = MrpWO.search(
[('production_id', '=', production.id)],
order='sequence, id',
# Customer
customer = ''
so_name = production.origin or ''
if production.x_fc_portal_job_id and production.x_fc_portal_job_id.partner_id:
customer = production.x_fc_portal_job_id.partner_id.name or ''
elif so_name:
so = env['sale.order'].search([('name', '=', so_name)], limit=1)
if so:
customer = so.partner_id.name or ''
product_qty = int(production.product_qty or 0)
recipe = production.x_fc_recipe_id
# Build a lookup so each operation node finds its matching WO by name.
# The bridge's _generate_workorders_from_recipe() copies node.name →
# wo.name, so this is a stable join key within one MO.
wos_by_name = {}
all_wos = MrpWO.browse([]) if MrpWO is not None else []
if MrpWO is not None:
all_wos = MrpWO.search(
[('production_id', '=', production.id)],
order='sequence, id',
)
for wo in all_wos:
key = (wo.name or '').strip()
if key and key not in wos_by_name:
wos_by_name[key] = wo
wo_kind_selection = (
dict(MrpWO._fields['x_fc_wo_kind'].selection)
if MrpWO is not None and 'x_fc_wo_kind' in MrpWO._fields else {}
)
masking_selection = (
dict(MrpWO._fields['x_fc_masking_material'].selection)
if MrpWO is not None and 'x_fc_masking_material' in MrpWO._fields else {}
)
nodes = []
for wo in work_orders:
def _f(wo, name):
return wo[name] if wo and name in wo._fields else False
def _dur_disp(mins):
if mins >= 60:
return f'{mins / 60:.1f}h'
if mins > 0:
return f'{int(mins)}m'
return ''
def _wo_payload(wo):
"""Manager-Desk style fields for one WO."""
qty_done = int(wo.qty_produced or 0)
qty_total = int(wo.qty_production or production.product_qty or 0)
# Duration display
duration_mins = wo.duration or 0
if duration_mins >= 60:
duration_display = f'{duration_mins / 60:.1f}h'
elif duration_mins > 0:
duration_display = f'{int(duration_mins)}m'
else:
duration_display = ''
# Build children — sub-state nodes
children = []
if wo.state in ('ready', 'waiting'):
children.append({
'id': f'{wo.id}_ready',
'name': f'Ready for {wo.workcenter_id.name or wo.name}',
'state': 'ready',
'qty_done': 0,
'qty_total': qty_total,
})
elif wo.state == 'progress':
children.append({
'id': f'{wo.id}_active',
'name': f'{wo.workcenter_id.name or wo.name}-ing',
'state': 'progress',
'qty_done': qty_done,
'qty_total': qty_total,
})
# Also show "remaining" child if partial
remaining = qty_total - qty_done
if remaining > 0:
children.append({
'id': f'{wo.id}_remaining',
'name': f'Ready for {wo.workcenter_id.name or wo.name}',
'state': 'ready',
'qty_done': 0,
'qty_total': remaining,
})
nodes.append({
'id': wo.id,
qty_total = int(wo.qty_production or product_qty or 0)
wo_kind = _f(wo, 'x_fc_wo_kind') or 'other'
assigned = _f(wo, 'x_fc_assigned_user_id')
bath = _f(wo, 'x_fc_bath_id')
tank = _f(wo, 'x_fc_tank_id')
oven = _f(wo, 'x_fc_oven_id')
rack = _f(wo, 'x_fc_rack_id')
masking = _f(wo, 'x_fc_masking_material')
return {
'workorder_id': wo.id,
'sequence': wo.sequence or 0,
'name': wo.display_name or wo.name,
'work_center_name': wo.workcenter_id.name if wo.workcenter_id else '',
'state': wo.state or '',
'wo_state': wo.state or '',
'qty_done': qty_done,
'qty_total': qty_total,
'duration_display': duration_display,
'children': children,
})
'wo_kind': wo_kind,
'wo_kind_label': wo_kind_selection.get(wo_kind, ''),
'assigned_user_name': assigned.name if assigned else '',
'bath': bath.name if bath else '',
'tank': tank.name if tank else '',
'oven': oven.name if oven else '',
'rack': rack.name if rack else '',
'masking_material': (
masking_selection.get(masking, '') if masking else ''
),
'duration_display': _dur_disp(wo.duration or 0),
'duration_expected_display': _dur_disp(wo.duration_expected or 0),
'missing_for_release': _f(wo, 'x_fc_missing_for_release') or '',
}
def _step_state_for(step_node, wo):
"""Map a recipe step's state from the parent operation's WO.
The step nodes are templates ("Ready For Blast", "Blast",
"Bake", etc.). We push the operation's WO state down so the
step that represents the live position renders highlighted.
Convention: a step whose name contains "ready" represents the
queued/waiting phase; the other step represents the action
phase.
"""
if not wo:
return ''
step_name = (step_node.name or '').lower()
is_ready_step = 'ready' in step_name
wo_state = wo.state or ''
if wo_state == 'done':
return 'done'
if wo_state in ('ready', 'waiting'):
return 'ready' if is_ready_step else 'pending'
if wo_state == 'progress':
return 'progress' if not is_ready_step else 'done'
return ''
def _step_qty_for(step_node, wo):
"""Live qty for a step — fed from the parent WO."""
if not wo or not wo.qty_production:
return (0, 0)
qty_done = int(wo.qty_produced or 0)
qty_total = int(wo.qty_production or 0)
step_name = (step_node.name or '').lower()
wo_state = wo.state or ''
if wo_state == 'done':
return (qty_total, qty_total)
if wo_state in ('ready', 'waiting'):
# Everything is queued
return (qty_total, qty_total) if 'ready' in step_name else (0, 0)
if wo_state == 'progress':
if 'ready' in step_name:
remaining = qty_total - qty_done
return (remaining, remaining) if remaining > 0 else (0, 0)
return (qty_done, qty_total)
return (0, 0)
# Track which WOs were attached to a recipe node — leftovers get
# pushed under the recipe root as orphan operations.
attached_wo_ids = set()
def _walk(node, parent_wo=None):
wo = wos_by_name.get((node.name or '').strip())
wo_data = {}
if node.node_type == 'operation' and wo:
attached_wo_ids.add(wo.id)
wo_data = _wo_payload(wo)
# If this node is a `step` whose parent operation has a WO,
# mirror the WO's state onto the step so the live phase
# ("Ready for X" or "X") renders highlighted.
step_state = ''
step_qty_done, step_qty_total = 0, 0
if node.node_type == 'step' and parent_wo:
step_state = _step_state_for(node, parent_wo)
step_qty_done, step_qty_total = _step_qty_for(node, parent_wo)
# Recurse — pass this operation's WO down so step children inherit
inherited_wo = wo if (node.node_type == 'operation' and wo) else parent_wo
children_payload = []
for child in node.child_ids.sorted('sequence'):
children_payload.append(_walk(child, inherited_wo))
return {
'id': f'n_{node.id}',
'name': node.name or '',
'node_type': node.node_type,
'icon': node.icon or '',
'sequence': node.sequence or 0,
'workorder_id': wo_data.get('workorder_id'),
'wo_state': wo_data.get('wo_state', ''),
'state': wo_data.get('wo_state') or step_state or '',
'qty_done': wo_data.get('qty_done') or step_qty_done or 0,
'qty_total': wo_data.get('qty_total') or step_qty_total or 0,
'wo_kind': wo_data.get('wo_kind', ''),
'wo_kind_label': wo_data.get('wo_kind_label', ''),
'assigned_user_name': wo_data.get('assigned_user_name', ''),
'bath': wo_data.get('bath', ''),
'tank': wo_data.get('tank', ''),
'oven': wo_data.get('oven', ''),
'rack': wo_data.get('rack', ''),
'masking_material': wo_data.get('masking_material', ''),
'duration_display': wo_data.get('duration_display', ''),
'duration_expected_display': wo_data.get(
'duration_expected_display', ''),
'missing_for_release': wo_data.get('missing_for_release', ''),
'children': children_payload,
}
if recipe:
root = _walk(recipe)
# Append orphan WOs (those not matched to any recipe node by name)
# so we don't lose them — these usually appear when the user
# adds ad-hoc WOs after generation.
for wo in all_wos:
if wo.id in attached_wo_ids:
continue
wo_data = _wo_payload(wo)
orphan = {
'id': f'wo_{wo.id}',
'name': wo.name or '',
'node_type': 'operation',
'icon': '',
'sequence': wo.sequence or 0,
'workorder_id': wo.id,
'wo_state': wo.state or '',
'state': wo.state or '',
'qty_done': wo_data['qty_done'],
'qty_total': wo_data['qty_total'],
'wo_kind': wo_data['wo_kind'],
'wo_kind_label': wo_data['wo_kind_label'],
'assigned_user_name': wo_data['assigned_user_name'],
'bath': wo_data['bath'],
'tank': wo_data['tank'],
'oven': wo_data['oven'],
'rack': wo_data['rack'],
'masking_material': wo_data['masking_material'],
'duration_display': wo_data['duration_display'],
'duration_expected_display': wo_data['duration_expected_display'],
'missing_for_release': wo_data['missing_for_release'],
'children': [],
}
root['children'].append(orphan)
else:
# No recipe — synth a root with WOs as direct operation children.
child_nodes = []
for wo in all_wos:
wo_data = _wo_payload(wo)
child_nodes.append({
'id': f'wo_{wo.id}',
'name': wo.name or '',
'node_type': 'operation',
'icon': '',
'sequence': wo.sequence or 0,
'workorder_id': wo.id,
'wo_state': wo.state or '',
'state': wo.state or '',
'qty_done': wo_data['qty_done'],
'qty_total': wo_data['qty_total'],
'wo_kind': wo_data['wo_kind'],
'wo_kind_label': wo_data['wo_kind_label'],
'assigned_user_name': wo_data['assigned_user_name'],
'bath': wo_data['bath'],
'tank': wo_data['tank'],
'oven': wo_data['oven'],
'rack': wo_data['rack'],
'masking_material': wo_data['masking_material'],
'duration_display': wo_data['duration_display'],
'duration_expected_display': wo_data['duration_expected_display'],
'missing_for_release': wo_data['missing_for_release'],
'children': [],
})
root = {
'id': 'root',
'name': production.product_id.display_name if production.product_id
else (production.name or 'Process'),
'node_type': 'recipe',
'icon': 'fa-sitemap',
'sequence': 0,
'children': child_nodes,
'workorder_id': None,
'state': production.state or '',
'wo_state': '',
'qty_done': 0, 'qty_total': 0,
'wo_kind': '', 'wo_kind_label': '',
'assigned_user_name': '', 'bath': '', 'tank': '', 'oven': '',
'rack': '', 'masking_material': '',
'duration_display': '', 'duration_expected_display': '',
'missing_for_release': '',
}
return {
'production_name': production.name or '',
'product_name': production.product_id.display_name if production.product_id else '',
'state': production.state or '',
'nodes': nodes,
'customer': customer,
'so_name': so_name,
'product_qty': product_qty,
'recipe': recipe.name if recipe else '',
'root': root,
}

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>