diff --git a/docs/adr/0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md b/docs/adr/0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md
new file mode 100644
index 00000000..502275df
--- /dev/null
+++ b/docs/adr/0019-work-order-import-is-setup-gated-and-carries-contractor-per-row.md
@@ -0,0 +1,104 @@
+# 19. Work-order import is the setup-gated final wizard step, and carries contractor per row
+
+Date: 2026-07-22
+
+## Status
+
+Accepted
+
+Numbering note: 0015–0017 are in flight on the reporting-redesign branch, and
+0010 alongside them (see ADR-0018's note). 0019 follows 0018 and avoids all of
+them.
+
+## Context
+
+The Ara Projects "second import" (issue #414) matches a client-supplied
+spreadsheet to Properties already in the DB and declares which Workstreams each
+Property gets, as one row per (Property, Workstream). Committing those rows as
+Work orders is #417.
+
+Two questions came up once the schema (PR #404) and the setup wizard shape
+(wireframes 02–07) were concrete:
+
+1. **When is a project allowed to import?** A `work_order` row is
+ `(property, project_workstream_stage, project_workstream_contractor,
+ reference, forecast_end)` — all NOT NULL except `forecast_end`. So a Work
+ order cannot exist for a workstream that has no stage ladder and no assigned
+ contractor. Importing against a half-configured project would fail row by
+ row at commit with nothing the user could act on up front.
+
+2. **Where does each row's contractor come from?** The original #417 sketch
+ derived the contractor from the workstream: each `project_workstream` was
+ assumed to have a single contractor assignment, so the importer would look
+ it up and every Work order for that workstream went to that one org. But
+ `project_workstream_contractor` is a **collection** — a workstream can have
+ more than one contractor assigned (CONTEXT.md, "Contractor"), and real
+ programmes split one workstream's properties across several contractors by
+ area or batch. A single-assignee lookup cannot express that.
+
+## Decision
+
+**Import is the final, setup-gated step of the setup wizard.**
+
+- The wizard's earlier steps select workstreams, configure each workstream's
+ stage ladder, and assign contractors. Import is the last step; #413 routes
+ the wizard into this same `/projects/[projectId]/import` screen. It also
+ stays reachable standalone from its own nav button.
+- The screen is **gated on setup completeness** regardless of entry point.
+ Rule: every `project_workstream` on the project must have **≥1
+ `project_workstream_stage` AND ≥1 `project_workstream_contractor`**, and the
+ project must have at least one workstream. Until then the drop zone is
+ replaced by a "finish setup" state naming the incomplete workstreams and
+ linking back to configuration — not a 404 and not a silent empty zone.
+- Evidence requirements (#412) are advisory and are **not** part of the gate.
+- The gate is computed by `computeImportReadiness` (pure) /
+ `loadImportReadiness` (one read-only query) in
+ `src/lib/projects/import/readiness.ts`, returning per-workstream detail so
+ both this page and #413 can render *what* is missing.
+
+**Contractor is supplied per import row.**
+
+- The template gains a required `Contractor` column. Each row names the
+ contractor that delivers that (Property, Workstream), and it must match a
+ contractor **already assigned** to that workstream via
+ `project_workstream_contractor`. Validation (#417) rejects a contractor that
+ is not an existing assignment.
+- **Contractor creation is out of scope for import.** Bringing a new company
+ into Ara and assigning it to a workstream is owned by the pre-existing
+ "invite company to Ara" flow; import only references assignments that flow
+ has already made. This is why the gate requires a contractor per workstream
+ before import opens — the assignments must exist first.
+
+**Stage is optional per row.**
+
+- The template gains an optional `Stage` column. A blank stage falls back at
+ commit to the workstream's first stage (`project_workstream_stage` ordered by
+ `order`, ascending — the ladder's start; see CONTEXT.md, "Stage"). A named
+ stage must match one on that workstream's ladder.
+
+The raw parser (`parse.ts`) stays header-agnostic: it does not enforce the
+required/optional distinction or that names resolve. Required-ness is the
+mapping step's job (#415) and value resolution is the commit step's (#417).
+
+## Consequences
+
+- **Trade-off vs the replaced #417 design.** Deriving the contractor from the
+ workstream's single assignee was simpler — one fewer required column, and no
+ per-row contractor to validate. We give that up because it silently assumed
+ one contractor per workstream, which the schema does not guarantee and real
+ programmes violate. Carrying the contractor per row costs the client a column
+ they must fill and a validation failure mode ("contractor X isn't assigned to
+ workstream Y"), but it is the only shape that supports a workstream split
+ across contractors, and it keeps the assignment set — not the spreadsheet —
+ as the source of truth for who may receive work.
+- The setup gate means a project cannot reach the drop zone mid-configuration.
+ That is the intended safety property (no un-committable imports), but it does
+ make import unreachable until contractors are assigned — acceptable because
+ assignment is a prerequisite for issuing any work order at all.
+- `readiness.ts` has two consumers (this page and #413), so its interface is
+ kept deliberately stable and detail-rich.
+- Not yet decided here: how a row's named contractor disambiguates when the
+ same org is assigned to a workstream more than once, and the exact
+ stage-name matching rule. Both are #417's to settle at commit; this ADR fixes
+ only that the contractor is per-row-and-pre-assigned and the stage is
+ optional-with-first-stage-fallback.
diff --git a/src/app/projects/[projectId]/import/components/ImportSetupIncomplete.tsx b/src/app/projects/[projectId]/import/components/ImportSetupIncomplete.tsx
new file mode 100644
index 00000000..b175c323
--- /dev/null
+++ b/src/app/projects/[projectId]/import/components/ImportSetupIncomplete.tsx
@@ -0,0 +1,76 @@
+import Link from "next/link";
+import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
+import type { ImportReadiness } from "@/lib/projects/import/readiness";
+
+/** Human-readable phrase for each unmet requirement. */
+const GAP_LABEL = {
+ stages: "no stages",
+ contractor: "no contractor assigned",
+} as const;
+
+/**
+ * Shown in place of the drop zone when the project is not yet set up enough to
+ * import against (ADR-0019). A plain server component — it renders the
+ * `ImportReadiness` the page already loaded, so no client state is involved.
+ *
+ * It deliberately explains *which* workstreams are incomplete and *why*, rather
+ * than a bare "not ready", so the user knows exactly what to fix before
+ * returning. `setupHref` points at the project's configuration; the concrete
+ * per-step wizard routes (select workstreams / configure stages / assign
+ * contractors) land in other issues, so the page passes a single sensible
+ * destination rather than deep-linking each gap.
+ */
+export function ImportSetupIncomplete({
+ readiness,
+ setupHref,
+}: {
+ readiness: ImportReadiness;
+ setupHref: string;
+}) {
+ return (
+
@@ -51,13 +74,21 @@ export default async function ImportPage(props: {
Import programme data
- Upload a file listing which workstreams each property takes — one row
- per property and workstream. A property taking Windows and Doors
- appears twice, sharing its identifier.
+ Upload a file listing which workstreams each property takes and which
+ contractor delivers each — one row per property and workstream. A
+ property taking Windows and Doors appears twice, sharing its
+ identifier.
-
+ {readiness.ready ? (
+
+ ) : (
+
+ )}
{[
diff --git a/src/lib/projects/import/parse.test.ts b/src/lib/projects/import/parse.test.ts
index 3ff07100..9e87c605 100644
--- a/src/lib/projects/import/parse.test.ts
+++ b/src/lib/projects/import/parse.test.ts
@@ -28,8 +28,8 @@ describe("parseImportSheet", () => {
it("returns headers, rows, count and a preview", () => {
const result = parseImportSheet([
HEADERS,
- ["PROP-0001", "Windows", "WO-1001", "2026-09-30"],
- ["PROP-0002", "Roofs", "", ""],
+ ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
+ ["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""],
]);
expect(result).toEqual({
@@ -37,13 +37,13 @@ describe("parseImportSheet", () => {
file: {
headers: HEADERS,
rows: [
- ["PROP-0001", "Windows", "WO-1001", "2026-09-30"],
- ["PROP-0002", "Roofs", "", ""],
+ ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
+ ["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""],
],
rowCount: 2,
preview: [
- ["PROP-0001", "Windows", "WO-1001", "2026-09-30"],
- ["PROP-0002", "Roofs", "", ""],
+ ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
+ ["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""],
],
},
});
@@ -102,16 +102,17 @@ describe("parseImportSheet", () => {
it("trims cells, drops blank rows, and pads ragged ones", () => {
const result = parseImportSheet([
HEADERS,
- [" PROP-0001 ", " Windows ", "", ""],
- ["", "", "", ""],
- ["PROP-0002", "Doors"],
+ [" PROP-0001 ", " Windows ", " Acme Windows Ltd ", "", "", ""],
+ ["", "", "", "", "", ""],
+ ["PROP-0002", "Doors", "Acme Doors Ltd"],
]);
expect(result.ok).toBe(true);
if (!result.ok) return;
+ // Short rows pad out to the 6-column header width.
expect(result.file.rows).toEqual([
- ["PROP-0001", "Windows", "", ""],
- ["PROP-0002", "Doors", "", ""],
+ ["PROP-0001", "Windows", "Acme Windows Ltd", "", "", ""],
+ ["PROP-0002", "Doors", "Acme Doors Ltd", "", "", ""],
]);
});
@@ -141,7 +142,7 @@ describe("parseImportSheet", () => {
it("caps the preview at IMPORT_PREVIEW_ROWS but keeps every row", () => {
const rows: string[][] = [HEADERS];
for (let i = 0; i < IMPORT_PREVIEW_ROWS + 5; i++) {
- rows.push([`PROP-${i}`, "Windows", "", ""]);
+ rows.push([`PROP-${i}`, "Windows", "Acme Windows Ltd", "", "", ""]);
}
const result = parseImportSheet(rows);
@@ -150,7 +151,14 @@ describe("parseImportSheet", () => {
expect(result.file.rowCount).toBe(IMPORT_PREVIEW_ROWS + 5);
expect(result.file.rows).toHaveLength(IMPORT_PREVIEW_ROWS + 5);
expect(result.file.preview).toHaveLength(IMPORT_PREVIEW_ROWS);
- expect(result.file.preview[0]).toEqual(["PROP-0", "Windows", "", ""]);
+ expect(result.file.preview[0]).toEqual([
+ "PROP-0",
+ "Windows",
+ "Acme Windows Ltd",
+ "",
+ "",
+ "",
+ ]);
});
it("errors on an entirely empty sheet", () => {
@@ -215,7 +223,10 @@ describe("parseImportSheet", () => {
describe("parseImportFile", () => {
it("parses a real XLSX buffer", () => {
const buf = workbookBuffer(
- [HEADERS, ["PROP-0001", "Windows", "WO-1001", "2026-09-30"]],
+ [
+ HEADERS,
+ ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
+ ],
"xlsx",
);
@@ -224,20 +235,22 @@ describe("parseImportFile", () => {
if (!result.ok) return;
expect(result.file.headers).toEqual(HEADERS);
expect(result.file.rows).toEqual([
- ["PROP-0001", "Windows", "WO-1001", "2026-09-30"],
+ ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
]);
});
it("parses a real CSV buffer", () => {
const buf = workbookBuffer(
- [HEADERS, ["PROP-0001", "Windows", "", ""]],
+ [HEADERS, ["PROP-0001", "Windows", "Acme Windows Ltd", "", "", ""]],
"csv",
);
const result = parseImportFile(buf);
expect(result.ok).toBe(true);
if (!result.ok) return;
- expect(result.file.rows).toEqual([["PROP-0001", "Windows", "", ""]]);
+ expect(result.file.rows).toEqual([
+ ["PROP-0001", "Windows", "Acme Windows Ltd", "", "", ""],
+ ]);
});
it("keeps the leading zero on a numeric-looking identifier", () => {
@@ -262,11 +275,30 @@ describe("parseImportFile", () => {
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.file.headers).toEqual(HEADERS);
- // The template demonstrates one property taking two workstreams.
+ // The template demonstrates one property taking two workstreams...
expect(result.file.rows[0][0]).toBe("PROP-0001");
expect(result.file.rows[1][0]).toBe("PROP-0001");
expect(result.file.rows[0][1]).toBe("Windows");
expect(result.file.rows[1][1]).toBe("Doors");
+ // ...with a Contractor on every row (required) and Stage blank on the
+ // second (optional, defaults to the workstream's first stage).
+ expect(result.file.rows[0][2]).toBe("Acme Windows Ltd");
+ expect(result.file.rows[1][2]).toBe("Acme Doors Ltd");
+ expect(result.file.rows[0][3]).toBe("Ordered");
+ expect(result.file.rows[1][3]).toBe("");
+ });
+
+ it("stays header-agnostic when the required Contractor column is absent", () => {
+ // The raw parser does not enforce required-ness — a file missing Contractor
+ // still parses cleanly, and the mapping (#415) / commit (#417) steps are
+ // what reject it. Enforcing here would make the mapping step unreachable.
+ const result = parseImportFile(
+ new TextEncoder().encode("Property identifier,Workstream\nPROP-1,Windows\n"),
+ );
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+ expect(result.file.headers).toEqual(["Property identifier", "Workstream"]);
});
it("rejects empty and single-line rubbish with a friendly error", () => {
diff --git a/src/lib/projects/import/parse.ts b/src/lib/projects/import/parse.ts
index 3f31f589..40a0da1a 100644
--- a/src/lib/projects/import/parse.ts
+++ b/src/lib/projects/import/parse.ts
@@ -14,10 +14,12 @@
*
* Scope note: this module is **header-agnostic on purpose**. It reports the
* headers it found and hands back the rows; it does not insist on the template
- * columns and does not validate cell contents. Mapping arbitrary client headings
- * onto the template columns is the mapping step (#415), and validating the
- * mapped values is #416. Rejecting an unrecognised heading here would make the
- * mapping step unreachable for exactly the files that need it most.
+ * columns and does not validate cell contents. In particular it does **not**
+ * enforce that `Contractor` is present or that `Workstream`/`Contractor` name a
+ * real assignment — required/optional is the mapping step's job (#415) and
+ * value validation is the commit step's (#417). Rejecting an unrecognised
+ * heading here would make the mapping step unreachable for exactly the files
+ * that need it most.
*/
import * as XLSX from "xlsx";
@@ -26,11 +28,26 @@ import * as XLSX from "xlsx";
* The columns the "Download template" artefact ships with, in order.
*
* `Property identifier` is the landlord property id *or* the UPRN — matching
- * decides which at validation time (#416). The last two are optional per row.
+ * decides which at validation time (#417).
+ *
+ * Required vs optional (enforced downstream, **not** here — see the module
+ * header; this list is only the template's shape):
+ * - `Property identifier` — required.
+ * - `Workstream` — required; must be one the project selected.
+ * - `Contractor` — required; must match a contractor already
+ * assigned to that workstream (ADR-0019).
+ * Contractor creation is out of scope — the
+ * existing "invite company to Ara" flow owns it.
+ * - `Stage` — optional; blank falls back to the workstream's
+ * first stage at commit (ADR-0019).
+ * - `Work order reference`— optional per row.
+ * - `Forecast end date` — optional per row.
*/
export const IMPORT_TEMPLATE_COLUMNS = [
"Property identifier",
"Workstream",
+ "Contractor",
+ "Stage",
"Work order reference",
"Forecast end date",
] as const;
@@ -238,17 +255,25 @@ export function hasAcceptedImportExtension(filename: string): boolean {
}
/**
- * The "Download template" artefact: a CSV carrying the expected headings plus
- * one worked example showing a Property that takes two Workstreams — the file
- * shape's one non-obvious rule, demonstrated rather than explained.
+ * The "Download template" artefact: a CSV carrying the expected headings plus a
+ * worked example that demonstrates the file shape's non-obvious rules rather
+ * than explaining them:
+ * - a Property taking two Workstreams appears on two rows sharing its
+ * identifier (rows 1 and 2, `PROP-0001`);
+ * - `Contractor` is filled on every row (it is required, and must already be
+ * assigned to that workstream — ADR-0019);
+ * - `Stage` is blank on rows 2 and 3 to show it is optional (it falls back to
+ * the workstream's first stage at commit);
+ * - `Work order reference` and `Forecast end date` are blank on the last row
+ * to show they are optional too.
*
* CSV rather than XLSX so it opens the same everywhere and stays diffable.
*/
export function buildImportTemplateCsv(): string {
const example = [
- ["PROP-0001", "Windows", "WO-1001", "2026-09-30"],
- ["PROP-0001", "Doors", "WO-1002", "2026-10-15"],
- ["PROP-0002", "Roofs", "", ""],
+ ["PROP-0001", "Windows", "Acme Windows Ltd", "Ordered", "WO-1001", "2026-09-30"],
+ ["PROP-0001", "Doors", "Acme Doors Ltd", "", "WO-1002", "2026-10-15"],
+ ["PROP-0002", "Roofs", "Sunrise Roofing Ltd", "", "", ""],
];
return (
[[...IMPORT_TEMPLATE_COLUMNS], ...example]
diff --git a/src/lib/projects/import/readiness.test.ts b/src/lib/projects/import/readiness.test.ts
new file mode 100644
index 00000000..c7a6bc3c
--- /dev/null
+++ b/src/lib/projects/import/readiness.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, it, vi } from "vitest";
+
+// The module under test imports `@/app/db/db` for its `loadImportReadiness`
+// loader. Mock it so importing the module opens no connection and the
+// production database is never touched — the pure `computeImportReadiness`
+// tests below never reach it, and the one loader test drives it explicitly.
+const mockSelect = vi.fn();
+vi.mock("@/app/db/db", () => ({ db: { select: () => mockSelect() } }));
+
+import {
+ computeImportReadiness,
+ loadImportReadiness,
+ type WorkstreamReadinessFacts,
+} from "./readiness";
+
+function facts(
+ over: Partial & { workstreamName: string },
+): WorkstreamReadinessFacts {
+ return {
+ projectWorkstreamId: 1n,
+ workstreamId: 10n,
+ stageCount: 1,
+ contractorCount: 1,
+ ...over,
+ };
+}
+
+describe("computeImportReadiness", () => {
+ it("is ready when every workstream has a stage and a contractor", () => {
+ const result = computeImportReadiness([
+ facts({ workstreamName: "Windows", projectWorkstreamId: 1n }),
+ facts({ workstreamName: "Doors", projectWorkstreamId: 2n }),
+ ]);
+
+ expect(result.ready).toBe(true);
+ expect(result.hasWorkstreams).toBe(true);
+ expect(result.incompleteWorkstreams).toEqual([]);
+ expect(result.workstreams.every((w) => w.complete)).toBe(true);
+ expect(result.workstreams[0].missing).toEqual([]);
+ });
+
+ it("is not ready, and reports 'contractor', when a workstream has no contractor", () => {
+ const result = computeImportReadiness([
+ facts({ workstreamName: "Windows" }),
+ facts({ workstreamName: "Doors", projectWorkstreamId: 2n, contractorCount: 0 }),
+ ]);
+
+ expect(result.ready).toBe(false);
+ expect(result.incompleteWorkstreams).toHaveLength(1);
+ const doors = result.incompleteWorkstreams[0];
+ expect(doors.workstreamName).toBe("Doors");
+ expect(doors.complete).toBe(false);
+ expect(doors.missing).toEqual(["contractor"]);
+ });
+
+ it("is not ready, and reports 'stages', when a workstream has no stage", () => {
+ const result = computeImportReadiness([
+ facts({ workstreamName: "Roofs", stageCount: 0 }),
+ ]);
+
+ expect(result.ready).toBe(false);
+ expect(result.incompleteWorkstreams[0].missing).toEqual(["stages"]);
+ });
+
+ it("reports both gaps, stages before contractor, when a workstream has neither", () => {
+ const result = computeImportReadiness([
+ facts({ workstreamName: "Heating", stageCount: 0, contractorCount: 0 }),
+ ]);
+
+ expect(result.incompleteWorkstreams[0].missing).toEqual([
+ "stages",
+ "contractor",
+ ]);
+ });
+
+ it("lists only the incomplete workstreams, but keeps all in `workstreams`", () => {
+ const result = computeImportReadiness([
+ facts({ workstreamName: "Windows", projectWorkstreamId: 1n }),
+ facts({ workstreamName: "Doors", projectWorkstreamId: 2n, stageCount: 0 }),
+ facts({ workstreamName: "Roofs", projectWorkstreamId: 3n, contractorCount: 0 }),
+ ]);
+
+ expect(result.workstreams).toHaveLength(3);
+ expect(result.incompleteWorkstreams.map((w) => w.workstreamName)).toEqual([
+ "Doors",
+ "Roofs",
+ ]);
+ });
+
+ it("is not ready when the project has selected no workstreams", () => {
+ // Vacuously "all complete", but there is nothing to import into.
+ const result = computeImportReadiness([]);
+
+ expect(result.ready).toBe(false);
+ expect(result.hasWorkstreams).toBe(false);
+ expect(result.workstreams).toEqual([]);
+ expect(result.incompleteWorkstreams).toEqual([]);
+ });
+
+ it("treats counts above one as complete (not just exactly one)", () => {
+ const result = computeImportReadiness([
+ facts({ workstreamName: "Windows", stageCount: 5, contractorCount: 3 }),
+ ]);
+
+ expect(result.ready).toBe(true);
+ });
+});
+
+describe("loadImportReadiness", () => {
+ it("coerces string counts from the driver and decides via the pure function", async () => {
+ // node-postgres returns count(distinct …) as a string; the loader must
+ // Number() it or every workstream would read as complete (Number("0") vs
+ // "0" truthiness). Drive the mocked query chain to prove the coercion.
+ const groupBy = vi.fn().mockResolvedValue([
+ {
+ projectWorkstreamId: 1n,
+ workstreamId: 10n,
+ workstreamName: "Windows",
+ stageCount: "2",
+ contractorCount: "1",
+ },
+ {
+ projectWorkstreamId: 2n,
+ workstreamId: 11n,
+ workstreamName: "Doors",
+ stageCount: "1",
+ contractorCount: "0",
+ },
+ ]);
+ const chain = {
+ from: () => chain,
+ innerJoin: () => chain,
+ leftJoin: () => chain,
+ where: () => chain,
+ groupBy,
+ };
+ mockSelect.mockReturnValue(chain);
+
+ const result = await loadImportReadiness(42n);
+
+ expect(groupBy).toHaveBeenCalledOnce();
+ expect(result.ready).toBe(false);
+ expect(result.workstreams[0]).toMatchObject({
+ workstreamName: "Windows",
+ stageCount: 2,
+ contractorCount: 1,
+ complete: true,
+ });
+ expect(result.incompleteWorkstreams).toHaveLength(1);
+ expect(result.incompleteWorkstreams[0]).toMatchObject({
+ workstreamName: "Doors",
+ missing: ["contractor"],
+ });
+ });
+
+ it("reports not-ready for a project with no workstream rows", async () => {
+ const chain = {
+ from: () => chain,
+ innerJoin: () => chain,
+ leftJoin: () => chain,
+ where: () => chain,
+ groupBy: vi.fn().mockResolvedValue([]),
+ };
+ mockSelect.mockReturnValue(chain);
+
+ const result = await loadImportReadiness(7n);
+
+ expect(result.ready).toBe(false);
+ expect(result.hasWorkstreams).toBe(false);
+ });
+});
diff --git a/src/lib/projects/import/readiness.ts b/src/lib/projects/import/readiness.ts
new file mode 100644
index 00000000..b061ff23
--- /dev/null
+++ b/src/lib/projects/import/readiness.ts
@@ -0,0 +1,166 @@
+/**
+ * Import readiness — is a Project set up enough to import work orders against?
+ * (issue #414, extended.)
+ *
+ * The gate (ADR-0019): **every** workstream the project selected
+ * (`project_workstream`) must have BOTH at least one stage
+ * (`project_workstream_stage`) AND at least one assigned contractor
+ * (`project_workstream_contractor`). A project with no workstreams is not
+ * importable either — there is nothing to import into. Evidence requirements
+ * (#412) are advisory and deliberately play no part in this gate.
+ *
+ * The module is split the way the rest of Ara Projects splits domain from
+ * persistence (see `@/lib/projects/authz` + its repository):
+ * - `computeImportReadiness` is **pure** — it takes per-workstream facts and
+ * decides. It imports no database client, so it unit-tests against
+ * in-memory fixtures with no connection.
+ * - `loadImportReadiness` is the single read-only round trip that gathers
+ * those facts for one project and hands them to the pure function.
+ *
+ * ## Reused by #413
+ *
+ * The final step of the Ara Projects setup wizard (#413) routes into this same
+ * import screen, so it needs the identical "is setup complete?" answer this
+ * page needs. Both call `loadImportReadiness(projectId)` and branch on
+ * `ImportReadiness.ready`; the `incompleteWorkstreams` detail is what each
+ * surface renders to tell the user what is still missing. Keep this interface
+ * stable for that reason — it has a second consumer.
+ */
+
+import { db } from "@/app/db/db";
+import { eq, sql } from "drizzle-orm";
+import {
+ projectWorkstream,
+ projectWorkstreamContractor,
+ projectWorkstreamStage,
+ workstream,
+} from "@/app/db/schema/projects/projects";
+
+/** The two things a workstream can be missing before the project can import. */
+export type ImportReadinessGap = "stages" | "contractor";
+
+/** The raw per-workstream facts the readiness decision is computed from. */
+export interface WorkstreamReadinessFacts {
+ /** `project_workstream.id`. */
+ projectWorkstreamId: bigint;
+ /** `project_workstream.workstream_id` — the reference-data workstream. */
+ workstreamId: bigint;
+ /** `workstream.name`, for rendering which workstreams are incomplete. */
+ workstreamName: string;
+ /** Count of `project_workstream_stage` rows for this workstream. */
+ stageCount: number;
+ /** Count of `project_workstream_contractor` rows for this workstream. */
+ contractorCount: number;
+}
+
+/** One workstream's readiness, derived from its facts. */
+export interface WorkstreamReadiness {
+ projectWorkstreamId: bigint;
+ workstreamId: bigint;
+ workstreamName: string;
+ stageCount: number;
+ contractorCount: number;
+ /** True when this workstream has both a stage and a contractor. */
+ complete: boolean;
+ /** Which requirements are unmet — empty when `complete`. In stable order. */
+ missing: ImportReadinessGap[];
+}
+
+/** The whole-project readiness verdict. */
+export interface ImportReadiness {
+ /**
+ * True only when the project has at least one workstream and every workstream
+ * is complete. This is the gate the import page and #413 branch on.
+ */
+ ready: boolean;
+ /** False when the project has selected no workstreams at all. */
+ hasWorkstreams: boolean;
+ /** Every workstream's readiness, in the order the facts arrived. */
+ workstreams: WorkstreamReadiness[];
+ /** Just the incomplete ones — what the "finish setup" state lists. */
+ incompleteWorkstreams: WorkstreamReadiness[];
+}
+
+/**
+ * Decide, from per-workstream facts, whether a project may import — pure, no
+ * database. A workstream is complete when it has ≥1 stage AND ≥1 contractor;
+ * the project is ready when it has workstreams and all of them are complete.
+ */
+export function computeImportReadiness(
+ facts: WorkstreamReadinessFacts[],
+): ImportReadiness {
+ const workstreams: WorkstreamReadiness[] = facts.map((f) => {
+ const missing: ImportReadinessGap[] = [];
+ // Order matters only for stable rendering: stages before contractor.
+ if (f.stageCount < 1) missing.push("stages");
+ if (f.contractorCount < 1) missing.push("contractor");
+ return {
+ projectWorkstreamId: f.projectWorkstreamId,
+ workstreamId: f.workstreamId,
+ workstreamName: f.workstreamName,
+ stageCount: f.stageCount,
+ contractorCount: f.contractorCount,
+ complete: missing.length === 0,
+ missing,
+ };
+ });
+
+ const incompleteWorkstreams = workstreams.filter((w) => !w.complete);
+ const hasWorkstreams = workstreams.length > 0;
+
+ return {
+ ready: hasWorkstreams && incompleteWorkstreams.length === 0,
+ hasWorkstreams,
+ workstreams,
+ incompleteWorkstreams,
+ };
+}
+
+/**
+ * Gather one project's per-workstream readiness facts and decide. A single
+ * read-only round trip: it writes nothing.
+ *
+ * The stage and contractor counts come from `count(distinct …)` over two left
+ * joins — the distinct is load-bearing, because joining both children at once
+ * produces a stages×contractors cartesian product per workstream that a plain
+ * `count(*)` would over-count. `count(distinct)` returns a bigint, which
+ * node-postgres hands back as a string, so each is coerced with `Number`.
+ */
+export async function loadImportReadiness(
+ projectId: bigint,
+): Promise {
+ const rows = await db
+ .select({
+ projectWorkstreamId: projectWorkstream.id,
+ workstreamId: projectWorkstream.workstreamId,
+ workstreamName: workstream.name,
+ stageCount: sql`count(distinct ${projectWorkstreamStage.id})`,
+ contractorCount: sql`count(distinct ${projectWorkstreamContractor.id})`,
+ })
+ .from(projectWorkstream)
+ .innerJoin(workstream, eq(projectWorkstream.workstreamId, workstream.id))
+ .leftJoin(
+ projectWorkstreamStage,
+ eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstream.id),
+ )
+ .leftJoin(
+ projectWorkstreamContractor,
+ eq(projectWorkstreamContractor.projectWorkstreamId, projectWorkstream.id),
+ )
+ .where(eq(projectWorkstream.projectId, projectId))
+ .groupBy(
+ projectWorkstream.id,
+ projectWorkstream.workstreamId,
+ workstream.name,
+ );
+
+ return computeImportReadiness(
+ rows.map((r) => ({
+ projectWorkstreamId: r.projectWorkstreamId,
+ workstreamId: r.workstreamId,
+ workstreamName: r.workstreamName,
+ stageCount: Number(r.stageCount),
+ contractorCount: Number(r.contractorCount),
+ })),
+ );
+}