mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
feat(ara-projects): step through workstreams, and drop the per-stage dates
Ladders are no longer stacked. A project with eight workstreams has forty stages, and one page of them turns configuring the last workstream into a scroll — so one ladder is on screen at a time, the "Workstream overrides" panel doubles as the picker, and the footer walks forwards through them before it offers to leave. The stages *within* a ladder stay listed together: they are dragged against each other, so they have to be seen together. `start_date` / `due_date` come out end-to-end — inputs, request schemas, payload, insert and update — rather than being hidden in the UI. A stage is a rung of a workflow, not a piece of scheduled work, so it has no date to hold; delivery dates already belong to a work order. The columns stay on the table and stay NULL until their keep-or-drop decision is made. This supersedes one of #411's acceptance criteria, recorded in ADR-0021. `updateStage` collapses to `renameStage` — the name is now the only field of a stage a user owns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
235af64b43
commit
7b65eae7bd
14 changed files with 313 additions and 602 deletions
|
|
@ -16,7 +16,7 @@ highest `order` (CONTEXT.md, **Stage**). `work_order.status`,
|
|||
`work_order.priority` and `project_workstream.current_stage_id` are deliberately
|
||||
untouched, deferred to #426.
|
||||
|
||||
Three things about that had to be decided before the screen could be written.
|
||||
Four things about that had to be decided.
|
||||
|
||||
### 1. Where the default ladder gets seeded
|
||||
|
||||
|
|
@ -52,6 +52,14 @@ contradict: a stage row cannot be inserted without *some* value in a `NOT NULL`
|
|||
column, so "seed the default ladder" and "never write `status`" cannot both be
|
||||
taken literally.
|
||||
|
||||
### 4. The optional per-stage dates
|
||||
|
||||
`project_workstream_stage` carries nullable `start_date` and `due_date`
|
||||
columns, and the issue asked for them to be exposed as optional inputs. On
|
||||
review of the built screen they were rejected: a stage is a rung of a workflow,
|
||||
not a piece of scheduled work, so there is no date it can meaningfully hold.
|
||||
Delivery dates belong to a Work order (`work_order.forecast_end` already exists).
|
||||
|
||||
## Decision
|
||||
|
||||
**The stage routes are keyed on `workstream.id`**, the catalogue row — the same
|
||||
|
|
@ -74,6 +82,12 @@ query, and not part of any API contract. It matches what
|
|||
`seed-projects-dashboard.ts` already writes, so seeded and user-created rows
|
||||
agree.
|
||||
|
||||
**`start_date` and `due_date` are not exposed and never written.** They are
|
||||
absent from the request schemas, the payload and the UI, so a ladder stage
|
||||
leaves them NULL for every row this screen creates. The columns stay on the
|
||||
table for now; whether they are dropped is a schema question deferred to a
|
||||
later issue, and nothing in #411 depends on the answer.
|
||||
|
||||
`project_workstream.current_stage_id` and `work_order.status` are genuinely
|
||||
never written by #411 — the AC holds for both columns as stated. A stage that
|
||||
`current_stage_id` points at cannot even be deleted from this screen.
|
||||
|
|
@ -108,6 +122,12 @@ never written by #411 — the AC holds for both columns as stated. A stage that
|
|||
yet**, because seeding is skipped for them. That is honest — the workstream
|
||||
genuinely has no stages, and the readiness gate says so — and it keeps a
|
||||
viewer from creating rows.
|
||||
- **The dates decision supersedes an acceptance criterion of #411**, which
|
||||
asked for the date inputs. It was taken after seeing them on the screen, and
|
||||
the issue should be amended rather than the code quietly diverging from it.
|
||||
- **A stage-level date has nowhere to come back to cheaply**: reinstating the
|
||||
inputs means a schema keep-or-drop decision first. That is the intended
|
||||
order — the columns are unused rather than half-used in the meantime.
|
||||
- **`status` carries no meaning and must not acquire one by accident.** Anything
|
||||
reading it today would be reading a placeholder. When #426 agrees the value
|
||||
set, that issue owns both the enum conversion and the backfill of rows written
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/**
|
||||
* PATCH / DELETE `.../workstreams/[workstreamId]/stages/[stageId]` (#411).
|
||||
* PATCH (rename) / DELETE `.../workstreams/[workstreamId]/stages/[stageId]`
|
||||
* (issue #411).
|
||||
*
|
||||
* Database mocked at the module boundary (`../queries` and the authz
|
||||
* repository); no connection is opened. The blocked-delete path — the one the
|
||||
|
|
@ -14,14 +15,14 @@ const {
|
|||
mockLoadAuthzUser,
|
||||
mockLoadProjectAuthzFacts,
|
||||
mockFindLadder,
|
||||
mockUpdateStage,
|
||||
mockRenameStage,
|
||||
mockDeleteStage,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetServerSession: vi.fn(),
|
||||
mockLoadAuthzUser: vi.fn(),
|
||||
mockLoadProjectAuthzFacts: vi.fn(),
|
||||
mockFindLadder: vi.fn(),
|
||||
mockUpdateStage: vi.fn(),
|
||||
mockRenameStage: vi.fn(),
|
||||
mockDeleteStage: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ vi.mock("@/app/repositories/projects/authzRepository", () => ({
|
|||
}));
|
||||
vi.mock("../queries", () => ({
|
||||
findLadder: mockFindLadder,
|
||||
updateStage: mockUpdateStage,
|
||||
renameStage: mockRenameStage,
|
||||
deleteStage: mockDeleteStage,
|
||||
}));
|
||||
|
||||
|
|
@ -54,20 +55,11 @@ const LADDER = {
|
|||
workstreamName: "Windows",
|
||||
isDefault: false,
|
||||
stages: [
|
||||
{
|
||||
id: "100",
|
||||
name: "Ordered",
|
||||
order: 0,
|
||||
startDate: "2026-09-03",
|
||||
dueDate: "2026-12-01",
|
||||
dependents: NO_DEPENDENTS,
|
||||
},
|
||||
{ id: "100", name: "Ordered", order: 0, dependents: NO_DEPENDENTS },
|
||||
{
|
||||
id: "101",
|
||||
name: "Closed",
|
||||
order: 1,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
dependents: { ...NO_DEPENDENTS, workOrders: 4 },
|
||||
},
|
||||
],
|
||||
|
|
@ -115,7 +107,7 @@ function patchRequest(body: unknown, stageId = "100") {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFindLadder.mockResolvedValue(LADDER);
|
||||
mockUpdateStage.mockResolvedValue({ kind: "updated" });
|
||||
mockRenameStage.mockResolvedValue({ kind: "renamed" });
|
||||
mockDeleteStage.mockResolvedValue({ kind: "deleted" });
|
||||
});
|
||||
|
||||
|
|
@ -135,92 +127,53 @@ describe("PATCH", () => {
|
|||
signedInAs([CONTRACTOR_ORG]);
|
||||
const res = await PATCH(patchRequest({ name: "On site" }), params());
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockUpdateStage).not.toHaveBeenCalled();
|
||||
expect(mockRenameStage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renames a stage", async () => {
|
||||
const res = await PATCH(patchRequest({ name: "On site" }), params());
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ stageId: "100", updated: true });
|
||||
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { name: "On site" });
|
||||
expect(await res.json()).toEqual({ stageId: "100", renamed: true });
|
||||
expect(mockRenameStage).toHaveBeenCalledWith(9n, 100n, "On site");
|
||||
});
|
||||
|
||||
it("sets the optional dates", async () => {
|
||||
const res = await PATCH(
|
||||
patchRequest({ startDate: "2026-09-03", dueDate: "2026-12-01" }),
|
||||
params("101"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 101n, {
|
||||
startDate: "2026-09-03",
|
||||
dueDate: "2026-12-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a date with null", async () => {
|
||||
const res = await PATCH(patchRequest({ dueDate: null }), params());
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { dueDate: null });
|
||||
});
|
||||
|
||||
it("treats an empty date string as a clear", async () => {
|
||||
const res = await PATCH(patchRequest({ startDate: "" }), params());
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { startDate: null });
|
||||
});
|
||||
|
||||
it("rejects dd/mm/yyyy on the wire", async () => {
|
||||
const res = await PATCH(patchRequest({ dueDate: "01/12/2026" }), params());
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockUpdateStage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks a new start date against the stored due date", async () => {
|
||||
// Stage 100 is due 2026-12-01; moving its start past that is rejected even
|
||||
// though the request carries only one end of the range.
|
||||
const res = await PATCH(patchRequest({ startDate: "2027-01-01" }), params());
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toContain("before the start date");
|
||||
expect(mockUpdateStage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a start date that clears the stored due date", async () => {
|
||||
const res = await PATCH(patchRequest({ startDate: "2026-11-30" }), params());
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("400s an empty patch", async () => {
|
||||
it("400s a patch with no name", async () => {
|
||||
const res = await PATCH(patchRequest({}), params());
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockUpdateStage).not.toHaveBeenCalled();
|
||||
expect(mockRenameStage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores any attempt to set status or order", async () => {
|
||||
// Neither column is this issue's to write (ADR-0021): unknown keys are
|
||||
// dropped by the schema, so they never reach the update.
|
||||
it("ignores any attempt to set status, order or the dates", async () => {
|
||||
// None of those columns is this issue's to write (ADR-0021): unknown keys
|
||||
// are dropped by the schema, so they never reach the rename.
|
||||
const res = await PATCH(
|
||||
patchRequest({ name: "On site", status: "complete", order: 0 }),
|
||||
patchRequest({
|
||||
name: "On site",
|
||||
status: "complete",
|
||||
order: 0,
|
||||
startDate: "2026-09-03",
|
||||
}),
|
||||
params(),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockUpdateStage).toHaveBeenCalledWith(9n, 100n, { name: "On site" });
|
||||
expect(mockRenameStage).toHaveBeenCalledWith(9n, 100n, "On site");
|
||||
});
|
||||
|
||||
it("404s a stage that is not in this workstream's ladder", async () => {
|
||||
const res = await PATCH(patchRequest({ name: "On site" }, "999"), params("999"));
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockUpdateStage).not.toHaveBeenCalled();
|
||||
expect(mockRenameStage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s a workstream of another project", async () => {
|
||||
mockFindLadder.mockResolvedValue(null);
|
||||
const res = await PATCH(patchRequest({ name: "On site" }), params());
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockUpdateStage).not.toHaveBeenCalled();
|
||||
expect(mockRenameStage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes a rejected name back as a 400 with its reason", async () => {
|
||||
mockUpdateStage.mockResolvedValue({
|
||||
mockRenameStage.mockResolvedValue({
|
||||
kind: "rejected",
|
||||
reason: 'This workstream already has a "Closed" stage.',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* `.../workstreams/[workstreamId]/stages/[stageId]` (issue #411).
|
||||
*
|
||||
* PATCH — rename a stage and/or set its optional start / due dates.
|
||||
* PATCH — rename a stage.
|
||||
* DELETE — remove a stage, subject to the ladder guardrails.
|
||||
*
|
||||
* The delete guardrails are the server's decision, always: the counts a
|
||||
|
|
@ -17,8 +17,7 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { authorizeWorkstreamRequest } from "../../../authorize";
|
||||
import { checkStageDates } from "../ladder";
|
||||
import { deleteStage, findLadder, updateStage } from "../queries";
|
||||
import { deleteStage, findLadder, renameStage } from "../queries";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{
|
||||
|
|
@ -30,33 +29,19 @@ type Params = {
|
|||
};
|
||||
|
||||
/**
|
||||
* `YYYY-MM-DD` on the wire, always (ADR-0019). `null` clears the date, an
|
||||
* absent key leaves it untouched, and `""` — what a half-cleared field sends —
|
||||
* means the same as `null`.
|
||||
* The name is the only field of a stage a user owns: `order` moves through the
|
||||
* collection route's reorder, and `status` / `start_date` / `due_date` are not
|
||||
* this issue's to write (ADR-0021).
|
||||
*/
|
||||
const optionalIsoDate = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Dates must be YYYY-MM-DD")
|
||||
.or(z.literal(""))
|
||||
.nullable()
|
||||
.transform((value) => (value ? value : null));
|
||||
|
||||
const patchSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
startDate: optionalIsoDate.optional(),
|
||||
dueDate: optionalIsoDate.optional(),
|
||||
})
|
||||
.refine(
|
||||
(patch) => Object.keys(patch).length > 0,
|
||||
"Nothing to update",
|
||||
);
|
||||
const patchSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
function parseId(raw: string): bigint | null {
|
||||
return /^\d+$/.test(raw) ? BigInt(raw) : null;
|
||||
}
|
||||
|
||||
/** PATCH — rename and/or re-date one stage. */
|
||||
/** PATCH — rename one stage. */
|
||||
export async function PATCH(req: NextRequest, props: Params) {
|
||||
const { projectId, workstreamId: workstreamIdParam, stageId } = await props.params;
|
||||
const auth = await authorizeWorkstreamRequest(projectId, "manage");
|
||||
|
|
@ -77,9 +62,7 @@ export async function PATCH(req: NextRequest, props: Params) {
|
|||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Scopes the stage to the project in the path, and gives the current dates —
|
||||
// a patch that sets only one end of the range still has to be checked
|
||||
// against the other end as stored.
|
||||
// Scopes the stage to the project in the path.
|
||||
const ladder = await findLadder(auth.projectId, workstreamId);
|
||||
if (!ladder) {
|
||||
return NextResponse.json(
|
||||
|
|
@ -87,24 +70,15 @@ export async function PATCH(req: NextRequest, props: Params) {
|
|||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const current = ladder.stages.find((stage) => stage.id === stageId);
|
||||
if (!current) {
|
||||
if (!ladder.stages.some((stage) => stage.id === stageId)) {
|
||||
return NextResponse.json({ error: "Stage not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const dates = checkStageDates(
|
||||
body.startDate === undefined ? current.startDate : body.startDate,
|
||||
body.dueDate === undefined ? current.dueDate : body.dueDate,
|
||||
);
|
||||
if (!dates.ok) {
|
||||
return NextResponse.json({ error: dates.error }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateStage(
|
||||
const result = await renameStage(
|
||||
BigInt(ladder.projectWorkstreamId),
|
||||
id,
|
||||
body,
|
||||
body.name,
|
||||
);
|
||||
if (result.kind === "not-found") {
|
||||
return NextResponse.json({ error: "Stage not found" }, { status: 404 });
|
||||
|
|
@ -112,11 +86,11 @@ export async function PATCH(req: NextRequest, props: Params) {
|
|||
if (result.kind === "rejected") {
|
||||
return NextResponse.json({ error: result.reason }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ stageId, updated: true });
|
||||
return NextResponse.json({ stageId, renamed: true });
|
||||
} catch (err) {
|
||||
console.error("PATCH .../stages/[stageId] failed:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Couldn't update the stage" },
|
||||
{ error: "Couldn't rename the stage" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
MIN_STAGES,
|
||||
NO_STAGE_DEPENDENTS,
|
||||
checkReorder,
|
||||
checkStageDates,
|
||||
checkStageName,
|
||||
contiguousOrders,
|
||||
decideDeleteStage,
|
||||
|
|
@ -157,30 +156,6 @@ describe("checkStageName", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("checkStageDates", () => {
|
||||
it("accepts a stage with no dates", () => {
|
||||
expect(checkStageDates(null, null)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("accepts either date on its own", () => {
|
||||
expect(checkStageDates("2026-09-03", null).ok).toBe(true);
|
||||
expect(checkStageDates(null, "2026-09-03").ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a due date on or after the start date", () => {
|
||||
expect(checkStageDates("2026-09-03", "2026-09-03").ok).toBe(true);
|
||||
expect(checkStageDates("2026-09-03", "2026-12-01").ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a due date before the start date", () => {
|
||||
// 03/09/2026 is 3 September and 09/03/2026 would be 9 March — the ISO
|
||||
// strings compare correctly whatever a browser's locale renders.
|
||||
const check = checkStageDates("2026-09-03", "2026-03-09");
|
||||
expect(check.ok).toBe(false);
|
||||
expect(!check.ok && check.error).toContain("before the start date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkReorder", () => {
|
||||
it("accepts a permutation of the ladder", () => {
|
||||
expect(checkReorder(["1", "2", "3"], ["3", "1", "2"])).toEqual({
|
||||
|
|
|
|||
|
|
@ -168,26 +168,6 @@ export function checkStageName(
|
|||
return { ok: true, name };
|
||||
}
|
||||
|
||||
export type StageDatesCheck = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Validate the optional per-stage dates.
|
||||
*
|
||||
* Both are `YYYY-MM-DD` — the only format that crosses a boundary (ADR-0019);
|
||||
* what the user reads and types is `dd/mm/yyyy`, and `DateInput` converts at
|
||||
* the field's edge. ISO dates sort lexicographically, so the ordering check is
|
||||
* a string comparison with no `Date` and therefore no timezone to get wrong.
|
||||
*/
|
||||
export function checkStageDates(
|
||||
startDate: string | null,
|
||||
dueDate: string | null,
|
||||
): StageDatesCheck {
|
||||
if (startDate && dueDate && dueDate < startDate) {
|
||||
return { ok: false, error: "The due date can't be before the start date." };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export type ReorderCheck =
|
||||
| { ok: true; ids: string[] }
|
||||
| { ok: false; error: string };
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@
|
|||
* the two always agree on shape — the page paints first, the handlers serve
|
||||
* every refetch after it. Same split as `../../queries.ts` for #410.
|
||||
*
|
||||
* Two columns are never written from this module, by decision (ADR-0021):
|
||||
* Several columns are never written from this module, by decision (ADR-0021):
|
||||
* - `project_workstream.current_stage_id` — deferred to #426; v1 derives a
|
||||
* workstream's position from its Work orders' stage FKs.
|
||||
* - `work_order.status` — the stage FK is the only progress mechanism.
|
||||
* - `project_workstream_stage.start_date` / `due_date` — dates describe a
|
||||
* work order's delivery, not a rung of a workflow, so a ladder stage has
|
||||
* no date to hold. The columns stay nullable and stay NULL; whether they
|
||||
* survive is a schema question for later.
|
||||
*
|
||||
* `project_workstream_stage.status` is a third case and the awkward one: it is
|
||||
* `project_workstream_stage.status` is the awkward case: it is
|
||||
* `NOT NULL` with no database default, so a row cannot be inserted without a
|
||||
* value. Inserts therefore write the placeholder constant below, once, and no
|
||||
* update path ever touches the column again. See ADR-0021.
|
||||
|
|
@ -51,9 +55,6 @@ export interface LadderStage {
|
|||
name: string;
|
||||
/** Contiguous from 0 within its ladder. */
|
||||
order: number;
|
||||
/** `YYYY-MM-DD`, or null. Optional per issue #411. */
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
/** What points at this stage — drives the delete guardrails' explanations. */
|
||||
dependents: StageDependents;
|
||||
}
|
||||
|
|
@ -76,8 +77,6 @@ type StageRow = {
|
|||
projectWorkstreamId: bigint;
|
||||
name: string;
|
||||
order: number;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
workOrders: number;
|
||||
evidenceRequirements: number;
|
||||
isCurrentStage: boolean;
|
||||
|
|
@ -101,8 +100,6 @@ async function loadStageRows(
|
|||
projectWorkstreamId: projectWorkstreamStage.projectWorkstreamId,
|
||||
name: projectWorkstreamStage.name,
|
||||
order: projectWorkstreamStage.order,
|
||||
startDate: projectWorkstreamStage.startDate,
|
||||
dueDate: projectWorkstreamStage.dueDate,
|
||||
workOrders: sql<number>`count(distinct ${workOrder.id})::int`,
|
||||
evidenceRequirements: sql<number>`count(distinct ${projectWorkstreamEvidenceRequirement.id})::int`,
|
||||
isCurrentStage: sql<boolean>`coalesce(bool_or(${projectWorkstream.currentStageId} = ${projectWorkstreamStage.id}), false)`,
|
||||
|
|
@ -133,8 +130,6 @@ function toLadderStage(row: StageRow): LadderStage {
|
|||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
order: row.order,
|
||||
startDate: row.startDate,
|
||||
dueDate: row.dueDate,
|
||||
dependents: {
|
||||
workOrders: row.workOrders,
|
||||
evidenceRequirements: row.evidenceRequirements,
|
||||
|
|
@ -352,7 +347,7 @@ export type AddStageResult =
|
|||
*/
|
||||
export async function addStage(
|
||||
projectWorkstreamId: bigint,
|
||||
stage: { name: string; startDate: string | null; dueDate: string | null },
|
||||
stage: { name: string },
|
||||
): Promise<AddStageResult> {
|
||||
return db.transaction(async (tx) => {
|
||||
const existing = await lockedStages(tx, projectWorkstreamId);
|
||||
|
|
@ -369,8 +364,6 @@ export async function addStage(
|
|||
projectWorkstreamId,
|
||||
name: check.name,
|
||||
order: existing.length,
|
||||
startDate: stage.startDate,
|
||||
dueDate: stage.dueDate,
|
||||
status: PLACEHOLDER_STAGE_STATUS,
|
||||
})
|
||||
.returning({ id: projectWorkstreamStage.id });
|
||||
|
|
@ -386,58 +379,41 @@ export async function addStage(
|
|||
});
|
||||
}
|
||||
|
||||
export type UpdateStageResult =
|
||||
| { kind: "updated" }
|
||||
export type RenameStageResult =
|
||||
| { kind: "renamed" }
|
||||
| { kind: "not-found" }
|
||||
| { kind: "rejected"; reason: string };
|
||||
|
||||
/**
|
||||
* Rename a stage and/or set its optional dates.
|
||||
* Rename a stage.
|
||||
*
|
||||
* Only the fields present in `patch` are written — a rename never clears a
|
||||
* date, and a date change never renames. `status`, `status_description` and
|
||||
* `order` are not writable here: order changes go through `reorderStages`, and
|
||||
* the status columns are not this issue's to write (ADR-0021).
|
||||
* The name is the only field of a stage a user owns: `order` changes go
|
||||
* through `reorderStages`, and `status`, `status_description`, `start_date`
|
||||
* and `due_date` are not this issue's to write (ADR-0021). The uniqueness
|
||||
* check runs against names re-read under the lock, excluding this stage — so a
|
||||
* stage may always keep its own name.
|
||||
*/
|
||||
export async function updateStage(
|
||||
export async function renameStage(
|
||||
projectWorkstreamId: bigint,
|
||||
stageId: bigint,
|
||||
patch: {
|
||||
name?: string;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
},
|
||||
): Promise<UpdateStageResult> {
|
||||
name: string,
|
||||
): Promise<RenameStageResult> {
|
||||
return db.transaction(async (tx) => {
|
||||
const existing = await lockedStages(tx, projectWorkstreamId);
|
||||
const target = existing.find((s) => s.id === stageId);
|
||||
if (!target) return { kind: "not-found" };
|
||||
if (!existing.some((s) => s.id === stageId)) return { kind: "not-found" };
|
||||
|
||||
const values: {
|
||||
name?: string;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
} = {};
|
||||
const check = checkStageName(
|
||||
name,
|
||||
existing.filter((s) => s.id !== stageId).map((s) => s.name),
|
||||
);
|
||||
if (!check.ok) return { kind: "rejected", reason: check.error };
|
||||
|
||||
if (patch.name !== undefined) {
|
||||
const check = checkStageName(
|
||||
patch.name,
|
||||
existing.filter((s) => s.id !== stageId).map((s) => s.name),
|
||||
);
|
||||
if (!check.ok) return { kind: "rejected", reason: check.error };
|
||||
values.name = check.name;
|
||||
}
|
||||
if (patch.startDate !== undefined) values.startDate = patch.startDate;
|
||||
if (patch.dueDate !== undefined) values.dueDate = patch.dueDate;
|
||||
await tx
|
||||
.update(projectWorkstreamStage)
|
||||
.set({ name: check.name })
|
||||
.where(eq(projectWorkstreamStage.id, stageId));
|
||||
|
||||
if (Object.keys(values).length > 0) {
|
||||
await tx
|
||||
.update(projectWorkstreamStage)
|
||||
.set(values)
|
||||
.where(eq(projectWorkstreamStage.id, stageId));
|
||||
}
|
||||
|
||||
return { kind: "updated" };
|
||||
return { kind: "renamed" };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,22 +58,8 @@ const LADDER = {
|
|||
workstreamName: "Windows",
|
||||
isDefault: true,
|
||||
stages: [
|
||||
{
|
||||
id: "100",
|
||||
name: "Ordered",
|
||||
order: 0,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
dependents: NO_DEPENDENTS,
|
||||
},
|
||||
{
|
||||
id: "101",
|
||||
name: "Closed",
|
||||
order: 1,
|
||||
startDate: "2026-09-03",
|
||||
dueDate: null,
|
||||
dependents: NO_DEPENDENTS,
|
||||
},
|
||||
{ id: "100", name: "Ordered", order: 0, dependents: NO_DEPENDENTS },
|
||||
{ id: "101", name: "Closed", order: 1, dependents: NO_DEPENDENTS },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -183,14 +169,10 @@ describe("POST", () => {
|
|||
const res = await POST(jsonRequest("POST", { name: "Snagging" }), params());
|
||||
expect(res.status).toBe(201);
|
||||
expect(await res.json()).toEqual({ stageId: "102" });
|
||||
expect(mockAddStage).toHaveBeenCalledWith(9n, {
|
||||
name: "Snagging",
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
});
|
||||
expect(mockAddStage).toHaveBeenCalledWith(9n, { name: "Snagging" });
|
||||
});
|
||||
|
||||
it("accepts the optional dates as YYYY-MM-DD", async () => {
|
||||
it("ignores dates on the way in — a stage has none (ADR-0021)", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest("POST", {
|
||||
name: "Snagging",
|
||||
|
|
@ -200,48 +182,7 @@ describe("POST", () => {
|
|||
params(),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockAddStage).toHaveBeenCalledWith(9n, {
|
||||
name: "Snagging",
|
||||
startDate: "2026-09-03",
|
||||
dueDate: "2026-12-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a date that is not YYYY-MM-DD", async () => {
|
||||
// dd/mm/yyyy is what the user types; DateInput converts before it is sent.
|
||||
const res = await POST(
|
||||
jsonRequest("POST", { name: "Snagging", startDate: "03/09/2026" }),
|
||||
params(),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockAddStage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats an empty date string as no date", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest("POST", { name: "Snagging", startDate: "", dueDate: "" }),
|
||||
params(),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(mockAddStage).toHaveBeenCalledWith(9n, {
|
||||
name: "Snagging",
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a due date before the start date", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest("POST", {
|
||||
name: "Snagging",
|
||||
startDate: "2026-09-03",
|
||||
dueDate: "2026-03-09",
|
||||
}),
|
||||
params(),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toContain("before the start date");
|
||||
expect(mockAddStage).not.toHaveBeenCalled();
|
||||
expect(mockAddStage).toHaveBeenCalledWith(9n, { name: "Snagging" });
|
||||
});
|
||||
|
||||
it("400s a body with no name", async () => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* POST — add a stage to the end of the ladder.
|
||||
* PATCH — reorder the whole ladder (`{ stageIds }`, every id exactly once).
|
||||
*
|
||||
* Rename, dates and delete are keyed on a single stage and live on
|
||||
* Rename and delete are keyed on a single stage and live on
|
||||
* `./[stageId]/route.ts`.
|
||||
*
|
||||
* Keyed on `workstream.id` — the catalogue row — because these routes are
|
||||
|
|
@ -23,31 +23,21 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { authorizeWorkstreamRequest } from "../../authorize";
|
||||
import { checkStageDates } from "./ladder";
|
||||
import { addStage, findLadder, reorderStages } from "./queries";
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ projectId: string; workstreamId: string }>;
|
||||
};
|
||||
|
||||
/** `YYYY-MM-DD` — see ADR-0019: the display format stops at the input's edge. */
|
||||
const isoDate = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Dates must be YYYY-MM-DD");
|
||||
|
||||
/**
|
||||
* An optional date field. The client sends `null` to clear one and omits the
|
||||
* key to leave it alone, so `""` is normalised to `null` rather than stored.
|
||||
* A stage is a name and a position, and the position is decided by where it is
|
||||
* added — so a name is the whole body. The `start_date` / `due_date` columns
|
||||
* exist on the table but describe a work order's delivery rather than a rung
|
||||
* of a workflow; they are deliberately left NULL (ADR-0021), so there is
|
||||
* nothing here to set them with.
|
||||
*/
|
||||
const optionalIsoDate = isoDate
|
||||
.or(z.literal(""))
|
||||
.nullish()
|
||||
.transform((value) => (value ? value : null));
|
||||
|
||||
const addSchema = z.object({
|
||||
name: z.string(),
|
||||
startDate: optionalIsoDate,
|
||||
dueDate: optionalIsoDate,
|
||||
});
|
||||
|
||||
const reorderSchema = z.object({
|
||||
|
|
@ -104,11 +94,6 @@ export async function POST(req: NextRequest, props: Params) {
|
|||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const dates = checkStageDates(body.startDate, body.dueDate);
|
||||
if (!dates.ok) {
|
||||
return NextResponse.json({ error: dates.error }, { status: 400 });
|
||||
}
|
||||
|
||||
// Existence and ownership before the write, and the translation from the
|
||||
// catalogue id in the path to the `project_workstream.id` the write takes.
|
||||
const ladder = await findLadder(auth.projectId, workstreamId);
|
||||
|
|
@ -122,8 +107,6 @@ export async function POST(req: NextRequest, props: Params) {
|
|||
try {
|
||||
const result = await addStage(BigInt(ladder.projectWorkstreamId), {
|
||||
name: body.name,
|
||||
startDate: body.startDate,
|
||||
dueDate: body.dueDate,
|
||||
});
|
||||
|
||||
if (result.kind === "rejected") {
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Layers } from "lucide-react";
|
||||
import { DEFAULT_LADDER } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder";
|
||||
import type { WorkstreamLadder } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
|
||||
|
||||
/**
|
||||
* The "Workstream overrides" panel from the wireframe (issue #411).
|
||||
*
|
||||
* Answers one question at a glance: which workstreams still follow the
|
||||
* standard ladder, and which have been changed. That matters because most
|
||||
* projects leave most workstreams alone — the exceptions are what a reviewer
|
||||
* is looking for, and hunting for them through five expanded editors is how
|
||||
* they get missed.
|
||||
*
|
||||
* `isDefault` is decided server-side by `isDefaultLadder`, so this panel and
|
||||
* the API agree on what "default" means rather than each having a copy.
|
||||
*/
|
||||
export function LadderOverridesPanel({
|
||||
ladders,
|
||||
}: {
|
||||
ladders: WorkstreamLadder[];
|
||||
}) {
|
||||
const custom = ladders.filter((l) => !l.isDefault);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-xl border border-gray-200 bg-gray-50/60 p-4"
|
||||
data-testid="ladder-overrides"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-gray-400" />
|
||||
<h2 className="text-sm font-semibold text-gray-900">
|
||||
Workstream overrides
|
||||
</h2>
|
||||
<span className="text-xs text-gray-500">
|
||||
{custom.length === 0
|
||||
? `all ${ladders.length === 1 ? "" : `${ladders.length} `}on the standard ladder`
|
||||
: `${custom.length} of ${ladders.length} customised`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{ladders.map((ladder) => (
|
||||
<li key={ladder.workstreamId}>
|
||||
<a
|
||||
href={`#ladder-${ladder.workstreamId}`}
|
||||
className="flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs transition hover:border-gray-300"
|
||||
data-testid={`ladder-override-${ladder.workstreamId}`}
|
||||
data-default={ladder.isDefault}
|
||||
>
|
||||
<span className="font-semibold text-gray-800">
|
||||
{ladder.workstreamName}
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
"rounded-full px-1.5 py-0.5 text-[11px] font-medium",
|
||||
ladder.isDefault
|
||||
? "bg-gray-100 text-gray-600"
|
||||
: "bg-indigo-50 text-indigo-700",
|
||||
].join(" ")}
|
||||
>
|
||||
{ladder.isDefault ? "Default" : "Custom"}
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
{ladder.stages.length}{" "}
|
||||
{ladder.stages.length === 1 ? "stage" : "stages"}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
The standard ladder is {DEFAULT_LADDER.join(" → ")}.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,9 +20,7 @@ import {
|
|||
import { Loader2, Plus, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { DateInput } from "@/app/components/DateInput";
|
||||
import { ConfirmDialog } from "@/app/components/ConfirmDialog";
|
||||
import { IMPOSSIBLE_DATE_MESSAGE, isIsoDate } from "./dateEntry";
|
||||
import { stageLaddersQueryKey } from "./queryKey";
|
||||
import {
|
||||
contiguousOrders,
|
||||
|
|
@ -74,8 +72,8 @@ function applyOrder(stages: LadderStage[], ids: string[]): LadderStage[] {
|
|||
|
||||
type Notice = { tone: "error" | "info"; text: string };
|
||||
|
||||
/** A blank add-a-stage form. */
|
||||
const EMPTY_DRAFT = { name: "", startDate: "", dueDate: "" };
|
||||
/** A blank add-a-stage form. A stage is a name and nothing else. */
|
||||
const EMPTY_DRAFT = { name: "" };
|
||||
|
||||
export function StageLadderEditor({
|
||||
projectId,
|
||||
|
|
@ -128,11 +126,7 @@ export function StageLadderEditor({
|
|||
send(base, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: draft.name,
|
||||
startDate: draft.startDate || null,
|
||||
dueDate: draft.dueDate || null,
|
||||
}),
|
||||
body: JSON.stringify({ name: draft.name }),
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
if (!result.ok) {
|
||||
|
|
@ -147,18 +141,12 @@ export function StageLadderEditor({
|
|||
onError: (err: Error) => setNotice({ tone: "error", text: err.message }),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async ({
|
||||
stage,
|
||||
patch,
|
||||
}: {
|
||||
stage: LadderStage;
|
||||
patch: { name?: string; startDate?: string | null; dueDate?: string | null };
|
||||
}) =>
|
||||
const rename = useMutation({
|
||||
mutationFn: async ({ stage, name }: { stage: LadderStage; name: string }) =>
|
||||
send(`${base}/${stage.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
if (!result.ok) {
|
||||
|
|
@ -166,7 +154,7 @@ export function StageLadderEditor({
|
|||
} else {
|
||||
setNotice(null);
|
||||
}
|
||||
// Even a refusal refetches: the row must snap back to what was stored,
|
||||
// Even a refusal refetches: the row must snap back to the stored name,
|
||||
// not sit showing an edit the database rejected.
|
||||
onChanged();
|
||||
},
|
||||
|
|
@ -234,28 +222,9 @@ export function StageLadderEditor({
|
|||
},
|
||||
});
|
||||
|
||||
const busy = add.isLoading || update.isLoading || remove.isLoading;
|
||||
const busy = add.isLoading || rename.isLoading || remove.isLoading;
|
||||
const stageIds = ladder.stages.map((s) => s.id);
|
||||
|
||||
/**
|
||||
* Add the drafted stage, unless a date is finished but impossible.
|
||||
*
|
||||
* `DateInput` hands "31/02/2026" through as typed rather than reporting it
|
||||
* empty, so the check happens here and the user is told which field is wrong
|
||||
* — the server's "Dates must be YYYY-MM-DD" is true but unhelpful to someone
|
||||
* looking at a `dd/mm/yyyy` field.
|
||||
*/
|
||||
function submitAdd() {
|
||||
const bad = ([draft.startDate, draft.dueDate] as const).some(
|
||||
(value) => value !== "" && !isIsoDate(value),
|
||||
);
|
||||
if (bad) {
|
||||
setNotice({ tone: "error", text: IMPOSSIBLE_DATE_MESSAGE });
|
||||
return;
|
||||
}
|
||||
add.mutate();
|
||||
}
|
||||
|
||||
/** Commit a drop, unless the row was put back where it came from. */
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
|
|
@ -342,8 +311,7 @@ export function StageLadderEditor({
|
|||
stageCount={ladder.stages.length}
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onRename={(name) => update.mutate({ stage, patch: { name } })}
|
||||
onDates={(patch) => update.mutate({ stage, patch })}
|
||||
onRename={(name) => rename.mutate({ stage, name })}
|
||||
onDelete={() => setPendingDelete(stage)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -373,40 +341,14 @@ export function StageLadderEditor({
|
|||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && draft.name.trim() && !busy) {
|
||||
submitAdd();
|
||||
add.mutate();
|
||||
}
|
||||
}}
|
||||
data-testid={`add-stage-name-${ladder.workstreamId}`}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex w-[150px] flex-col gap-1">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
Start date
|
||||
</span>
|
||||
<DateInput
|
||||
value={draft.startDate}
|
||||
disabled={busy}
|
||||
onChange={(value) =>
|
||||
setDraft((d) => ({ ...d, startDate: value }))
|
||||
}
|
||||
data-testid={`add-stage-start-${ladder.workstreamId}`}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex w-[150px] flex-col gap-1">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
Due date
|
||||
</span>
|
||||
<DateInput
|
||||
value={draft.dueDate}
|
||||
disabled={busy}
|
||||
onChange={(value) =>
|
||||
setDraft((d) => ({ ...d, dueDate: value }))
|
||||
}
|
||||
data-testid={`add-stage-due-${ladder.workstreamId}`}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
onClick={submitAdd}
|
||||
onClick={() => add.mutate()}
|
||||
disabled={busy || draft.name.trim().length === 0}
|
||||
data-testid={`add-stage-submit-${ladder.workstreamId}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,26 +5,11 @@ import { useSortable } from "@dnd-kit/sortable";
|
|||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Flag, GripVertical, Lock, Trash2 } from "lucide-react";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { DateInput } from "@/app/components/DateInput";
|
||||
import type { LadderStage } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
|
||||
import { IMPOSSIBLE_DATE_MESSAGE, isIsoDate } from "./dateEntry";
|
||||
|
||||
/** The two optional per-stage dates, as they are named on the wire. */
|
||||
type DateField = "startDate" | "dueDate";
|
||||
type DateDraft = Partial<Record<DateField, string>>;
|
||||
type DateErrors = Partial<Record<DateField, boolean>>;
|
||||
|
||||
/**
|
||||
* One stage in the ladder editor (issue #411).
|
||||
*
|
||||
* ## Dates
|
||||
*
|
||||
* `start_date` and `due_date` are optional per-stage fields, entered through
|
||||
* `DateInput` — never `<input type="date">`, whose display format follows the
|
||||
* *browser's* locale and would render 03/09/2026 as 3 March on a US-configured
|
||||
* machine (ADR-0019). What crosses the boundary here is `YYYY-MM-DD`; the
|
||||
* `dd/mm/yyyy` a person reads and types stops at the field's edge.
|
||||
*
|
||||
* ## Reordering
|
||||
*
|
||||
* The row is dragged by its grip, not by its body — the body is full of text
|
||||
|
|
@ -35,12 +20,9 @@ type DateErrors = Partial<Record<DateField, boolean>>;
|
|||
*
|
||||
* ## Saving
|
||||
*
|
||||
* The name saves on blur and the dates on change, because a date is complete
|
||||
* the moment it parses — there is no half-typed state worth persisting, and a
|
||||
* calendar pick may never blur the field. The row keeps a draft so typing is
|
||||
* not fought by refetches, and re-syncs from the server value whenever it
|
||||
* changes: adjusting state during render rather than in an effect, the same
|
||||
* pattern `DateInput` itself uses.
|
||||
* The name saves on blur. The row keeps a draft so typing is not fought by
|
||||
* refetches, and re-syncs from the server value whenever it changes —
|
||||
* adjusting state during render rather than in an effect.
|
||||
*/
|
||||
export function StageRow({
|
||||
stage,
|
||||
|
|
@ -49,7 +31,6 @@ export function StageRow({
|
|||
canManage,
|
||||
busy,
|
||||
onRename,
|
||||
onDates,
|
||||
onDelete,
|
||||
}: {
|
||||
stage: LadderStage;
|
||||
|
|
@ -59,7 +40,6 @@ export function StageRow({
|
|||
/** Another mutation on this ladder is in flight. */
|
||||
busy: boolean;
|
||||
onRename: (name: string) => void;
|
||||
onDates: (patch: { startDate?: string | null; dueDate?: string | null }) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const {
|
||||
|
|
@ -70,10 +50,10 @@ export function StageRow({
|
|||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: stage.id, disabled: !canManage || busy });
|
||||
// The stored name, tracked so the draft can be rebuilt whenever it moves —
|
||||
// a rename landed, or another tab edited it. Keyed on the name alone: a date
|
||||
// save refetches this row too, and must not throw away a rename being typed.
|
||||
// `DateInput` keeps its own text, so the dates need no draft here.
|
||||
// The stored name, tracked so the draft can be rebuilt whenever it moves — a
|
||||
// rename landed, or another tab edited it. A reorder refetches the row too,
|
||||
// and must not throw away a rename being typed, so this is keyed on the name
|
||||
// rather than on the row as a whole.
|
||||
const [synced, setSynced] = useState(stage.name);
|
||||
const [name, setName] = useState(stage.name);
|
||||
|
||||
|
|
@ -82,35 +62,6 @@ export function StageRow({
|
|||
setName(stage.name);
|
||||
}
|
||||
|
||||
// What each date field last reported, and whether it is currently holding a
|
||||
// complete-but-impossible entry. Both are per-field and short-lived: a save
|
||||
// or a refetch clears them.
|
||||
const [typed, setTyped] = useState<DateDraft>({});
|
||||
const [dateErrors, setDateErrors] = useState<DateErrors>({});
|
||||
|
||||
/**
|
||||
* Save a date the moment it becomes real. A clear is *not* saved here: an
|
||||
* empty value is reported for every keystroke of an incomplete entry too, so
|
||||
* committing one would wipe the stored date as soon as the field was touched.
|
||||
* `blurDate` handles the genuine clear.
|
||||
*/
|
||||
function changeDate(field: DateField, value: string) {
|
||||
setTyped((t) => ({ ...t, [field]: value }));
|
||||
setDateErrors((e) => ({ ...e, [field]: false }));
|
||||
if (isIsoDate(value) && value !== stage[field]) onDates({ [field]: value });
|
||||
}
|
||||
|
||||
/** Commit an emptied field, and flag an entry that is not a real date. */
|
||||
function blurDate(field: DateField) {
|
||||
const value = typed[field];
|
||||
if (value === undefined) return;
|
||||
if (value === "") {
|
||||
if (stage[field] !== null) onDates({ [field]: null });
|
||||
return;
|
||||
}
|
||||
if (!isIsoDate(value)) setDateErrors((e) => ({ ...e, [field]: true }));
|
||||
}
|
||||
|
||||
const isTerminal = index === stageCount - 1;
|
||||
const { workOrders, evidenceRequirements, isCurrentStage } = stage.dependents;
|
||||
// Signposting only — the server decides, and says so in its 409.
|
||||
|
|
@ -203,46 +154,6 @@ export function StageRow({
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<label className="flex w-[140px] flex-col gap-0.5">
|
||||
<span className="text-[11px] font-medium text-gray-400">Start</span>
|
||||
<DateInput
|
||||
className="h-9"
|
||||
value={stage.startDate}
|
||||
disabled={!canManage || busy}
|
||||
onChange={(value) => changeDate("startDate", value)}
|
||||
onBlur={() => blurDate("startDate")}
|
||||
data-testid={`stage-start-${stage.id}`}
|
||||
/>
|
||||
{dateErrors.startDate && (
|
||||
<span
|
||||
className="text-[11px] text-red-600"
|
||||
data-testid={`stage-start-error-${stage.id}`}
|
||||
>
|
||||
{IMPOSSIBLE_DATE_MESSAGE}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="flex w-[140px] flex-col gap-0.5">
|
||||
<span className="text-[11px] font-medium text-gray-400">Due</span>
|
||||
<DateInput
|
||||
className="h-9"
|
||||
value={stage.dueDate}
|
||||
disabled={!canManage || busy}
|
||||
onChange={(value) => changeDate("dueDate", value)}
|
||||
onBlur={() => blurDate("dueDate")}
|
||||
data-testid={`stage-due-${stage.id}`}
|
||||
/>
|
||||
{dateErrors.dueDate && (
|
||||
<span
|
||||
className="text-[11px] text-red-600"
|
||||
data-testid={`stage-due-error-${stage.id}`}
|
||||
>
|
||||
{IMPOSSIBLE_DATE_MESSAGE}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{canManage && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
|
@ -7,7 +8,7 @@ import { ArrowLeft, ArrowRight, Info } from "lucide-react";
|
|||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import type { WorkstreamLadder } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
|
||||
import { StageLadderEditor } from "./StageLadderEditor";
|
||||
import { LadderOverridesPanel } from "./LadderOverridesPanel";
|
||||
import { WorkstreamStepper } from "./WorkstreamStepper";
|
||||
import { stageLaddersQueryKey } from "./queryKey";
|
||||
|
||||
/**
|
||||
|
|
@ -18,18 +19,29 @@ import { stageLaddersQueryKey } from "./queryKey";
|
|||
* from the setup hub (#444). The only difference between the two is where the
|
||||
* footer sends you.
|
||||
*
|
||||
* ## One workstream at a time
|
||||
*
|
||||
* A project with eight workstreams has forty stages, and stacking every ladder
|
||||
* down one page turns configuring the last one into a scroll. So the ladders
|
||||
* are stepped through instead: one on screen, the rest a click away in the
|
||||
* stepper above it, and the footer walking forwards through them before it
|
||||
* offers to leave. The stages *within* a ladder stay listed together — they
|
||||
* are dragged against each other, so they have to be seen together.
|
||||
*
|
||||
* Which workstream is open is local state, not a URL parameter: it is a
|
||||
* position within a step, not a step of its own, and a half-configured ladder
|
||||
* is not something to deep-link back into.
|
||||
*
|
||||
* ## One query, many ladders
|
||||
*
|
||||
* The ladders are held in a single cache entry fetched from the per-workstream
|
||||
* GET endpoints in parallel, rather than one query per editor. The overrides
|
||||
* panel and the editors then read the same object, so a rename cannot show as
|
||||
* "custom" in one place and "default" in the other. Each editor owns its own
|
||||
* mutations and its own error line — a failure belongs to the workstream it
|
||||
* happened in, not to the page.
|
||||
* Every ladder is held in a single cache entry, fetched from the per-workstream
|
||||
* GET endpoints in parallel. The stepper and the open editor then read the same
|
||||
* object, so a rename cannot show as "custom" in one place and "default" in the
|
||||
* other, and switching workstreams costs nothing — the data is already there.
|
||||
*
|
||||
* The set of workstreams itself is fixed for the visit: it is chosen on the
|
||||
* previous step (#410), and adding one there is a navigation, which re-runs
|
||||
* the server component and re-seeds.
|
||||
* previous step (#410), and adding one there is a navigation, which re-runs the
|
||||
* server component and re-seeds.
|
||||
*/
|
||||
|
||||
/** Which way the user arrived, and therefore where the footer leads. */
|
||||
|
|
@ -57,11 +69,18 @@ export function StagesSetup({
|
|||
projectId: string;
|
||||
mode: SetupEntryMode;
|
||||
canManage: boolean;
|
||||
/** Never empty — the page renders its own empty state instead. */
|
||||
initialLadders: WorkstreamLadder[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Held by id rather than by index, so a refetch that reorders or drops a
|
||||
// workstream cannot silently move the user to a different one.
|
||||
const [openWorkstreamId, setOpenWorkstreamId] = useState(
|
||||
initialLadders[0].workstreamId,
|
||||
);
|
||||
|
||||
const workstreamIds = initialLadders.map((l) => l.workstreamId);
|
||||
|
||||
const { data: ladders } = useQuery({
|
||||
|
|
@ -76,8 +95,21 @@ export function StagesSetup({
|
|||
queryKey: stageLaddersQueryKey(projectId),
|
||||
});
|
||||
|
||||
// Falls back to the first ladder if the open one has gone, which keeps the
|
||||
// screen rendering something rather than blanking.
|
||||
const openIndex = Math.max(
|
||||
0,
|
||||
ladders.findIndex((l) => l.workstreamId === openWorkstreamId),
|
||||
);
|
||||
const open = ladders[openIndex];
|
||||
const onLast = openIndex === ladders.length - 1;
|
||||
const withoutStages = ladders.filter((l) => l.stages.length === 0);
|
||||
|
||||
function step(by: 1 | -1) {
|
||||
const next = ladders[openIndex + by];
|
||||
if (next) setOpenWorkstreamId(next.workstreamId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
{!canManage && (
|
||||
|
|
@ -91,37 +123,57 @@ export function StagesSetup({
|
|||
</p>
|
||||
)}
|
||||
|
||||
<LadderOverridesPanel ladders={ladders} />
|
||||
<WorkstreamStepper
|
||||
ladders={ladders}
|
||||
activeWorkstreamId={open.workstreamId}
|
||||
onSelect={setOpenWorkstreamId}
|
||||
/>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
{ladders.map((ladder) => (
|
||||
<StageLadderEditor
|
||||
key={ladder.workstreamId}
|
||||
projectId={projectId}
|
||||
ladder={ladder}
|
||||
canManage={canManage}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-6">
|
||||
<StageLadderEditor
|
||||
// Keyed so switching workstreams gives the editor a clean slate — a
|
||||
// half-typed new stage name belongs to the ladder it was typed in.
|
||||
key={open.workstreamId}
|
||||
projectId={projectId}
|
||||
ladder={open}
|
||||
canManage={canManage}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center justify-between border-t border-gray-200 pt-6">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="mt-8 flex items-center justify-between border-t border-gray-200 pt-6">
|
||||
{openIndex === 0 ? (
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => step(-1)}
|
||||
data-testid="stages-previous-workstream"
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
{ladders[openIndex - 1].workstreamName}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs text-gray-500" data-testid="stages-summary">
|
||||
Workstream {openIndex + 1} of {ladders.length}
|
||||
{withoutStages.length > 0
|
||||
? `${withoutStages.length} ${
|
||||
withoutStages.length === 1 ? "workstream has" : "workstreams have"
|
||||
} no stages yet`
|
||||
: `${ladders.length} ${
|
||||
ladders.length === 1 ? "ladder" : "ladders"
|
||||
} configured${canManage ? " · changes save as you go" : ""}`}
|
||||
? ` · ${withoutStages.length} still ${
|
||||
withoutStages.length === 1 ? "has" : "have"
|
||||
} no stages`
|
||||
: canManage && " · changes save as you go"}
|
||||
</span>
|
||||
{mode === "hub" ? (
|
||||
|
||||
{!onLast ? (
|
||||
<Button onClick={() => step(1)} data-testid="stages-next-workstream">
|
||||
{ladders[openIndex + 1].workstreamName}
|
||||
<ArrowRight className="ml-1.5 h-4 w-4" />
|
||||
</Button>
|
||||
) : mode === "hub" ? (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/projects/${projectId}/settings`}
|
||||
|
|
|
|||
110
src/app/projects/[projectId]/setup/stages/WorkstreamStepper.tsx
Normal file
110
src/app/projects/[projectId]/setup/stages/WorkstreamStepper.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, Layers } from "lucide-react";
|
||||
import { DEFAULT_LADDER } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/ladder";
|
||||
import type { WorkstreamLadder } from "@/app/api/projects/[projectId]/workstreams/[workstreamId]/stages/queries";
|
||||
|
||||
/**
|
||||
* The workstream picker above the ladder editor (issue #411).
|
||||
*
|
||||
* Doing double duty, deliberately. It is the wireframe's "Workstream
|
||||
* overrides" panel — which workstreams still follow the standard ladder and
|
||||
* which have been changed, at a glance — *and* it is how you get from one
|
||||
* workstream to the next, because only one ladder is on screen at a time.
|
||||
* Merging them keeps the overview and the navigation from disagreeing about
|
||||
* where you are.
|
||||
*
|
||||
* `isDefault` is decided server-side by `isDefaultLadder`, so this panel and
|
||||
* the API agree on what "default" means rather than each keeping a copy.
|
||||
*/
|
||||
export function WorkstreamStepper({
|
||||
ladders,
|
||||
activeWorkstreamId,
|
||||
onSelect,
|
||||
}: {
|
||||
ladders: WorkstreamLadder[];
|
||||
activeWorkstreamId: string;
|
||||
onSelect: (workstreamId: string) => void;
|
||||
}) {
|
||||
const custom = ladders.filter((l) => !l.isDefault);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="rounded-xl border border-gray-200 bg-gray-50/60 p-4"
|
||||
aria-label="Workstreams"
|
||||
data-testid="ladder-overrides"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-gray-400" />
|
||||
<h2 className="text-sm font-semibold text-gray-900">
|
||||
Workstream overrides
|
||||
</h2>
|
||||
<span className="text-xs text-gray-500">
|
||||
{custom.length === 0
|
||||
? "all on the standard ladder"
|
||||
: `${custom.length} of ${ladders.length} customised`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{ladders.map((ladder) => {
|
||||
const active = ladder.workstreamId === activeWorkstreamId;
|
||||
return (
|
||||
<li key={ladder.workstreamId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(ladder.workstreamId)}
|
||||
aria-current={active ? "step" : undefined}
|
||||
className={[
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition",
|
||||
active
|
||||
? "border-indigo-600 bg-white shadow-sm"
|
||||
: "border-gray-200 bg-white hover:border-gray-300",
|
||||
].join(" ")}
|
||||
data-testid={`ladder-override-${ladder.workstreamId}`}
|
||||
data-default={ladder.isDefault}
|
||||
data-active={active}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
active
|
||||
? "font-semibold text-indigo-700"
|
||||
: "font-semibold text-gray-800"
|
||||
}
|
||||
>
|
||||
{ladder.workstreamName}
|
||||
</span>
|
||||
{ladder.stages.length === 0 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
No stages
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={[
|
||||
"rounded-full px-1.5 py-0.5 text-[11px] font-medium",
|
||||
ladder.isDefault
|
||||
? "bg-gray-100 text-gray-600"
|
||||
: "bg-indigo-50 text-indigo-700",
|
||||
].join(" ")}
|
||||
>
|
||||
{ladder.isDefault ? "Default" : "Custom"}
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
{ladder.stages.length}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
The standard ladder is {DEFAULT_LADDER.join(" → ")}.
|
||||
</p>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/**
|
||||
* How the stage editor decides a typed date is worth saving (issue #411).
|
||||
*
|
||||
* `DateInput` reports three different things through one `onChange`, and each
|
||||
* needs a different response from a field that saves as you type:
|
||||
*
|
||||
* - `"2026-09-03"` — a real date. Save it.
|
||||
* - `""` — *either* an empty field or one mid-keystroke ("03/0"). Saving on
|
||||
* sight would clear a stored date the moment the user touched it, so a
|
||||
* clear is only committed when the field is left empty (on blur).
|
||||
* - `"31/02/2026"` — finished, but not a date that exists. `DateInput` hands
|
||||
* these through as typed precisely so they are not mistaken for "empty";
|
||||
* they are shown back to the user rather than sent.
|
||||
*
|
||||
* See `@/app/components/DateInput` and ADR-0019.
|
||||
*/
|
||||
|
||||
/** `YYYY-MM-DD` — the only date format that crosses a boundary. */
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** Whether a `DateInput` value is a stored-shape date. */
|
||||
export function isIsoDate(value: string): boolean {
|
||||
return ISO_DATE.test(value);
|
||||
}
|
||||
|
||||
/** Shown under a field holding a complete entry that is not a real date. */
|
||||
export const IMPOSSIBLE_DATE_MESSAGE = "That isn't a real date (dd/mm/yyyy).";
|
||||
Loading…
Add table
Reference in a new issue