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)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.2.1.0',
|
'version': '19.0.2.2.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -38,9 +38,18 @@ full design rationale and §6.2 of the implementation plan for task list.
|
|||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'views/res_config_settings_views.xml',
|
'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_sticker.xml',
|
||||||
'report/report_fp_job_traveller.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,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
|
|||||||
@@ -253,6 +253,26 @@ class FpJob(models.Model):
|
|||||||
)
|
)
|
||||||
return True
|
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)
|
# 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