From a18ef6c405ab4ca609d86fdbb3359e76b4584772 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 22 May 2026 21:52:26 -0400 Subject: [PATCH] feat(fusion_plating_shopfloor): JobWorkspace client action (header/steps/side/rail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan tasks P1.12 through P1.15 batched. Full-screen OWL component registered as fp_job_workspace. Layout: STICKY HEADER WO #, customer, part, qty/done, deadline, WorkflowChip, holds badge STICKY WORKFLOW BAR 9-stage dots (passed/current/pending) + Next-action button driving advance_milestone STEP LIST All steps with state icons; active step auto-expanded with recipe chips (thickness/ dwell/bake/sign-off) + instructions + Start/ Finish buttons; blocked steps show GateViz; override-excluded steps faded SIDE PANEL Customer spec PDF link, attachments list, chatter notes STICKY ACTION RAIL Create Hold (HoldComposer modal), Add Note (chatter via message_post), Issue Cert (when draft cert exists), Next Milestone Auto-refresh every 15s. Sign-off steps route Finish through SignaturePad → /fp/workspace/sign_off. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fusion_plating_shopfloor/__manifest__.py | 4 + .../static/src/js/job_workspace.js | 212 ++++++++++++ .../static/src/scss/job_workspace.scss | 319 ++++++++++++++++++ .../static/src/xml/job_workspace.xml | 230 +++++++++++++ 4 files changed, 765 insertions(+) create mode 100644 fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js create mode 100644 fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss create mode 100644 fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index b5a0172e..351d5e0c 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -80,6 +80,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss', 'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml', 'fusion_plating_shopfloor/static/src/js/components/kanban_card.js', + # ---- Job Workspace (Phase 1 — tablet redesign) ---- + 'fusion_plating_shopfloor/static/src/scss/job_workspace.scss', + 'fusion_plating_shopfloor/static/src/xml/job_workspace.xml', + 'fusion_plating_shopfloor/static/src/js/job_workspace.js', 'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss', 'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss', 'fusion_plating_shopfloor/static/src/scss/plant_overview.scss', diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js new file mode 100644 index 00000000..9cca1c97 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js @@ -0,0 +1,212 @@ +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — Job Workspace (full-screen WO surface) +// Client action: fp_job_workspace +// +// Opens from: kanban tap (Landing — Phase 3), smart button (fp.job form), +// QR scan (FP-JOB/FP-STEP), manager dashboard card tap (Phase 4). +// +// Layout (top-to-bottom): +// sticky header — WO #, customer, part, qty/done, deadline, holds +// sticky workflow bar — 9-stage milestone dots + Next-action button +// scrollable main: +// left/center — step list with active expansion + GateViz +// right side panel — spec PDF link + attachments + chatter +// sticky action rail — Hold · Note · Milestone advance · Issue Cert +// +// Auto-refresh: every 15s. +// ============================================================================= + +import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { useService } from "@web/core/utils/hooks"; +import { WorkflowChip } from "./components/workflow_chip"; +import { GateViz } from "./components/gate_viz"; +import { FpSignaturePad } from "./components/signature_pad"; +import { FpHoldComposer } from "./components/hold_composer"; + +export class FpJobWorkspace extends Component { + static template = "fusion_plating_shopfloor.JobWorkspace"; + static props = ["*"]; + static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer }; + + setup() { + this.notification = useService("notification"); + this.action = useService("action"); + this.dialog = useService("dialog"); + + this.state = useState({ + data: null, + jobId: null, + focusStepId: null, + }); + + onMounted(async () => { + const params = (this.props.action && this.props.action.params) || {}; + this.state.jobId = params.job_id || null; + this.state.focusStepId = params.focus_step_id || null; + await this.refresh(); + this._refreshInterval = setInterval(() => this.refresh(), 15000); + }); + + onWillUnmount(() => { + if (this._refreshInterval) clearInterval(this._refreshInterval); + }); + } + + // ---- Data refresh ------------------------------------------------------ + async refresh() { + if (!this.state.jobId) return; + try { + const res = await rpc("/fp/workspace/load", { job_id: this.state.jobId }); + if (res && res.ok) { + this.state.data = res; + } else if (res && res.error) { + this.notification.add(res.error, { type: "danger" }); + } + } catch (err) { + this.notification.add(err.message || String(err), { type: "danger" }); + } + } + + // ---- Navigation -------------------------------------------------------- + onBack() { + // Close workspace; return to whatever spawned the action + this.action.doAction({ type: "ir.actions.act_window_close" }); + } + + onJumpToBlocker({ model, id }) { + // If the predecessor is in this same workspace, just scroll to it + const inThisJob = (this.state.data.steps || []).find((s) => s.id === id); + if (inThisJob) { + const el = document.querySelector(`[data-step-id="${id}"]`); + if (el) el.scrollIntoView({ behavior: "smooth", block: "center" }); + return; + } + this.action.doAction({ + type: "ir.actions.act_window", + res_model: model, + res_id: id, + views: [[false, "form"]], + target: "current", + }); + } + + // ---- Step state helpers ------------------------------------------------ + iconForStepState(state) { + const map = { + ready: "○", paused: "⏸", in_progress: "▶", + done: "✓", skipped: "✕", cancelled: "✕", + }; + return map[state] || "○"; + } + + isStepActive(step) { + return step.state === "in_progress"; + } + + // ---- Step actions ------------------------------------------------------ + async onStartStep(stepId) { + try { + const res = await rpc("/fp/shopfloor/start_wo", { workorder_id: stepId }); + if (res && res.ok) { + this.notification.add("Step started.", { type: "success" }); + await this.refresh(); + } else { + this.notification.add((res && res.error) || "Start failed", { type: "danger" }); + } + } catch (err) { + this.notification.add(err.message, { type: "danger" }); + } + } + + async onFinishStep(step) { + if (step.requires_signoff) { + this.dialog.add(FpSignaturePad, { + title: `Sign to finish ${step.name}`, + contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`, + onSubmit: async (dataUri) => { + try { + const res = await rpc("/fp/workspace/sign_off", { + step_id: step.id, + signature_data_uri: dataUri, + }); + if (res && res.ok) { + this.notification.add("Step signed off and finished.", { type: "success" }); + await this.refresh(); + } else { + this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" }); + } + } catch (err) { + this.notification.add(err.message, { type: "danger" }); + } + }, + }); + return; + } + // Plain finish — no signature required + try { + const res = await rpc("/fp/shopfloor/stop_wo", { + workorder_id: step.id, finish: true, + }); + if (res && res.ok) { + this.notification.add("Step finished.", { type: "success" }); + await this.refresh(); + } else { + this.notification.add((res && res.error) || "Finish failed", { type: "danger" }); + } + } catch (err) { + this.notification.add(err.message, { type: "danger" }); + } + } + + // ---- Action rail handlers --------------------------------------------- + onCreateHold() { + const job = this.state.data.job; + const remaining = Math.max(1, (job.qty || 0) - (job.qty_done || 0)); + this.dialog.add(FpHoldComposer, { + jobId: this.state.jobId, + defaultQty: remaining, + partRef: job.part_number || "", + onCreated: () => this.refresh(), + }); + } + + async onAddNote() { + const text = window.prompt("Add a note to this WO:"); + if (!text) return; + try { + // ORM call for message_post — keeps chatter behaviour identical + // to back-office posts (handles HTML escaping, subscribers, etc.) + await rpc("/web/dataset/call_kw", { + model: "fp.job", + method: "message_post", + args: [[this.state.jobId]], + kwargs: { body: text, message_type: "comment" }, + }); + this.notification.add("Note added.", { type: "success" }); + await this.refresh(); + } catch (err) { + this.notification.add(err.message, { type: "danger" }); + } + } + + async onAdvanceMilestone() { + try { + const res = await rpc("/fp/workspace/advance_milestone", { + job_id: this.state.jobId, + }); + if (res && res.ok) { + this.notification.add("Milestone advanced.", { type: "success" }); + await this.refresh(); + } else { + this.notification.add((res && res.error) || "Advance failed", { type: "warning" }); + } + } catch (err) { + this.notification.add(err.message, { type: "danger" }); + } + } +} + +registry.category("actions").add("fp_job_workspace", FpJobWorkspace); diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss new file mode 100644 index 00000000..a4a198e2 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss @@ -0,0 +1,319 @@ +// ============================================================================= +// JobWorkspace — full-screen WO surface +// Dark-mode aware via $o-webclient-color-scheme branch. +// ============================================================================= + +$o-webclient-color-scheme: bright !default; + +$_ws-page-hex: #f3f4f6; +$_ws-card-hex: #ffffff; +$_ws-border-hex: #d8dadd; +$_ws-text-hex: #1d1d1f; + +@if $o-webclient-color-scheme == dark { + $_ws-page-hex: #1a1d21 !global; + $_ws-card-hex: #22262d !global; + $_ws-border-hex: #424245 !global; + $_ws-text-hex: #f5f5f7 !global; +} + +.o_fp_ws { + display: flex; + flex-direction: column; + height: 100%; + background: $_ws-page-hex; + color: $_ws-text-hex; + overflow: hidden; +} + +.o_fp_ws_loading { + margin: auto; + text-align: center; + color: var(--text-secondary, #666); + + > div { margin-top: 0.6rem; } +} + +// ---- HEADER ------------------------------------------------------------ +.o_fp_ws_head { + background: $_ws-card-hex; + border-bottom: 1px solid $_ws-border-hex; + padding: 0.6rem 1rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.o_fp_ws_head_l, .o_fp_ws_head_r { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.o_fp_ws_back { padding: 0.25rem 0.5rem; } +.o_fp_ws_wo { font-weight: 700; font-size: 1.1rem; } +.o_fp_ws_dot { color: var(--text-secondary, #999); } +.o_fp_ws_cust, .o_fp_ws_part { color: var(--text-secondary, #555); } + +.o_fp_ws_pill { + background: $_ws-page-hex; + border: 1px solid $_ws-border-hex; + padding: 0.2rem 0.55rem; + border-radius: 4px; + font-size: 0.78rem; + color: var(--text-secondary, #555); +} + +.o_fp_ws_holds_ok { + background: rgba(52, 199, 89, 0.12); + color: #1d6e2f; + border-color: rgba(52, 199, 89, 0.3); +} + +.o_fp_ws_holds_red { + background: rgba(255, 59, 48, 0.12); + color: #b00018; + border-color: rgba(255, 59, 48, 0.3); +} + +// ---- WORKFLOW BAR ------------------------------------------------------ +.o_fp_ws_bar { + background: $_ws-page-hex; + border-bottom: 1px solid $_ws-border-hex; + padding: 0.55rem 1rem; + display: flex; + align-items: center; + gap: 1rem; +} + +.o_fp_ws_bar_line { + flex: 1; + display: flex; + align-items: center; + gap: 0.3rem; +} + +.o_fp_ws_dot_wrap { + display: flex; + flex-direction: column; + align-items: center; + min-width: 60px; + + .o_fp_ws_bar_dot { + width: 14px; + height: 14px; + border-radius: 50%; + background: $_ws-border-hex; + border: 2px solid $_ws-border-hex; + } + + .o_fp_ws_bar_label { + font-size: 0.65rem; + color: var(--text-secondary, #888); + margin-top: 0.2rem; + text-align: center; + } + + &.done .o_fp_ws_bar_dot { + background: #34c759; + border-color: #34c759; + } + + &.current .o_fp_ws_bar_dot { + background: #0071e3; + border-color: #0071e3; + box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.25); + } + + &.current .o_fp_ws_bar_label { + color: #0071e3; + font-weight: 600; + } +} + +.o_fp_ws_link { + flex: 0 0 8px; + height: 2px; + background: $_ws-border-hex; + margin-top: -16px; + + &.done { background: #34c759; } +} + +.o_fp_ws_next { white-space: nowrap; } + +// ---- MAIN (step list + side panel) ------------------------------------- +.o_fp_ws_main { + flex: 1; + display: grid; + grid-template-columns: 1.7fr 1fr; + overflow: hidden; + + @media (max-width: 900px) { grid-template-columns: 1fr; } +} + +.o_fp_ws_steps { + padding: 0.7rem 1rem; + overflow-y: auto; + border-right: 1px solid $_ws-border-hex; +} + +.o_fp_ws_side { + padding: 0.7rem 1rem; + overflow-y: auto; + background: $_ws-page-hex; +} + +.o_fp_ws_empty { + text-align: center; + padding: 2rem 1rem; + color: var(--text-secondary, #999); + + > div { margin-top: 0.5rem; } +} + +// ---- STEP ROW --------------------------------------------------------- +.o_fp_ws_step { + background: $_ws-card-hex; + border: 1px solid $_ws-border-hex; + border-radius: 6px; + padding: 0.5rem 0.7rem; + margin-bottom: 0.4rem; + + &.done { opacity: 0.7; } + &.active { + border: 2px solid #0071e3; + padding: 0.6rem 0.75rem; + box-shadow: 0 0 0 1px #0071e3; + } + &.blocked { + background: rgba(255, 159, 10, 0.06); + border-color: #ff9f0a; + } + &.excluded { opacity: 0.5; } +} + +.o_fp_ws_step_l1 { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.o_fp_ws_step_icon { width: 18px; text-align: center; font-weight: 700; } +.o_fp_ws_step_num { color: var(--text-secondary, #999); font-size: 0.78rem; min-width: 50px; } +.o_fp_ws_step_name { font-weight: 600; } +.o_fp_ws_step_meta { color: var(--text-secondary, #999); font-size: 0.78rem; margin-left: auto; } + +.o_fp_ws_step_badge { + background: #0071e3; + color: white; + padding: 0.1rem 0.4rem; + border-radius: 3px; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.04em; + margin-left: 0.4rem; +} + +.o_fp_ws_step_detail { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px dashed $_ws-border-hex; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.o_fp_ws_step_chips { display: flex; gap: 0.3rem; flex-wrap: wrap; } +.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--text-secondary, #555); font-style: italic; } +.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; } + +.o_fp_ws_step_excluded { + font-size: 0.78rem; + color: var(--text-secondary, #888); + font-style: italic; +} + +// ---- Chips (inline mini-chips for recipe targets) --------------------- +.o_fp_chip { + padding: 0.15rem 0.45rem; + border-radius: 999px; + font-size: 0.72rem; +} + +.o_fp_chip_info { background: rgba(0, 113, 227, 0.12); color: #0050a0; } +.o_fp_chip_warning { background: rgba(255, 159, 10, 0.15); color: #b06600; } + +@if $o-webclient-color-scheme == dark { + .o_fp_chip_info { color: #6cb6ff; } + .o_fp_chip_warning { color: #ffb84d; } +} + +// ---- SIDE PANEL CARDS ------------------------------------------------- +.o_fp_ws_side_card { + background: $_ws-card-hex; + border: 1px solid $_ws-border-hex; + border-radius: 6px; + padding: 0.55rem 0.7rem; + margin-bottom: 0.45rem; + + h4 { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary, #777); + margin-bottom: 0.35rem; + } +} + +.o_fp_ws_spec_link { + display: flex; + gap: 0.4rem; + align-items: center; + font-size: 0.82rem; +} + +.o_fp_ws_attach { + display: flex; + justify-content: space-between; + padding: 0.2rem 0; + font-size: 0.78rem; + border-bottom: 1px dashed $_ws-border-hex; + + &:last-child { border-bottom: none; } +} + +.o_fp_ws_note { + font-size: 0.78rem; + padding: 0.3rem 0; +} + +.o_fp_ws_note_h { + display: flex; + gap: 0.4rem; + font-size: 0.72rem; + color: var(--text-secondary, #777); +} + +.o_fp_ws_note .author { font-weight: 600; } +.o_fp_ws_note .body { color: var(--text-secondary, #555); margin-top: 0.15rem; } + +.o_fp_ws_empty_small { + color: var(--text-secondary, #999); + font-size: 0.75rem; + font-style: italic; +} + +// ---- ACTION RAIL ------------------------------------------------------ +.o_fp_ws_rail { + background: $_ws-card-hex; + border-top: 1px solid $_ws-border-hex; + padding: 0.55rem 1rem; + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml new file mode 100644 index 00000000..b112164b --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml @@ -0,0 +1,230 @@ + + + + +
+ + +
+ +
Loading Job Workspace…
+
+ + + + +
+
+ + + · + + + · + + +
+
+ + / done + · scrap + + + Due + + + + holds + +
+
+ + +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ + +
+
+ +
Recipe not generated for this WO.
+
+ + +
+ +
+ + Step + + ACTIVE + + + · min + +
+ + +
+ +
+ + 🎯 Thickness + + + ⏱ Dwell min + + + 🔥 Bake ° + + + ✎ Sign-off required + +
+ + +
+ +
+ + +
+ Skipped per recipe override for this WO +
+ + + + + +
+ + +
+
+ +
+
+
+
+
+
+ + +
+ +
+

Customer spec

+ +
+ +
+

Drawings & attachments

+ +
+ 📄 + Open +
+
+
+ +
+

Notes

+
+ No notes yet. +
+ +
+
+ + +
+
+
+ +
+ +
+
+ + +
+ + + + + +
+ + +
+
+ +