From 2ad066045ab8374e0d950067b82c2adb09f2eeeb Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 6 Nov 2026 04:56:13 +0200 Subject: [PATCH] feat(repairs): one shared status vocabulary --- src/lib/repair-status.test.ts | 79 +++++++++++++++++++ src/lib/repair-status.ts | 143 ++++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 src/lib/repair-status.test.ts create mode 100644 src/lib/repair-status.ts diff --git a/src/lib/repair-status.test.ts b/src/lib/repair-status.test.ts new file mode 100644 index 0000000..018b656 --- /dev/null +++ b/src/lib/repair-status.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { + REPAIR_STATUS, + REPAIR_STATUS_LABEL, + REPAIR_STATUS_VARIANT, + REPAIR_TRANSITIONS, + nextStepFor, + assertRepairTransition, +} from "@/lib/repair-status"; + +describe("repair status vocabulary", () => { + it("lists the seven statuses in lifecycle order", () => { + expect([...REPAIR_STATUS]).toEqual([ + "new", + "assessing", + "quoted", + "approved", + "in_progress", + "completed", + "cancelled", + ]); + }); + + it("gives every status a label and a badge variant", () => { + for (const status of REPAIR_STATUS) { + expect(REPAIR_STATUS_LABEL[status]).toBeTruthy(); + expect(REPAIR_STATUS_VARIANT[status]).toBeTruthy(); + } + }); + + it("allows exactly the transitions the service allowed before", () => { + expect(REPAIR_TRANSITIONS.new).toEqual(["assessing", "quoted", "cancelled"]); + expect(REPAIR_TRANSITIONS.assessing).toEqual(["quoted", "cancelled"]); + expect(REPAIR_TRANSITIONS.quoted).toEqual([ + "approved", + "in_progress", + "cancelled", + ]); + expect(REPAIR_TRANSITIONS.approved).toEqual(["in_progress", "cancelled"]); + expect(REPAIR_TRANSITIONS.in_progress).toEqual(["completed", "cancelled"]); + expect(REPAIR_TRANSITIONS.completed).toEqual([]); + expect(REPAIR_TRANSITIONS.cancelled).toEqual([]); + }); + + it("names the next step for every live status", () => { + expect(nextStepFor("new")).toEqual({ + label: "Send to supplier", + action: "generate_repair_request", + to: "assessing", + }); + expect(nextStepFor("assessing")?.action).toBe("upload_supplier_quote"); + expect(nextStepFor("quoted")?.action).toBe("create_customer_quote"); + expect(nextStepFor("approved")?.action).toBe("start_work"); + expect(nextStepFor("in_progress")?.action).toBe("mark_completed"); + }); + + it("offers no next step once the job is finished", () => { + expect(nextStepFor("completed")).toBeNull(); + expect(nextStepFor("cancelled")).toBeNull(); + }); + + it("never offers a next step that is not a legal transition", () => { + for (const status of REPAIR_STATUS) { + const step = nextStepFor(status); + if (!step) continue; + expect(REPAIR_TRANSITIONS[status]).toContain(step.to); + } + }); + + it("rejects an illegal transition with a message naming the legal ones", () => { + expect(() => assertRepairTransition("completed", "new")).toThrow( + /Cannot transition repair from "completed" to "new"/, + ); + expect(() => assertRepairTransition("new", "completed")).toThrow( + /allowed: assessing, quoted, cancelled/, + ); + expect(() => assertRepairTransition("new", "assessing")).not.toThrow(); + }); +}); diff --git a/src/lib/repair-status.ts b/src/lib/repair-status.ts new file mode 100644 index 0000000..5371999 --- /dev/null +++ b/src/lib/repair-status.ts @@ -0,0 +1,143 @@ +/** + * The repair status vocabulary — ONE source for every repairs surface + * (office list, office detail, phone app, the repair request PDF, and + * the agent's tools). + * + * Before this module the vocabulary was copied in three places with + * three different sets of words: `repairs/page.tsx`, + * `repairs/[id]/page.tsx` and the PDF map in `repairs-service.ts`. A + * phone app with a status filter row cannot have three vocabularies. + * + * Pure module — no server imports, safe for client components. + */ + +/** The seven repair statuses, in lifecycle order. */ +export const REPAIR_STATUS = [ + "new", + "assessing", + "quoted", + "approved", + "in_progress", + "completed", + "cancelled", +] as const; + +export type RepairStatus = (typeof REPAIR_STATUS)[number]; + +/** + * What each status is called, everywhere. The words are the operator's: + * what happens on the floor, not what the workflow calls it. + */ +export const REPAIR_STATUS_LABEL: Record = { + new: "Booked in", + assessing: "With supplier", + quoted: "Quote in", + approved: "Approved", + in_progress: "On the bench", + completed: "Done", + cancelled: "Cancelled", +}; + +/** Badge variant per status. Colour is a SECOND signal — the label always shows too. */ +export const REPAIR_STATUS_VARIANT: Record< + RepairStatus, + "outline" | "secondary" | "default" | "destructive" +> = { + new: "outline", + assessing: "secondary", + quoted: "secondary", + approved: "default", + in_progress: "secondary", + completed: "default", + cancelled: "destructive", +}; + +/** + * Allowed manual transitions. The guard below is the ONLY thing that + * decides whether a status may change — the service, the REST routes and + * the MCP tools all call it. + */ +export const REPAIR_TRANSITIONS: Record = { + new: ["assessing", "quoted", "cancelled"], + assessing: ["quoted", "cancelled"], + quoted: ["approved", "in_progress", "cancelled"], + approved: ["in_progress", "cancelled"], + in_progress: ["completed", "cancelled"], + completed: [], + cancelled: [], +}; + +/** The single action that moves a repair forward from a given status. */ +export type RepairAction = + | "generate_repair_request" + | "upload_supplier_quote" + | "create_customer_quote" + | "start_work" + | "mark_completed"; + +export interface RepairNextStep { + /** Button / sentence label, e.g. "Send to supplier". */ + label: string; + /** Stable action id for the UI and the agent. */ + action: RepairAction; + /** + * The status this step leads to. For `create_customer_quote` the + * button opens a form — the status flips to `approved` when the + * customer's order lands, not when the form opens. + */ + to: RepairStatus; +} + +const NEXT_STEP: Record = { + new: { + label: "Send to supplier", + action: "generate_repair_request", + to: "assessing", + }, + assessing: { + label: "Upload supplier quote", + action: "upload_supplier_quote", + to: "quoted", + }, + quoted: { + label: "Make customer quote", + action: "create_customer_quote", + to: "approved", + }, + approved: { + label: "Start work", + action: "start_work", + to: "in_progress", + }, + in_progress: { + label: "Mark completed", + action: "mark_completed", + to: "completed", + }, + completed: null, + cancelled: null, +}; + +/** + * The next step for a repair, or null when the job is finished. + * `to` is always a legal transition out of `status`. + */ +export function nextStepFor(status: RepairStatus): RepairNextStep | null { + return NEXT_STEP[status] ?? null; +} + +/** + * Throws unless `to` is a legal transition out of `from`. The message + * names the legal targets so the caller can show it to the operator. + */ +export function assertRepairTransition( + from: RepairStatus, + to: RepairStatus, +): void { + const allowed = REPAIR_TRANSITIONS[from] ?? []; + if (allowed.includes(to)) return; + const list = allowed.length > 0 ? allowed.join(", ") : "none"; + throw new Error( + `Cannot transition repair from "${from}" to "${to}" (allowed: ${list}).`, + ); +}