mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
feat(ara-projects): make work_order.priority a boolean and show it in the table (#419)
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.
This commit is contained in:
parent
d75bdf0ac4
commit
0e69c080e1
8 changed files with 92 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ interface WorkOrderQueryRow extends Record<string, unknown> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ function raw(overrides: Partial<RawWorkOrderRow> = {}): 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<typeof project, "select">;
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => <DocsBadge row={row.original} />,
|
||||
},
|
||||
|
||||
{
|
||||
id: "priority",
|
||||
accessorKey: "priority",
|
||||
header: () => <HeaderLabel>Priority</HeaderLabel>,
|
||||
// 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 ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"
|
||||
data-testid="work-order-priority"
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden />
|
||||
Priority
|
||||
</span>
|
||||
) : (
|
||||
<span className="sr-only">Not a priority</span>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
id: "action",
|
||||
header: () => <span className="sr-only">Action</span>,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue