Workflow declutter: auto-advance + hide redundant action buttons

Producer feedback: too many "Start Work" buttons everywhere, unclear
forward path, lots of clicking. This commit collapses the common cases.

Server (stage-service.updateStageStatus):
  - Any TERMINAL transition (APPROVED / DELIVERED / SKIPPED) now
    auto-opens every downstream stage whose prereqs are now satisfied.
    Previously only `isCriticalGate` stages did this, and the unblock
    landed them in NOT_STARTED — still requiring a Start Work click.
    Now the gate requirement is dropped and unblocked stages flip
    straight to IN_PROGRESS with a startDate stamp.

UI (deliverable detail stage rows):
  - BLOCKED rows: no transition buttons. The blocked hint + lock icon
    explain why nothing's actionable here (path forward is to finish
    the upstream prereq).
  - Stages with approvalType != NONE: hide the inline transition
    buttons entirely — StageReviewPanel below the row owns Approve /
    Request changes / Send to client / New revision, so duplicates
    just cluttered the view.
  - Everywhere else: same buttons as before, with the muted "Skip /
    Reopen" outline so the forward action is the obvious primary.

UI (stage-review-panel):
  - Approve button relabelled "Approve & advance" with a tooltip
    explaining that it auto-opens the next stage.

Net effect: a typical 5-stage pipeline that previously needed
~10 button clicks to walk through (Start, Submit, Approve, Start,
Submit, Approve, ...) now needs ~5 — the first Start Work, then
each terminal transition cascades automatically.

What's NOT yet in this commit (acknowledged as follow-ups):
  - Resources page deliverable-level assignment toggle.
  - Revision-rework: when a stage requests changes via a configured
    rework path, the intermediate stages between rework target and
    current should reset. The rework transition itself still works;
    just the intermediate cleanup is pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-15 15:59:17 -04:00
parent 02f18e6eab
commit f0eb29dd0c
3 changed files with 84 additions and 47 deletions

View file

@ -439,45 +439,76 @@ export default function DeliverableDetailPage() {
/>
</div>
{available.length > 0 && (
<div className="ml-auto flex flex-wrap gap-1">
{available.map((nextStatus) => {
const isSkip = nextStatus === "SKIPPED";
const isReopen =
(nextStatus === "IN_PROGRESS" &&
(stage.status === "APPROVED" ||
stage.status === "DELIVERED")) ||
(nextStatus === "NOT_STARTED" &&
stage.status === "SKIPPED");
const label = isReopen
? "Reopen"
: (TRANSITION_LABELS[nextStatus] ?? nextStatus);
{(() => {
// Visible action set rules (Phase 5b — declutter):
// - BLOCKED rows: no buttons (path forward is the
// upstream prereq; we don't want producers to click
// "Skip" by accident). The lock icon below still
// explains why nothing is actionable.
// - Stages with approvalType !== NONE: the
// StageReviewPanel owns the Approve / Request changes
// / Send to client / New revision affordances. No
// duplicates in this row.
// - All other transitions are visible — but
// destructive / sideways moves (Skip, Reopen) stay
// muted outline so the forward path is the obvious
// primary action.
const stageApprovalType =
stage.stageDefinition?.approvalType ??
stage.template?.approvalType ??
"NONE";
const hasReviewPanel = stageApprovalType !== "NONE";
const isBlocked = stage.status === "BLOCKED";
return (
<Button
key={nextStatus}
size="sm"
variant={isSkip || isReopen ? "outline" : "default"}
className={cn(
"h-6 text-[13px] px-2",
(isSkip || isReopen) && "text-[var(--muted-foreground)]"
)}
disabled={updateStage.isPending}
onClick={() => handleTransition(stage.id, nextStatus)}
>
{isSkip ? (
<SkipForward className="mr-1 h-3 w-3" />
) : isReopen ? (
<RotateCcw className="mr-1 h-3 w-3" />
) : (
<ChevronRight className="mr-1 h-3 w-3" />
)}
{label}
</Button>
);
})}
</div>
)}
if (isBlocked || hasReviewPanel || available.length === 0) {
return null;
}
return (
<div className="ml-auto flex flex-wrap gap-1">
{available.map((nextStatus) => {
const isSkip = nextStatus === "SKIPPED";
const isReopen =
(nextStatus === "IN_PROGRESS" &&
(stage.status === "APPROVED" ||
stage.status === "DELIVERED")) ||
(nextStatus === "NOT_STARTED" &&
stage.status === "SKIPPED");
const label = isReopen
? "Reopen"
: (TRANSITION_LABELS[nextStatus] ?? nextStatus);
return (
<Button
key={nextStatus}
size="sm"
variant={
isSkip || isReopen ? "outline" : "default"
}
className={cn(
"h-6 text-[13px] px-2",
(isSkip || isReopen) &&
"text-[var(--muted-foreground)]"
)}
disabled={updateStage.isPending}
onClick={() =>
handleTransition(stage.id, nextStatus)
}
>
{isSkip ? (
<SkipForward className="mr-1 h-3 w-3" />
) : isReopen ? (
<RotateCcw className="mr-1 h-3 w-3" />
) : (
<ChevronRight className="mr-1 h-3 w-3" />
)}
{label}
</Button>
);
})}
</div>
);
})()}
</div>
{/* Sub-status + blocked hint on their own third line only when present */}

View file

@ -193,9 +193,10 @@ export function StageReviewPanel({
className="h-6 px-2 text-[12px]"
disabled={updateStageStatus.isPending}
onClick={handleApprove}
title="Approve this stage and auto-open the next stage(s)"
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Approve
Approve &amp; advance
</Button>
)}
@ -206,6 +207,7 @@ export function StageReviewPanel({
className="h-6 px-2 text-[12px]"
disabled={updateStageStatus.isPending}
onClick={handleRequestChanges}
title="Mark this stage as needing changes; producer will iterate"
>
<XCircle className="mr-1 h-3 w-3" />
Request changes

View file

@ -403,11 +403,12 @@ export async function updateStageStatus(
downstreamStageNames: [] as string[],
};
// Check if this will unblock downstream stages
if (
(newStatus === "APPROVED" || newStatus === "DELIVERED") &&
stage.template.isCriticalGate
) {
// Check if this will auto-advance downstream stages. Phase 5b: ALL
// terminal transitions (APPROVED / DELIVERED / SKIPPED) auto-open
// every stage whose prerequisites are now satisfied — not just
// critical gates. Producers stop hitting "Start Work" on every row.
const TERMINAL: StageStatus[] = ["APPROVED", "DELIVERED", "SKIPPED"];
if (TERMINAL.includes(newStatus)) {
const allStages = stage.deliverable.stages.map((s) => ({
id: s.id,
status: s.id === stageId ? newStatus : s.status,
@ -462,8 +463,11 @@ export async function updateStageStatus(
include: { template: { include: { dependsOn: true } } },
});
// If this stage was just APPROVED/DELIVERED and is a critical gate, unblock downstream
if ((newStatus === "APPROVED" || newStatus === "DELIVERED") && stage.template.isCriticalGate) {
// Phase 5b: any terminal transition auto-advances every downstream
// stage whose prerequisites are now satisfied. Set them IN_PROGRESS
// directly (not NOT_STARTED) — producers shouldn't have to click
// "Start Work" on every unblocked row.
if (TERMINAL.includes(newStatus)) {
const allStages = stage.deliverable.stages.map((s) => ({
id: s.id,
status: s.id === stageId ? newStatus : s.status,
@ -482,7 +486,7 @@ export async function updateStageStatus(
if (idsToUnblock.length > 0) {
await tx.deliverableStage.updateMany({
where: { id: { in: idsToUnblock } },
data: { status: "NOT_STARTED" },
data: { status: "IN_PROGRESS", startDate: now },
});
}
}