feat(ara-projects): admin work-orders table page (#419)

/projects/[projectId]/work-orders — TanStack Table v8 over the shadcn Table,
columns factored like your-projects/live/PropertyTableColumns.tsx: WO ref,
asset ref, address, workstream, contractor, stage, forecast end, docs, and a
row action to the work-order detail.

The server owns filtering, ordering and paging; the client owns only the
question being asked. A Server Component renders the filter choices and the
unfiltered first page through the same loadWorkOrderPage the route handler
uses, and seeds TanStack Query v4 with it (keepPreviousData, not v5's
placeholderData), so there is no empty flash and no second implementation of
the first paint. No polling, so no refetchInterval signature involved.

Cursor stack rather than a page number: keyset pagination cannot derive
"previous", so the client remembers the cursors it used. Changing a filter
clears the stack — a cursor is a position in one filtered set.

Nothing in the UI decides anything. overdue / progress / evidence counts
arrive already derived by @/lib/projects/derivations; the stage badge takes
its name live from the stage FK and its colour from the derived progress, so
a rename propagates and a project that renames "Completed" keeps the
completed treatment. Docs badges are advisory-only per CONTEXT.md.

Forecast end renders through toLocaleDateString("en-GB") off a local-midnight
Date built by isoToLocalDate — dd/mm/yyyy, per CLAUDE.md and ADR-0019, with
no UTC-parse day shift.

Contractors get a note rather than a 404: they can see the project, so the
lie would be worse than the gap. Their scoped view is a later issue.
This commit is contained in:
Daniel Roth 2026-07-23 12:51:42 +00:00
parent bbc2843fdf
commit 0daae49587
7 changed files with 906 additions and 11 deletions

View file

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

View file

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

View file

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

View file

@ -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<WorkOrderFilterSelection>) =>
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 (
<div
className="flex flex-wrap items-center gap-2"
data-testid="work-order-filters"
>
<FilterSelect
ariaLabel="Filter by workstream"
testId="filter-workstream"
placeholder="Workstream"
anyLabel="All workstreams"
value={selection.workstreamId}
disabled={disabled || options.workstreams.length === 0}
options={options.workstreams.map((w) => ({ value: w.id, label: w.name }))}
onChange={(workstreamId) =>
onChange(withWorkstreamFilter(selection, options.stages, workstreamId))
}
/>
<FilterSelect
ariaLabel="Filter by contractor"
testId="filter-contractor"
placeholder="Contractor"
anyLabel="All contractors"
value={selection.contractorOrganisationId}
disabled={disabled || options.contractors.length === 0}
options={options.contractors.map((c) => ({
value: c.organisationId,
label: c.name,
}))}
onChange={(contractorOrganisationId) =>
update({ contractorOrganisationId })
}
/>
<FilterSelect
ariaLabel="Filter by stage"
testId="filter-stage"
placeholder="Stage"
anyLabel="All stages"
value={selection.stageId}
disabled={disabled || stages.length === 0}
options={stages.map((stage) => ({
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 })}
/>
<FilterToggle
label="Overdue only"
testId="filter-overdue"
checked={selection.overdueOnly}
disabled={disabled}
onChange={(overdueOnly) => update({ overdueOnly })}
/>
<FilterToggle
label="Missing evidence"
testId="filter-missing-evidence"
checked={selection.missingEvidence}
disabled={disabled}
onChange={(missingEvidence) => update({ missingEvidence })}
/>
{hasActiveFilters(selection) && (
<button
type="button"
className="ml-1 text-xs font-medium text-blue-600 hover:text-blue-800"
onClick={() => onChange(NO_FILTERS)}
data-testid="filter-clear"
>
Clear filters
</button>
)}
</div>
);
}
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 (
<Select
value={value ?? ANY}
onValueChange={(next) => onChange(next === ANY ? null : next)}
disabled={disabled}
>
<SelectTrigger
aria-label={ariaLabel}
data-testid={testId}
className="h-9 w-auto min-w-[10rem] text-sm"
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ANY}>
<span className="text-gray-500">{anyLabel}</span>
</SelectItem>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span className="font-medium">{option.label}</span>
{option.hint && (
<span className="ml-2 text-xs text-gray-500">{option.hint}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function FilterToggle({
label,
checked,
onChange,
disabled,
testId,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled: boolean;
testId: string;
}) {
return (
<label
className={`inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border px-3 text-sm transition-colors ${
checked
? "border-blue-300 bg-blue-50 text-blue-800"
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
} ${disabled ? "cursor-not-allowed opacity-60" : ""}`}
>
<input
type="checkbox"
className="h-3.5 w-3.5 accent-blue-600"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
data-testid={testId}
/>
{label}
</label>
);
}

View file

@ -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<WorkOrderRow>[] {
return [
{
id: "reference",
accessorKey: "reference",
header: () => <HeaderLabel>WO ref</HeaderLabel>,
cell: ({ row }) => (
<span className="font-mono text-xs font-medium text-gray-900">
{row.original.reference}
</span>
),
},
{
id: "landlordPropertyId",
accessorKey: "landlordPropertyId",
header: () => <HeaderLabel>Asset ref</HeaderLabel>,
cell: ({ row }) => (
<span className="font-mono text-xs text-gray-500">
{row.original.landlordPropertyId ?? <Blank />}
</span>
),
},
{
id: "address",
accessorKey: "address",
header: () => <HeaderLabel>Address</HeaderLabel>,
cell: ({ row }) => (
<div className="max-w-[240px]">
<p className="truncate text-sm text-gray-900">
{row.original.address ?? <Blank />}
</p>
{row.original.postcode && (
<p className="truncate text-xs text-gray-400">
{row.original.postcode}
</p>
)}
</div>
),
},
{
id: "workstreamName",
accessorKey: "workstreamName",
header: () => <HeaderLabel>Workstream</HeaderLabel>,
cell: ({ row }) => (
<span className="text-sm text-gray-700">
{row.original.workstreamName}
</span>
),
},
{
id: "contractorName",
accessorKey: "contractorName",
header: () => <HeaderLabel>Contractor</HeaderLabel>,
cell: ({ row }) =>
row.original.contractorName ? (
<span className="text-sm text-gray-700">
{row.original.contractorName}
</span>
) : (
<span className="text-sm italic text-gray-400">Unassigned</span>
),
},
{
id: "stageName",
accessorKey: "stageName",
header: () => <HeaderLabel>Stage</HeaderLabel>,
// 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 }) => (
<StageBadge
name={row.original.stageName}
progress={row.original.progress}
/>
),
},
{
id: "forecastEnd",
accessorKey: "forecastEnd",
header: () => <HeaderLabel>Forecast end</HeaderLabel>,
cell: ({ row }) => (
<div className="whitespace-nowrap">
<span
className={
row.original.overdue
? "text-sm font-semibold text-red-600"
: "text-sm text-gray-600"
}
>
{formatForecastEnd(row.original.forecastEnd)}
</span>
{row.original.overdue && (
<span
className="ml-2 rounded bg-red-50 px-1.5 py-0.5 text-[11px] font-semibold text-red-700"
data-testid="work-order-overdue"
>
Overdue
</span>
)}
</div>
),
},
{
id: "docs",
header: () => <HeaderLabel>Docs</HeaderLabel>,
cell: ({ row }) => <DocsBadge row={row.original} />,
},
{
id: "action",
header: () => <span className="sr-only">Action</span>,
cell: ({ row }) => (
<Link
href={`/projects/${projectId}/work-orders/${row.original.id}`}
className="inline-flex items-center rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 transition-colors hover:border-gray-300 hover:bg-gray-50"
data-testid="work-order-open"
>
Open
</Link>
),
},
];
}
/**
* `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 (
<span
className={`inline-flex whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium ${tone}`}
data-testid="work-order-stage"
>
{name}
</span>
);
}
/**
* 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 (
<span className="text-xs text-gray-400" title="No required documents configured">
</span>
);
}
const tone = row.missingRequiredEvidence
? "border-amber-200 bg-amber-50 text-amber-700"
: "border-emerald-200 bg-emerald-50 text-emerald-700";
return (
<span
className={`inline-flex whitespace-nowrap rounded-md border px-2 py-0.5 font-mono text-xs font-medium ${tone}`}
title={
row.missingRequiredEvidence
? `${row.evidence.missing} required document(s) outstanding — advisory only`
: "All required documents uploaded"
}
data-testid="work-order-docs"
>
{satisfied}/{required}
</span>
);
}
function HeaderLabel({ children }: { children: React.ReactNode }) {
return (
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
{children}
</span>
);
}
function Blank() {
return <span className="text-gray-300"></span>;
}

View file

@ -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<WorkOrderFilterSelection>(NO_FILTERS);
/** Cursors used to reach the current page; empty means page one. */
const [cursors, setCursors] = useState<string[]>([]);
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<WorkOrderPage> => {
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 (
<div className="space-y-4" data-testid="work-orders-view">
<div className="flex flex-wrap items-center justify-between gap-3">
<WorkOrderFilterBar
options={options}
selection={selection}
onChange={changeFilters}
disabled={isFetching && !data}
/>
<p className="text-sm text-gray-500" data-testid="work-orders-total">
{page.total.toLocaleString("en-GB")}{" "}
{page.total === 1 ? "work order" : "work orders"}
{hasActiveFilters(selection) && " matching"}
</p>
</div>
{isError && (
<p
className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
role="alert"
data-testid="work-orders-error"
>
{(error as Error).message}
</p>
)}
<div
className={`rounded-xl border border-gray-200 bg-white transition-opacity ${
isFetching ? "opacity-60" : ""
}`}
aria-busy={isFetching}
>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="h-10">
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{page.workOrders.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={table.getAllColumns().length}
className="py-12 text-center text-sm text-gray-400"
data-testid="work-orders-empty"
>
{hasActiveFilters(selection)
? "No work orders match these filters."
: "No work orders yet. They arrive with the programme import."}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-testid="work-order-row">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="px-4 py-2.5">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500" data-testid="work-orders-range">
{page.workOrders.length === 0
? "Showing 0 work orders"
: `Showing ${firstRowNumber.toLocaleString("en-GB")}${lastRowNumber.toLocaleString(
"en-GB",
)} of ${page.total.toLocaleString("en-GB")}`}
</p>
<div className="flex items-center gap-2">
<PageButton
label="Previous"
testId="work-orders-previous"
disabled={cursors.length === 0 || isFetching}
onClick={() => setCursors(cursors.slice(0, -1))}
/>
<PageButton
label="Next"
testId="work-orders-next"
disabled={!page.nextCursor || isFetching}
onClick={() => {
if (page.nextCursor) setCursors([...cursors, page.nextCursor]);
}}
/>
</div>
</div>
</div>
);
}
function PageButton({
label,
onClick,
disabled,
testId,
}: {
label: string;
onClick: () => void;
disabled: boolean;
testId: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
data-testid={testId}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white"
>
{label}
</button>
);
}

View file

@ -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 (
<Shell projectId={projectId} showImport={false}>
<p
className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-8 text-center text-sm text-gray-500"
data-testid="work-orders-contractor-notice"
>
Work orders issued to your organisation will appear here. The
contractor view is still being built.
</p>
</Shell>
);
}
const asOf = new Date();
const [options, initialPage] = await Promise.all([
loadFilterOptions(project.id),
loadWorkOrderPage(project.id, DEFAULT_QUERY, asOf),
]);
return (
<Shell projectId={projectId} showImport>
<WorkOrdersView
projectId={projectId}
options={options}
initialPage={initialPage}
/>
</Shell>
);
}
function Shell({
projectId,
showImport,
children,
}: {
projectId: string;
showImport: boolean;
children: React.ReactNode;
}) {
return (
<div className="mx-auto max-w-7xl space-y-6 px-6 py-10">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-widest text-gray-400">
Ara Projects
</p>
<h1
className="text-3xl font-extrabold tracking-tight text-gray-900"
data-testid="work-orders-heading"
>
Work orders
</h1>
</div>
{showImport && (
<Link
href={`/projects/${projectId}/import`}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
data-testid="work-orders-import-link"
>
Import
</Link>
)}
</header>
{children}
</div>
);
}