mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
commit
a380c7ebe6
10 changed files with 1574 additions and 0 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
233
src/app/repositories/projects/workOrderDetailRepository.ts
Normal file
233
src/app/repositories/projects/workOrderDetailRepository.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
290
src/lib/projects/workOrderDetail.test.ts
Normal file
290
src/lib/projects/workOrderDetail.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
307
src/lib/projects/workOrderDetail.ts
Normal file
307
src/lib/projects/workOrderDetail.ts
Normal 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";
|
||||
Loading…
Add table
Reference in a new issue