diff --git a/src/app/api/projects/[projectId]/work-orders/filters.test.ts b/src/app/api/projects/[projectId]/work-orders/filters.test.ts index 144a1741..f9e63775 100644 --- a/src/app/api/projects/[projectId]/work-orders/filters.test.ts +++ b/src/app/api/projects/[projectId]/work-orders/filters.test.ts @@ -13,6 +13,7 @@ import { NO_FILTERS, hasActiveFilters, parseWorkOrderQuery, + withWorkstreamFilter, workOrderSearchParams, type WorkOrderFilterSelection, } from "./filters"; @@ -141,6 +142,49 @@ describe("workOrderSearchParams", () => { }); }); +describe("withWorkstreamFilter", () => { + const STAGES = [ + { id: "101", workstreamId: "10" }, + { id: "201", workstreamId: "20" }, + ]; + + it("drops a stage that belongs to a different workstream", () => { + const next = withWorkstreamFilter( + { ...NO_FILTERS, stageId: "101" }, + STAGES, + "20", + ); + expect(next).toMatchObject({ workstreamId: "20", stageId: null }); + }); + + it("keeps a stage that belongs to the workstream being selected", () => { + const next = withWorkstreamFilter( + { ...NO_FILTERS, stageId: "101" }, + STAGES, + "10", + ); + expect(next).toMatchObject({ workstreamId: "10", stageId: "101" }); + }); + + it("keeps the stage when the workstream filter is cleared", () => { + const next = withWorkstreamFilter( + { ...NO_FILTERS, stageId: "101" }, + STAGES, + null, + ); + expect(next).toMatchObject({ workstreamId: null, stageId: "101" }); + }); + + it("leaves the other filters alone", () => { + const next = withWorkstreamFilter( + { ...NO_FILTERS, overdueOnly: true, missingEvidence: true }, + STAGES, + "20", + ); + expect(next).toMatchObject({ overdueOnly: true, missingEvidence: true }); + }); +}); + describe("hasActiveFilters", () => { it("is false only when nothing at all is narrowed", () => { expect(hasActiveFilters(NO_FILTERS)).toBe(false); diff --git a/src/app/api/projects/[projectId]/work-orders/filters.ts b/src/app/api/projects/[projectId]/work-orders/filters.ts index a012c88a..afc8366d 100644 --- a/src/app/api/projects/[projectId]/work-orders/filters.ts +++ b/src/app/api/projects/[projectId]/work-orders/filters.ts @@ -37,6 +37,22 @@ export const DEFAULT_PAGE_SIZE = 50; */ export const MAX_PAGE_SIZE = 200; +/** + * The choices the filter bar offers, all read live from the project's setup. + * + * Declared here rather than beside the query that loads it (`./queries`) so a + * client component can name the type without pulling the database client into + * its module graph. + */ +export interface WorkOrderFilterOptions { + /** Project Workstreams, named from the `workstream` reference data. */ + workstreams: { id: string; name: string }[]; + /** Every organisation assigned as contractor to any of the project's workstreams. */ + contractors: { organisationId: string; name: string }[]; + /** Every stage of every workstream, so the stage filter can narrow to one workstream. */ + stages: { id: string; name: string; workstreamId: string; order: number }[]; +} + /** The filter bar's state: what the user has narrowed the table to. */ export interface WorkOrderFilterSelection { /** `project_workstream.id` — the Project Workstream, not the catalogue row. */ @@ -60,6 +76,29 @@ export const NO_FILTERS: WorkOrderFilterSelection = { missingEvidence: false, }; +/** + * Change the workstream filter, dropping a stage filter that cannot survive it. + * + * A stage belongs to exactly one Project Workstream, so a selection that keeps + * "Windows · In progress" while narrowing to Roofs composes into a guaranteed + * empty table — and the user has no way to tell that from "Roofs has no work + * orders in progress". Filters compose; a pair that cannot compose is dropped + * rather than shown. + */ +export function withWorkstreamFilter( + selection: WorkOrderFilterSelection, + stages: readonly { id: string; workstreamId: string }[], + workstreamId: string | null, +): WorkOrderFilterSelection { + const stage = stages.find((candidate) => candidate.id === selection.stageId); + const keepStage = workstreamId === null || stage?.workstreamId === workstreamId; + return { + ...selection, + workstreamId, + stageId: keepStage ? selection.stageId : null, + }; +} + /** True when nothing is narrowed, so the UI can label the empty state honestly. */ export function hasActiveFilters(selection: WorkOrderFilterSelection): boolean { return ( diff --git a/src/app/api/projects/[projectId]/work-orders/queries.ts b/src/app/api/projects/[projectId]/work-orders/queries.ts index 826ec121..7b34a96f 100644 --- a/src/app/api/projects/[projectId]/work-orders/queries.ts +++ b/src/app/api/projects/[projectId]/work-orders/queries.ts @@ -43,19 +43,9 @@ import { loadStageLadderEntries, type ApplicableEvidencePair, } from "@/app/repositories/projects/dashboardRepository"; -import type { WorkOrderQuery } from "./filters"; +import type { WorkOrderFilterOptions, WorkOrderQuery } from "./filters"; import { toWorkOrderRow, type RawWorkOrderRow, type WorkOrderPage } from "./rows"; -/** The choices the filter bar offers, all read live from the project's setup. */ -export interface WorkOrderFilterOptions { - /** Project Workstreams, named from the `workstream` reference data. */ - workstreams: { id: string; name: string }[]; - /** Every organisation assigned as contractor to any of the project's workstreams. */ - contractors: { organisationId: string; name: string }[]; - /** Every stage of every workstream, so the stage filter can narrow to one workstream. */ - stages: { id: string; name: string; workstreamId: string; order: number }[]; -} - /** * Load one page of the table. * diff --git a/src/app/projects/[projectId]/work-orders/components/WorkOrderFilterBar.tsx b/src/app/projects/[projectId]/work-orders/components/WorkOrderFilterBar.tsx new file mode 100644 index 00000000..f75e6b97 --- /dev/null +++ b/src/app/projects/[projectId]/work-orders/components/WorkOrderFilterBar.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/app/shadcn_components/ui/select"; +import { + NO_FILTERS, + hasActiveFilters, + withWorkstreamFilter, + type WorkOrderFilterOptions, + type WorkOrderFilterSelection, +} from "@/app/api/projects/[projectId]/work-orders/filters"; + +/** + * The work-orders filter bar (issue #419). + * + * Every control is a *narrowing* of the same query — the filters compose, they + * do not replace one another — and each change is reported as a whole new + * selection so the table can reset its cursor in one place rather than in five + * handlers. + * + * The two toggles are the derived ones. Their meaning is not defined here or in + * this component's parent: "overdue" and "missing required evidence" are the + * shared rules from `@/lib/projects/derivations`, applied server-side, and the + * labels below just name them. + */ + +/** Radix reserves the empty string as a `Select` value, so "any" travels as a sentinel. */ +const ANY = "__any__"; + +export function WorkOrderFilterBar({ + options, + selection, + onChange, + disabled = false, +}: { + options: WorkOrderFilterOptions; + selection: WorkOrderFilterSelection; + onChange: (next: WorkOrderFilterSelection) => void; + disabled?: boolean; +}) { + const update = (patch: Partial) => + onChange({ ...selection, ...patch }); + + // Stages belong to one Project Workstream each, so once a workstream is + // chosen the other workstreams' stages are not merely irrelevant — picking + // one would guarantee an empty table. + const stages = selection.workstreamId + ? options.stages.filter((s) => s.workstreamId === selection.workstreamId) + : options.stages; + + const workstreamNames = new Map(options.workstreams.map((w) => [w.id, w.name])); + + return ( +
+ ({ value: w.id, label: w.name }))} + onChange={(workstreamId) => + onChange(withWorkstreamFilter(selection, options.stages, workstreamId)) + } + /> + + ({ + value: c.organisationId, + label: c.name, + }))} + onChange={(contractorOrganisationId) => + update({ contractorOrganisationId }) + } + /> + + ({ + value: stage.id, + label: stage.name, + // Two workstreams can both have an "In progress"; say whose it is. + hint: selection.workstreamId + ? undefined + : workstreamNames.get(stage.workstreamId), + }))} + onChange={(stageId) => update({ stageId })} + /> + + update({ overdueOnly })} + /> + + update({ missingEvidence })} + /> + + {hasActiveFilters(selection) && ( + + )} +
+ ); +} + +interface FilterOption { + value: string; + label: string; + hint?: string; +} + +function FilterSelect({ + value, + options, + onChange, + placeholder, + anyLabel, + ariaLabel, + testId, + disabled, +}: { + value: string | null; + options: FilterOption[]; + onChange: (value: string | null) => void; + placeholder: string; + anyLabel: string; + ariaLabel: string; + testId: string; + disabled: boolean; +}) { + return ( + + ); +} + +function FilterToggle({ + label, + checked, + onChange, + disabled, + testId, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; + disabled: boolean; + testId: string; +}) { + return ( + + ); +} diff --git a/src/app/projects/[projectId]/work-orders/components/WorkOrderTableColumns.tsx b/src/app/projects/[projectId]/work-orders/components/WorkOrderTableColumns.tsx new file mode 100644 index 00000000..83978106 --- /dev/null +++ b/src/app/projects/[projectId]/work-orders/components/WorkOrderTableColumns.tsx @@ -0,0 +1,253 @@ +"use client"; + +import Link from "next/link"; +import type { ColumnDef } from "@tanstack/react-table"; +import { isoToLocalDate } from "@/utils/dates"; +import type { WorkOrderRow } from "@/app/api/projects/[projectId]/work-orders/rows"; + +/** + * Columns for the admin work-orders table (issue #419). + * + * Factored as a column factory like + * `your-projects/live/PropertyTableColumns.tsx`, so the table component stays + * about state and this file stays about presentation. + * + * Two things this file deliberately does **not** do: + * + * - **Decide anything.** `overdue`, `progress` and the evidence counts arrive + * already derived by `@/lib/projects/derivations` (#418) via the route + * handler. A cell that recomputed "is this late?" from `forecastEnd` would be + * a second definition of overdue. + * - **Sort.** The rows are one server-side page of a set that runs to + * thousands, so a client-side sort would reorder *this page only* — which + * reads as sorting the table and is not. Ordering is fixed server-side by + * `work_order.id`, the same key the cursor pages on. + */ + +/** `projectId` is needed only to build the row action's href. */ +export function createWorkOrderColumns( + projectId: string, +): ColumnDef[] { + return [ + { + id: "reference", + accessorKey: "reference", + header: () => WO ref, + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + + { + id: "landlordPropertyId", + accessorKey: "landlordPropertyId", + header: () => Asset ref, + cell: ({ row }) => ( + + {row.original.landlordPropertyId ?? } + + ), + }, + + { + id: "address", + accessorKey: "address", + header: () => Address, + cell: ({ row }) => ( +
+

+ {row.original.address ?? } +

+ {row.original.postcode && ( +

+ {row.original.postcode} +

+ )} +
+ ), + }, + + { + id: "workstreamName", + accessorKey: "workstreamName", + header: () => Workstream, + cell: ({ row }) => ( + + {row.original.workstreamName} + + ), + }, + + { + id: "contractorName", + accessorKey: "contractorName", + header: () => Contractor, + cell: ({ row }) => + row.original.contractorName ? ( + + {row.original.contractorName} + + ) : ( + Unassigned + ), + }, + + { + id: "stageName", + accessorKey: "stageName", + header: () => Stage, + // The name is read live through the work order's stage FK on every + // request, so a stage rename shows up here without a backfill. + cell: ({ row }) => ( + + ), + }, + + { + id: "forecastEnd", + accessorKey: "forecastEnd", + header: () => Forecast end, + cell: ({ row }) => ( +
+ + {formatForecastEnd(row.original.forecastEnd)} + + {row.original.overdue && ( + + Overdue + + )} +
+ ), + }, + + { + id: "docs", + header: () => Docs, + cell: ({ row }) => , + }, + + { + id: "action", + header: () => Action, + cell: ({ row }) => ( + + Open + + ), + }, + ]; +} + +/** + * `YYYY-MM-DD` → `dd/mm/yyyy`. + * + * This is a UK product: `03/09/2026` is 3 September, and rendering it as 3 + * March is silently wrong data rather than a cosmetic bug (CLAUDE.md, + * ADR-0019). `isoToLocalDate` builds the `Date` from its parts, at *local* + * midnight — `new Date("2026-07-30")` is midnight UTC, which west of Greenwich + * renders as the 29th. + */ +function formatForecastEnd(iso: string | null): string { + const date = isoToLocalDate(iso); + return date ? date.toLocaleDateString("en-GB") : "—"; +} + +/** + * The stage, toned by where it sits on its workstream's ladder. + * + * The *name* is whatever the project called the stage; the *colour* comes from + * the derived progress, so a project that renames "Completed" to "Signed off" + * keeps the completed treatment. + */ +function StageBadge({ + name, + progress, +}: { + name: string; + progress: WorkOrderRow["progress"]; +}) { + const tone = + progress === "complete" + ? "border-emerald-200 bg-emerald-50 text-emerald-700" + : progress === "in_progress" + ? "border-blue-200 bg-blue-50 text-blue-700" + : progress === "not_started" + ? "border-gray-200 bg-gray-50 text-gray-600" + : // Stage outside the loaded ladders — show the name, claim nothing. + "border-gray-200 bg-white text-gray-500"; + + return ( + + {name} + + ); +} + +/** + * Required-evidence completeness — **advisory only** (CONTEXT.md: "Evidence is + * advisory-only in v1 — badges and counts, never a gate"). Nothing here blocks + * anything; it is a prompt to go and chase a document. + */ +function DocsBadge({ row }: { row: WorkOrderRow }) { + const { required, satisfied } = row.evidence; + + if (required === 0) { + return ( + + — + + ); + } + + const tone = row.missingRequiredEvidence + ? "border-amber-200 bg-amber-50 text-amber-700" + : "border-emerald-200 bg-emerald-50 text-emerald-700"; + + return ( + + {satisfied}/{required} + + ); +} + +function HeaderLabel({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Blank() { + return ; +} diff --git a/src/app/projects/[projectId]/work-orders/components/WorkOrdersView.tsx b/src/app/projects/[projectId]/work-orders/components/WorkOrdersView.tsx new file mode 100644 index 00000000..f5297fc0 --- /dev/null +++ b/src/app/projects/[projectId]/work-orders/components/WorkOrdersView.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/app/shadcn_components/ui/table"; +import { + DEFAULT_PAGE_SIZE, + NO_FILTERS, + hasActiveFilters, + workOrderSearchParams, + type WorkOrderFilterOptions, + type WorkOrderFilterSelection, +} from "@/app/api/projects/[projectId]/work-orders/filters"; +import type { WorkOrderPage } from "@/app/api/projects/[projectId]/work-orders/rows"; +import { WorkOrderFilterBar } from "./WorkOrderFilterBar"; +import { createWorkOrderColumns } from "./WorkOrderTableColumns"; + +/** + * The admin work-orders table (issue #419). + * + * A programme runs to thousands of work orders, so **the server owns the + * filtering, the ordering and the paging**; this component owns only the + * question being asked. That split is what the two pieces of state below are: + * the filter selection, and a stack of the cursors used to reach the current + * page. + * + * ## Why a cursor stack rather than a page number + * + * The route handler paginates by keyset (`work_order.id > cursor`), which stays + * correct under any combination of filters and never re-reads rows the user has + * already passed. The cost is that "the previous page" is not a number the + * server can derive — so the client remembers the cursors it has used, pushing + * on Next and popping on Previous. + * + * Changing a filter clears that stack. A cursor is a position in *one* filtered + * set; carrying it into a different one would land the user in the middle of a + * result set they have not seen the start of. + * + * TanStack Query v4 throughout (`keepPreviousData`, not v5's `placeholderData`), + * seeded from the server-rendered first page so there is no empty flash. No + * polling, so no `refetchInterval` signature to get wrong. + */ +export function WorkOrdersView({ + projectId, + options, + initialPage, +}: { + projectId: string; + options: WorkOrderFilterOptions; + /** The unfiltered first page, rendered on the server by the same loader. */ + initialPage: WorkOrderPage; +}) { + const [selection, setSelection] = + useState(NO_FILTERS); + /** Cursors used to reach the current page; empty means page one. */ + const [cursors, setCursors] = useState([]); + + const cursor = cursors.length > 0 ? cursors[cursors.length - 1] : null; + const search = workOrderSearchParams(selection, { cursor }).toString(); + + const { data, isFetching, isError, error } = useQuery({ + queryKey: ["projects", projectId, "work-orders", search], + queryFn: async (): Promise => { + const res = await fetch( + `/api/projects/${projectId}/work-orders${search ? `?${search}` : ""}`, + ); + const body = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(body.error ?? "Couldn't load the work orders"); + } + return body; + }, + // Seed only the query this page was rendered for — an empty search string + // is exactly the unfiltered first page. Seeding any other key would show + // one filter's rows under another's controls. + initialData: search === "" ? initialPage : undefined, + keepPreviousData: true, + }); + + const page = data ?? { workOrders: [], nextCursor: null, total: 0 }; + + const table = useReactTable({ + data: page.workOrders, + // Rebuilt each render, which is free here: the table holds no state of its + // own to reset — sorting, filtering and pagination all live on the server. + columns: createWorkOrderColumns(projectId), + getCoreRowModel: getCoreRowModel(), + }); + + const firstRowNumber = cursors.length * DEFAULT_PAGE_SIZE + 1; + const lastRowNumber = firstRowNumber + page.workOrders.length - 1; + + function changeFilters(next: WorkOrderFilterSelection) { + setSelection(next); + setCursors([]); + } + + return ( +
+
+ +

+ {page.total.toLocaleString("en-GB")}{" "} + {page.total === 1 ? "work order" : "work orders"} + {hasActiveFilters(selection) && " matching"} +

+
+ + {isError && ( +

+ {(error as Error).message} +

+ )} + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {page.workOrders.length === 0 ? ( + + + {hasActiveFilters(selection) + ? "No work orders match these filters." + : "No work orders yet. They arrive with the programme import."} + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} + +
+
+ +
+

+ {page.workOrders.length === 0 + ? "Showing 0 work orders" + : `Showing ${firstRowNumber.toLocaleString("en-GB")}–${lastRowNumber.toLocaleString( + "en-GB", + )} of ${page.total.toLocaleString("en-GB")}`} +

+
+ setCursors(cursors.slice(0, -1))} + /> + { + if (page.nextCursor) setCursors([...cursors, page.nextCursor]); + }} + /> +
+
+
+ ); +} + +function PageButton({ + label, + onClick, + disabled, + testId, +}: { + label: string; + onClick: () => void; + disabled: boolean; + testId: string; +}) { + return ( + + ); +} diff --git a/src/app/projects/[projectId]/work-orders/page.tsx b/src/app/projects/[projectId]/work-orders/page.tsx new file mode 100644 index 00000000..19806f5a --- /dev/null +++ b/src/app/projects/[projectId]/work-orders/page.tsx @@ -0,0 +1,110 @@ +import Link from "next/link"; +import { requireProjectAccess } from "../../guards"; +import { canManageProject } from "@/lib/projects/authz"; +import { DEFAULT_QUERY } from "@/app/api/projects/[projectId]/work-orders/filters"; +import { + loadFilterOptions, + loadWorkOrderPage, +} from "@/app/api/projects/[projectId]/work-orders/queries"; +import { WorkOrdersView } from "./components/WorkOrdersView"; + +export const metadata = { + title: "Work orders | Ara", +}; + +/** + * The admin work-orders table (issue #419). + * + * A Server Component that renders the *first* page — filter choices and the + * unfiltered first 50 rows — and hands them to the client table as seed data. + * Every subsequent filter change and page step goes through + * `GET /api/projects/[projectId]/work-orders`, which calls the same + * `loadWorkOrderPage`, so the first paint and every refetch afterwards are + * produced by identical code rather than by two implementations that have to be + * kept in step. + * + * Authorization: `requireProjectAccess` (#409) settles visibility, and + * `canManageProject` settles *this* table. It is the admin view — internal and + * client-org, per the issue. A contractor can see the project, so a 404 would + * be a lie; they get a note instead, and their own scoped view lands in a later + * issue. The route handler enforces the same rule independently: this page is + * not the security boundary. + * + * The wireframe's Omit / Export controls are out of scope (#426). Import links + * to the import flow, which is where work orders come from (#417) — until it + * runs, this table renders its empty state. + */ +export default async function WorkOrdersPage(props: { + params: Promise<{ projectId: string }>; +}) { + const { projectId } = await props.params; + const { user, project } = await requireProjectAccess(projectId); + + if (!canManageProject(user, project)) { + return ( + +

+ Work orders issued to your organisation will appear here. The + contractor view is still being built. +

+
+ ); + } + + const asOf = new Date(); + const [options, initialPage] = await Promise.all([ + loadFilterOptions(project.id), + loadWorkOrderPage(project.id, DEFAULT_QUERY, asOf), + ]); + + return ( + + + + ); +} + +function Shell({ + projectId, + showImport, + children, +}: { + projectId: string; + showImport: boolean; + children: React.ReactNode; +}) { + return ( +
+
+
+

+ Ara Projects +

+

+ Work orders +

+
+ {showImport && ( + + Import + + )} +
+ {children} +
+ ); +}