Auto-advance fix + NONE stages = Open/Close only
The earlier auto-advance commit (f0eb29d) didn't actually fire on
dynamic pipelines because the dependency engine was still reading
template.dependsOn — the legacy seed graph, almost always empty for
pipelines built in the new editor. Producers saw downstream stages
stay BLOCKED forever no matter how many terminal transitions fired.
dependency-engine.ts:
- Resolves prerequisites against three sources in priority order:
1. stageDefinition.dependsOn (V2 dynamic edges, by stageDefinitionId)
2. template.dependsOn (legacy seed graph)
3. order fallback (depend on the immediately previous-order stage)
- getStageIdsToUnblock now also considers NOT_STARTED stages, not
just BLOCKED ones — when a gate completes, every waiting stage
flips to IN_PROGRESS.
stage-service.ts:
- All four allStages.map blocks now include `order` + a normalised
`stageDefinition` so the engine sees the dynamic graph.
- Stage fetches include stageDefinition.dependsOn.
- Bulk update path: unblock target is IN_PROGRESS now (was
NOT_STARTED) to match the single-update behaviour.
deliverable detail page:
- NONE-approval stages use a minimal transition set:
NOT_STARTED → IN_PROGRESS → APPROVED → IN_PROGRESS (reopen).
No more Submit-for-Review / Approve / Mark-Delivered / Request-
Changes / Skip noise on stages that have no review concept.
- The APPROVED button on a NONE stage reads "Mark Complete" so the
affordance signals workflow close, not a review approval.
Net effect on a typical 5-stage pipeline:
Click Start Work on stage 1 → Mark Complete → stage 2 auto-opens
→ Mark Complete → stage 3 auto-opens, and so on. One terminal
click per stage. FORMAL/SIMPLE stages still go through the
StageReviewPanel for the review flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f0eb29dd0c
commit
819288d36c
3 changed files with 168 additions and 61 deletions
|
|
@ -351,7 +351,34 @@ export default function DeliverableDetailPage() {
|
|||
</h2>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{stages.map((stage: any) => {
|
||||
const available = TRANSITIONS[stage.status] ?? [];
|
||||
// Per-stage approval type drives both the inline transition
|
||||
// set AND whether the StageReviewPanel below the row owns
|
||||
// the action buttons. NONE = open/close only (Start Work,
|
||||
// Mark Complete, Reopen — nothing else). SIMPLE/FORMAL hand
|
||||
// off to the review panel.
|
||||
const stageApprovalType =
|
||||
stage.stageDefinition?.approvalType ??
|
||||
stage.template?.approvalType ??
|
||||
"NONE";
|
||||
const isNoneApproval = stageApprovalType === "NONE";
|
||||
|
||||
// For NONE stages, override the transition set with a minimal
|
||||
// open/close shape so producers never see Submit-for-Review,
|
||||
// Approve, Request-Changes, Mark-Delivered on a non-review row.
|
||||
const NONE_TRANSITIONS: Record<string, string[]> = {
|
||||
BLOCKED: [],
|
||||
NOT_STARTED: ["IN_PROGRESS"],
|
||||
IN_PROGRESS: ["APPROVED"],
|
||||
IN_REVIEW: ["APPROVED"],
|
||||
CHANGES_REQUESTED: ["IN_PROGRESS"],
|
||||
APPROVED: ["IN_PROGRESS"],
|
||||
DELIVERED: ["IN_PROGRESS"],
|
||||
SKIPPED: ["NOT_STARTED"],
|
||||
};
|
||||
const available = isNoneApproval
|
||||
? NONE_TRANSITIONS[stage.status] ?? []
|
||||
: TRANSITIONS[stage.status] ?? [];
|
||||
|
||||
const assignments = stage.assignments ?? [];
|
||||
const isGate = stage.stageDefinition?.isCriticalGate ?? stage.template.isCriticalGate;
|
||||
const isOptional = stage.stageDefinition?.isOptional ?? stage.template.isOptional;
|
||||
|
|
@ -474,9 +501,15 @@ export default function DeliverableDetailPage() {
|
|||
stage.status === "DELIVERED")) ||
|
||||
(nextStatus === "NOT_STARTED" &&
|
||||
stage.status === "SKIPPED");
|
||||
// NONE-approval stages don't have the review
|
||||
// concept — relabel APPROVED to "Mark Complete"
|
||||
// so the affordance reads as a workflow close,
|
||||
// not a review approval.
|
||||
const label = isReopen
|
||||
? "Reopen"
|
||||
: (TRANSITION_LABELS[nextStatus] ?? nextStatus);
|
||||
: isNoneApproval && nextStatus === "APPROVED"
|
||||
? "Mark Complete"
|
||||
: (TRANSITION_LABELS[nextStatus] ?? nextStatus);
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import type { StageStatus } from "@/generated/prisma/client";
|
|||
interface StageWithDeps {
|
||||
id: string;
|
||||
status: StageStatus;
|
||||
/** Pipeline order — used as a fallback when no explicit deps exist. */
|
||||
order: number;
|
||||
template: {
|
||||
id: string;
|
||||
slug: string;
|
||||
|
|
@ -10,60 +12,98 @@ interface StageWithDeps {
|
|||
isOptional: boolean;
|
||||
dependsOn: { prerequisiteId: string }[];
|
||||
};
|
||||
/**
|
||||
* Dynamic pipeline edges (PipelineStageDependencyV2). When present these
|
||||
* take precedence over `template.dependsOn` — that's the legacy seed
|
||||
* graph and is almost always empty for dynamic pipelines.
|
||||
*/
|
||||
stageDefinition?: {
|
||||
id: string;
|
||||
dependsOn: { prerequisiteId: string }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
const TERMINAL = new Set<StageStatus>(["APPROVED", "DELIVERED", "SKIPPED"]);
|
||||
|
||||
/**
|
||||
* Returns the IDs of stages that this stage depends on. Resolves against
|
||||
* three sources in priority order:
|
||||
* 1. stageDefinition.dependsOn (dynamic V2 edges, by stageDefinitionId)
|
||||
* 2. template.dependsOn (legacy template-level seed graph)
|
||||
* 3. order fallback (immediately previous-order stage)
|
||||
*
|
||||
* Returns the matching DeliverableStage IDs from `allStages` (not the
|
||||
* prerequisite-table IDs) so callers can look up status directly.
|
||||
*/
|
||||
function resolvePrerequisiteStageIds(
|
||||
stage: StageWithDeps,
|
||||
allStages: StageWithDeps[]
|
||||
): string[] {
|
||||
const defDeps = stage.stageDefinition?.dependsOn ?? [];
|
||||
if (defDeps.length > 0) {
|
||||
const prereqDefIds = defDeps.map((d) => d.prerequisiteId);
|
||||
return allStages
|
||||
.filter(
|
||||
(s) => s.stageDefinition && prereqDefIds.includes(s.stageDefinition.id)
|
||||
)
|
||||
.map((s) => s.id);
|
||||
}
|
||||
|
||||
const tmplDeps = stage.template.dependsOn ?? [];
|
||||
if (tmplDeps.length > 0) {
|
||||
const prereqTmplIds = tmplDeps.map((d) => d.prerequisiteId);
|
||||
return allStages
|
||||
.filter((s) => prereqTmplIds.includes(s.template.id))
|
||||
.map((s) => s.id);
|
||||
}
|
||||
|
||||
// Order-based fallback: depend on the stage with the immediately
|
||||
// previous order on this deliverable. Producers building dynamic
|
||||
// pipelines without drawing explicit forward arrows still get a
|
||||
// sensible left-to-right cascade.
|
||||
const ordered = [...allStages].sort((a, b) => a.order - b.order);
|
||||
const idx = ordered.findIndex((s) => s.id === stage.id);
|
||||
if (idx <= 0) return [];
|
||||
return [ordered[idx - 1].id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stage can transition to IN_PROGRESS.
|
||||
* All prerequisite stages must be complete (APPROVED, DELIVERED, or SKIPPED).
|
||||
* Reopening from terminal states (APPROVED, DELIVERED, SKIPPED) is allowed
|
||||
* since the stage already passed its dependencies previously.
|
||||
* Check if a stage can transition to IN_PROGRESS — all prerequisite
|
||||
* stages must be in a terminal state. Reopening from terminal states
|
||||
* is always allowed (the prereqs were already satisfied when we got
|
||||
* there the first time).
|
||||
*/
|
||||
export function canStageStart(
|
||||
stage: StageWithDeps,
|
||||
allStages: StageWithDeps[]
|
||||
): { allowed: boolean; reason?: string } {
|
||||
// Allow reopening from terminal states — dependencies were already satisfied
|
||||
const reopenable = ["APPROVED", "DELIVERED", "SKIPPED"];
|
||||
if (reopenable.includes(stage.status)) {
|
||||
return { allowed: true };
|
||||
}
|
||||
if (TERMINAL.has(stage.status)) return { allowed: true };
|
||||
|
||||
if (stage.status !== "BLOCKED" && stage.status !== "NOT_STARTED") {
|
||||
return { allowed: false, reason: `Stage is currently ${stage.status}` };
|
||||
}
|
||||
|
||||
const prerequisiteTemplateIds = stage.template.dependsOn.map(
|
||||
(d) => d.prerequisiteId
|
||||
);
|
||||
|
||||
for (const prereqTemplateId of prerequisiteTemplateIds) {
|
||||
const prereqStage = allStages.find(
|
||||
(s) => s.template.id === prereqTemplateId
|
||||
);
|
||||
|
||||
if (!prereqStage) {
|
||||
const prereqIds = resolvePrerequisiteStageIds(stage, allStages);
|
||||
for (const prereqId of prereqIds) {
|
||||
const prereq = allStages.find((s) => s.id === prereqId);
|
||||
if (!prereq) {
|
||||
return { allowed: false, reason: "Missing prerequisite stage" };
|
||||
}
|
||||
|
||||
const isComplete =
|
||||
prereqStage.status === "APPROVED" ||
|
||||
prereqStage.status === "DELIVERED" ||
|
||||
prereqStage.status === "SKIPPED";
|
||||
|
||||
if (!isComplete) {
|
||||
if (!TERMINAL.has(prereq.status)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Prerequisite "${prereqStage.template.slug}" is not yet complete`,
|
||||
reason: `Prerequisite "${prereq.template.slug}" is not yet complete`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of stage IDs that should be unblocked
|
||||
* when a critical gate stage is approved.
|
||||
* After a stage hits a terminal state, return every stage that should
|
||||
* now auto-open. Includes both BLOCKED stages whose prereqs are now
|
||||
* satisfied AND NOT_STARTED stages waiting on the same gate — both
|
||||
* paths flip to IN_PROGRESS so producers don't have to click.
|
||||
*/
|
||||
export function getStageIdsToUnblock(
|
||||
approvedStage: StageWithDeps,
|
||||
|
|
@ -72,33 +112,27 @@ export function getStageIdsToUnblock(
|
|||
const unblockIds: string[] = [];
|
||||
|
||||
for (const stage of allStages) {
|
||||
if (stage.status !== "BLOCKED") continue;
|
||||
// Active stages stay where they are; only consider those still waiting.
|
||||
if (stage.status !== "BLOCKED" && stage.status !== "NOT_STARTED") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this stage depends on the just-approved stage
|
||||
const dependsOnApproved = stage.template.dependsOn.some(
|
||||
(d) => d.prerequisiteId === approvedStage.template.id
|
||||
);
|
||||
const prereqIds = resolvePrerequisiteStageIds(stage, allStages);
|
||||
if (prereqIds.length === 0) continue; // No deps → user starts manually.
|
||||
|
||||
if (!dependsOnApproved) continue;
|
||||
// Does this stage depend on the just-approved stage?
|
||||
if (!prereqIds.includes(approvedStage.id)) continue;
|
||||
|
||||
// Check if ALL prerequisites are now met (not just the one we approved)
|
||||
const allPrereqsMet = stage.template.dependsOn.every((dep) => {
|
||||
const prereq = allStages.find((s) => s.template.id === dep.prerequisiteId);
|
||||
// All prereqs satisfied? (Treat the just-approved stage as terminal
|
||||
// even though its DB row hasn't been refreshed in this snapshot.)
|
||||
const allPrereqsMet = prereqIds.every((prereqId) => {
|
||||
if (prereqId === approvedStage.id) return true;
|
||||
const prereq = allStages.find((s) => s.id === prereqId);
|
||||
if (!prereq) return false;
|
||||
|
||||
// The just-approved stage counts as approved
|
||||
if (prereq.id === approvedStage.id) return true;
|
||||
|
||||
return (
|
||||
prereq.status === "APPROVED" ||
|
||||
prereq.status === "DELIVERED" ||
|
||||
prereq.status === "SKIPPED"
|
||||
);
|
||||
return TERMINAL.has(prereq.status);
|
||||
});
|
||||
|
||||
if (allPrereqsMet) {
|
||||
unblockIds.push(stage.id);
|
||||
}
|
||||
if (allPrereqsMet) unblockIds.push(stage.id);
|
||||
}
|
||||
|
||||
return unblockIds;
|
||||
|
|
|
|||
|
|
@ -139,12 +139,14 @@ export async function bulkUpdateStageStatuses(
|
|||
where: { AND: [{ id: { in: stageIds } }, visibility] },
|
||||
include: {
|
||||
template: { include: { dependsOn: true } },
|
||||
stageDefinition: { include: { dependsOn: true } },
|
||||
deliverable: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, organizationId: true } },
|
||||
stages: {
|
||||
include: {
|
||||
template: { include: { dependsOn: true } },
|
||||
stageDefinition: { include: { dependsOn: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -184,6 +186,7 @@ export async function bulkUpdateStageStatuses(
|
|||
const allStages = stage.deliverable.stages.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.status,
|
||||
order: s.stageDefinition?.order ?? s.template.order ?? 0,
|
||||
template: {
|
||||
id: s.template.id,
|
||||
slug: s.template.slug,
|
||||
|
|
@ -191,6 +194,12 @@ export async function bulkUpdateStageStatuses(
|
|||
isOptional: s.template.isOptional,
|
||||
dependsOn: s.template.dependsOn,
|
||||
},
|
||||
stageDefinition: s.stageDefinition
|
||||
? {
|
||||
id: s.stageDefinition.id,
|
||||
dependsOn: s.stageDefinition.dependsOn ?? [],
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
const currentStage = allStages.find((s) => s.id === item.stageId)!;
|
||||
const depCheck = canStageStart(currentStage, allStages);
|
||||
|
|
@ -262,6 +271,7 @@ export async function bulkUpdateStageStatuses(
|
|||
const allStages = original.deliverable.stages.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.id === item.stageId ? matchingInput.newStatus : s.status,
|
||||
order: s.stageDefinition?.order ?? s.template.order ?? 0,
|
||||
template: {
|
||||
id: s.template.id,
|
||||
slug: s.template.slug,
|
||||
|
|
@ -269,13 +279,21 @@ export async function bulkUpdateStageStatuses(
|
|||
isOptional: s.template.isOptional,
|
||||
dependsOn: s.template.dependsOn,
|
||||
},
|
||||
stageDefinition: s.stageDefinition
|
||||
? {
|
||||
id: s.stageDefinition.id,
|
||||
dependsOn: s.stageDefinition.dependsOn ?? [],
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
const approvedStage = allStages.find((s) => s.id === item.stageId)!;
|
||||
const idsToUnblock = getStageIdsToUnblock(approvedStage, allStages);
|
||||
if (idsToUnblock.length > 0) {
|
||||
// Match the singular-update path: auto-advance to IN_PROGRESS
|
||||
// (not NOT_STARTED) so producers don't have to click Start.
|
||||
await tx.deliverableStage.updateMany({
|
||||
where: { id: { in: idsToUnblock } },
|
||||
data: { status: "NOT_STARTED" },
|
||||
data: { status: "IN_PROGRESS", startDate: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -341,21 +359,22 @@ export async function updateStageStatus(
|
|||
if (!parentLookup) throw new VisibilityError("Stage not found");
|
||||
await assertProjectVisible(parentLookup.deliverable.projectId, ctx);
|
||||
|
||||
// Fetch the stage with its template, dependencies, and sibling stages
|
||||
// Fetch the stage with its template (legacy seed) AND the dynamic
|
||||
// stageDefinition (the V2 graph the user actually draws in the
|
||||
// pipeline editor). The dependency-engine prefers V2 when present,
|
||||
// falls back to template, then to plain pipeline `order`.
|
||||
const stage = await prisma.deliverableStage.findUnique({
|
||||
where: { id: stageId },
|
||||
include: {
|
||||
template: {
|
||||
include: { dependsOn: true },
|
||||
},
|
||||
template: { include: { dependsOn: true } },
|
||||
stageDefinition: { include: { dependsOn: true } },
|
||||
deliverable: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, organizationId: true } },
|
||||
stages: {
|
||||
include: {
|
||||
template: {
|
||||
include: { dependsOn: true },
|
||||
},
|
||||
template: { include: { dependsOn: true } },
|
||||
stageDefinition: { include: { dependsOn: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -376,6 +395,7 @@ export async function updateStageStatus(
|
|||
const allStages = stage.deliverable.stages.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.status,
|
||||
order: s.stageDefinition?.order ?? s.template.order ?? 0,
|
||||
template: {
|
||||
id: s.template.id,
|
||||
slug: s.template.slug,
|
||||
|
|
@ -383,6 +403,12 @@ export async function updateStageStatus(
|
|||
isOptional: s.template.isOptional,
|
||||
dependsOn: s.template.dependsOn,
|
||||
},
|
||||
stageDefinition: s.stageDefinition
|
||||
? {
|
||||
id: s.stageDefinition.id,
|
||||
dependsOn: s.stageDefinition.dependsOn ?? [],
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
const currentStage = allStages.find((s) => s.id === stageId)!;
|
||||
|
|
@ -412,6 +438,7 @@ export async function updateStageStatus(
|
|||
const allStages = stage.deliverable.stages.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.id === stageId ? newStatus : s.status,
|
||||
order: s.stageDefinition?.order ?? s.template.order ?? 0,
|
||||
template: {
|
||||
id: s.template.id,
|
||||
slug: s.template.slug,
|
||||
|
|
@ -419,6 +446,12 @@ export async function updateStageStatus(
|
|||
isOptional: s.template.isOptional,
|
||||
dependsOn: s.template.dependsOn,
|
||||
},
|
||||
stageDefinition: s.stageDefinition
|
||||
? {
|
||||
id: s.stageDefinition.id,
|
||||
dependsOn: s.stageDefinition.dependsOn ?? [],
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
const approvedStage = allStages.find((s) => s.id === stageId)!;
|
||||
const idsToUnblock = getStageIdsToUnblock(approvedStage, allStages);
|
||||
|
|
@ -471,6 +504,7 @@ export async function updateStageStatus(
|
|||
const allStages = stage.deliverable.stages.map((s) => ({
|
||||
id: s.id,
|
||||
status: s.id === stageId ? newStatus : s.status,
|
||||
order: s.stageDefinition?.order ?? s.template.order ?? 0,
|
||||
template: {
|
||||
id: s.template.id,
|
||||
slug: s.template.slug,
|
||||
|
|
@ -478,6 +512,12 @@ export async function updateStageStatus(
|
|||
isOptional: s.template.isOptional,
|
||||
dependsOn: s.template.dependsOn,
|
||||
},
|
||||
stageDefinition: s.stageDefinition
|
||||
? {
|
||||
id: s.stageDefinition.id,
|
||||
dependsOn: s.stageDefinition.dependsOn ?? [],
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
const approvedStage = allStages.find((s) => s.id === stageId)!;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue