feat(ara-projects): project settings / setup hub at /settings

Builds the page behind two links that previously 404'd — the per-project
nav's Settings item and the import screen's "finish setup first" CTA.

ADR-0019 made work-order import setup-gated, which turned setup from a
one-shot wizard into something a project accretes over time. This is the
return path: setup status, the four /setup/* steps as revisitable links,
and the editable project details, in that order — the CTA's sender needs
the readiness answer first, so it is the first band on the page.

The setup-status panel renders `loadImportReadiness` verbatim. It restates
no part of the gate: `readiness.ready` is the import screen's own verdict,
and `setupSteps.ts` only rearranges that value for display, so "ready to
import" cannot mean two different things in two places. A test walks the
readiness space and asserts every gated step is done exactly when the
helper says ready. Evidence requirements are badged Optional, never
outstanding — ADR-0019 leaves them out of the gate.

Settings is a management surface, so it is `canManageProject`, not the
layout's view guard: a contractor with legitimate access to the project
404s here rather than seeing a form they may not submit.

The four `/setup/*` hrefs live in one exported list. Those routes are
#410-413, in flight — the hub is what links into all of them, so a slug
that lands differently is one line to correct, not four scattered literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-23 10:12:40 +00:00
parent 45ba4e9d6d
commit beef7cc42a
6 changed files with 943 additions and 0 deletions

View file

@ -0,0 +1,279 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import {
projectDetailsToFormValues,
updateProjectSchema,
type ProjectDetails,
type UpdateProjectFormValues,
} from "@/lib/projects/updateProject";
import type { SelectOption } from "@/lib/projects/createProject";
import { DateInput } from "@/app/components/DateInput";
import { Button } from "@/app/shadcn_components/ui/button";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/app/shadcn_components/ui/form";
import { Input } from "@/app/shadcn_components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/app/shadcn_components/ui/select";
/**
* The HTTP call, kept outside the component so the component contains no
* `fetch` TanStack Query owns the request lifecycle. Route handlers answer
* errors as `{ error: string }`, so a failure surfaces the server's own message.
*/
async function updateProject(
projectId: string,
values: UpdateProjectFormValues,
): Promise<{ id: string }> {
const response = await fetch(`/api/projects/${projectId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
const body = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(body?.error ?? "Could not save the project details");
}
return body;
}
export interface ProjectDetailsFormProps {
details: ProjectDetails;
projectTypes: SelectOption[];
}
/**
* Edit a project's details from the settings hub (issue #444).
*
* The same six fields the create-project modal collects, minus the owning
* organisation re-homing a project would change who can see it, so it is
* shown read-only rather than offered as an edit (see `updateProjectSchema`).
* The resolver is `updateProjectSchema`, which is derived from
* `createProjectSchema`, so the two forms validate identically.
*
* On success the form resets to the values it just saved which clears the
* dirty state and makes the Save button correctly go quiet and
* `router.refresh()` re-renders the server component above it, so the heading
* and the readiness panel reflect the edit without a full reload.
*/
export function ProjectDetailsForm({
details,
projectTypes,
}: ProjectDetailsFormProps) {
const router = useRouter();
const form = useForm<UpdateProjectFormValues>({
resolver: zodResolver(updateProjectSchema),
defaultValues: projectDetailsToFormValues(details),
});
const mutation = useMutation(
(values: UpdateProjectFormValues) => updateProject(details.id, values),
{
onSuccess: (_data, values) => {
form.reset(values);
router.refresh();
},
},
);
return (
<section data-testid="project-details">
<h2 className="text-lg font-semibold text-gray-900">Project details</h2>
<p className="text-sm text-gray-500 mt-1">
What this programme of works is called, who it is for, and when it runs.
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
className="mt-4 space-y-4 rounded-xl border border-gray-200 bg-white p-6"
data-testid="project-details-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Project name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g. Riverside retrofit 2026"
data-testid="project-details-name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>Client</FormLabel>
<p
className="text-sm text-gray-700"
data-testid="project-details-organisation"
>
{details.organisationName ?? "—"}
</p>
<FormDescription>
A project cannot be moved to another organisation that would
change who can see it.
</FormDescription>
</FormItem>
<FormField
control={form.control}
name="projectTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Project type</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger data-testid="project-details-type">
<SelectValue placeholder="Select type..." />
</SelectTrigger>
</FormControl>
<SelectContent>
{projectTypes.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<textarea
{...field}
rows={3}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="What is this programme of works for?"
data-testid="project-details-description"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="startDate"
render={({ field }) => (
<FormItem>
<FormLabel>Start date</FormLabel>
<FormControl>
<DateInput
{...field}
data-testid="project-details-start-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endDate"
render={({ field }) => (
<FormItem>
<FormLabel>Estimated end date</FormLabel>
<FormControl>
<DateInput
{...field}
data-testid="project-details-end-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="domnaAdminAccess"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
data-testid="project-details-domna-admin-access"
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Grant Domna admin access</FormLabel>
<FormDescription>
Domna admins can help configure this project, support
imports, and view project data. This can be revoked at any
time.
</FormDescription>
</div>
</FormItem>
)}
/>
{mutation.isError && (
<p
role="alert"
className="text-sm text-red-600"
data-testid="project-details-error"
>
{(mutation.error as Error).message}
</p>
)}
{mutation.isSuccess && !form.formState.isDirty && (
<p
role="status"
className="text-sm text-emerald-700"
data-testid="project-details-saved"
>
Saved.
</p>
)}
<div className="flex justify-end">
<Button
type="submit"
disabled={mutation.isLoading || !form.formState.isDirty}
data-testid="project-details-submit"
>
{mutation.isLoading ? "Saving…" : "Save changes"}
</Button>
</div>
</form>
</Form>
</section>
);
}

View file

@ -0,0 +1,112 @@
import Link from "next/link";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import type { ImportReadiness } from "@/lib/projects/import/readiness";
import { readinessSummary } from "../setupSteps";
/**
* Human-readable phrase for each unmet requirement. Deliberately the same two
* phrases the import screen's "finish setup" state uses, so a user who follows
* its CTA here reads the identical words about the identical workstreams.
*/
const GAP_LABEL = {
stages: "no stages",
contractor: "no contractor assigned",
} as const;
/**
* The setup-status panel: where a project stands against the import gate.
*
* A plain server component over the `ImportReadiness` the page loaded from the
* shared helper (`@/lib/projects/import/readiness`). It states no rule of its
* own `readiness.ready` is the import gate's own verdict, read verbatim, so a
* project this panel calls ready is exactly one the import screen lets through.
*
* It is the first thing on the hub because #414's "finish setup first" CTA
* links here: following that link must land on the answer to "what is still
* missing?", not on a form.
*/
export function SetupStatusPanel({
readiness,
importHref,
}: {
readiness: ImportReadiness;
importHref: string;
}) {
const ready = readiness.ready;
return (
<section
data-testid="setup-status-panel"
data-ready={ready ? "true" : "false"}
className={`rounded-2xl border p-6 ${
ready
? "border-emerald-200 bg-emerald-50/60"
: "border-amber-200 bg-amber-50/70"
}`}
>
<div className="flex items-start">
{ready ? (
<CheckCircleIcon className="h-6 w-6 mr-3 shrink-0 text-emerald-600" />
) : (
<ExclamationTriangleIcon className="h-6 w-6 mr-3 shrink-0 text-amber-500" />
)}
<div className="min-w-0 flex-1">
<h2
className={`text-lg font-semibold ${
ready ? "text-emerald-900" : "text-amber-900"
}`}
>
{ready ? "Ready to import" : "Setup not finished"}
</h2>
<p
className={`mt-1 text-sm ${
ready ? "text-emerald-800" : "text-amber-800"
}`}
data-testid="setup-status-summary"
>
{readinessSummary(readiness)}
</p>
{readiness.hasWorkstreams && (
<ul className="mt-4 space-y-2" data-testid="setup-status-workstreams">
{readiness.workstreams.map((w) => (
<li
key={String(w.projectWorkstreamId)}
data-testid="setup-status-workstream"
data-complete={w.complete ? "true" : "false"}
className="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
>
<span className="font-medium text-gray-800 truncate">
{w.workstreamName}
</span>
{w.complete ? (
<span className="shrink-0 text-xs font-medium text-emerald-700">
stages · contractor
</span>
) : (
<span className="shrink-0 text-xs font-medium text-amber-700">
{w.missing.map((gap) => GAP_LABEL[gap]).join(" · ")}
</span>
)}
</li>
))}
</ul>
)}
{ready && (
<Link
href={importHref}
data-testid="setup-status-import-link"
className="mt-5 inline-flex items-center rounded-md bg-brandblue px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brandblue/90"
>
Import work orders
</Link>
)}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,97 @@
import Link from "next/link";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
import type { SetupStep, SetupStepState } from "../setupSteps";
/** How each state is badged. `optional` is neutral — it is never a warning. */
const STATE_BADGE: Record<SetupStepState, { label: string; className: string }> =
{
done: {
label: "Done",
className: "bg-emerald-50 text-emerald-700 border-emerald-200",
},
todo: {
label: "Needs setup",
className: "bg-amber-50 text-amber-700 border-amber-200",
},
optional: {
label: "Optional",
className: "bg-gray-50 text-gray-500 border-gray-200",
},
};
/** "Windows, Doors and 2 more" — the workstreams a step is outstanding for. */
function outstandingLine(step: SetupStep): string | null {
if (step.outstanding.length === 0) return null;
const names = step.outstanding.map((w) => w.workstreamName);
const shown = names.slice(0, 3).join(", ");
const rest = names.length - 3;
return rest > 0 ? `Outstanding for ${shown} and ${rest} more` : `Outstanding for ${shown}`;
}
/**
* The four setup steps, as revisitable links rather than wizard stops.
*
* Setup is something a user comes back to (ADR-0019 made import setup-gated, so
* a project is configured over time), which is why every step is a plain link
* to an independently addressable route and none of them is disabled or locked
* behind the one before it. The numbering records the order of the first-run
* walk-through; it does not constrain the order things may be edited in.
*/
export function SetupStepsList({ steps }: { steps: SetupStep[] }) {
return (
<section data-testid="setup-steps">
<h2 className="text-lg font-semibold text-gray-900">Project setup</h2>
<p className="text-sm text-gray-500 mt-1">
Every step stays editable change any of it whenever you need to.
</p>
<ul className="mt-4 space-y-3">
{steps.map((step) => {
const badge = STATE_BADGE[step.state];
const outstanding = outstandingLine(step);
return (
<li key={step.key}>
<Link
href={step.href}
data-testid={`setup-step-${step.key}`}
data-state={step.state}
className="group flex items-start gap-4 rounded-xl border border-gray-200 bg-white p-4 transition hover:border-brandblue/40 hover:bg-gray-50/60"
>
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-100 text-xs font-semibold text-gray-600">
{step.position}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-gray-900">
{step.title}
</span>
<span
className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${badge.className}`}
>
{badge.label}
</span>
</span>
<span className="mt-1 block text-xs leading-relaxed text-gray-500">
{step.description}
</span>
{outstanding && (
<span
className="mt-1 block text-xs font-medium text-amber-700"
data-testid={`setup-step-${step.key}-outstanding`}
>
{outstanding}
</span>
)}
</span>
<ArrowRightIcon className="mt-1 h-4 w-4 shrink-0 text-gray-300 transition group-hover:text-brandblue" />
</Link>
</li>
);
})}
</ul>
</section>
);
}

View file

@ -0,0 +1,93 @@
import { notFound } from "next/navigation";
import { requireProjectAccess } from "../../guards";
import { canManageProject } from "@/lib/projects/authz";
import { loadImportReadiness } from "@/lib/projects/import/readiness";
import { loadProjectDetails } from "@/app/repositories/projects/projectSettingsRepository";
import { listProjectTypes } from "@/app/repositories/projects/projectsRepository";
import { buildSetupSteps } from "./setupSteps";
import { SetupStatusPanel } from "./components/SetupStatusPanel";
import { SetupStepsList } from "./components/SetupStepsList";
import { ProjectDetailsForm } from "./components/ProjectDetailsForm";
export const metadata = {
title: "Project settings | Ara",
};
/**
* Project settings / setup hub (issue #444).
*
* The target of two links that previously 404'd — the per-project nav's
* **Settings** item and the import screen's "finish setup first" CTA and the
* home of setup as a thing you *return to*. ADR-0019 made work-order import
* setup-gated, which turned setup from a one-shot wizard into something a
* project accretes over time: create it, add workstreams, configure stages and
* contractors as they are agreed, import once every workstream is ready.
*
* The page is three bands, in the order the CTA's sender needs them:
*
* 1. **Setup status** where the project stands against the import gate,
* straight off `loadImportReadiness`. The rule is not restated here; this
* page renders the same `ImportReadiness` value the import screen branches
* on, so "ready" cannot mean two different things in two places.
* 2. **Setup steps** the four `/setup/*` routes as revisitable links, each
* independently addressable, none locked behind the one before it.
* 3. **Project details** the editable fields, saved through
* `PATCH /api/projects/[projectId]`.
*
* Authorization is stricter than viewing: settings is a management surface, so
* a contractor with legitimate view access must not reach it at all. 404 rather
* than 403, matching the import page and the guards' own reasoning project
* existence stays private, and "not yours to manage" leaks nothing extra.
*/
export default async function ProjectSettingsPage(props: {
params: Promise<{ projectId: string }>;
}) {
const { projectId } = await props.params;
// Resolves the user, parses the id segment and enforces *view* access —
// a junk id or a project outside the user's organisation 404s in the guard.
const { user, project } = await requireProjectAccess(projectId);
if (!canManageProject(user, project)) notFound();
// Read-only: the readiness verdict, the editable row, and the type options.
const [readiness, details, projectTypes] = await Promise.all([
loadImportReadiness(project.id),
loadProjectDetails(project.id),
listProjectTypes(),
]);
// The guard proved the project exists; a null here means it was deleted
// between the two reads, which is a 404 like any other missing project.
if (!details) notFound();
const steps = buildSetupSteps(projectId, readiness);
return (
<div className="max-w-4xl mx-auto px-6 py-10 space-y-10">
<header>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Ara Projects
</p>
<h1
className="text-3xl font-extrabold text-gray-900 tracking-tight"
data-testid="project-settings-heading"
>
{details.name}
</h1>
<p className="text-sm text-gray-500 mt-1">
Project settings and setup
{details.organisationName ? ` · ${details.organisationName}` : ""}
</p>
</header>
<SetupStatusPanel
readiness={readiness}
importHref={`/projects/${projectId}/import`}
/>
<SetupStepsList steps={steps} />
<ProjectDetailsForm details={details} projectTypes={projectTypes} />
</div>
);
}

View file

@ -0,0 +1,212 @@
import { describe, expect, it, vi } from "vitest";
// The db module is stubbed *before* the readiness module is imported, so no
// `pg.Pool` is ever constructed and this suite cannot open a connection. Only
// the pure `computeImportReadiness` is exercised; `loadImportReadiness` is not.
vi.mock("@/app/db/db", () => ({
db: {
select: () => {
throw new Error("setupSteps.test must not query the database");
},
},
pool: {},
}));
import {
computeImportReadiness,
type WorkstreamReadinessFacts,
} from "@/lib/projects/import/readiness";
import { buildSetupSteps, readinessSummary, workstreamsMissing } from "./setupSteps";
const PROJECT_ID = "7";
function facts(
overrides: Partial<WorkstreamReadinessFacts> & { workstreamName: string },
): WorkstreamReadinessFacts {
return {
projectWorkstreamId: 1n,
workstreamId: 1n,
stageCount: 5,
contractorCount: 1,
...overrides,
};
}
const WINDOWS = facts({ projectWorkstreamId: 10n, workstreamName: "Windows" });
const DOORS_NO_STAGES = facts({
projectWorkstreamId: 11n,
workstreamName: "Doors",
stageCount: 0,
});
const ROOFS_NO_CONTRACTOR = facts({
projectWorkstreamId: 12n,
workstreamName: "Roofs",
contractorCount: 0,
});
const HEATING_NEITHER = facts({
projectWorkstreamId: 13n,
workstreamName: "Heating",
stageCount: 0,
contractorCount: 0,
});
/** The step keyed `key`, for readability in the assertions below. */
function step(steps: ReturnType<typeof buildSetupSteps>, key: string) {
const found = steps.find((s) => s.key === key);
if (!found) throw new Error(`no ${key} step`);
return found;
}
describe("buildSetupSteps", () => {
it("links every step at an independently addressable /setup route", () => {
const steps = buildSetupSteps(PROJECT_ID, computeImportReadiness([WINDOWS]));
expect(steps.map((s) => s.href)).toEqual([
"/projects/7/setup/workstreams",
"/projects/7/setup/stages",
"/projects/7/setup/evidence",
"/projects/7/setup/contractors",
]);
// Numbered for the first-run walk-through, but nothing here is a gate: the
// hub renders them all as plain links whatever state they are in.
expect(steps.map((s) => s.position)).toEqual([1, 2, 3, 4]);
});
it("marks every gated step done for a fully set-up project", () => {
const readiness = computeImportReadiness([WINDOWS]);
const steps = buildSetupSteps(PROJECT_ID, readiness);
expect(readiness.ready).toBe(true);
expect(step(steps, "workstreams").state).toBe("done");
expect(step(steps, "stages").state).toBe("done");
expect(step(steps, "contractors").state).toBe("done");
});
it("keeps evidence requirements optional even when nothing else is set up", () => {
// Evidence is advisory in v1 (ADR-0019 leaves it out of the import gate),
// so the hub must never present it as something import is waiting on.
for (const readiness of [
computeImportReadiness([]),
computeImportReadiness([HEATING_NEITHER]),
computeImportReadiness([WINDOWS]),
]) {
const evidence = step(buildSetupSteps(PROJECT_ID, readiness), "evidence");
expect(evidence.state).toBe("optional");
expect(evidence.outstanding).toEqual([]);
}
});
it("flags only the step a workstream is actually missing", () => {
const steps = buildSetupSteps(
PROJECT_ID,
computeImportReadiness([WINDOWS, DOORS_NO_STAGES]),
);
expect(step(steps, "stages").state).toBe("todo");
expect(step(steps, "stages").outstanding.map((w) => w.workstreamName)).toEqual([
"Doors",
]);
// Doors has a contractor, and Windows has both — nothing is outstanding here.
expect(step(steps, "contractors").state).toBe("done");
});
it("names a workstream under both steps when it is missing both", () => {
const steps = buildSetupSteps(
PROJECT_ID,
computeImportReadiness([HEATING_NEITHER]),
);
expect(step(steps, "stages").outstanding.map((w) => w.workstreamName)).toEqual([
"Heating",
]);
expect(
step(steps, "contractors").outstanding.map((w) => w.workstreamName),
).toEqual(["Heating"]);
});
it("treats a project with no workstreams as everything still to do", () => {
const steps = buildSetupSteps(PROJECT_ID, computeImportReadiness([]));
expect(step(steps, "workstreams").state).toBe("todo");
// With nothing selected there is nothing to configure stages or contractors
// for — calling those steps "done" because no workstream reports a gap
// would be vacuously true and would read as ready when import is not.
expect(step(steps, "stages").state).toBe("todo");
expect(step(steps, "contractors").state).toBe("todo");
expect(step(steps, "stages").outstanding).toEqual([]);
});
it("never calls a step done while the shared helper withholds readiness", () => {
// The reconciliation the acceptance criteria ask for: any project the gate
// refuses must show at least one gated step still outstanding, so the hub
// can never look finished on a project the import screen turns away.
const cases: WorkstreamReadinessFacts[][] = [
[],
[DOORS_NO_STAGES],
[ROOFS_NO_CONTRACTOR],
[HEATING_NEITHER],
[WINDOWS, DOORS_NO_STAGES],
[WINDOWS, ROOFS_NO_CONTRACTOR, HEATING_NEITHER],
[WINDOWS],
];
for (const workstreams of cases) {
const readiness = computeImportReadiness(workstreams);
const steps = buildSetupSteps(PROJECT_ID, readiness);
const allDone = steps
.filter((s) => s.state !== "optional")
.every((s) => s.state === "done");
expect(allDone).toBe(readiness.ready);
}
});
});
describe("workstreamsMissing", () => {
it("splits the incomplete workstreams by which requirement is unmet", () => {
const readiness = computeImportReadiness([
WINDOWS,
DOORS_NO_STAGES,
ROOFS_NO_CONTRACTOR,
HEATING_NEITHER,
]);
expect(
workstreamsMissing(readiness, "stages").map((w) => w.workstreamName),
).toEqual(["Doors", "Heating"]);
expect(
workstreamsMissing(readiness, "contractor").map((w) => w.workstreamName),
).toEqual(["Roofs", "Heating"]);
});
});
describe("readinessSummary", () => {
it("says ready only when the shared helper does", () => {
expect(readinessSummary(computeImportReadiness([WINDOWS]))).toContain(
"Ready to import",
);
});
it("counts the incomplete workstreams, agreeing in number with the helper", () => {
const readiness = computeImportReadiness([
WINDOWS,
DOORS_NO_STAGES,
HEATING_NEITHER,
]);
expect(readiness.incompleteWorkstreams).toHaveLength(2);
expect(readinessSummary(readiness)).toBe(
"2 workstreams need setup before you can import work orders.",
);
});
it("uses the singular for one incomplete workstream", () => {
expect(
readinessSummary(computeImportReadiness([WINDOWS, DOORS_NO_STAGES])),
).toBe("1 workstream needs setup before you can import work orders.");
});
it("says what to do first when no workstreams are selected", () => {
expect(readinessSummary(computeImportReadiness([]))).toContain(
"No workstreams yet",
);
});
});

View file

@ -0,0 +1,150 @@
/**
* The setup steps the hub links into, and how far each one has got (issue #444).
*
* Pure and db-free: it takes an `ImportReadiness` already decided by the
* shared helper at `@/lib/projects/import/readiness` and only *rearranges* it
* for display. It imports that module for its types alone (`import type`, so
* nothing is pulled in at runtime) and re-derives none of its rules. In
* particular the hub's overall verdict is `readiness.ready` read verbatim, so
* "ready to import" on this page and the import gate itself cannot disagree.
*
* ## The step routes
*
* `/projects/[projectId]/setup/*` is owned by #410#413, which are in flight.
* The hub is the surface that links into all four, so the canonical list of
* hrefs lives here one place to correct if a slug lands differently, rather
* than four string literals scattered through the page. The slugs follow
* CONTEXT.md's domain language (Workstream, Stage, Evidence requirement,
* Contractor), matching `/setup/workstreams`, which #409 already routes to.
*/
import type {
ImportReadiness,
ImportReadinessGap,
WorkstreamReadiness,
} from "@/lib/projects/import/readiness";
/** The four configuration steps, in the order the first run walks them. */
export type SetupStepKey =
| "workstreams"
| "stages"
| "evidence"
| "contractors";
/**
* How far a step has got.
*
* - `done` nothing outstanding.
* - `todo` something the import gate is waiting on.
* - `optional` configurable, but never blocking. Evidence requirements are
* advisory in v1 (ADR-0019 leaves them out of the gate), so the
* hub must not nag about them or imply import is waiting on them.
*/
export type SetupStepState = "done" | "todo" | "optional";
export interface SetupStep {
key: SetupStepKey;
/** Position in the first-run walk-through, 1-based. */
position: number;
title: string;
description: string;
/** Absolute path — every step is independently addressable from here. */
href: string;
state: SetupStepState;
/**
* Which workstreams this step is outstanding for. Empty unless `state` is
* `todo`; drives the "3 workstreams need stages" line under the step.
*/
outstanding: WorkstreamReadiness[];
}
/** The workstreams the readiness helper reports as missing one requirement. */
export function workstreamsMissing(
readiness: ImportReadiness,
gap: ImportReadinessGap,
): WorkstreamReadiness[] {
return readiness.incompleteWorkstreams.filter((w) => w.missing.includes(gap));
}
/**
* Build the four step cards for one project.
*
* The states are a straight reading of the readiness verdict:
* - workstreams done once the project has selected any at all; with none,
* the other three have nothing to configure and are `todo` by association.
* - stages / contractors done when no workstream reports that gap. These
* are the two the gate is made of.
* - evidence always `optional`, never `todo`. It is not in the gate.
*/
export function buildSetupSteps(
projectId: string,
readiness: ImportReadiness,
): SetupStep[] {
const base = `/projects/${projectId}/setup`;
const missingStages = workstreamsMissing(readiness, "stages");
const missingContractors = workstreamsMissing(readiness, "contractor");
const gated = (
outstanding: WorkstreamReadiness[],
): Pick<SetupStep, "state" | "outstanding"> =>
!readiness.hasWorkstreams || outstanding.length > 0
? { state: "todo", outstanding }
: { state: "done", outstanding: [] };
return [
{
key: "workstreams",
position: 1,
title: "Workstreams",
description:
"Which categories of works this project covers. Everything else is configured per workstream.",
href: `${base}/workstreams`,
state: readiness.hasWorkstreams ? "done" : "todo",
outstanding: [],
},
{
key: "stages",
position: 2,
title: "Stages",
description:
"Each workstream's ordered stage ladder — the only thing that moves a work order along.",
href: `${base}/stages`,
...gated(missingStages),
},
{
key: "evidence",
position: 3,
title: "Evidence requirements",
description:
"Which documents each workstream expects. Advisory — these never block import or a stage change.",
href: `${base}/evidence`,
state: "optional",
outstanding: [],
},
{
key: "contractors",
position: 4,
title: "Contractors",
description:
"Who delivers each workstream, and what they may update or upload.",
href: `${base}/contractors`,
...gated(missingContractors),
},
];
}
/**
* The one-line summary above the step list.
*
* Phrased from `readiness.ready` and the helper's own incomplete count, so the
* sentence a user reads here is true of the import screen too.
*/
export function readinessSummary(readiness: ImportReadiness): string {
if (!readiness.hasWorkstreams) {
return "No workstreams yet — start by choosing which categories of works this project covers.";
}
if (readiness.ready) {
return "Ready to import. Every workstream has a stage ladder and an assigned contractor.";
}
const n = readiness.incompleteWorkstreams.length;
return `${n} ${n === 1 ? "workstream needs" : "workstreams need"} setup before you can import work orders.`;
}