mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
45ba4e9d6d
commit
beef7cc42a
6 changed files with 943 additions and 0 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
93
src/app/projects/[projectId]/settings/page.tsx
Normal file
93
src/app/projects/[projectId]/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
212
src/app/projects/[projectId]/settings/setupSteps.test.ts
Normal file
212
src/app/projects/[projectId]/settings/setupSteps.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
150
src/app/projects/[projectId]/settings/setupSteps.ts
Normal file
150
src/app/projects/[projectId]/settings/setupSteps.ts
Normal 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.`;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue