From 170398ab6f9801f247a3c4277022a846d7a3f08e Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 24 May 2026 18:38:22 -0400 Subject: [PATCH] feat(workspace): per-kind step action buttons in Job Workspace Fix: in the Job Workspace tablet view, the Start button was buried inside a parent t-if that required the step to already be in_progress or blocked. So ready/paused steps showed no buttons at all - operators couldn't advance the WO from this screen (the reason the user couldn't complete anything on WO-30057). Template restructure (job_workspace.xml): - Always-visible line 1 (icon + step# + name + ACTIVE/PAUSED badge + meta) - Non-terminal detail panel (chips + instructions + opt-out + GateViz) visible on every non-done step so operator reads ahead - Action row dispatched per-kind via getStepActions() helper Per-kind action dispatcher (job_workspace.js): - in_progress -> Record Inputs, Pause, Finish (or Finish & Sign Off) - paused -> Resume, Record Inputs, Finish - contract_review (ready) -> Open QA-005 Form - gating (ready) -> Mark Passed (1-click start+finish) - requires_rack_assignment -> Start (Assign Rack) - opens FpRackPartsDialog - else (ready) -> Start 5 new handlers: onPauseStep / onResumeStep / onMarkPassed / onOpenContractReview / onStartWithRack. Pause and Resume use ORM RPC (button_pause/button_resume) since no HTTP endpoint exists. New model method (fp.job.step.action_mark_gating_passed): - 1-click pass for gating steps - does button_start + button_finish in one transaction, posts chatter "Gate X marked passed by Y" - Raises UserError if called on a non-gating step (defensive) - Bypasses S21 required-inputs gate (gating steps have no inputs) Controller: workspace_controller.py adds requires_rack_assignment to the step payload so the JS dispatcher can route correctly. Spec: docs/superpowers/specs/2026-05-24-workspace-step-actions-design.md Sub-B (Record Inputs tablet polish: inputmode/prefill/date pickers/ signature pad/camera) is brainstormed but deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-24-workspace-step-actions-design.md | 425 ++++++++++++++++++ .../fusion_plating_jobs/__manifest__.py | 2 +- .../fusion_plating_jobs/models/fp_job_step.py | 44 ++ .../fusion_plating_shopfloor/__manifest__.py | 2 +- .../controllers/workspace_controller.py | 3 + .../static/src/js/job_workspace.js | 148 +++++- .../static/src/xml/job_workspace.xml | 53 +-- 7 files changed, 644 insertions(+), 33 deletions(-) create mode 100644 fusion_plating/docs/superpowers/specs/2026-05-24-workspace-step-actions-design.md diff --git a/fusion_plating/docs/superpowers/specs/2026-05-24-workspace-step-actions-design.md b/fusion_plating/docs/superpowers/specs/2026-05-24-workspace-step-actions-design.md new file mode 100644 index 00000000..f55f2ccf --- /dev/null +++ b/fusion_plating/docs/superpowers/specs/2026-05-24-workspace-step-actions-design.md @@ -0,0 +1,425 @@ +# Job Workspace — Per-Kind Step Actions + +**Date:** 2026-05-24 +**Modules:** `fusion_plating_jobs`, `fusion_plating_shopfloor` +**Status:** Approved, awaiting implementation plan. +**Sub-project:** A of 2. Sub-B (Record Inputs tablet polish — `inputmode`, prefill, +date/time pickers, signature pad, camera) is brainstormed but DEFERRED. + +--- + +## Problem + +Operator opens WO-30057 in the Job Workspace tablet view. Step 1 (Contract Review) +shows ✓ (auto-completed from prior QA-005). Steps 2-12 each show only a bare +`○ Step N ` row — **no Start button, no action of any kind**. The operator +has no way to advance the job from this screen, even though every step is +`state='ready'` and `can_start=True` on the backend. + +### Root cause + +In [`job_workspace.xml:105`](../../../fusion_plating_shopfloor/static/src/xml/job_workspace.xml), +the expanded step-detail block is gated: + +```xml + +
+ ... + +
+ +
+
+
+``` + +`isStepActive` returns true only when `step.state === 'in_progress'`. For a +`state='ready'` step with no blocker, the parent `` is false — the whole +detail block (incl. the inner Start button) never renders. **Dead code.** + +### Secondary gaps + +Even if Start were reachable, certain step kinds need different actions, not a +generic Start/Finish chain: + +| Kind | Today (broken) | What it actually needs | +|---|---|---| +| `contract_review` | Hidden Start button | **Open QA-005 Form** button (uses existing `_fp_open_contract_review`) | +| `gating` | Hidden Start, then operator clicks Finish too | **1-click "Mark Passed"** (no work to do — it's an admin gate) | +| `requires_rack_assignment=True` | Hidden Start | Start should open the **Rack Parts** dialog first | +| `state='paused'` | Hidden Start | Should show **Resume** + Finish + Record Inputs | +| all kinds, `state='in_progress'` | Shows Finish, Record Inputs | Missing a **Pause** button | + +### Operator can't see what's coming + +The recipe-author info (thickness target, dwell time, bake temp, sign-off +required) currently only renders on the active step. Operators can't read ahead +to know what they're about to start. CLAUDE.md S20 "Tablet usability pass" called +this out for the per-step kanban; the same gap exists in the Job Workspace. + +--- + +## Approved fix + +### Change 1 — Template restructure + +Replace the parent-gated detail block in [`job_workspace.xml:88-170`](../../../fusion_plating_shopfloor/static/src/xml/job_workspace.xml) +with three independent rendering layers per step: + +```xml +
+ +
+ + Step + + ACTIVE + PAUSED + ...assignee, duration... +
+ + +
+ +
+ 🎯 Thickness ... + ⏱ Dwell ... + 🔥 Bake ...° + ✎ Sign-off +
+ + +
+ + +
+ Skipped per recipe override +
+ + + +
+ + +
+ + + +
+
+``` + +Keep the existing `isStepActive(step)` helper for the ACTIVE badge but **don't** +let it gate the detail block. + +### Change 2 — `getStepActions(step)` per-kind dispatcher + +New JS helper in [`job_workspace.js`](../../../fusion_plating_shopfloor/static/src/js/job_workspace.js). +Returns an array of action descriptors based on `step.state` + `step.kind` + +`step.requires_rack_assignment` + `step.requires_signoff`: + +```js +getStepActions(step) { + // Done/skipped/cancelled → no actions (caller already hides) + if (['done', 'skipped', 'cancelled'].includes(step.state)) return []; + // Blocked → no actions (caller already shows GateViz) + if (step.blocker_kind && step.blocker_kind !== 'none') return []; + if (step.override_excluded) return []; + + const actions = []; + if (step.state === 'in_progress') { + actions.push({ key: 'record_inputs', label: 'Record Inputs', icon: 'fa fa-pencil', cssClass: 'btn btn-secondary' }); + actions.push({ key: 'pause', label: 'Pause', icon: 'fa fa-pause', cssClass: 'btn btn-light' }); + actions.push({ + key: 'finish', + label: step.requires_signoff ? 'Finish & Sign Off' : 'Finish', + icon: 'fa fa-check', cssClass: 'btn btn-success' + }); + return actions; + } + if (step.state === 'paused') { + actions.push({ key: 'resume', label: 'Resume', icon: 'fa fa-play', cssClass: 'btn btn-primary' }); + actions.push({ key: 'record_inputs', label: 'Record Inputs', icon: 'fa fa-pencil', cssClass: 'btn btn-secondary' }); + actions.push({ + key: 'finish', + label: step.requires_signoff ? 'Finish & Sign Off' : 'Finish', + icon: 'fa fa-check', cssClass: 'btn btn-success' + }); + return actions; + } + // state in ('pending', 'ready') — entry-point per kind + if (step.kind === 'contract_review') { + actions.push({ key: 'open_contract_review', label: 'Open QA-005 Form', + icon: 'fa fa-file-text-o', cssClass: 'btn btn-primary' }); + return actions; + } + if (step.kind === 'gating') { + actions.push({ key: 'mark_passed', label: 'Mark Passed', + icon: 'fa fa-check-circle', cssClass: 'btn btn-success' }); + return actions; + } + if (step.requires_rack_assignment) { + actions.push({ key: 'start_with_rack', label: 'Start (Assign Rack)', + icon: 'fa fa-server', cssClass: 'btn btn-primary' }); + return actions; + } + // Default + actions.push({ key: 'start', label: 'Start', icon: 'fa fa-play', cssClass: 'btn btn-primary' }); + return actions; +} +``` + +### Change 3 — `dispatchStepAction(step, key)` + +Single router method that delegates to handler methods: + +```js +async dispatchStepAction(step, key) { + switch (key) { + case 'start': return this.onStartStep(step.id); + case 'resume': return this.onResumeStep(step); // button_resume — distinct from button_start + case 'pause': return this.onPauseStep(step); + case 'record_inputs': return this.onRecordInputs(step); + case 'finish': return this.onFinishStep(step); + case 'mark_passed': return this.onMarkPassed(step); + case 'open_contract_review': return this.onOpenContractReview(step); + case 'start_with_rack': return this.onStartWithRack(step); + } +} +``` + +### Change 4 — New JS handlers + +**`onPauseStep(step)`** — calls `fp.job.step.button_pause` via ORM RPC. +(No `/fp/shopfloor/pause_wo` HTTP endpoint exists; the legacy stop_wo +endpoint's docstring claims pause isn't implemented but `button_pause` +does exist in `fusion_plating/models/fp_job_step.py:320`. Using ORM +RPC sidesteps the need to add a new HTTP route.) + +```js +async onPauseStep(step) { + const reason = window.prompt(`Pause reason for "${step.name}"?`, ''); + if (reason === null) return; // operator cancelled + try { + await rpc('/web/dataset/call_kw', { + model: 'fp.job.step', method: 'button_pause', + args: [[step.id]], + kwargs: { reason: reason || 'no reason given' }, + }); + this.notification.add('Step paused.', { type: 'success' }); + await this.refresh(); + } catch (err) { + this.notification.add(err.message, { type: 'danger' }); + } +} +``` + +**`onResumeStep(step)`** — calls `fp.job.step.button_resume` via ORM RPC. +Distinct from `onStartStep` because the model has separate methods: +`button_start` is for state=ready → in_progress; `button_resume` is for +state=paused → in_progress (preserves accrued time + reason audit). + +```js +async onResumeStep(step) { + try { + await rpc('/web/dataset/call_kw', { + model: 'fp.job.step', method: 'button_resume', + args: [[step.id]], kwargs: {}, + }); + this.notification.add('Step resumed.', { type: 'success' }); + await this.refresh(); + } catch (err) { + this.notification.add(err.message, { type: 'danger' }); + } +} +``` + +**`onMarkPassed(step)`** — calls a new ORM method `action_mark_gating_passed` +which does `button_start` + `button_finish` in one server call: + +```js +async onMarkPassed(step) { + try { + await rpc('/web/dataset/call_kw', { + model: 'fp.job.step', method: 'action_mark_gating_passed', + args: [[step.id]], kwargs: {}, + }); + this.notification.add('Gate marked passed.', { type: 'success' }); + await this.refresh(); + } catch (err) { + this.notification.add(err.message, { type: 'danger' }); + } +} +``` + +**`onOpenContractReview(step)`** — calls the existing `_fp_open_contract_review` +helper on `fp.job.step` (per CLAUDE.md Policy B section). Returns an act_window +that the action service opens. After dialog close, refresh: + +```js +async onOpenContractReview(step) { + try { + const result = await rpc('/web/dataset/call_kw', { + model: 'fp.job.step', method: '_fp_open_contract_review', + args: [[step.id]], kwargs: {}, + }); + if (result) { + await this.action.doAction(result, { onClose: () => this.refresh() }); + } + } catch (err) { + this.notification.add(err.message || "Couldn't open QA-005", { type: 'danger' }); + } +} +``` + +**`onStartWithRack(step)`** — opens the existing Rack Parts dialog from +`move_controller.py`. On commit (rack assigned + parts loaded), calls +`onStartStep(step.id)`. Implementation reuses `FpRackPartsDialog` from +`fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js`: + +```js +async onStartWithRack(step) { + this.dialog.add(FpRackPartsDialog, { + jobId: this.state.jobId, + stepId: step.id, + partRef: this.state.data.job.part_number || '', + defaultQty: this.state.data.job.qty || 1, + onCommitted: async () => { + // Rack assigned → now start the step + await this.onStartStep(step.id); + }, + }); +} +``` + +### Change 5 — New backend method `action_mark_gating_passed` + +In [`fusion_plating_jobs/models/fp_job_step.py`](../../../fusion_plating_jobs/models/fp_job_step.py), +add: + +```python +def action_mark_gating_passed(self): + """1-click pass for gating steps (kind=='gating'). Performs + button_start() then button_finish() in the same transaction. + Posts chatter ("Gate marked passed by ") on the parent job. + + Only valid for state in (ready, pending, paused) — defensive + NOOP otherwise (idempotent on repeat clicks). + """ + for step in self: + if step.state in ('done', 'skipped', 'cancelled'): + continue + kind_code = step.recipe_node_id.kind_id.code if ( + step.recipe_node_id and step.recipe_node_id.kind_id + ) else None + if kind_code != 'gating': + raise UserError(_( + "action_mark_gating_passed is only valid for gating " + "steps (this step has kind=%s).") % (kind_code or 'unknown')) + if step.state not in ('ready', 'pending', 'paused'): + continue + # Resume if paused, then start, then finish — bypass the input + # gate (gating steps have no required inputs by design). + if step.state == 'paused': + step.button_resume() + if step.state != 'in_progress': + step.button_start() + step.with_context( + fp_skip_required_inputs_gate=True, + ).button_finish() + step.job_id.message_post(body=_( + 'Gate "%(name)s" marked passed by %(user)s.' + ) % {'name': step.name, 'user': self.env.user.name}) + return True +``` + +### Change 6 — Verify controller payload has `requires_rack_assignment` + +The workspace controller payload at +[`workspace_controller.py:75-95`](../../../fusion_plating_shopfloor/controllers/workspace_controller.py) +already includes `kind`, `state`, `can_start`, `requires_signoff`, +`blocker_kind`. Verify `requires_rack_assignment` is included; if not, add it: + +```python +'requires_rack_assignment': bool(getattr(step, 'requires_rack_assignment', False)), +``` + +### Change 7 — Version bumps + +| Module | From | To | +|---|---|---| +| `fusion_plating_jobs` | `19.0.10.26.0` | `19.0.10.27.0` (new `action_mark_gating_passed` method) | +| `fusion_plating_shopfloor` | `19.0.33.1.4` | `19.0.33.1.5` (JS + XML restructure + controller payload) | + +No data migration needed — purely behavioural / UX. + +--- + +## Test plan + +### Manual smoke (after deploy) + +1. Open WO-30057 in Job Workspace. +2. Confirm Step 1 (Contract Review, done) shows ✓ + name, NO buttons. +3. Confirm Step 2 (Masking, ready) shows **Start** button. +4. Click Start → confirm step transitions to `in_progress` → buttons swap to Record Inputs, Pause, Finish. +5. Click Pause → confirm prompt → confirm step transitions to `paused` → buttons swap to Resume, Record Inputs, Finish. +6. Click Resume → confirm back to `in_progress` + correct buttons. +7. Click Finish → confirm step completes → next step (Incoming Inspection, ready) now shows Start. +8. Locate a job with a Contract Review step that hasn't been auto-completed (rare — most parts have prior QA-005). Confirm **Open QA-005 Form** button. Click → form opens. Submit → refresh → step completes. +9. Locate or create a job with a Gating step (kind='gating'). Confirm **✓ Mark Passed** button. Click → step jumps from ready to done in one click. +10. Find a step where `requires_signoff=True`. Click Finish → signature pad opens (existing behaviour). Sign → step completes. +11. Find a blocked step (predecessor not done). Confirm GateViz renders, NO action buttons. +12. Find an opt-out step (`override_excluded=True`). Confirm "Skipped per recipe override" notice, NO action buttons. + +### Smoke for chip / instructions visibility + +13. On any in-flight job, confirm chips (🎯 thickness, ⏱ dwell, 🔥 bake, ✎ sign-off) + recipe instructions render on **every non-done step** (not just the active one). Operator can read ahead. + +### Battle test followup + +Defer to Sub B (no new automated test for this UX-only change — covered by manual smoke). + +--- + +## Out of scope (explicit) + +- **`inputmode` attributes / number keyboards / prefill / date/time pickers / + signature pad in Record Inputs / camera capture** — all deferred to Sub B + (record-inputs tablet polish). +- **Auditing every kind's default input prompts** — deferred to Sub B. The + existing dialog renders all 15 input_types; Sub B verifies each is good UX. +- **Skip step button** — supervisor-only, accessible via backend form. Not + adding to operator workspace. +- **Reassign step** — supervisor-only. +- **Per-recipe ordering or kind fixes** — already covered by recent recipe + cleanup spec. + +--- + +## Files touched + +| File | Change | +|---|---| +| `fusion_plating_shopfloor/static/src/xml/job_workspace.xml` | Template restructure — always-visible action row + non-terminal detail block (Change 1) | +| `fusion_plating_shopfloor/static/src/js/job_workspace.js` | `getStepActions`, `dispatchStepAction`, `onPauseStep`, `onMarkPassed`, `onOpenContractReview`, `onStartWithRack` (Changes 2-4) | +| `fusion_plating_shopfloor/static/src/scss/job_workspace.scss` | Minor styling for the action row (consistent spacing across button counts) | +| `fusion_plating_shopfloor/controllers/workspace_controller.py` | Add `requires_rack_assignment` to step payload if missing (Change 6) | +| `fusion_plating_shopfloor/__manifest__.py` | Bump to `19.0.33.1.5` (Change 7) | +| `fusion_plating_jobs/models/fp_job_step.py` | Add `action_mark_gating_passed()` method (Change 5) | +| `fusion_plating_jobs/__manifest__.py` | Bump to `19.0.10.27.0` (Change 7) | + +Estimated diff: ~200 lines added, ~50 modified, ~10 deleted. diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 003b4f53..f7183e9f 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.10.26.0', + 'version': '19.0.10.27.0', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index 2a23d464..565cddb0 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -1397,6 +1397,50 @@ class FpJobStep(models.Model): }, } + def action_mark_gating_passed(self): + """1-click pass for gating steps (kind=='gating'). + + Performs button_start (or button_resume if paused) followed by + button_finish in the same transaction. Posts a chatter audit on + the parent job naming the user. + + Only valid for kind='gating' steps in state in (ready, pending, + paused). NOOPs on already-terminal steps for idempotency. Raises + UserError if called on a non-gating step (defensive — UI dispatcher + only renders Mark Passed for gating kinds). + + Bypasses the S21 required-inputs gate (gating steps have no + required inputs by design — they're admin gates). + + Spec: 2026-05-24-workspace-step-actions-design.md Change 5. + """ + for step in self: + if step.state in ('done', 'skipped', 'cancelled'): + continue + kind_code = ( + step.recipe_node_id.kind_id.code + if step.recipe_node_id and step.recipe_node_id.kind_id + else None + ) + if kind_code != 'gating': + raise UserError(_( + "Mark Passed is only valid for gating steps. " + "This step's kind is %s." + ) % (kind_code or 'unknown')) + if step.state not in ('ready', 'pending', 'paused'): + continue + if step.state == 'paused': + step.button_resume() + if step.state != 'in_progress': + step.button_start() + step.with_context( + fp_skip_required_inputs_gate=True, + ).button_finish() + step.job_id.message_post(body=_( + 'Gate "%(name)s" marked passed by %(user)s.' + ) % {'name': step.name, 'user': self.env.user.name}) + return True + def _fp_contract_review_redirect(self): """Return an ir.actions.act_window opening the part's QA-005 Contract Review form, or False to indicate "no redirect needed". diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index b852bac6..7e5211c0 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.33.1.4', + 'version': '19.0.33.1.5', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py index 9200126b..751baca5 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py @@ -89,6 +89,9 @@ class FpWorkspaceController(http.Controller): 'dwell_time_minutes': step.dwell_time_minutes or 0, 'bake_setpoint_temp': step.bake_setpoint_temp or 0, 'requires_signoff': bool(getattr(step, 'requires_signoff', False)), + 'requires_rack_assignment': bool(getattr( + step, 'requires_rack_assignment', False, + )), 'can_start': bool(step.can_start) if 'can_start' in step._fields else ( step.state in ('ready', 'paused') and step.blocker_kind == 'none' ), 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 index 8b34121b..6f1d570d 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js @@ -27,11 +27,12 @@ import { GateViz } from "./components/gate_viz"; import { FpSignaturePad } from "./components/signature_pad"; import { FpHoldComposer } from "./components/hold_composer"; import { FpTabletLock } from "./tablet_lock"; +import { FpRackPartsDialog } from "./rack_parts_dialog"; export class FpJobWorkspace extends Component { static template = "fusion_plating_shopfloor.JobWorkspace"; static props = ["*"]; - static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock }; + static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog }; setup() { this.notification = useService("notification"); @@ -138,6 +139,79 @@ export class FpJobWorkspace extends Component { return step.state === "in_progress"; } + // ---- Per-kind action dispatcher (Spec A 2026-05-24) ------------------- + // getStepActions returns the list of action descriptors to render for a + // step based on its current state + kind. dispatchStepAction routes the + // selected action key to the right handler. See spec + // 2026-05-24-workspace-step-actions-design.md. + + getStepActions(step) { + // Terminal states render no action buttons (template also guards). + if (["done", "skipped", "cancelled"].includes(step.state)) return []; + // Blocked / opt-out steps show GateViz / notice, no actions. + if (step.blocker_kind && step.blocker_kind !== "none") return []; + if (step.override_excluded) return []; + + const actions = []; + if (step.state === "in_progress") { + actions.push({ key: "record_inputs", label: "Record Inputs", + icon: "fa fa-pencil", cssClass: "btn btn-secondary" }); + actions.push({ key: "pause", label: "Pause", + icon: "fa fa-pause", cssClass: "btn btn-light" }); + actions.push({ + key: "finish", + label: step.requires_signoff ? "Finish & Sign Off" : "Finish", + icon: "fa fa-check", cssClass: "btn btn-success", + }); + return actions; + } + if (step.state === "paused") { + actions.push({ key: "resume", label: "Resume", + icon: "fa fa-play", cssClass: "btn btn-primary" }); + actions.push({ key: "record_inputs", label: "Record Inputs", + icon: "fa fa-pencil", cssClass: "btn btn-secondary" }); + actions.push({ + key: "finish", + label: step.requires_signoff ? "Finish & Sign Off" : "Finish", + icon: "fa fa-check", cssClass: "btn btn-success", + }); + return actions; + } + // state in ('pending', 'ready') — entry-point per kind. + if (step.kind === "contract_review") { + actions.push({ key: "open_contract_review", label: "Open QA-005 Form", + icon: "fa fa-file-text-o", cssClass: "btn btn-primary" }); + return actions; + } + if (step.kind === "gating") { + actions.push({ key: "mark_passed", label: "Mark Passed", + icon: "fa fa-check-circle", cssClass: "btn btn-success" }); + return actions; + } + if (step.requires_rack_assignment) { + actions.push({ key: "start_with_rack", label: "Start (Assign Rack)", + icon: "fa fa-server", cssClass: "btn btn-primary" }); + return actions; + } + // Default — plain Start + actions.push({ key: "start", label: "Start", + icon: "fa fa-play", cssClass: "btn btn-primary" }); + return actions; + } + + async dispatchStepAction(step, key) { + switch (key) { + case "start": return this.onStartStep(step.id); + case "resume": return this.onResumeStep(step); + case "pause": return this.onPauseStep(step); + case "record_inputs": return this.onRecordInputs(step); + case "finish": return this.onFinishStep(step); + case "mark_passed": return this.onMarkPassed(step); + case "open_contract_review": return this.onOpenContractReview(step); + case "start_with_rack": return this.onStartWithRack(step); + } + } + // ---- Step actions ------------------------------------------------------ async onStartStep(stepId) { try { @@ -214,6 +288,78 @@ export class FpJobWorkspace extends Component { } } + // ---- New per-kind handlers (Spec A 2026-05-24) ------------------------ + + async onPauseStep(step) { + const reason = window.prompt(`Pause reason for "${step.name}"?`, ""); + if (reason === null) return; // operator cancelled + try { + await rpc("/web/dataset/call_kw", { + model: "fp.job.step", method: "button_pause", + args: [[step.id]], + kwargs: { reason: reason || "no reason given" }, + }); + this.notification.add("Step paused.", { type: "success" }); + await this.refresh(); + } catch (err) { + this.notification.add(err.message || "Pause failed", { type: "danger" }); + } + } + + async onResumeStep(step) { + try { + await rpc("/web/dataset/call_kw", { + model: "fp.job.step", method: "button_resume", + args: [[step.id]], kwargs: {}, + }); + this.notification.add("Step resumed.", { type: "success" }); + await this.refresh(); + } catch (err) { + this.notification.add(err.message || "Resume failed", { type: "danger" }); + } + } + + async onMarkPassed(step) { + try { + await rpc("/web/dataset/call_kw", { + model: "fp.job.step", method: "action_mark_gating_passed", + args: [[step.id]], kwargs: {}, + }); + this.notification.add("Gate marked passed.", { type: "success" }); + await this.refresh(); + } catch (err) { + this.notification.add(err.message || "Mark passed failed", { type: "danger" }); + } + } + + async onOpenContractReview(step) { + try { + const result = await rpc("/web/dataset/call_kw", { + model: "fp.job.step", method: "_fp_open_contract_review", + args: [[step.id]], kwargs: {}, + }); + if (result) { + await this.action.doAction(result, { onClose: () => this.refresh() }); + } + } catch (err) { + this.notification.add(err.message || "Couldn't open QA-005", { type: "danger" }); + } + } + + async onStartWithRack(step) { + const job = this.state.data.job; + this.dialog.add(FpRackPartsDialog, { + jobId: this.state.jobId, + stepId: step.id, + partRef: job.part_number || "", + defaultQty: job.qty || 1, + onCommitted: async () => { + // Rack assigned → now start the step + await this.onStartStep(step.id); + }, + }); + } + // ---- Action rail handlers --------------------------------------------- onCreateHold() { const job = this.state.data.job; 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 index bb282052..7ecf7134 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml @@ -86,26 +86,31 @@
+
Step - ACTIVE + ACTIVE + PAUSED · min
- + +
- -
+ +
🎯 Thickness @@ -118,10 +123,13 @@ ✎ Sign-off required + + 🔧 Rack assignment +
- -
+
@@ -140,30 +148,15 @@ jumpTargetId="step.blocker_jump_target_id" onJump.bind="onJumpToBlocker"/> - +
- - - -
-
- + t-if="!step.override_excluded and step.blocker_kind === 'none'"> + + +