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>
18 KiB
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,
the expanded step-detail block is gated:
<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
with three independent rendering layers per step:
<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.
Returns an array of action descriptors based on step.state + step.kind +
step.requires_rack_assignment + step.requires_signoff:
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:
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.)
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).
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:
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:
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:
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,
add:
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
already includes kind, state, can_start, requires_signoff,
blocker_kind. Verify requires_rack_assignment is included; if not, add it:
'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)
- Open WO-30057 in Job Workspace.
- Confirm Step 1 (Contract Review, done) shows ✓ + name, NO buttons.
- Confirm Step 2 (Masking, ready) shows Start button.
- Click Start → confirm step transitions to
in_progress→ buttons swap to Record Inputs, Pause, Finish. - Click Pause → confirm prompt → confirm step transitions to
paused→ buttons swap to Resume, Record Inputs, Finish. - Click Resume → confirm back to
in_progress+ correct buttons. - Click Finish → confirm step completes → next step (Incoming Inspection, ready) now shows Start.
- 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.
- 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.
- Find a step where
requires_signoff=True. Click Finish → signature pad opens (existing behaviour). Sign → step completes. - Find a blocked step (predecessor not done). Confirm GateViz renders, NO action buttons.
- Find an opt-out step (
override_excluded=True). Confirm "Skipped per recipe override" notice, NO action buttons.
Smoke for chip / instructions visibility
- 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)
inputmodeattributes / 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.