Merge pull request #458 from Hestia-Homes/issue-422-work-order-detail

Ara Projects: work-order detail — stage stepper + evidence checklist (#422)
This commit is contained in:
Daniel Roth 2026-07-27 12:00:34 +01:00 committed by GitHub
commit a380c7ebe6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1574 additions and 0 deletions

View file

@ -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 (
<button
type="button"
disabled
data-testid="work-order-stage-update-slot"
className="cursor-not-allowed rounded-lg border border-gray-200 px-3 py-1.5 text-sm font-medium text-gray-400"
title="Coming soon"
>
Update stage
</button>
);
}
/** Slot for the evidence-upload control (#424), shown when `canUploadEvidence`. */
export function EvidenceUploadSlot() {
return (
<button
type="button"
disabled
data-testid="work-order-evidence-upload-slot"
className="cursor-not-allowed rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-400"
title="Coming soon"
>
Upload
</button>
);
}

View file

@ -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 (
<header data-testid="work-order-header">
<p className="mb-1 text-xs font-semibold uppercase tracking-widest text-gray-400">
Ara Projects · Work order
</p>
<div className="flex flex-wrap items-center gap-3">
<h1
className="font-mono text-3xl font-extrabold tracking-tight text-gray-900"
data-testid="work-order-reference"
>
{header.reference}
</h1>
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-700">
{header.workstreamName}
</span>
{header.priority ? (
<span className="rounded-full bg-amber-100 px-3 py-1 text-sm font-semibold text-amber-800">
Priority
</span>
) : null}
</div>
<dl className="mt-3 flex flex-wrap gap-x-8 gap-y-1 text-sm text-gray-600">
<div className="flex gap-1.5">
<dt className="text-gray-400">Contractor</dt>
<dd className="font-medium text-gray-800">
{header.contractorName ??
`Organisation ${header.contractorOrganisationId}`}
</dd>
</div>
<div className="flex gap-1.5">
<dt className="text-gray-400">Forecast end</dt>
<dd className="font-medium text-gray-800">
{formatUkDate(header.forecastEnd) || "—"}
</dd>
</div>
<div className="flex gap-1.5">
<dt className="text-gray-400">Current stage</dt>
<dd className="font-medium text-gray-800">{header.currentStageName}</dd>
</div>
</dl>
</header>
);
}
/** Address + landlord property id, joined via `work_order.property_id`. */
export function PropertySummary({ header }: { header: WorkOrderDetailHeader }) {
const { property } = header;
return (
<section
aria-label="Property"
data-testid="work-order-property"
className="rounded-xl border border-gray-200 bg-white px-4 py-4"
>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-widest text-gray-400">
Property
</h2>
<p className="text-base font-semibold text-gray-900">
{property.address ?? "Address unavailable"}
{property.postcode ? (
<span className="font-normal text-gray-500"> · {property.postcode}</span>
) : null}
</p>
<p className="mt-1 text-sm text-gray-500">
Landlord property id:{" "}
<span className="font-medium text-gray-700">
{property.landlordPropertyId ?? "—"}
</span>
</p>
</section>
);
}
/** 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 (
<section aria-label="Order progress" data-testid="work-order-stepper">
<div className="mb-3 flex items-baseline justify-between">
<h2 className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Order progress
</h2>
{stageUpdateSlot}
</div>
{steps.length === 0 ? (
<EmptyPanel>This workstream has no stages configured.</EmptyPanel>
) : (
<ol className="flex flex-wrap gap-2">
{steps.map((step) => (
<li
key={step.id.toString()}
data-testid="work-order-step"
data-state={step.state}
aria-current={step.state === "current" ? "step" : undefined}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${stepClasses(
step.state,
)}`}
>
<span
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${dotClasses(
step.state,
)}`}
>
{step.state === "done" ? "✓" : step.order}
</span>
<span className="font-medium">{step.name}</span>
</li>
))}
</ol>
)}
</section>
);
}
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 (
<section aria-label="Required evidence" data-testid="work-order-evidence">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Required evidence
</h2>
<EvidenceSummaryBadge summary={summary} />
</div>
{groups.length === 0 ? (
<EmptyPanel data-testid="work-order-evidence-empty">
No evidence requirements configured for this workstream.
</EmptyPanel>
) : (
<div className="space-y-5">
{groups.map((group) => (
<div key={group.stageId?.toString() ?? "any"}>
<h3 className="mb-2 text-sm font-semibold text-gray-700">
{group.stageName}
</h3>
<ul className="space-y-2">
{group.items.map((item) => (
<li
key={item.requirementId.toString()}
data-testid="work-order-evidence-item"
data-status={item.status}
className="rounded-xl border border-gray-200 bg-white px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium text-gray-900">
{fileTypeLabel(item.fileType)}
{!item.required ? (
<span className="ml-2 text-xs font-normal text-gray-400">
Optional
</span>
) : null}
</p>
{item.status === "uploaded" ? (
<ul className="mt-1 space-y-0.5">
{item.files.map((file) => (
<li
key={file.id.toString()}
className="text-sm text-gray-500"
>
{fileNameOf(file.s3FileKey)}
<span className="text-gray-400">
{" · Uploaded "}
{formatUkDate(toCalendarDay(file.uploadedAt)) ||
"date unknown"}
</span>
</li>
))}
</ul>
) : (
<p className="mt-1 text-sm text-gray-400">
{item.namingRule
? `Naming rule: ${item.namingRule}`
: "No file uploaded yet"}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<EvidenceStatusBadge status={item.status} />
{uploadSlotFor?.(item.requirementId)}
</div>
</div>
</li>
))}
</ul>
</div>
))}
</div>
)}
</section>
);
}
/** "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 (
<span
data-testid="work-order-evidence-summary"
className={`rounded-full px-3 py-1 text-sm font-semibold ${
summary.required === 0
? "bg-gray-100 text-gray-500"
: complete
? "bg-green-100 text-green-800"
: "bg-amber-100 text-amber-800"
}`}
>
{summary.satisfied} of {summary.required} required uploaded
</span>
);
}
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 (
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-semibold ${config.className}`}
>
{config.label}
</span>
);
}
function EmptyPanel({
children,
...rest
}: {
children: React.ReactNode;
"data-testid"?: string;
}) {
return (
<div
{...rest}
className="rounded-xl border border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-400"
>
{children}
</div>
);
}
/** 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;
}

View file

@ -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;
}

View file

@ -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;
}) => <a href={href}>{children}</a>,
}));
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");
});
});

View file

@ -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 (
<div className="mx-auto max-w-5xl space-y-8 px-6 py-10">
<Link
href={`/projects/${projectId}/work-orders`}
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
Work orders
</Link>
<WorkOrderHeader header={detail.header} />
<PropertySummary header={detail.header} />
<StageStepper
steps={view.stepper}
stageUpdateSlot={mayUpdateStage ? <StageUpdateSlot /> : null}
/>
<EvidenceChecklist
groups={view.checklist.groups}
summary={view.checklist.summary}
uploadSlotFor={
mayUploadEvidence ? () => <EvidenceUploadSlot /> : undefined
}
/>
</div>
);
}

View file

@ -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<WorkOrderDetail | null> {
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<WorkOrderDetailStage[]> {
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<WorkOrderDetailRequirement[]> {
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<WorkOrderDetailFile[]> {
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);
}

View file

@ -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();

View file

@ -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.
*

View file

@ -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<WorkOrderDetailFile> & { 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<StageLadderEntry>((s) => ({
id: s.id,
projectWorkstreamId: 0n,
order: s.order,
})),
);
const applicable = new Set(
requiredEvidenceRequirementIds(
ladders,
requirements.map<EvidenceRequirementEntry>((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,
});
});
});

View file

@ -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<bigint, WorkOrderDetailFile[]> {
const byRequirement = new Map<bigint, WorkOrderDetailFile[]>();
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<bigint, WorkOrderDetailStage>,
): 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<string, EvidenceChecklistItem[]>();
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";