diff --git a/src/app/projects/[projectId]/work-orders/[workOrderId]/components/ActionSlots.tsx b/src/app/projects/[projectId]/work-orders/[workOrderId]/components/ActionSlots.tsx
new file mode 100644
index 00000000..1a411f0e
--- /dev/null
+++ b/src/app/projects/[projectId]/work-orders/[workOrderId]/components/ActionSlots.tsx
@@ -0,0 +1,45 @@
+/**
+ * Empty action slots for the work-order detail page (issue #422).
+ *
+ * The detail page decides *whether* an action is offered — the page passes each
+ * slot only when the matching authz guard (`canUpdateStage`, `canUploadEvidence`)
+ * admits the user. What the action *does* is out of scope here: advancing the
+ * stage lands in #423, uploading evidence in #424. These components are the
+ * placeholders those issues replace, disabled so nothing is clickable yet, and
+ * carrying stable test ids so the guard wiring can be asserted before the
+ * behaviour exists.
+ *
+ * Rendering the slot is itself the permission signal: a user who may not act
+ * never receives the slot, so absence — not a disabled control — is how the UI
+ * withholds an action.
+ */
+
+/** Slot for the stage-update control (#423), shown when `canUpdateStage`. */
+export function StageUpdateSlot() {
+ return (
+
+ Update stage
+
+ );
+}
+
+/** Slot for the evidence-upload control (#424), shown when `canUploadEvidence`. */
+export function EvidenceUploadSlot() {
+ return (
+
+ Upload
+
+ );
+}
diff --git a/src/app/projects/[projectId]/work-orders/[workOrderId]/components/WorkOrderDetailPanels.tsx b/src/app/projects/[projectId]/work-orders/[workOrderId]/components/WorkOrderDetailPanels.tsx
new file mode 100644
index 00000000..ea457677
--- /dev/null
+++ b/src/app/projects/[projectId]/work-orders/[workOrderId]/components/WorkOrderDetailPanels.tsx
@@ -0,0 +1,322 @@
+/**
+ * Presentational pieces of the work-order detail page (issue #422).
+ *
+ * All Server Components: the page is a read of already-folded data, so nothing
+ * here needs client state, an effect or a query hook (project React
+ * conventions). These components hold no rules — the stepper states and the
+ * evidence summary arrive already derived by `@/lib/projects/workOrderDetail`,
+ * which reuses `@/lib/projects/derivations` so the counts reconcile with the
+ * dashboard and the work-orders table.
+ *
+ * The action affordances — advancing the stage and uploading evidence — are
+ * **slots only** here. They are rendered per the authz guards the page passes
+ * in, but the interactions themselves land in #423 (stage update) and #424
+ * (evidence upload); this issue draws the empty frames they will fill.
+ */
+import { formatUkDate } from "@/utils/dates";
+import { toCalendarDay } from "@/lib/projects/derivations";
+import { fileTypeLabel } from "@/app/projects/[projectId]/setup/evidence/fileTypes";
+import type {
+ EvidenceChecklistGroup,
+ StageStep,
+ WorkOrderDetailView,
+} from "@/lib/projects/workOrderDetail";
+import type { EvidenceCompleteness } from "@/lib/projects/derivations";
+import type { WorkOrderDetailHeader } from "@/app/repositories/projects/workOrderDetailRepository";
+
+/** The page header: reference, workstream, contractor org, forecast end. */
+export function WorkOrderHeader({ header }: { header: WorkOrderDetailHeader }) {
+ return (
+
+
+ Ara Projects · Work order
+
+
+
+ {header.reference}
+
+
+ {header.workstreamName}
+
+ {header.priority ? (
+
+ Priority
+
+ ) : null}
+
+
+
+
Contractor
+
+ {header.contractorName ??
+ `Organisation ${header.contractorOrganisationId}`}
+
+
+
+
Forecast end
+
+ {formatUkDate(header.forecastEnd) || "—"}
+
+
+
+
Current stage
+ {header.currentStageName}
+
+
+
+ );
+}
+
+/** Address + landlord property id, joined via `work_order.property_id`. */
+export function PropertySummary({ header }: { header: WorkOrderDetailHeader }) {
+ const { property } = header;
+ return (
+
+
+ Property
+
+
+ {property.address ?? "Address unavailable"}
+ {property.postcode ? (
+ · {property.postcode}
+ ) : null}
+
+
+ Landlord property id:{" "}
+
+ {property.landlordPropertyId ?? "—"}
+
+
+
+ );
+}
+
+/** The stage stepper: the workstream ladder in order, current stage highlighted. */
+export function StageStepper({
+ steps,
+ stageUpdateSlot,
+}: {
+ steps: StageStep[];
+ /** The stage-update action slot (#423), rendered by the page only when allowed. */
+ stageUpdateSlot?: React.ReactNode;
+}) {
+ return (
+
+
+
+ Order progress
+
+ {stageUpdateSlot}
+
+ {steps.length === 0 ? (
+ This workstream has no stages configured.
+ ) : (
+
+ {steps.map((step) => (
+
+
+ {step.state === "done" ? "✓" : step.order}
+
+ {step.name}
+
+ ))}
+
+ )}
+
+ );
+}
+
+function stepClasses(state: StageStep["state"]): string {
+ switch (state) {
+ case "current":
+ return "border-blue-500 bg-blue-50 text-blue-900";
+ case "done":
+ return "border-gray-200 bg-white text-gray-500";
+ case "upcoming":
+ return "border-dashed border-gray-200 bg-white text-gray-400";
+ }
+}
+
+function dotClasses(state: StageStep["state"]): string {
+ switch (state) {
+ case "current":
+ return "bg-blue-600 text-white";
+ case "done":
+ return "bg-gray-300 text-white";
+ case "upcoming":
+ return "bg-gray-100 text-gray-400";
+ }
+}
+
+/** The evidence checklist: requirements grouped by stage tag, with a summary badge. */
+export function EvidenceChecklist({
+ groups,
+ summary,
+ uploadSlotFor,
+}: {
+ groups: EvidenceChecklistGroup[];
+ summary: EvidenceCompleteness;
+ /**
+ * Renders the per-requirement upload slot (#424), or null when the caller is
+ * not allowed to upload. Called per requirement so a future implementation
+ * can target a single requirement.
+ */
+ uploadSlotFor?: (requirementId: bigint) => React.ReactNode;
+}) {
+ return (
+
+
+
+ Required evidence
+
+
+
+
+ {groups.length === 0 ? (
+
+ No evidence requirements configured for this workstream.
+
+ ) : (
+
+ {groups.map((group) => (
+
+
+ {group.stageName}
+
+
+ {group.items.map((item) => (
+
+
+
+
+ {fileTypeLabel(item.fileType)}
+ {!item.required ? (
+
+ Optional
+
+ ) : null}
+
+ {item.status === "uploaded" ? (
+
+ {item.files.map((file) => (
+
+ {fileNameOf(file.s3FileKey)}
+
+ {" · Uploaded "}
+ {formatUkDate(toCalendarDay(file.uploadedAt)) ||
+ "date unknown"}
+
+
+ ))}
+
+ ) : (
+
+ {item.namingRule
+ ? `Naming rule: ${item.namingRule}`
+ : "No file uploaded yet"}
+
+ )}
+
+
+
+ {uploadSlotFor?.(item.requirementId)}
+
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+/** "n of m required uploaded" — advisory, reconciles with the table/dashboard. */
+function EvidenceSummaryBadge({ summary }: { summary: EvidenceCompleteness }) {
+ const complete = summary.required > 0 && summary.missing === 0;
+ return (
+
+ {summary.satisfied} of {summary.required} required uploaded
+
+ );
+}
+
+function EvidenceStatusBadge({
+ status,
+}: {
+ status: EvidenceChecklistGroup["items"][number]["status"];
+}) {
+ const config = {
+ uploaded: { label: "Uploaded", className: "bg-green-100 text-green-800" },
+ missing: { label: "Missing", className: "bg-amber-100 text-amber-800" },
+ optional: { label: "Not uploaded", className: "bg-gray-100 text-gray-500" },
+ }[status];
+ return (
+
+ {config.label}
+
+ );
+}
+
+function EmptyPanel({
+ children,
+ ...rest
+}: {
+ children: React.ReactNode;
+ "data-testid"?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Recover a display filename from an S3 key (its last path segment). */
+function fileNameOf(s3FileKey: string): string {
+ const segment = s3FileKey.split("/").filter(Boolean).pop();
+ return segment && segment.length > 0 ? segment : s3FileKey;
+}
diff --git a/src/app/projects/[projectId]/work-orders/[workOrderId]/ids.ts b/src/app/projects/[projectId]/work-orders/[workOrderId]/ids.ts
new file mode 100644
index 00000000..470722fd
--- /dev/null
+++ b/src/app/projects/[projectId]/work-orders/[workOrderId]/ids.ts
@@ -0,0 +1,18 @@
+/**
+ * Path-segment parsing for the work-order detail route (issue #422).
+ *
+ * Kept out of `page.tsx` because Next.js rejects any non-default export from a
+ * page module beyond its handful of allowed fields — a helper exported there
+ * fails the build. Mirrors `parseProjectId` in `../../../guards`.
+ */
+
+/**
+ * Parse a `[workOrderId]` path segment into the bigint PK it addresses, or null
+ * for anything that is not a bare positive integer — so a junk segment becomes
+ * a 404 rather than a Drizzle error.
+ */
+export function parseWorkOrderId(workOrderId: string): bigint | null {
+ if (!/^\d+$/.test(workOrderId)) return null;
+ const parsed = BigInt(workOrderId);
+ return parsed > 0n ? parsed : null;
+}
diff --git a/src/app/projects/[projectId]/work-orders/[workOrderId]/page.test.tsx b/src/app/projects/[projectId]/work-orders/[workOrderId]/page.test.tsx
new file mode 100644
index 00000000..54c8d7d9
--- /dev/null
+++ b/src/app/projects/[projectId]/work-orders/[workOrderId]/page.test.tsx
@@ -0,0 +1,195 @@
+/**
+ * Work-order detail page (issue #422).
+ *
+ * The repository and the route guard are mocked at the module boundary — no
+ * database connection is opened — but the authorization *decisions*
+ * (`canViewWorkOrder`, `canUpdateStage`, `canUploadEvidence`) are the real ones
+ * from `@/lib/projects/authz`; only the facts they read are faked. The two
+ * acceptance criteria under test: a contractor scoped to another org gets a
+ * server-side 404, and a work order with zero requirements renders cleanly.
+ */
+import { describe, expect, it, beforeEach, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+const { mockRequireProjectAccess, mockLoadWorkOrderDetail, mockNotFound } =
+ vi.hoisted(() => ({
+ mockRequireProjectAccess: vi.fn(),
+ mockLoadWorkOrderDetail: vi.fn(),
+ mockNotFound: vi.fn(() => {
+ throw new Error("NEXT_NOT_FOUND");
+ }),
+ }));
+
+vi.mock("../../../guards", () => ({
+ requireProjectAccess: mockRequireProjectAccess,
+}));
+vi.mock("@/app/repositories/projects/workOrderDetailRepository", () => ({
+ loadWorkOrderDetail: mockLoadWorkOrderDetail,
+}));
+vi.mock("next/navigation", () => ({ notFound: mockNotFound }));
+// next/link drags app-router client internals into a node test; a plain anchor
+// is all the page needs rendered.
+vi.mock("next/link", () => ({
+ default: ({
+ href,
+ children,
+ }: {
+ href: string;
+ children: React.ReactNode;
+ }) => {children} ,
+}));
+
+import WorkOrderDetailPage from "./page";
+import { parseWorkOrderId } from "./ids";
+
+const CLIENT_ORG = "11111111-1111-1111-1111-111111111111";
+const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222";
+const OTHER_CONTRACTOR_ORG = "33333333-3333-3333-3333-333333333333";
+
+function signedInAs(organisationIds: string[], email = "someone@landlord.example") {
+ mockRequireProjectAccess.mockResolvedValue({
+ user: { id: 7n, email, organisationIds },
+ project: {
+ id: 5n,
+ organisationId: CLIENT_ORG,
+ domnaAdminAccess: false,
+ contractorOrganisationIds: [CONTRACTOR_ORG, OTHER_CONTRACTOR_ORG],
+ },
+ });
+}
+
+function makeDetail(overrides: {
+ requirements?: unknown[];
+ files?: unknown[];
+ contractorOrganisationId?: string;
+} = {}) {
+ return {
+ header: {
+ id: 1n,
+ reference: "WO-23881",
+ forecastEnd: "2026-07-12",
+ priority: false,
+ currentStageId: 20n,
+ currentStageName: "In progress",
+ projectWorkstreamId: 200n,
+ workstreamName: "Windows",
+ contractorOrganisationId:
+ overrides.contractorOrganisationId ?? CONTRACTOR_ORG,
+ contractorName: "Contractor A",
+ property: {
+ id: 900n,
+ address: "12 High St",
+ postcode: "AB1 2CD",
+ landlordPropertyId: "100245",
+ },
+ },
+ authz: {
+ id: 1n,
+ contractorOrganisationId:
+ overrides.contractorOrganisationId ?? CONTRACTOR_ORG,
+ updateStagesPermission: false,
+ uploadDocumentsPermission: false,
+ },
+ stages: [
+ { id: 10n, name: "Ordered", order: 1 },
+ { id: 20n, name: "In progress", order: 2 },
+ { id: 30n, name: "Completed", order: 3 },
+ ],
+ requirements: overrides.requirements ?? [],
+ files: overrides.files ?? [],
+ };
+}
+
+const params = (projectId = "5", workOrderId = "1") => ({
+ params: Promise.resolve({ projectId, workOrderId }),
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockNotFound.mockImplementation(() => {
+ throw new Error("NEXT_NOT_FOUND");
+ });
+});
+
+describe("parseWorkOrderId", () => {
+ it("accepts a bare positive integer", () => {
+ expect(parseWorkOrderId("42")).toBe(42n);
+ });
+
+ it("rejects junk, zero and negatives so they 404 rather than hit the db", () => {
+ expect(parseWorkOrderId("abc")).toBeNull();
+ expect(parseWorkOrderId("0")).toBeNull();
+ expect(parseWorkOrderId("-1")).toBeNull();
+ expect(parseWorkOrderId("1.5")).toBeNull();
+ });
+});
+
+describe("WorkOrderDetailPage — authorization", () => {
+ it("404s a non-numeric work order id before touching the repository", async () => {
+ signedInAs([CLIENT_ORG]);
+ await expect(
+ WorkOrderDetailPage(params("5", "abc")),
+ ).rejects.toThrow("NEXT_NOT_FOUND");
+ expect(mockLoadWorkOrderDetail).not.toHaveBeenCalled();
+ });
+
+ it("404s when the work order is not in the project", async () => {
+ signedInAs([CLIENT_ORG]);
+ mockLoadWorkOrderDetail.mockResolvedValue(null);
+ await expect(WorkOrderDetailPage(params())).rejects.toThrow("NEXT_NOT_FOUND");
+ });
+
+ it("404s a contractor scoped to another org (evidence never loads)", async () => {
+ // A contractor on the project, but on a different workstream/org than the
+ // one this work order is issued to.
+ signedInAs([OTHER_CONTRACTOR_ORG], "install@contractor.example");
+ mockLoadWorkOrderDetail.mockResolvedValue(
+ makeDetail({ contractorOrganisationId: CONTRACTOR_ORG }),
+ );
+ await expect(WorkOrderDetailPage(params())).rejects.toThrow("NEXT_NOT_FOUND");
+ });
+
+ it("does not offer action slots to a contractor without the permission flags", async () => {
+ signedInAs([CONTRACTOR_ORG], "install@contractor.example");
+ mockLoadWorkOrderDetail.mockResolvedValue(makeDetail());
+ const html = renderToStaticMarkup(await WorkOrderDetailPage(params()));
+ expect(html).not.toContain("work-order-stage-update-slot");
+ expect(html).not.toContain("work-order-evidence-upload-slot");
+ });
+});
+
+describe("WorkOrderDetailPage — rendering", () => {
+ it("renders cleanly for a work order with zero requirements", async () => {
+ signedInAs([CLIENT_ORG]);
+ mockLoadWorkOrderDetail.mockResolvedValue(makeDetail({ requirements: [] }));
+
+ const html = renderToStaticMarkup(await WorkOrderDetailPage(params()));
+
+ expect(html).toContain("WO-23881");
+ expect(html).toContain("12 High St");
+ expect(html).toContain("100245");
+ // The advisory summary reads 0 of 0, and the checklist shows its empty state.
+ expect(html).toContain("0 of 0 required uploaded");
+ expect(html).toContain("No evidence requirements configured");
+ });
+
+ it("offers both action slots to a client (admin) user", async () => {
+ signedInAs([CLIENT_ORG]);
+ mockLoadWorkOrderDetail.mockResolvedValue(
+ makeDetail({
+ requirements: [
+ {
+ id: 1n,
+ fileType: "handover_pack",
+ projectWorkstreamStageId: null,
+ required: true,
+ namingRule: null,
+ },
+ ],
+ }),
+ );
+ const html = renderToStaticMarkup(await WorkOrderDetailPage(params()));
+ expect(html).toContain("work-order-stage-update-slot");
+ expect(html).toContain("work-order-evidence-upload-slot");
+ });
+});
diff --git a/src/app/projects/[projectId]/work-orders/[workOrderId]/page.tsx b/src/app/projects/[projectId]/work-orders/[workOrderId]/page.tsx
new file mode 100644
index 00000000..d3dbbecf
--- /dev/null
+++ b/src/app/projects/[projectId]/work-orders/[workOrderId]/page.tsx
@@ -0,0 +1,96 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { requireProjectAccess } from "../../../guards";
+import {
+ canUpdateStage,
+ canUploadEvidence,
+ canViewWorkOrder,
+} from "@/lib/projects/authz";
+import { buildWorkOrderDetailView } from "@/lib/projects/workOrderDetail";
+import { loadWorkOrderDetail } from "@/app/repositories/projects/workOrderDetailRepository";
+import {
+ EvidenceChecklist,
+ PropertySummary,
+ StageStepper,
+ WorkOrderHeader,
+} from "./components/WorkOrderDetailPanels";
+import { EvidenceUploadSlot, StageUpdateSlot } from "./components/ActionSlots";
+import { parseWorkOrderId } from "./ids";
+
+export const metadata = {
+ title: "Work order | Ara",
+};
+
+/**
+ * Work-order detail (issue #422) — a Server Component over the read model in
+ * `@/lib/projects/workOrderDetail`.
+ *
+ * One page serves internal, client and contractor users; what differs between
+ * them is the *action slots*, not the read. Authorization runs in two steps:
+ * `requireProjectAccess` (#409) settles project visibility — a junk id or a
+ * project the user may not see is a 404 inside the guard — and then
+ * `canViewWorkOrder` (#408) scopes *this* work order, so a contractor assigned
+ * to a different workstream, who can see the project, still gets a 404 for a
+ * work order issued to another organisation. Every refusal is `notFound`, never
+ * a 403, so a work order's existence does not leak outside the org it belongs
+ * to (the issue's acceptance criterion).
+ *
+ * The stage stepper and evidence checklist come pre-folded from the pure view
+ * builder, which reuses `@/lib/projects/derivations` — so the "n of m required
+ * uploaded" badge here is the same number the work-orders table (#419) shows
+ * for the row, by construction rather than by a second, drifting calculation.
+ *
+ * The action affordances are **slots only**: they are rendered per
+ * `canUpdateStage` / `canUploadEvidence`, but advancing the stage (#423) and
+ * uploading evidence (#424) land in later issues. Cut per the issue: the
+ * activity log (#426), the booking/tenant panel (#427) and any hard
+ * completion-blocking — evidence is advisory-only in v1.
+ */
+export default async function WorkOrderDetailPage(props: {
+ params: Promise<{ projectId: string; workOrderId: string }>;
+}) {
+ const { projectId, workOrderId } = await props.params;
+ const { user, project } = await requireProjectAccess(projectId);
+
+ const id = parseWorkOrderId(workOrderId);
+ if (id === null) notFound();
+
+ const detail = await loadWorkOrderDetail(project.id, id);
+ if (!detail) notFound();
+ if (!canViewWorkOrder(user, project, detail.authz)) notFound();
+
+ const view = buildWorkOrderDetailView({
+ currentStageId: detail.header.currentStageId,
+ stages: detail.stages,
+ requirements: detail.requirements,
+ files: detail.files,
+ });
+
+ const mayUpdateStage = canUpdateStage(user, project, detail.authz);
+ const mayUploadEvidence = canUploadEvidence(user, project, detail.authz);
+
+ return (
+
+
+ ← Work orders
+
+
+
+
+
: null}
+ />
+
: undefined
+ }
+ />
+
+ );
+}
diff --git a/src/app/repositories/projects/workOrderDetailRepository.ts b/src/app/repositories/projects/workOrderDetailRepository.ts
new file mode 100644
index 00000000..b209f22e
--- /dev/null
+++ b/src/app/repositories/projects/workOrderDetailRepository.ts
@@ -0,0 +1,233 @@
+/**
+ * Ara Projects work-order detail repository (issue #422).
+ *
+ * The persistence boundary for the work-order detail page: it owns the Drizzle
+ * queries and returns domain-shaped rows, so the page and the pure view builder
+ * (`@/lib/projects/workOrderDetail`) never see a table shape. The *rules* —
+ * which evidence applies, what the summary counts — live in
+ * `@/lib/projects/derivations` and its callers, never here.
+ *
+ * The lookup is scoped to `(projectId, workOrderId)`: a work order id belonging
+ * to another project resolves to `null`, which the page turns into a 404, so a
+ * caller with access to project A cannot read a work order of project B by id.
+ *
+ * A handful of round trips, none per-row: the work-order header, then the
+ * workstream's full stage ladder, its evidence requirements, and the evidence
+ * files submitted against this work order. The ladder and requirements are read
+ * whole (not filtered to the current stage) because the stepper shows every
+ * stage and the checklist lists every requirement.
+ */
+import { db } from "@/app/db/db";
+import { and, eq } from "drizzle-orm";
+import {
+ projectWorkstream,
+ projectWorkstreamContractor,
+ projectWorkstreamEvidenceRequirement,
+ projectWorkstreamStage,
+ workOrder,
+ workstream,
+} from "@/app/db/schema/projects/projects";
+import { organisation } from "@/app/db/schema/organisation";
+import { property } from "@/app/db/schema/property";
+import { uploadedFiles } from "@/app/db/schema/uploaded_files";
+import type { WorkOrderAuthzFacts } from "@/lib/projects/authz";
+import type {
+ WorkOrderDetailFile,
+ WorkOrderDetailRequirement,
+ WorkOrderDetailStage,
+} from "@/lib/projects/workOrderDetail";
+
+/** The header + property + contractor facts of one work order. */
+export interface WorkOrderDetailHeader {
+ id: bigint;
+ reference: string;
+ forecastEnd: string | null;
+ priority: boolean;
+ /** `work_order.project_workstream_stage_id` — the stepper highlight. */
+ currentStageId: bigint;
+ currentStageName: string;
+ /** `project_workstream.id`. */
+ projectWorkstreamId: bigint;
+ /** `workstream.name` — reference data, so a rename propagates. */
+ workstreamName: string;
+ contractorOrganisationId: string;
+ contractorName: string | null;
+ property: {
+ id: bigint;
+ address: string | null;
+ postcode: string | null;
+ /** `property.landlord_property_id` — the landlord's own asset reference. */
+ landlordPropertyId: string | null;
+ };
+}
+
+/** Everything the detail page needs, loaded and domain-shaped. */
+export interface WorkOrderDetail {
+ header: WorkOrderDetailHeader;
+ /** The permission-relevant facts, so the page can gate the view and the action slots. */
+ authz: WorkOrderAuthzFacts;
+ stages: WorkOrderDetailStage[];
+ requirements: WorkOrderDetailRequirement[];
+ files: WorkOrderDetailFile[];
+}
+
+/**
+ * Load one work order's detail, scoped to its project. Returns `null` when no
+ * work order with that id exists *in that project* — the page treats that as a
+ * 404 so a foreign work order id neither loads nor confirms its existence.
+ */
+export async function loadWorkOrderDetail(
+ projectId: bigint,
+ workOrderId: bigint,
+): Promise {
+ const [found] = await db
+ .select({
+ id: workOrder.id,
+ reference: workOrder.reference,
+ forecastEnd: workOrder.forecastEnd,
+ priority: workOrder.priority,
+ currentStageId: workOrder.projectWorkstreamStageId,
+ currentStageName: projectWorkstreamStage.name,
+ projectWorkstreamId: projectWorkstream.id,
+ workstreamName: workstream.name,
+ contractorOrganisationId: projectWorkstreamContractor.organisationId,
+ contractorName: organisation.name,
+ updateStagesPermission: projectWorkstreamContractor.updateStagesPermission,
+ uploadDocumentsPermission:
+ projectWorkstreamContractor.uploadDocumentsPermission,
+ propertyId: property.id,
+ address: property.address,
+ postcode: property.postcode,
+ landlordPropertyId: property.landlordPropertyId,
+ })
+ .from(workOrder)
+ .innerJoin(
+ projectWorkstreamStage,
+ eq(projectWorkstreamStage.id, workOrder.projectWorkstreamStageId),
+ )
+ .innerJoin(
+ projectWorkstream,
+ eq(projectWorkstream.id, projectWorkstreamStage.projectWorkstreamId),
+ )
+ .innerJoin(workstream, eq(workstream.id, projectWorkstream.workstreamId))
+ .innerJoin(
+ projectWorkstreamContractor,
+ eq(
+ projectWorkstreamContractor.id,
+ workOrder.projectWorkstreamContractorId,
+ ),
+ )
+ .innerJoin(
+ organisation,
+ eq(organisation.id, projectWorkstreamContractor.organisationId),
+ )
+ .innerJoin(property, eq(property.id, workOrder.propertyId))
+ .where(
+ and(
+ eq(workOrder.id, workOrderId),
+ eq(projectWorkstream.projectId, projectId),
+ ),
+ )
+ .limit(1);
+
+ if (!found) return null;
+
+ const [stages, requirements, files] = await Promise.all([
+ loadWorkstreamStages(found.projectWorkstreamId),
+ loadWorkstreamRequirements(found.projectWorkstreamId),
+ loadWorkOrderEvidence(found.id),
+ ]);
+
+ return {
+ header: {
+ id: found.id,
+ reference: found.reference,
+ forecastEnd: found.forecastEnd,
+ priority: found.priority,
+ currentStageId: found.currentStageId,
+ currentStageName: found.currentStageName,
+ projectWorkstreamId: found.projectWorkstreamId,
+ workstreamName: found.workstreamName,
+ contractorOrganisationId: found.contractorOrganisationId,
+ contractorName: found.contractorName,
+ property: {
+ id: found.propertyId,
+ address: found.address,
+ postcode: found.postcode,
+ landlordPropertyId: found.landlordPropertyId,
+ },
+ },
+ authz: {
+ id: found.id,
+ contractorOrganisationId: found.contractorOrganisationId,
+ updateStagesPermission: found.updateStagesPermission,
+ uploadDocumentsPermission: found.uploadDocumentsPermission,
+ },
+ stages,
+ requirements,
+ files,
+ };
+}
+
+/** Every stage of the workstream, ordered by `order`, for the stepper and ladder. */
+async function loadWorkstreamStages(
+ projectWorkstreamId: bigint,
+): Promise {
+ return db
+ .select({
+ id: projectWorkstreamStage.id,
+ name: projectWorkstreamStage.name,
+ order: projectWorkstreamStage.order,
+ })
+ .from(projectWorkstreamStage)
+ .where(eq(projectWorkstreamStage.projectWorkstreamId, projectWorkstreamId))
+ .orderBy(projectWorkstreamStage.order);
+}
+
+/** Every evidence requirement configured on the workstream. */
+async function loadWorkstreamRequirements(
+ projectWorkstreamId: bigint,
+): Promise {
+ return db
+ .select({
+ id: projectWorkstreamEvidenceRequirement.id,
+ fileType: projectWorkstreamEvidenceRequirement.fileType,
+ projectWorkstreamStageId:
+ projectWorkstreamEvidenceRequirement.projectWorkstreamStageId,
+ required: projectWorkstreamEvidenceRequirement.required,
+ namingRule: projectWorkstreamEvidenceRequirement.namingRule,
+ })
+ .from(projectWorkstreamEvidenceRequirement)
+ .where(
+ eq(
+ projectWorkstreamEvidenceRequirement.projectWorkstreamId,
+ projectWorkstreamId,
+ ),
+ );
+}
+
+/**
+ * The evidence submitted against this work order — `uploaded_files` rows with
+ * `file_source = 'projects'`, matched to requirements via
+ * `project_workstream_evidence_requirement_id`.
+ */
+async function loadWorkOrderEvidence(
+ workOrderId: bigint,
+): Promise {
+ return db
+ .select({
+ id: uploadedFiles.id,
+ requirementId: uploadedFiles.projectWorkstreamEvidenceRequirementId,
+ fileType: uploadedFiles.fileType,
+ s3FileKey: uploadedFiles.s3FileKey,
+ uploadedAt: uploadedFiles.s3UploadTimestamp,
+ })
+ .from(uploadedFiles)
+ .where(
+ and(
+ eq(uploadedFiles.workOrderId, workOrderId),
+ eq(uploadedFiles.source, "projects"),
+ ),
+ )
+ .orderBy(uploadedFiles.s3UploadTimestamp);
+}
diff --git a/src/lib/projects/authz.test.ts b/src/lib/projects/authz.test.ts
index 1a1e4c24..3580bc2a 100644
--- a/src/lib/projects/authz.test.ts
+++ b/src/lib/projects/authz.test.ts
@@ -4,6 +4,7 @@ import {
canUpdateStage,
canUploadEvidence,
canViewProject,
+ canViewWorkOrder,
isInternalEmail,
resolveProjectRole,
summariseContractorCapabilities,
@@ -172,6 +173,48 @@ describe("canManageProject", () => {
});
});
+describe("canViewWorkOrder", () => {
+ const project = makeProject();
+
+ it("admits internal and client regardless of the assignment", () => {
+ const workOrder = makeWorkOrder();
+ expect(canViewWorkOrder(internal, project, workOrder)).toBe(true);
+ expect(canViewWorkOrder(client, project, workOrder)).toBe(true);
+ });
+
+ it("admits a contractor viewing a work order issued to their own org", () => {
+ const workOrder = makeWorkOrder({ contractorOrganisationId: CONTRACTOR_ORG });
+ expect(canViewWorkOrder(contractor, project, workOrder)).toBe(true);
+ });
+
+ it("needs no permission flag to view — the flags gate actions, not the read", () => {
+ const workOrder = makeWorkOrder({
+ contractorOrganisationId: CONTRACTOR_ORG,
+ updateStagesPermission: false,
+ uploadDocumentsPermission: false,
+ });
+ expect(canViewWorkOrder(contractor, project, workOrder)).toBe(true);
+ });
+
+ it("refuses a contractor scoped to another workstream's org (the AC's foreign contractor)", () => {
+ const other = makeUser({ organisationIds: [OTHER_CONTRACTOR_ORG] });
+ const workOrder = makeWorkOrder({ contractorOrganisationId: CONTRACTOR_ORG });
+ // They can see the project — both orgs are contractors on it — but not this
+ // work order, which belongs to a different org.
+ expect(resolveProjectRole(other, project)).toBe("contractor");
+ expect(canViewWorkOrder(other, project, workOrder)).toBe(false);
+ });
+
+ it("refuses a user with no role on the project", () => {
+ expect(canViewWorkOrder(stranger, project, makeWorkOrder())).toBe(false);
+ });
+
+ it("refuses a Domna user when admin access is off and they have no membership", () => {
+ const locked = makeProject({ domnaAdminAccess: false });
+ expect(canViewWorkOrder(internal, locked, makeWorkOrder())).toBe(false);
+ });
+});
+
describe("canUpdateStage", () => {
const project = makeProject();
diff --git a/src/lib/projects/authz.ts b/src/lib/projects/authz.ts
index 32776552..a5e650fa 100644
--- a/src/lib/projects/authz.ts
+++ b/src/lib/projects/authz.ts
@@ -119,6 +119,31 @@ export function canManageProject(
return role === "internal" || role === "client";
}
+/**
+ * View a single Work order's detail page.
+ *
+ * Internal and client may view any work order on a project they can see. A
+ * contractor may view only a work order issued to an organisation they belong
+ * to — being a contractor on some *other* workstream of the same project (and
+ * so able to `canViewProject`) is not enough to see another org's work order.
+ * Viewing needs no assignment permission flag; the flags gate the *actions*
+ * (`canUpdateStage`, `canUploadEvidence`), not the read.
+ *
+ * A refused view is a 404, not a 403, so a work order's existence does not leak
+ * to a contractor scoped away from it — the same "don't confirm it exists"
+ * rule `canViewProject` follows for projects.
+ */
+export function canViewWorkOrder(
+ user: AuthzUser,
+ project: ProjectAuthzFacts,
+ workOrder: WorkOrderAuthzFacts,
+): boolean {
+ const role = resolveProjectRole(user, project);
+ if (role === "internal" || role === "client") return true;
+ if (role !== "contractor") return false;
+ return user.organisationIds.includes(workOrder.contractorOrganisationId);
+}
+
/**
* Move a Work order along its stage ladder.
*
diff --git a/src/lib/projects/workOrderDetail.test.ts b/src/lib/projects/workOrderDetail.test.ts
new file mode 100644
index 00000000..71b7c1a5
--- /dev/null
+++ b/src/lib/projects/workOrderDetail.test.ts
@@ -0,0 +1,290 @@
+/**
+ * The work-order detail read model (issue #422).
+ *
+ * These tests pin the two acceptance criteria this module owns: a work order
+ * with **zero requirements** folds to an empty checklist with a null-ratio
+ * summary, and the summary's counts **agree with the shared derivations** — the
+ * same `requiredEvidenceRequirementIds` / `evidenceCompleteness` the dashboard
+ * (#418) and the work-orders table (#419) use — so a badge here cannot drift
+ * from the row's badge there.
+ */
+import { describe, expect, it } from "vitest";
+import {
+ buildStageLadders,
+ evidenceCompleteness,
+ requiredEvidenceRequirementIds,
+ type EvidenceRequirementEntry,
+ type StageLadderEntry,
+} from "./derivations";
+import {
+ ANY_STAGE_GROUP_LABEL,
+ buildWorkOrderDetailView,
+ type WorkOrderDetailFile,
+ type WorkOrderDetailInput,
+ type WorkOrderDetailRequirement,
+ type WorkOrderDetailStage,
+} from "./workOrderDetail";
+
+// A three-stage ladder: Ordered(1) → In progress(2) → Completed(3).
+const STAGES: WorkOrderDetailStage[] = [
+ { id: 10n, name: "Ordered", order: 1 },
+ { id: 20n, name: "In progress", order: 2 },
+ { id: 30n, name: "Completed", order: 3 },
+];
+
+function file(
+ overrides: Partial & { requirementId: bigint | null },
+): WorkOrderDetailFile {
+ return {
+ id: 1n,
+ fileType: "handover_pack",
+ s3FileKey: "projects/wo/handover.pdf",
+ uploadedAt: "2026-07-04T09:00:00.000Z",
+ ...overrides,
+ };
+}
+
+describe("buildWorkOrderDetailView — stage stepper", () => {
+ it("marks stages done / current / upcoming by order relative to the current stage", () => {
+ const view = buildWorkOrderDetailView({
+ currentStageId: 20n,
+ stages: STAGES,
+ requirements: [],
+ files: [],
+ });
+ expect(view.stepper.map((s) => [s.name, s.state])).toEqual([
+ ["Ordered", "done"],
+ ["In progress", "current"],
+ ["Completed", "upcoming"],
+ ]);
+ });
+
+ it("orders the stepper by `order` even if stages arrive unsorted", () => {
+ const view = buildWorkOrderDetailView({
+ currentStageId: 10n,
+ stages: [STAGES[2], STAGES[0], STAGES[1]],
+ requirements: [],
+ files: [],
+ });
+ expect(view.stepper.map((s) => s.name)).toEqual([
+ "Ordered",
+ "In progress",
+ "Completed",
+ ]);
+ });
+});
+
+describe("buildWorkOrderDetailView — zero requirements", () => {
+ const view = buildWorkOrderDetailView({
+ currentStageId: 10n,
+ stages: STAGES,
+ requirements: [],
+ files: [],
+ });
+
+ it("renders an empty checklist with no groups", () => {
+ expect(view.checklist.groups).toEqual([]);
+ });
+
+ it("summarises as 0 of 0 with a null ratio, not 0%", () => {
+ expect(view.checklist.summary).toEqual({
+ required: 0,
+ satisfied: 0,
+ missing: 0,
+ ratio: null,
+ });
+ });
+
+ it("still renders the stepper", () => {
+ expect(view.stepper).toHaveLength(3);
+ });
+});
+
+describe("buildWorkOrderDetailView — grouping by stage tag", () => {
+ const requirements: WorkOrderDetailRequirement[] = [
+ {
+ id: 1n,
+ fileType: "site_note",
+ projectWorkstreamStageId: null,
+ required: true,
+ namingRule: null,
+ },
+ {
+ id: 2n,
+ fileType: "mcs_compliance_certificate",
+ projectWorkstreamStageId: 20n,
+ required: true,
+ namingRule: "WO-*-cert",
+ },
+ {
+ id: 3n,
+ fileType: "handover_pack",
+ projectWorkstreamStageId: 30n,
+ required: true,
+ namingRule: null,
+ },
+ ];
+
+ const view = buildWorkOrderDetailView({
+ currentStageId: 20n,
+ stages: STAGES,
+ requirements,
+ files: [],
+ });
+
+ it("puts stage-less requirements under 'Any stage', first", () => {
+ expect(view.checklist.groups[0].stageId).toBeNull();
+ expect(view.checklist.groups[0].stageName).toBe(ANY_STAGE_GROUP_LABEL);
+ expect(view.checklist.groups[0].items.map((i) => i.requirementId)).toEqual([
+ 1n,
+ ]);
+ });
+
+ it("orders the stage groups by the stage's ladder order", () => {
+ expect(view.checklist.groups.map((g) => g.stageName)).toEqual([
+ ANY_STAGE_GROUP_LABEL,
+ "In progress",
+ "Completed",
+ ]);
+ });
+
+ it("lists a not-yet-reached requirement but does not mark it applicable", () => {
+ const completedGroup = view.checklist.groups.find(
+ (g) => g.stageId === 30n,
+ )!;
+ const handover = completedGroup.items[0];
+ // Visible in the checklist...
+ expect(handover.requirementId).toBe(3n);
+ // ...but the work order is only at stage 2, so it does not yet count.
+ expect(handover.applicable).toBe(false);
+ // The earlier stages' required requirements do apply.
+ const anyStage = view.checklist.groups[0].items[0];
+ const inProgress = view.checklist.groups
+ .find((g) => g.stageId === 20n)!
+ .items[0];
+ expect(anyStage.applicable).toBe(true);
+ expect(inProgress.applicable).toBe(true);
+ });
+
+ it("carries the naming-rule hint through to the item", () => {
+ const inProgress = view.checklist.groups.find((g) => g.stageId === 20n)!;
+ expect(inProgress.items[0].namingRule).toBe("WO-*-cert");
+ });
+});
+
+describe("buildWorkOrderDetailView — file matching and status", () => {
+ const requirements: WorkOrderDetailRequirement[] = [
+ {
+ id: 1n,
+ fileType: "site_note",
+ projectWorkstreamStageId: null,
+ required: true,
+ namingRule: null,
+ },
+ {
+ id: 2n,
+ fileType: "installer_feedback",
+ projectWorkstreamStageId: null,
+ required: false,
+ namingRule: null,
+ },
+ ];
+
+ it("matches files to requirements by requirement id and marks them uploaded", () => {
+ const view = buildWorkOrderDetailView({
+ currentStageId: 10n,
+ stages: STAGES,
+ requirements,
+ files: [file({ id: 100n, requirementId: 1n })],
+ });
+ const items = view.checklist.groups[0].items;
+ const siteNote = items.find((i) => i.requirementId === 1n)!;
+ expect(siteNote.status).toBe("uploaded");
+ expect(siteNote.files.map((f) => f.id)).toEqual([100n]);
+ });
+
+ it("marks a required requirement with no file 'missing', an optional one 'optional'", () => {
+ const view = buildWorkOrderDetailView({
+ currentStageId: 10n,
+ stages: STAGES,
+ requirements,
+ files: [],
+ });
+ const items = view.checklist.groups[0].items;
+ expect(items.find((i) => i.requirementId === 1n)!.status).toBe("missing");
+ expect(items.find((i) => i.requirementId === 2n)!.status).toBe("optional");
+ });
+
+ it("ignores ad-hoc files with no requirement id", () => {
+ const view = buildWorkOrderDetailView({
+ currentStageId: 10n,
+ stages: STAGES,
+ requirements,
+ files: [file({ id: 200n, requirementId: null })],
+ });
+ const siteNote = view.checklist.groups[0].items.find(
+ (i) => i.requirementId === 1n,
+ )!;
+ expect(siteNote.status).toBe("missing");
+ expect(siteNote.files).toEqual([]);
+ });
+});
+
+describe("buildWorkOrderDetailView — summary reconciles with the shared derivations", () => {
+ // A mix: a stage-less required req (satisfied), a reached required req
+ // (missing), a not-yet-reached required req, and an optional req — exactly
+ // the cases where a naive count would disagree with the derivation helper.
+ const requirements: WorkOrderDetailRequirement[] = [
+ { id: 1n, fileType: "site_note", projectWorkstreamStageId: null, required: true, namingRule: null },
+ { id: 2n, fileType: "mcs_compliance_certificate", projectWorkstreamStageId: 20n, required: true, namingRule: null },
+ { id: 3n, fileType: "handover_pack", projectWorkstreamStageId: 30n, required: true, namingRule: null },
+ { id: 4n, fileType: "installer_feedback", projectWorkstreamStageId: 20n, required: false, namingRule: null },
+ ];
+ const files: WorkOrderDetailFile[] = [file({ id: 100n, requirementId: 1n })];
+ const input: WorkOrderDetailInput = {
+ currentStageId: 20n,
+ stages: STAGES,
+ requirements,
+ files,
+ };
+
+ it("counts exactly the applicable required requirements the dashboard/table would", () => {
+ const view = buildWorkOrderDetailView(input);
+
+ // Independently recompute via the shared helpers, as #418/#419 do.
+ const ladders = buildStageLadders(
+ STAGES.map((s) => ({
+ id: s.id,
+ projectWorkstreamId: 0n,
+ order: s.order,
+ })),
+ );
+ const applicable = new Set(
+ requiredEvidenceRequirementIds(
+ ladders,
+ requirements.map((r) => ({
+ id: r.id,
+ projectWorkstreamId: 0n,
+ projectWorkstreamStageId: r.projectWorkstreamStageId,
+ required: r.required,
+ })),
+ input.currentStageId,
+ ),
+ );
+ const satisfiedIds = new Set(
+ files.map((f) => f.requirementId).filter((x): x is bigint => x !== null),
+ );
+ const satisfied = [...applicable].filter((id) => satisfiedIds.has(id)).length;
+ const expected = evidenceCompleteness(applicable.size, satisfied);
+
+ expect(view.checklist.summary).toEqual(expected);
+ // Concretely: reqs 1 and 2 apply at stage 2 (req 3 is stage 3, req 4 is
+ // optional); only req 1 has a file.
+ expect(view.checklist.summary).toEqual({
+ required: 2,
+ satisfied: 1,
+ missing: 1,
+ ratio: 0.5,
+ });
+ });
+});
diff --git a/src/lib/projects/workOrderDetail.ts b/src/lib/projects/workOrderDetail.ts
new file mode 100644
index 00000000..1fff282b
--- /dev/null
+++ b/src/lib/projects/workOrderDetail.ts
@@ -0,0 +1,307 @@
+/**
+ * Ara Projects — the work-order detail read model (issue #422).
+ *
+ * Pure, like its siblings `./derivations`, `./authz` and `./dashboardSummary`:
+ * it takes the domain-shaped rows a repository loaded and folds them into the
+ * two views the detail page renders — the **stage stepper** and the **evidence
+ * checklist** — importing no database client and no React.
+ *
+ * ## Why the counts here reconcile with the dashboard and the work-orders table
+ *
+ * The summary badge ("n of m required uploaded") is *not* computed by counting
+ * whatever this module feels like counting. `m` is
+ * `requiredEvidenceRequirementIds(...)` — the exact applicability rule the
+ * programme dashboard (#418) and the work-orders table (#419) push into SQL —
+ * and the pair `(m, n)` is fed through `evidenceCompleteness`, the same clamp
+ * those surfaces use. So a work order's badge on the table and its badge on
+ * this page are the same number by construction, which is an acceptance
+ * criterion of this issue.
+ *
+ * The *checklist* is deliberately broader than the *summary*: it lists every
+ * one of the workstream's requirements grouped by stage (so a contractor can
+ * see what a later stage will ask for), while the summary counts only the
+ * required requirements that already **apply** at the work order's current
+ * stage. A requirement shown under a not-yet-reached stage is therefore visible
+ * but does not drag the badge down — matching `requiredEvidenceRequirementIds`,
+ * which only claims a stage-scoped requirement once the work order has reached
+ * that stage.
+ */
+import {
+ buildStageLadders,
+ evidenceCompleteness,
+ requiredEvidenceRequirementIds,
+ type EvidenceCompleteness,
+ type EvidenceRequirementEntry,
+ type StageLadderEntry,
+} from "./derivations";
+
+/** One `project_workstream_stage` of the work order's workstream. */
+export interface WorkOrderDetailStage {
+ id: bigint;
+ name: string;
+ order: number;
+}
+
+/** One `project_workstream_evidence_requirement` of the workstream. */
+export interface WorkOrderDetailRequirement {
+ id: bigint;
+ /** The required `file_type` enum value; the component turns it into a label. */
+ fileType: string;
+ /** Null when the requirement applies to the workstream as a whole ("Any stage"). */
+ projectWorkstreamStageId: bigint | null;
+ required: boolean;
+ /** The optional naming-rule hint shown beside a missing requirement. */
+ namingRule: string | null;
+}
+
+/** One `uploaded_files` row submitted against the work order (`file_source = 'projects'`). */
+export interface WorkOrderDetailFile {
+ id: bigint;
+ /** `project_workstream_evidence_requirement_id`; null for an ad-hoc upload. */
+ requirementId: bigint | null;
+ fileType: string | null;
+ /** `s3_file_key` — a filename can be recovered from it when nothing better exists. */
+ s3FileKey: string;
+ /** `s3_upload_timestamp`, as Drizzle hands back a `timestamp`. */
+ uploadedAt: string | Date | null;
+}
+
+/** The inputs the view builder folds — the repository's rows, minus authz. */
+export interface WorkOrderDetailInput {
+ /** The work order's current stage FK — the stepper's highlight and the summary's "as at" stage. */
+ currentStageId: bigint;
+ /** Every stage of the work order's workstream, for the ladder and the stepper. */
+ stages: readonly WorkOrderDetailStage[];
+ requirements: readonly WorkOrderDetailRequirement[];
+ files: readonly WorkOrderDetailFile[];
+}
+
+/** Where a stage sits relative to the work order's current stage. */
+export type StageStepState = "done" | "current" | "upcoming";
+
+/** One step of the stage stepper. */
+export interface StageStep {
+ id: bigint;
+ name: string;
+ order: number;
+ state: StageStepState;
+}
+
+/** An evidence line's advisory state — never a gate (CONTEXT.md: evidence is advisory in v1). */
+export type EvidenceItemStatus = "uploaded" | "missing" | "optional";
+
+/** One requirement in the checklist, with the files that satisfy it. */
+export interface EvidenceChecklistItem {
+ requirementId: bigint;
+ fileType: string;
+ required: boolean;
+ /**
+ * Whether this requirement counts toward the summary badge — i.e. it is
+ * `required` and already applies at the work order's current stage. A
+ * required requirement of a not-yet-reached stage is listed (so it is
+ * visible) but not applicable (so it does not lower the badge).
+ */
+ applicable: boolean;
+ namingRule: string | null;
+ files: WorkOrderDetailFile[];
+ /**
+ * `uploaded` when at least one file matches, else `missing` for a required
+ * requirement and `optional` for a non-required one. Advisory only.
+ */
+ status: EvidenceItemStatus;
+}
+
+/** A checklist group: one stage tag, or the stage-less "Any stage" bucket. */
+export interface EvidenceChecklistGroup {
+ /** The stage id, or null for the "Any stage" (stage-less) group. */
+ stageId: bigint | null;
+ /** The stage name, or "Any stage" for the stage-less group. */
+ stageName: string;
+ items: EvidenceChecklistItem[];
+}
+
+/** The whole detail read model: the stepper and the checklist with its summary. */
+export interface WorkOrderDetailView {
+ stepper: StageStep[];
+ checklist: {
+ groups: EvidenceChecklistGroup[];
+ /** "n of m required uploaded" — reconciles with the dashboard/table by construction. */
+ summary: EvidenceCompleteness;
+ };
+}
+
+/** The label the stage-less group carries. */
+export const ANY_STAGE_GROUP_LABEL = "Any stage";
+
+/**
+ * Fold the work order's stages, requirements and files into the detail view.
+ *
+ * The one place a rule is applied is the summary: `applicableRequiredIds` is
+ * `requiredEvidenceRequirementIds`, and the badge is `evidenceCompleteness` of
+ * (applicable required, of those satisfied). Everything else is presentation —
+ * grouping and ordering — with no judgement of its own.
+ */
+export function buildWorkOrderDetailView(
+ input: WorkOrderDetailInput,
+): WorkOrderDetailView {
+ const stages = [...input.stages].sort((a, b) => a.order - b.order);
+ const stageById = new Map(stages.map((s) => [s.id, s]));
+
+ // A synthetic workstream id so the shared ladder builder can classify these
+ // stages; every stage here belongs to the one workstream, so the value is
+ // irrelevant as long as it is shared.
+ const WORKSTREAM = 0n;
+ const ladderEntries: StageLadderEntry[] = stages.map((s) => ({
+ id: s.id,
+ projectWorkstreamId: WORKSTREAM,
+ order: s.order,
+ }));
+ const requirementEntries: EvidenceRequirementEntry[] = input.requirements.map(
+ (r) => ({
+ id: r.id,
+ projectWorkstreamId: WORKSTREAM,
+ projectWorkstreamStageId: r.projectWorkstreamStageId,
+ required: r.required,
+ }),
+ );
+
+ const ladders = buildStageLadders(ladderEntries);
+ const applicableRequiredIds = new Set(
+ requiredEvidenceRequirementIds(
+ ladders,
+ requirementEntries,
+ input.currentStageId,
+ ),
+ );
+
+ const filesByRequirement = groupFilesByRequirement(input.files);
+
+ const items: EvidenceChecklistItem[] = input.requirements.map((requirement) => {
+ const files = filesByRequirement.get(requirement.id) ?? [];
+ const status: EvidenceItemStatus =
+ files.length > 0 ? "uploaded" : requirement.required ? "missing" : "optional";
+ return {
+ requirementId: requirement.id,
+ fileType: requirement.fileType,
+ required: requirement.required,
+ applicable: applicableRequiredIds.has(requirement.id),
+ namingRule: requirement.namingRule,
+ files,
+ status,
+ };
+ });
+
+ // The summary counts only the requirements that apply at the current stage —
+ // the same set the dashboard and the table count.
+ const applicableItems = items.filter((item) => item.applicable);
+ const satisfied = applicableItems.filter(
+ (item) => item.status === "uploaded",
+ ).length;
+ const summary = evidenceCompleteness(applicableItems.length, satisfied);
+
+ return {
+ stepper: buildStepper(stages, input.currentStageId),
+ checklist: {
+ groups: buildGroups(items, input.requirements, stageById),
+ summary,
+ },
+ };
+}
+
+/** Classify each stage against the work order's current stage, by `order`. */
+function buildStepper(
+ stages: readonly WorkOrderDetailStage[],
+ currentStageId: bigint,
+): StageStep[] {
+ const currentOrder = stages.find((s) => s.id === currentStageId)?.order ?? null;
+ return stages.map((stage) => ({
+ id: stage.id,
+ name: stage.name,
+ order: stage.order,
+ state: stepStateOf(stage, currentStageId, currentOrder),
+ }));
+}
+
+function stepStateOf(
+ stage: WorkOrderDetailStage,
+ currentStageId: bigint,
+ currentOrder: number | null,
+): StageStepState {
+ if (stage.id === currentStageId) return "current";
+ // A current stage id that is not among the stages (a loading bug) leaves
+ // every other step "upcoming" rather than guessing a position.
+ if (currentOrder === null) return "upcoming";
+ return stage.order < currentOrder ? "done" : "upcoming";
+}
+
+/** Bucket the files by the requirement they satisfy; ad-hoc (null) files drop out. */
+function groupFilesByRequirement(
+ files: readonly WorkOrderDetailFile[],
+): Map {
+ const byRequirement = new Map();
+ for (const file of files) {
+ if (file.requirementId === null) continue;
+ const bucket = byRequirement.get(file.requirementId) ?? [];
+ bucket.push(file);
+ byRequirement.set(file.requirementId, bucket);
+ }
+ return byRequirement;
+}
+
+/**
+ * Group the checklist items by stage tag, ordered by stage `order`, with the
+ * stage-less "Any stage" bucket first (those requirements apply throughout, so
+ * they read naturally at the top). A group is emitted only when it has items.
+ */
+function buildGroups(
+ items: readonly EvidenceChecklistItem[],
+ requirements: readonly WorkOrderDetailRequirement[],
+ stageById: ReadonlyMap,
+): EvidenceChecklistGroup[] {
+ const itemById = new Map(items.map((item) => [item.requirementId, item]));
+ // Group key: the stage id, or a sentinel for the stage-less bucket.
+ const buckets = new Map();
+ for (const requirement of requirements) {
+ const item = itemById.get(requirement.id);
+ if (!item) continue;
+ const key =
+ requirement.projectWorkstreamStageId === null
+ ? ANY_STAGE_KEY
+ : requirement.projectWorkstreamStageId.toString();
+ const bucket = buckets.get(key) ?? [];
+ bucket.push(item);
+ buckets.set(key, bucket);
+ }
+
+ const groups: EvidenceChecklistGroup[] = [];
+ const anyStageItems = buckets.get(ANY_STAGE_KEY);
+ if (anyStageItems) {
+ groups.push({
+ stageId: null,
+ stageName: ANY_STAGE_GROUP_LABEL,
+ items: anyStageItems,
+ });
+ }
+
+ const stageGroups: { order: number; group: EvidenceChecklistGroup }[] = [];
+ for (const [key, groupItems] of buckets) {
+ if (key === ANY_STAGE_KEY) continue;
+ const stage = stageById.get(BigInt(key));
+ stageGroups.push({
+ // A requirement pointing at a stage we did not load sorts last and is
+ // labelled by its id rather than vanishing.
+ order: stage?.order ?? Number.MAX_SAFE_INTEGER,
+ group: {
+ stageId: BigInt(key),
+ stageName: stage?.name ?? `Stage ${key}`,
+ items: groupItems,
+ },
+ });
+ }
+ stageGroups.sort((a, b) => a.order - b.order);
+
+ return groups.concat(stageGroups.map((s) => s.group));
+}
+
+/** Map key standing in for "no stage tag". */
+const ANY_STAGE_KEY = "any";