feat(repairs): one shared status vocabulary
This commit is contained in:
parent
fb5f0211e9
commit
2ad066045a
2 changed files with 222 additions and 0 deletions
79
src/lib/repair-status.test.ts
Normal file
79
src/lib/repair-status.test.ts
Normal 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
143
src/lib/repair-status.ts
Normal 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}).`,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue