From 034a6560ad278e4a748619facf86ea762c7705b6 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 25 Apr 2026 04:20:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(jobs):=20Phase=206=20=E2=80=94=20Process?= =?UTF-8?q?=20Tree=20OWL=20component=20for=20fp.job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fusion_plating_jobs/__manifest__.py | 11 +- .../fusion_plating_jobs/models/fp_job.py | 20 + .../static/src/js/job_process_tree.js | 207 ++++++++++ .../static/src/scss/job_process_tree.scss | 384 ++++++++++++++++++ .../static/src/xml/job_process_tree.xml | 122 ++++++ .../views/fp_job_form_inherit.xml | 23 ++ .../views/job_process_tree_action.xml | 12 + 7 files changed, 778 insertions(+), 1 deletion(-) create mode 100644 fusion_plating/fusion_plating_jobs/static/src/js/job_process_tree.js create mode 100644 fusion_plating/fusion_plating_jobs/static/src/scss/job_process_tree.scss create mode 100644 fusion_plating/fusion_plating_jobs/static/src/xml/job_process_tree.xml create mode 100644 fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml create mode 100644 fusion_plating/fusion_plating_jobs/views/job_process_tree_action.xml diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 67c79c84..f6140560 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -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, diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index b94c4c03..d8087c2e 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -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) # diff --git a/fusion_plating/fusion_plating_jobs/static/src/js/job_process_tree.js b/fusion_plating/fusion_plating_jobs/static/src/js/job_process_tree.js new file mode 100644 index 00000000..bed06eca --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/js/job_process_tree.js @@ -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: } +// 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); diff --git a/fusion_plating/fusion_plating_jobs/static/src/scss/job_process_tree.scss b/fusion_plating/fusion_plating_jobs/static/src/scss/job_process_tree.scss new file mode 100644 index 00000000..c007e696 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/scss/job_process_tree.scss @@ -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; + } +} diff --git a/fusion_plating/fusion_plating_jobs/static/src/xml/job_process_tree.xml b/fusion_plating/fusion_plating_jobs/static/src/xml/job_process_tree.xml new file mode 100644 index 00000000..943db807 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/xml/job_process_tree.xml @@ -0,0 +1,122 @@ + + + + + + +
+ + +
+ +
+
+
+ + + + + · + + +
+
+ + +
+ + +
+
+ + +
+ + + + + +
+
+ + + + + +
+ + +
+ +
+

+ Process + + · + +

+
+ + + + · + · Qty + · + · % +
+
+
+ + +
+ +

Loading process...

+
+ + +
+ +
No job selected.
+
+
+ +
No recipe assigned to this job.
+
+ + +
+ + + +
+ +
+
+ + diff --git a/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml new file mode 100644 index 00000000..52ef952a --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml @@ -0,0 +1,23 @@ + + + + + fp.job.form.jobs.inherit + fp.job + + + +