changes
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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