From 0e69c080e1d2e766edb6785ac261b36542377989 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 23 Jul 2026 14:55:29 +0000 Subject: [PATCH] feat(ara-projects): make work_order.priority a boolean and show it in the table (#419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit priority was one of the "enum values TBC" text columns. It turns out to be a flag, not a scale — "look at this one first" — so there is no value set left to agree, and text was modelling a decision nobody needs to make. Now boolean NOT NULL DEFAULT false: a third "unknown" state has no meaning for a flag, and the table either renders it or renders nothing. Deliberately independent of overdue. Overdue is derived from the forecast date and the stage ladder; priority is stored, because someone marked it. A flagged work order may be well inside its dates and a late one nobody flagged is not a priority, so they are separate columns and separate badges. Read path: selected and grouped in the page query, carried through RawWorkOrderRow -> WorkOrderRow, rendered as the wireframe's Priority column between Docs and the row action. Shown only when true; a column of "No" chips would give the exception the same weight as the rule. Docs updated where they claimed otherwise: CONTEXT.md gains a Priority entry and no longer lists the column as untouched, and schema.md no longer groups it with the TBC-enum text columns. MIGRATION NOT INCLUDED AND NOT APPLIED. This commit changes the Drizzle schema only; work_order.priority is still `text` in the shared production database, so the boolean the code expects is not there yet. Generating and applying the migration is the repo owner's call. Two notes for whoever does: the table holds 0 rows today (verified by a read-only SELECT), so the change is lossless; and Postgres refuses ALTER COLUMN ... TYPE boolean on a text column without a USING clause, which drizzle-kit will not emit. --- CONTEXT.md | 6 ++++- .../[projectId]/work-orders/queries.test.ts | 7 +++++ .../[projectId]/work-orders/queries.ts | 7 ++++- .../[projectId]/work-orders/rows.test.ts | 26 +++++++++++++++++++ .../projects/[projectId]/work-orders/rows.ts | 9 +++++++ src/app/db/schema/projects/projects.ts | 12 ++++++--- src/app/db/schema/projects/schema.md | 12 ++++++--- .../components/WorkOrderTableColumns.tsx | 22 ++++++++++++++++ 8 files changed, 92 insertions(+), 9 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 62859a70..62a4ee06 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -161,13 +161,17 @@ A category of works (Windows, Doors, Roofs, Electrical, Heating, …) — refere _Avoid_: measure (Measure is taken by the Scenarios domain), trade, work type **Stage**: -One step in a Project Workstream's ordered ladder (`project_workstream_stage`, positioned by `order`). Each Project Workstream owns its own ladder — different workstreams may follow different workflows. The v1 default ladder is Ordered / In progress / Completed / Charged / Closed, and the terminal stage is the one with the highest `order`. A Work order's stage FK (`work_order.project_workstream_stage_id`) is the **only lifecycle mechanism in v1** — `work_order.status`, `work_order.priority` and `project_workstream.current_stage_id` exist in the schema but are untouched. +One step in a Project Workstream's ordered ladder (`project_workstream_stage`, positioned by `order`). Each Project Workstream owns its own ladder — different workstreams may follow different workflows. The v1 default ladder is Ordered / In progress / Completed / Charged / Closed, and the terminal stage is the one with the highest `order`. A Work order's stage FK (`work_order.project_workstream_stage_id`) is the **only lifecycle mechanism in v1** — `work_order.status` and `project_workstream.current_stage_id` exist in the schema but are untouched. `work_order.priority` is now used, but it is not a lifecycle mechanism: see **Priority**. _Avoid_: status (the unused text column), phase, step **Work order**: One unit of work issued to a contractor: a (Property, Project Workstream Stage, contractor organisation, unique reference, forecast end date) row in `work_order`. Its position in the workflow is its Stage FK (see **Stage**). _Avoid_: job, ticket, task (a **Task** is the BulkUpload orchestration handle) +**Priority**: +A **flag on a Work order** (`work_order.priority`, boolean), meaning "look at this one first". It is a *stored* fact — someone marks it — and so is deliberately independent of **Overdue**, which is derived from the forecast end date and the Stage ladder: a flagged work order may be well inside its dates, and a late one nobody flagged is not a priority. Not a scale: there are no priority *levels* in v1, only flagged and not. +_Avoid_: urgency, severity, high/medium/low (there are no levels), escalation + **Evidence requirement** / **Evidence**: An **Evidence requirement** (`project_workstream_evidence_requirement`) is configuration: a required `file_type` (the existing enum) per Project Workstream, optionally narrowed to a single Stage. **Evidence** is a submission: an `uploaded_files` row with `file_source = 'projects'`, linked to its Work order (`work_order_id`) and optionally to the requirement it satisfies. Evidence is **advisory-only in v1** — badges and counts, never a gate: it does not block stage transitions and there is no approval state. _Avoid_: document (generic), attachment, proof diff --git a/src/app/api/projects/[projectId]/work-orders/queries.test.ts b/src/app/api/projects/[projectId]/work-orders/queries.test.ts index 1f484f87..eb258530 100644 --- a/src/app/api/projects/[projectId]/work-orders/queries.test.ts +++ b/src/app/api/projects/[projectId]/work-orders/queries.test.ts @@ -173,6 +173,13 @@ describe("buildWorkOrderPageQuery", () => { expect(page().sql).toContain("LEFT JOIN project_workstream_contractor"); }); + it("selects the priority flag and groups by it", () => { + const { sql } = page(); + expect(sql).toContain("wo.priority"); + // Selected alongside an aggregate, so it has to appear in the GROUP BY. + expect(sql).toMatch(/GROUP BY[^)]*wo\.priority/); + }); + it("reads the stage and workstream names live, never from the work order", () => { const { sql } = page(); expect(sql).toContain("pws.name"); diff --git a/src/app/api/projects/[projectId]/work-orders/queries.ts b/src/app/api/projects/[projectId]/work-orders/queries.ts index 7b34a96f..bf8eacd6 100644 --- a/src/app/api/projects/[projectId]/work-orders/queries.ts +++ b/src/app/api/projects/[projectId]/work-orders/queries.ts @@ -188,6 +188,7 @@ interface WorkOrderQueryRow extends Record { stage_id: string; stage_name: string; forecast_end: string | null; + priority: boolean; required_count: string | number; satisfied_count: string | number; } @@ -208,6 +209,9 @@ function toRawWorkOrderRow(row: WorkOrderQueryRow): RawWorkOrderRow { // A `date` column arrives as `YYYY-MM-DD`; take the day part defensively so // a driver that ever hands back a timestamp cannot leak a time into the UI. forecastEnd: toCalendarDay(row.forecast_end), + // `work_order.priority` is a boolean column; `Boolean()` guards only against + // a driver handing back null for a row written before it was NOT NULL. + priority: Boolean(row.priority), requiredEvidence: Number(row.required_count), satisfiedEvidence: Number(row.satisfied_count), }; @@ -340,6 +344,7 @@ function workOrderScope( wo.id, wo.reference, wo.forecast_end, + wo.priority, wo.project_workstream_stage_id AS stage_id, pws.name AS stage_name, pw.id AS workstream_id, @@ -377,7 +382,7 @@ function workOrderScope( AND a.requirement_id = uf.project_workstream_evidence_requirement_id ) WHERE ${and(...conditions)} - GROUP BY wo.id, wo.reference, wo.forecast_end, + GROUP BY wo.id, wo.reference, wo.forecast_end, wo.priority, wo.project_workstream_stage_id, pws.name, pw.id, w.name, p.landlord_property_id, p.address, p.postcode, pwc.organisation_id, org.name diff --git a/src/app/api/projects/[projectId]/work-orders/rows.test.ts b/src/app/api/projects/[projectId]/work-orders/rows.test.ts index a7507b42..34d5f546 100644 --- a/src/app/api/projects/[projectId]/work-orders/rows.test.ts +++ b/src/app/api/projects/[projectId]/work-orders/rows.test.ts @@ -35,6 +35,7 @@ function raw(overrides: Partial = {}): RawWorkOrderRow { stageId: 102n, stageName: "In progress", forecastEnd: "2026-07-30", + priority: false, requiredEvidence: 4, satisfiedEvidence: 2, ...overrides, @@ -139,6 +140,31 @@ describe("toWorkOrderRow", () => { expect(row.overdue).toBe(true); }); + it("carries the priority flag through as a stored fact, not a derivation", () => { + expect(toWorkOrderRow(LADDERS, raw({ priority: true }), ASOF).priority).toBe( + true, + ); + expect(toWorkOrderRow(LADDERS, raw(), ASOF).priority).toBe(false); + }); + + it("keeps priority independent of overdue — a flag is not a deadline", () => { + // A priority work order comfortably inside its forecast is not overdue, + // and an overdue one nobody flagged is not a priority. + const flaggedOnTime = toWorkOrderRow( + LADDERS, + raw({ priority: true, forecastEnd: "2026-08-30" }), + ASOF, + ); + expect(flaggedOnTime).toMatchObject({ priority: true, overdue: false }); + + const lateUnflagged = toWorkOrderRow( + LADDERS, + raw({ priority: false, forecastEnd: "2026-07-01" }), + ASOF, + ); + expect(lateUnflagged).toMatchObject({ priority: false, overdue: true }); + }); + it("carries the live stage name through, so a rename propagates", () => { const row = toWorkOrderRow( LADDERS, diff --git a/src/app/api/projects/[projectId]/work-orders/rows.ts b/src/app/api/projects/[projectId]/work-orders/rows.ts index 91288ba7..51f91ec7 100644 --- a/src/app/api/projects/[projectId]/work-orders/rows.ts +++ b/src/app/api/projects/[projectId]/work-orders/rows.ts @@ -54,6 +54,12 @@ export interface RawWorkOrderRow { stageName: string; /** `work_order.forecast_end` as Drizzle hands back a `date`: `YYYY-MM-DD`. */ forecastEnd: string | null; + /** + * `work_order.priority` — a flag, not a scale, and a stored fact rather than + * a derived one: someone marked this work order as needing attention first. + * Independent of `overdue`, which the ladder and the calendar decide. + */ + priority: boolean; requiredEvidence: number; satisfiedEvidence: number; } @@ -73,6 +79,8 @@ export interface WorkOrderRow { stageName: string; /** `YYYY-MM-DD`, the only date format that crosses this boundary. The table formats it. */ forecastEnd: string | null; + /** Flagged as priority. A stored fact, not a derivation — see `RawWorkOrderRow`. */ + priority: boolean; /** Null when the row's stage was not loaded into the ladders — see derivations. */ progress: WorkOrderProgress | null; overdue: boolean; @@ -115,6 +123,7 @@ export function toWorkOrderRow( stageId: raw.stageId.toString(), stageName: raw.stageName, forecastEnd: raw.forecastEnd, + priority: raw.priority, progress: progressOfWorkOrder(ladders, derivationInput), overdue: isOverdue(ladders, derivationInput, asOf), evidence, diff --git a/src/app/db/schema/projects/projects.ts b/src/app/db/schema/projects/projects.ts index 221c0602..dc5293ed 100644 --- a/src/app/db/schema/projects/projects.ts +++ b/src/app/db/schema/projects/projects.ts @@ -7,8 +7,11 @@ // `project_workstream_contractor.organisation_id` are uuid FKs to it. // - Property has a bigint PK (see property.ts), so every `property_id` FK is // bigint, not the integer the doc assumed. -// - status / priority columns whose enum values are still TBC are modelled as -// `text` for now; convert to pgEnum once the value sets are agreed. +// - status columns whose enum values are still TBC are modelled as `text` for +// now; convert to pgEnum once the value sets are agreed. +// - `work_order.priority` started as one of those `text` columns and is now a +// boolean: it turned out to be a flag ("look at this one first"), not a +// scale, so there is no value set left to agree. See CONTEXT.md, "Priority". import { type AnyPgColumn, bigint, @@ -181,7 +184,10 @@ export const workOrder = pgTable("work_order", { // Enum values TBC — modelled as text until the value sets are agreed. status: text("status").notNull(), forecastEnd: date("forecast_end"), - priority: text("priority"), + // A flag, not a scale: a work order either is flagged as priority or is not. + // NOT NULL DEFAULT false because a third "unknown" state has no meaning for a + // flag — the table renders it or renders nothing. + priority: boolean("priority").notNull().default(false), }); export type Project = InferModel; diff --git a/src/app/db/schema/projects/schema.md b/src/app/db/schema/projects/schema.md index 38d5f0ff..11f645b2 100644 --- a/src/app/db/schema/projects/schema.md +++ b/src/app/db/schema/projects/schema.md @@ -66,9 +66,13 @@ Project > optional evidence-requirement link) carries this. > - **Property has a `bigint` PK** (`bigserial`), not an integer one. Every > `property_id` FK is `bigint`. -> - **`status` / `priority` columns are `text` for now**, not Postgres enums — -> their value sets are still TBC and an empty enum can't be created. Convert to -> `pgEnum` once the values are agreed. +> - **`status` columns are `text` for now**, not Postgres enums — their value +> sets are still TBC and an empty enum can't be created. Convert to `pgEnum` +> once the values are agreed. +> - **`work_order.priority` is a `boolean`**, not one of those `text` columns. +> It is a flag ("look at this one first"), not a scale, so no value set was +> ever needed. Changed under #419, when the admin work-orders table surfaced +> it as a column. > - **No new `uploaded_by` column** on `uploaded_files`; the existing > `uploaded_by` `bigint` FK → user is reused. > - **No `document_type` table.** Evidence requirements reference the existing @@ -256,7 +260,7 @@ Represents work issued to a contractor. | project_workstream_stage_id | bigint | No | FK → Project Workstream Stage | | status | text | No | Values TBC — `text` for now, convert to Postgres enum once agreed | | forecast_end | date | Yes | | -| priority | text | Yes | Values TBC — `text` for now, convert to Postgres enum once agreed | +| priority | boolean | No | Flag, not a scale — "look at this one first". Default `false` | --- diff --git a/src/app/projects/[projectId]/work-orders/components/WorkOrderTableColumns.tsx b/src/app/projects/[projectId]/work-orders/components/WorkOrderTableColumns.tsx index 83978106..cf761591 100644 --- a/src/app/projects/[projectId]/work-orders/components/WorkOrderTableColumns.tsx +++ b/src/app/projects/[projectId]/work-orders/components/WorkOrderTableColumns.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { AlertTriangle } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; import { isoToLocalDate } from "@/utils/dates"; import type { WorkOrderRow } from "@/app/api/projects/[projectId]/work-orders/rows"; @@ -141,6 +142,27 @@ export function createWorkOrderColumns( cell: ({ row }) => , }, + { + id: "priority", + accessorKey: "priority", + header: () => Priority, + // A stored flag, unlike every other badge in this table: nothing derives + // it, someone set it. Rendered only when true — a column of "No" chips + // would give the exception the same weight as the rule. + cell: ({ row }) => + row.original.priority ? ( + + + Priority + + ) : ( + Not a priority + ), + }, + { id: "action", header: () => Action,