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

View file

@ -403,11 +403,12 @@ export async function updateStageStatus(
downstreamStageNames: [] as string[], downstreamStageNames: [] as string[],
}; };
// Check if this will unblock downstream stages // Check if this will auto-advance downstream stages. Phase 5b: ALL
if ( // terminal transitions (APPROVED / DELIVERED / SKIPPED) auto-open
(newStatus === "APPROVED" || newStatus === "DELIVERED") && // every stage whose prerequisites are now satisfied — not just
stage.template.isCriticalGate // 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) => ({ const allStages = stage.deliverable.stages.map((s) => ({
id: s.id, id: s.id,
status: s.id === stageId ? newStatus : s.status, status: s.id === stageId ? newStatus : s.status,
@ -462,8 +463,11 @@ export async function updateStageStatus(
include: { template: { include: { dependsOn: true } } }, include: { template: { include: { dependsOn: true } } },
}); });
// If this stage was just APPROVED/DELIVERED and is a critical gate, unblock downstream // Phase 5b: any terminal transition auto-advances every downstream
if ((newStatus === "APPROVED" || newStatus === "DELIVERED") && stage.template.isCriticalGate) { // 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) => ({ const allStages = stage.deliverable.stages.map((s) => ({
id: s.id, id: s.id,
status: s.id === stageId ? newStatus : s.status, status: s.id === stageId ? newStatus : s.status,
@ -482,7 +486,7 @@ export async function updateStageStatus(
if (idsToUnblock.length > 0) { if (idsToUnblock.length > 0) {
await tx.deliverableStage.updateMany({ await tx.deliverableStage.updateMany({
where: { id: { in: idsToUnblock } }, where: { id: { in: idsToUnblock } },
data: { status: "NOT_STARTED" }, data: { status: "IN_PROGRESS", startDate: now },
}); });
} }
} }