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

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