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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 18:38:22 -04:00
parent d4e95dcd47
commit 170398ab6f
7 changed files with 644 additions and 33 deletions

View File

@@ -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 <name>` 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
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
<div class="o_fp_ws_step_detail">
...
<!-- Start button is INSIDE this parent gate -->
<div t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
<button t-on-click="() => this.onStartStep(step.id)">Start</button>
</div>
</div>
</t>
```
`isStepActive` returns true only when `step.state === 'in_progress'`. For a
`state='ready'` step with no blocker, the parent `<t t-if>` 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
<div t-att-class="...">
<!-- [ALWAYS] Line 1: icon + step# + name + meta -->
<div class="o_fp_ws_step_l1">
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
<span class="o_fp_ws_step_name" t-esc="step.name"/>
<span t-if="step.state === 'in_progress'" class="o_fp_ws_step_badge">ACTIVE</span>
<span t-if="step.state === 'paused'" class="o_fp_ws_step_badge o_fp_ws_step_badge_paused">PAUSED</span>
<span class="o_fp_ws_step_meta">...assignee, duration...</span>
</div>
<!-- [NON-TERMINAL] Read-ahead detail: chips + instructions + GateViz -->
<div class="o_fp_ws_step_detail"
t-if="step.state not in ('done', 'skipped', 'cancelled')">
<!-- chips (thickness/dwell/bake/signoff) -->
<div class="o_fp_ws_step_chips">
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">🎯 Thickness ...</span>
<span t-if="step.dwell_time_minutes" class="o_fp_chip o_fp_chip_info">⏱ Dwell ...</span>
<span t-if="step.bake_setpoint_temp" class="o_fp_chip o_fp_chip_warning">🔥 Bake ...°</span>
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">✎ Sign-off</span>
</div>
<!-- recipe instructions -->
<div t-if="step.instructions" class="o_fp_ws_step_instr"><t t-esc="step.instructions"/></div>
<!-- opt-out -->
<div t-if="step.override_excluded" class="o_fp_ws_step_excluded">
<i class="fa fa-ban"/> Skipped per recipe override
</div>
<!-- blocker viz -->
<GateViz t-if="step.blocker_kind !== 'none'"
canStart="false"
blockerKind="step.blocker_kind"
blockerReason="step.blocker_reason"
jumpTargetModel="step.blocker_jump_target_model"
jumpTargetId="step.blocker_jump_target_id"
onJump.bind="onJumpToBlocker"/>
</div>
<!-- [ACTIONABLE] Action row — per-kind buttons per the dispatcher -->
<div class="o_fp_ws_step_actions"
t-if="!step.override_excluded
and step.blocker_kind === 'none'
and step.state not in ('done', 'skipped', 'cancelled')">
<t t-foreach="getStepActions(step)" t-as="action" t-key="action.key">
<button t-att-class="action.cssClass"
t-on-click="() => this.dispatchStepAction(step, action.key)">
<i t-att-class="action.icon"/> <t t-esc="action.label"/>
</button>
</t>
</div>
</div>
```
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 <user>") 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.

View File

@@ -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.',

View File

@@ -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".

View File

@@ -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.',

View File

@@ -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'
),

View File

@@ -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;

View File

@@ -86,26 +86,31 @@
<t t-foreach="state.data.steps" t-as="step" t-key="step.id">
<div t-att-class="'o_fp_ws_step ' + step.state +
(isStepActive(step) ? ' active' : '') +
(step.state === 'in_progress' ? ' active' : '') +
(step.state === 'paused' ? ' paused' : '') +
(step.override_excluded ? ' excluded' : '') +
(step.blocker_kind !== 'none' ? ' blocked' : '')"
t-att-data-step-id="step.id">
<!-- ALWAYS visible: line 1 (icon + step# + name + badges + meta) -->
<div class="o_fp_ws_step_l1">
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
<span class="o_fp_ws_step_name" t-esc="step.name"/>
<span t-if="isStepActive(step)" class="o_fp_ws_step_badge">ACTIVE</span>
<span t-if="step.state === 'in_progress'" class="o_fp_ws_step_badge">ACTIVE</span>
<span t-if="step.state === 'paused'" class="o_fp_ws_step_badge o_fp_ws_step_badge_paused">PAUSED</span>
<span class="o_fp_ws_step_meta">
<t t-if="step.assigned_user_name"><t t-esc="step.assigned_user_name"/></t>
<t t-if="step.duration_actual"> · <t t-esc="Math.round(step.duration_actual)"/> min</t>
</span>
</div>
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
<!-- NON-TERMINAL: read-ahead detail (chips + instructions + opt-out + GateViz) -->
<t t-if="!['done', 'skipped', 'cancelled'].includes(step.state)">
<div class="o_fp_ws_step_detail">
<!-- Recipe chips (only on active step) -->
<div class="o_fp_ws_step_chips" t-if="isStepActive(step)">
<!-- Recipe chips: visible on every non-done step so operator reads ahead -->
<div class="o_fp_ws_step_chips"
t-if="step.thickness_target or step.dwell_time_minutes or step.bake_setpoint_temp or step.requires_signoff or step.requires_rack_assignment">
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">
🎯 Thickness <t t-esc="step.thickness_target"/> <t t-esc="step.thickness_uom or 'mils'"/>
</span>
@@ -118,10 +123,13 @@
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">
✎ Sign-off required
</span>
<span t-if="step.requires_rack_assignment" class="o_fp_chip o_fp_chip_info">
🔧 Rack assignment
</span>
</div>
<!-- Recipe author instructions (only on active step) -->
<div t-if="step.instructions and isStepActive(step)"
<!-- Recipe author instructions -->
<div t-if="step.instructions"
class="o_fp_ws_step_instr">
<t t-esc="step.instructions"/>
</div>
@@ -140,30 +148,15 @@
jumpTargetId="step.blocker_jump_target_id"
onJump.bind="onJumpToBlocker"/>
<!-- Action buttons (only when unblocked) -->
<!-- Action buttons: dispatched per-kind via getStepActions -->
<div class="o_fp_ws_step_actions"
t-if="isStepActive(step) and step.blocker_kind === 'none'">
<button class="btn btn-secondary me-2"
t-on-click="() => this.onRecordInputs(step)">
<i class="fa fa-pencil"/> Record Inputs
</button>
<button t-if="step.requires_signoff"
class="btn btn-success"
t-on-click="() => this.onFinishStep(step)">
<i class="fa fa-check"/> Finish &amp; Sign Off
</button>
<button t-else=""
class="btn btn-success"
t-on-click="() => this.onFinishStep(step)">
<i class="fa fa-check"/> Finish
</button>
</div>
<div class="o_fp_ws_step_actions"
t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
<button class="btn btn-primary"
t-on-click="() => this.onStartStep(step.id)">
<i class="fa fa-play"/> Start
</button>
t-if="!step.override_excluded and step.blocker_kind === 'none'">
<t t-foreach="getStepActions(step)" t-as="action" t-key="action.key">
<button t-att-class="action.cssClass + ' me-2'"
t-on-click="() => this.dispatchStepAction(step, action.key)">
<i t-att-class="action.icon"/> <t t-esc="action.label"/>
</button>
</t>
</div>
</div>
</t>