From 909657fff715d82657b428f7d4b88f5c2f75e6d7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 08:42:32 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(ara-projects):=20work-order=20detail?= =?UTF-8?q?=20page=20=E2=80=94=20stage=20stepper=20+=20evidence=20checklis?= =?UTF-8?q?t=20(#422)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /projects/[projectId]/work-orders/[workOrderId] as an authz-checked Server Component: header (reference, workstream, contractor org, forecast end), property summary (address + landlord property id), a stage stepper rendering the workstream ladder in order with the current stage highlighted, and an evidence checklist grouping the workstream's requirements by stage tag (stage-less under "Any stage"), each showing matched uploaded_files or an advisory "missing" badge plus naming-rule hint, with an "n of m required uploaded" summary badge. The summary reuses requiredEvidenceRequirementIds + evidenceCompleteness from @/lib/projects/derivations, so the counts reconcile with the dashboard (#418) and work-orders table (#419) by construction rather than a second calculation. Authorization is two-step: requireProjectAccess settles project visibility, then a new canViewWorkOrder scopes the work order — a contractor from another org gets a server-side 404, never a 403, so existence does not leak. Empty action slots for stage update (#423) and evidence upload (#424) are rendered per the canUpdateStage / canUploadEvidence guards but not implemented. Cut per the issue: activity log (#426), booking/tenant panel (#427), and hard completion-blocking (evidence is advisory in v1). Tests: canViewWorkOrder cases; pure view-builder (zero-requirements render, grouping, file matching, counts-agree-with-derivations); page-level authz 404 paths and zero-requirements render. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[workOrderId]/components/ActionSlots.tsx | 45 +++ .../components/WorkOrderDetailPanels.tsx | 322 ++++++++++++++++++ .../work-orders/[workOrderId]/page.test.tsx | 194 +++++++++++ .../work-orders/[workOrderId]/page.tsx | 106 ++++++ .../projects/workOrderDetailRepository.ts | 233 +++++++++++++ src/lib/projects/authz.test.ts | 43 +++ src/lib/projects/authz.ts | 25 ++ src/lib/projects/workOrderDetail.test.ts | 290 ++++++++++++++++ src/lib/projects/workOrderDetail.ts | 307 +++++++++++++++++ 9 files changed, 1565 insertions(+) create mode 100644 src/app/projects/[projectId]/work-orders/[workOrderId]/components/ActionSlots.tsx create mode 100644 src/app/projects/[projectId]/work-orders/[workOrderId]/components/WorkOrderDetailPanels.tsx create mode 100644 src/app/projects/[projectId]/work-orders/[workOrderId]/page.test.tsx create mode 100644 src/app/projects/[projectId]/work-orders/[workOrderId]/page.tsx create mode 100644 src/app/repositories/projects/workOrderDetailRepository.ts create mode 100644 src/lib/projects/workOrderDetail.test.ts create mode 100644 src/lib/projects/workOrderDetail.ts 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 ( + + ); +} + +/** Slot for the evidence-upload control (#424), shown when `canUploadEvidence`. */ +export function EvidenceUploadSlot() { + return ( + + ); +} 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) => ( +
  1. + + {step.state === "done" ? "✓" : step.order} + + {step.name} +
  2. + ))} +
+ )} +
+ ); +} + +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]/page.test.tsx b/src/app/projects/[projectId]/work-orders/[workOrderId]/page.test.tsx new file mode 100644 index 00000000..5111b09a --- /dev/null +++ b/src/app/projects/[projectId]/work-orders/[workOrderId]/page.test.tsx @@ -0,0 +1,194 @@ +/** + * 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, { parseWorkOrderId } from "./page"; + +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..6a6641e8 --- /dev/null +++ b/src/app/projects/[projectId]/work-orders/[workOrderId]/page.tsx @@ -0,0 +1,106 @@ +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"; + +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 + } + /> +
+ ); +} + +/** + * 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. Mirrors `parseProjectId` in the guards. + */ +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/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 11681c59..add3951d 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, type AuthzUser, @@ -169,6 +170,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 7fbd15c4..fec87968 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"; From 00ed03098a40d38573fbc1e2c30690fbcee6a944 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 24 Jul 2026 08:54:46 +0000 Subject: [PATCH 2/2] fix(ara-projects): move parseWorkOrderId out of page.tsx (#422) Next.js rejects any non-default export from a page module beyond its allowed fields, so the `parseWorkOrderId` helper failed the production type check on Vercel. Move it to a colocated `ids.ts`; page.tsx now exports only `metadata` and the default page. Behaviour and tests are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../work-orders/[workOrderId]/ids.ts | 18 ++++++++++++++++++ .../work-orders/[workOrderId]/page.test.tsx | 3 ++- .../work-orders/[workOrderId]/page.tsx | 12 +----------- 3 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 src/app/projects/[projectId]/work-orders/[workOrderId]/ids.ts 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 index 5111b09a..54c8d7d9 100644 --- a/src/app/projects/[projectId]/work-orders/[workOrderId]/page.test.tsx +++ b/src/app/projects/[projectId]/work-orders/[workOrderId]/page.test.tsx @@ -39,7 +39,8 @@ vi.mock("next/link", () => ({ }) => {children}, })); -import WorkOrderDetailPage, { parseWorkOrderId } from "./page"; +import WorkOrderDetailPage from "./page"; +import { parseWorkOrderId } from "./ids"; const CLIENT_ORG = "11111111-1111-1111-1111-111111111111"; const CONTRACTOR_ORG = "22222222-2222-2222-2222-222222222222"; diff --git a/src/app/projects/[projectId]/work-orders/[workOrderId]/page.tsx b/src/app/projects/[projectId]/work-orders/[workOrderId]/page.tsx index 6a6641e8..d3dbbecf 100644 --- a/src/app/projects/[projectId]/work-orders/[workOrderId]/page.tsx +++ b/src/app/projects/[projectId]/work-orders/[workOrderId]/page.tsx @@ -15,6 +15,7 @@ import { WorkOrderHeader, } from "./components/WorkOrderDetailPanels"; import { EvidenceUploadSlot, StageUpdateSlot } from "./components/ActionSlots"; +import { parseWorkOrderId } from "./ids"; export const metadata = { title: "Work order | Ara", @@ -93,14 +94,3 @@ export default async function WorkOrderDetailPage(props: { ); } - -/** - * 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. Mirrors `parseProjectId` in the guards. - */ -export function parseWorkOrderId(workOrderId: string): bigint | null { - if (!/^\d+$/.test(workOrderId)) return null; - const parsed = BigInt(workOrderId); - return parsed > 0n ? parsed : null; -}