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:
@@ -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;
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user