Files
Odoo-Modules/fusion_plating/docs/superpowers/specs/2026-05-24-workspace-step-actions-design.md
gsinghpal 170398ab6f 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>
2026-05-24 18:38:22 -04:00

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)

  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

  1. 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.