feat(jobs): Phase 6 — Process Tree OWL component for fp.job
Ports fusion_plating_shopfloor's process_tree.js to bind to fp.job instead of mrp.production. Consumes the /fp/jobs/process_tree JSON endpoint built in Phase 6 lean. Renders the recipe tree as cards. Each operation card shows the step state (pending/ready/in_progress/done/etc.) when there's a matching fp.job.step. Click an operation card -> open the step form. Click Back -> return to the job form. New 'Process Tree' button on the fp.job form (manager-only) launches the client action with job_id context. Manifest 19.0.2.1.0 -> 19.0.2.2.0. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.2.1.0',
|
||||
'version': '19.0.2.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'description': """
|
||||
@@ -38,9 +38,18 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/job_process_tree_action.xml',
|
||||
'views/fp_job_form_inherit.xml',
|
||||
'report/report_fp_job_sticker.xml',
|
||||
'report/report_fp_job_traveller.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_plating_jobs/static/src/scss/job_process_tree.scss',
|
||||
'fusion_plating_jobs/static/src/js/job_process_tree.js',
|
||||
'fusion_plating_jobs/static/src/xml/job_process_tree.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
|
||||
@@ -253,6 +253,26 @@ class FpJob(models.Model):
|
||||
)
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UI — Process Tree client action (Phase 6)
|
||||
# ------------------------------------------------------------------
|
||||
def action_open_process_tree(self):
|
||||
"""Open the OWL process-tree visualization for this job.
|
||||
|
||||
Launches the fp_job_process_tree client action with job_id in
|
||||
context. The component fetches /fp/jobs/process_tree and renders
|
||||
the recipe -> sub_process -> operation hierarchy as cards with
|
||||
per-step state badges.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_job_process_tree',
|
||||
'context': {'job_id': self.id},
|
||||
'name': 'Process Tree — %s' % (self.name or ''),
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle hooks (Tasks 2.6, 2.7, 2.8)
|
||||
#
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Job Process Tree (horizontal hierarchical view, fp.job)
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// Renders an fp.job's recipe (recipe → sub_process → operation) as a
|
||||
// horizontal bracket tree, port of fusion_plating_shopfloor's process_tree.js
|
||||
// rebound to fp.job + fp.job.step (instead of mrp.production + mrp.workorder).
|
||||
//
|
||||
// Action context:
|
||||
// job_id — required; the fp.job whose recipe to render
|
||||
// back_step_id — optional; if set, the back button returns to that step
|
||||
// instead of the job form
|
||||
//
|
||||
// Endpoint: POST /fp/jobs/process_tree (fusion_plating_jobs/controllers)
|
||||
// payload : { job_id: <int> }
|
||||
// response : { job_name, partner, state, qty, recipe_name, progress_pct,
|
||||
// tree: { id, name, node_type, sequence,
|
||||
// step_id, step_state, step_assigned_user,
|
||||
// duration_expected, duration_actual, children: [...] } }
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class JobProcessTree extends Component {
|
||||
static template = "fusion_plating_jobs.JobProcessTree";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
|
||||
this.state = useState({
|
||||
jobName: "",
|
||||
partner: "",
|
||||
jobState: "",
|
||||
qty: 0,
|
||||
recipe: "",
|
||||
progressPct: 0,
|
||||
root: null,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this.loadTree();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Action context -----------------------------------------------------
|
||||
|
||||
get _ctx() {
|
||||
const a = this.props.action || {};
|
||||
return { ...(a.context || {}), ...(a.params || {}) };
|
||||
}
|
||||
get jobId() { return this._ctx.job_id || null; }
|
||||
get backStepId() { return this._ctx.back_step_id || null; }
|
||||
get backLabel() {
|
||||
return this.backStepId ? "Back to Step" : "Back to Job";
|
||||
}
|
||||
|
||||
// ---- Data ---------------------------------------------------------------
|
||||
|
||||
async loadTree() {
|
||||
const jobId = this.jobId;
|
||||
if (!jobId) {
|
||||
this.notification.add(
|
||||
"No job specified for the process tree.",
|
||||
{ type: "warning" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const r = await rpc("/fp/jobs/process_tree", {
|
||||
job_id: jobId,
|
||||
});
|
||||
if (r && !r.error) {
|
||||
this.state.jobName = r.job_name || "";
|
||||
this.state.partner = r.partner || "";
|
||||
this.state.jobState = r.state || "";
|
||||
this.state.qty = r.qty || 0;
|
||||
this.state.recipe = r.recipe_name || "";
|
||||
this.state.progressPct = r.progress_pct || 0;
|
||||
this.state.root = r.tree || null;
|
||||
} else if (r && r.error) {
|
||||
this.notification.add(r.error, { type: "warning" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
`Failed to load process tree: ${err.message || err}`,
|
||||
{ type: "danger" },
|
||||
);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Navigation ---------------------------------------------------------
|
||||
|
||||
onNodeClick(node) {
|
||||
// Only operation cards with a matching fp.job.step are clickable —
|
||||
// they open the underlying step form.
|
||||
if (!node || !node.step_id) {
|
||||
return;
|
||||
}
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.job.step",
|
||||
res_id: node.step_id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
onBack() {
|
||||
const stepId = this.backStepId;
|
||||
if (stepId) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.job.step",
|
||||
res_id: parseInt(stepId, 10),
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Default back: open the job form.
|
||||
const jobId = this.jobId;
|
||||
if (jobId) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "fp.job",
|
||||
res_id: parseInt(jobId, 10),
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Fallback — pop the stack.
|
||||
this.action.doAction({ type: "ir.actions.act_window_close" });
|
||||
}
|
||||
|
||||
// ---- Helpers ------------------------------------------------------------
|
||||
|
||||
/** Return the css class chain for a node card (state + node_type). */
|
||||
getCardClass(node) {
|
||||
const parts = ["o_fp_jpt_card"];
|
||||
parts.push(`o_fp_jpt_type_${node.node_type || "unknown"}`);
|
||||
if (node.step_state) {
|
||||
parts.push(`o_fp_jpt_state_${node.step_state}`);
|
||||
}
|
||||
if (node.step_id) {
|
||||
parts.push("o_fp_jpt_clickable");
|
||||
}
|
||||
if (this.isHighlight(node)) {
|
||||
parts.push("o_fp_jpt_highlight");
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** Highlight steps that are live (ready / in_progress / paused). */
|
||||
isHighlight(node) {
|
||||
return node.step_state === "ready"
|
||||
|| node.step_state === "in_progress"
|
||||
|| node.step_state === "paused";
|
||||
}
|
||||
|
||||
/** Friendly label for the step state badge. */
|
||||
stateLabel(node) {
|
||||
if (!node.step_state) return null;
|
||||
const map = {
|
||||
pending: "Pending",
|
||||
ready: "Ready",
|
||||
in_progress: "In Progress",
|
||||
paused: "Paused",
|
||||
done: "Done",
|
||||
skipped: "Skipped",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
return map[node.step_state] || node.step_state;
|
||||
}
|
||||
|
||||
/** Concise duration label: "actual / expected min" when available. */
|
||||
durationLabel(node) {
|
||||
const exp = node.duration_expected;
|
||||
const act = node.duration_actual;
|
||||
if (act && exp) return `${act.toFixed(0)}/${exp.toFixed(0)} min`;
|
||||
if (exp) return `${exp.toFixed(0)} min`;
|
||||
if (act) return `${act.toFixed(0)} min`;
|
||||
return "";
|
||||
}
|
||||
|
||||
nodeIcon(node) {
|
||||
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";
|
||||
default: return "fa-square";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_job_process_tree", JobProcessTree);
|
||||
@@ -0,0 +1,384 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Job Process Tree (horizontal hierarchical, v1, 2026-04)
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// Parallel of fusion_plating_shopfloor's process_tree.scss, rebound to
|
||||
// fp.job. Self-contained — does NOT pull in the shopfloor token partial,
|
||||
// so this module stays free of the shopfloor dependency.
|
||||
//
|
||||
// Class prefix: .o_fp_jpt_* (Job Process Tree)
|
||||
//
|
||||
// Hierarchical bracket tree:
|
||||
//
|
||||
// [Recipe]──┬──[Sub-Process]──┬──[Operation]
|
||||
// │ └──[Operation]
|
||||
// ├──[Operation]
|
||||
// └──[Operation]
|
||||
//
|
||||
// Each .o_fp_jpt_node is `display: flex` with:
|
||||
// - the card on the left
|
||||
// - .o_fp_jpt_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.
|
||||
// =============================================================================
|
||||
|
||||
|
||||
// Suppress hover transforms on touch devices so taps don't leave cards
|
||||
// stuck in the hover state.
|
||||
@media (hover: none) {
|
||||
.o_fp_job_process_tree [class*="o_fp_jpt_"]:hover {
|
||||
transform: none !important;
|
||||
box-shadow: inherit !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Connector geometry ------------------------------------------------------
|
||||
// Tweaking these recalculates the whole bracket-tree layout.
|
||||
$jpt-card-h : 44px; // nominal card height (centre stays at h/2)
|
||||
$jpt-row-gap : 12px; // vertical gap between sibling children
|
||||
$jpt-indent : 36px; // horizontal gap from parent → children
|
||||
$jpt-stub : 28px; // horizontal connector segment length
|
||||
$jpt-line-color : #6b7280; // connector colour
|
||||
$jpt-line-width : 2px;
|
||||
|
||||
|
||||
.o_fp_job_process_tree.o_fp_jpt_v1 {
|
||||
height: 100%;
|
||||
overflow: auto; // both axes — wide trees scroll horizontally
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background-color: var(--o-action, #f7f7f8);
|
||||
color: var(--bs-body-color, #1a1d21);
|
||||
|
||||
@media (max-width: 600px) { padding: 12px; gap: 12px; }
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Header (compact strip)
|
||||
// -------------------------------------------------------------------------
|
||||
.o_fp_jpt_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--bs-body-bg, #ffffff);
|
||||
border: 1px solid #d8dadd;
|
||||
border-radius: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
.o_fp_jpt_back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--bs-tertiary-bg, #f1f3f5);
|
||||
color: var(--bs-body-color, #1a1d21);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #d8dadd;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
&:hover {
|
||||
background-color: #e9ecef;
|
||||
border-color: #c5c8cc;
|
||||
}
|
||||
}
|
||||
.o_fp_jpt_title_block { flex: 1 1 auto; min-width: 0; }
|
||||
.o_fp_jpt_title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
.o_fp_jpt_job_name { font-weight: 600; opacity: 0.8; }
|
||||
}
|
||||
.o_fp_jpt_subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 2px;
|
||||
.fa { margin-right: 2px; opacity: 0.7; }
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Empty / loading
|
||||
// -------------------------------------------------------------------------
|
||||
.o_fp_jpt_empty {
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
opacity: 0.7;
|
||||
background-color: var(--bs-body-bg, #ffffff);
|
||||
border: 1px solid #d8dadd;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
max-width: 520px;
|
||||
> .fa { font-size: 1.75rem; margin-bottom: 8px; opacity: 0.6; }
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tree canvas — horizontally scrollable
|
||||
// -------------------------------------------------------------------------
|
||||
.o_fp_jpt_canvas {
|
||||
padding: 12px 0;
|
||||
min-width: max-content; // let cards push the canvas wider for scroll
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Recursive node — flex row of [card | children-column]
|
||||
// -------------------------------------------------------------------------
|
||||
.o_fp_jpt_node {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Card (Steelhead-style: dark fill, rounded)
|
||||
// -------------------------------------------------------------------------
|
||||
.o_fp_jpt_card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 220px;
|
||||
max-width: 340px;
|
||||
min-height: $jpt-card-h;
|
||||
padding: 8px 12px;
|
||||
background-color: #2b2f36; // dark slate
|
||||
color: #f1f3f5;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25;
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
z-index: 1; // sit above connector lines
|
||||
transition: transform 0.1s ease,
|
||||
box-shadow 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
|
||||
&.o_fp_jpt_clickable {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||
background-color: #353a42;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Card type tints (subtle) -------------------------------------
|
||||
&.o_fp_jpt_type_recipe {
|
||||
background-color: #1f2329;
|
||||
font-weight: 700;
|
||||
}
|
||||
&.o_fp_jpt_type_sub_process {
|
||||
background-color: #262a31;
|
||||
font-weight: 600;
|
||||
}
|
||||
&.o_fp_jpt_type_step {
|
||||
background-color: #353a42;
|
||||
font-size: 0.8rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
// ---- Live state highlight ----------------------------------------
|
||||
&.o_fp_jpt_state_in_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_jpt_highlight.o_fp_jpt_state_ready {
|
||||
background-color: #c0392b; // ready 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_jpt_state_paused {
|
||||
background-color: #b5651d; // amber — paused
|
||||
color: #fff;
|
||||
}
|
||||
&.o_fp_jpt_state_done {
|
||||
background-color: #1e8449; // green for completed
|
||||
color: #fff;
|
||||
}
|
||||
&.o_fp_jpt_state_skipped,
|
||||
&.o_fp_jpt_state_cancelled { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.o_fp_jpt_card_icon {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
opacity: 0.85;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.o_fp_jpt_card_body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.o_fp_jpt_card_title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.o_fp_jpt_card_meta {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.75;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 6px;
|
||||
.fa { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.o_fp_jpt_card_right {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.o_fp_jpt_card_open {
|
||||
opacity: 0.55;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State badge (right side of operation cards)
|
||||
// -------------------------------------------------------------------------
|
||||
.o_fp_jpt_state_badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
&.o_fp_jpt_state_badge_pending { background-color: rgba(255,255,255,.12); color: #c8ccd2; }
|
||||
&.o_fp_jpt_state_badge_ready { background-color: rgba(255, 193, 7, .25); color: #ffd866; }
|
||||
&.o_fp_jpt_state_badge_in_progress { background-color: rgba(13, 110, 253, .25); color: #6ea8fe; }
|
||||
&.o_fp_jpt_state_badge_paused { background-color: rgba(255, 145, 0, .28); color: #ffb86b; }
|
||||
&.o_fp_jpt_state_badge_done { background-color: rgba(25, 135, 84, .28); color: #75d4a4; }
|
||||
&.o_fp_jpt_state_badge_skipped { background-color: rgba(108, 117, 125, .35); color: #d0d4d9; }
|
||||
&.o_fp_jpt_state_badge_cancelled { background-color: rgba(220, 53, 69, .25); color: #f1aeb5; }
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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.
|
||||
// -------------------------------------------------------------------------
|
||||
.o_fp_jpt_children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $jpt-row-gap;
|
||||
margin-left: $jpt-indent;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -#{$jpt-indent};
|
||||
top: calc(#{$jpt-card-h} / 2); // parent-card vertical centre
|
||||
width: $jpt-indent;
|
||||
height: $jpt-line-width;
|
||||
background-color: $jpt-line-color;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Connector lines (bracket style, drawn from CSS only)
|
||||
//
|
||||
// Each child .o_fp_jpt_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_jpt_children > .o_fp_jpt_node {
|
||||
position: relative;
|
||||
padding-left: $jpt-stub; // room for the horizontal stub
|
||||
|
||||
// -- horizontal stub from bus column → card --------------------------
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(#{$jpt-card-h} / 2); // align with card vertical centre
|
||||
width: $jpt-stub;
|
||||
height: $jpt-line-width;
|
||||
background-color: $jpt-line-color;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
// -- vertical bus segment (default: full row, top → bottom) ----------
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(-#{$jpt-row-gap} / 2); // bridge gap to sibling above
|
||||
bottom: calc(-#{$jpt-row-gap} / 2); // bridge gap to sibling below
|
||||
width: $jpt-line-width;
|
||||
background-color: $jpt-line-color;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
// First child — vertical only from card centre → bottom of row
|
||||
&:first-child::after {
|
||||
top: calc(#{$jpt-card-h} / 2);
|
||||
}
|
||||
// Last child — vertical only from top of row → card centre
|
||||
&:last-child::after {
|
||||
bottom: calc(100% - (#{$jpt-card-h} / 2));
|
||||
}
|
||||
// Only child — vertical only at the card centre point
|
||||
&:first-child:last-child::after {
|
||||
top: calc(#{$jpt-card-h} / 2);
|
||||
bottom: calc(100% - (#{$jpt-card-h} / 2));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pulse on live (in_progress / ready) cards
|
||||
// -------------------------------------------------------------------------
|
||||
@keyframes o_fp_jpt_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_jpt_card.o_fp_jpt_state_in_progress,
|
||||
.o_fp_jpt_card.o_fp_jpt_highlight.o_fp_jpt_state_ready {
|
||||
animation: o_fp_jpt_pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Job Process Tree — horizontal hierarchical view, fp.job edition.
|
||||
Recursive template renders the recipe -> sub-process -> operation
|
||||
hierarchy from the /fp/jobs/process_tree endpoint, with bracket
|
||||
connectors between cards drawn from CSS.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- =====================================================================
|
||||
RECURSIVE NODE TEMPLATE
|
||||
Expects a `node` set in the t-call context.
|
||||
===================================================================== -->
|
||||
<t t-name="fusion_plating_jobs.JobProcessNode">
|
||||
<div class="o_fp_jpt_node">
|
||||
|
||||
<!-- The card itself -->
|
||||
<div t-att-class="getCardClass(node)"
|
||||
t-on-click="() => this.onNodeClick(node)">
|
||||
<i t-attf-class="o_fp_jpt_card_icon fa #{ nodeIcon(node) }"/>
|
||||
<div class="o_fp_jpt_card_body">
|
||||
<div class="o_fp_jpt_card_title" t-esc="node.name"/>
|
||||
<div class="o_fp_jpt_card_meta"
|
||||
t-if="node.step_assigned_user or durationLabel(node)">
|
||||
<span t-if="node.step_assigned_user">
|
||||
<i class="fa fa-user me-1"/><t t-esc="node.step_assigned_user"/>
|
||||
</span>
|
||||
<span t-if="durationLabel(node)">
|
||||
<t t-if="node.step_assigned_user"> · </t>
|
||||
<i class="fa fa-clock-o me-1"/><t t-esc="durationLabel(node)"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right-side: state badge / open icon -->
|
||||
<div class="o_fp_jpt_card_right">
|
||||
<span t-if="node.step_state"
|
||||
t-attf-class="o_fp_jpt_state_badge o_fp_jpt_state_badge_#{ node.step_state }"
|
||||
t-esc="stateLabel(node)"/>
|
||||
<i class="o_fp_jpt_card_open fa fa-external-link"
|
||||
t-if="node.step_id"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Children — recurse -->
|
||||
<div class="o_fp_jpt_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_jobs.JobProcessNode">
|
||||
<t t-set="node" t-value="child"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
|
||||
<!-- =====================================================================
|
||||
ROOT TEMPLATE
|
||||
===================================================================== -->
|
||||
<t t-name="fusion_plating_jobs.JobProcessTree">
|
||||
<div class="o_fp_job_process_tree o_fp_jpt_v1">
|
||||
|
||||
<!-- ========== HEADER ========== -->
|
||||
<div class="o_fp_jpt_header">
|
||||
<button class="o_fp_jpt_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_jpt_title_block">
|
||||
<h2 class="o_fp_jpt_title mb-0">
|
||||
<i class="fa fa-sitemap me-2"/>Process
|
||||
<span t-if="state.jobName" class="o_fp_jpt_job_name">
|
||||
· <t t-esc="state.jobName"/>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="o_fp_jpt_subtitle">
|
||||
<span t-if="state.partner">
|
||||
<i class="fa fa-user me-1"/><t t-esc="state.partner"/>
|
||||
</span>
|
||||
<span t-if="state.jobState"> · <t t-esc="state.jobState"/></span>
|
||||
<span t-if="state.qty"> · Qty <t t-esc="state.qty"/></span>
|
||||
<span t-if="state.recipe"> · <i class="fa fa-flask me-1"/><t t-esc="state.recipe"/></span>
|
||||
<span t-if="state.progressPct"> · <t t-esc="state.progressPct.toFixed(0)"/>%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== LOADING ========== -->
|
||||
<div class="o_fp_jpt_loading text-center py-4" t-if="state.loading">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<p class="mt-2 text-muted small">Loading process...</p>
|
||||
</div>
|
||||
|
||||
<!-- ========== EMPTY ========== -->
|
||||
<div class="o_fp_jpt_empty"
|
||||
t-if="!state.loading and !jobId">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<div>No job selected.</div>
|
||||
</div>
|
||||
<div class="o_fp_jpt_empty"
|
||||
t-if="!state.loading and jobId and !state.root">
|
||||
<i class="fa fa-sitemap"/>
|
||||
<div>No recipe assigned to this job.</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== TREE ========== -->
|
||||
<div class="o_fp_jpt_canvas" t-if="state.root">
|
||||
<t t-call="fusion_plating_jobs.JobProcessNode">
|
||||
<t t-set="node" t-value="state.root"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Adds a "Process Tree" button to the fp.job form header, launching
|
||||
the fp_job_process_tree client action with job_id in context.
|
||||
|
||||
Hidden while the job is in draft (no recipe-derived steps yet).
|
||||
-->
|
||||
<record id="view_fp_job_form_jobs_inherit" model="ir.ui.view">
|
||||
<field name="name">fp.job.form.jobs.inherit</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="inherit_id" ref="fusion_plating.view_fp_job_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_open_process_tree" type="object"
|
||||
string="Process Tree"
|
||||
class="btn-secondary"
|
||||
icon="fa-sitemap"
|
||||
invisible="state == 'draft'"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
fp_job_process_tree client action — opened from the "Process Tree"
|
||||
button on the fp.job form (action_open_process_tree on fp.job)
|
||||
with {'job_id': self.id} in context.
|
||||
-->
|
||||
<record id="action_job_process_tree" model="ir.actions.client">
|
||||
<field name="name">Job Process Tree</field>
|
||||
<field name="tag">fp_job_process_tree</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user