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.