feat(repairs): one shared status vocabulary

This commit is contained in:
Dave 2026-11-06 04:56:13 +02:00
parent fb5f0211e9
commit 2ad066045a
2 changed files with 222 additions and 0 deletions

View file

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

143
src/lib/repair-status.ts Normal file
View file

@ -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<RepairStatus, string> = {
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<RepairStatus, RepairStatus[]> = {
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<RepairStatus, RepairNextStep | null> = {
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}).`,
);
}