fix(work-orders): require type and 11-digit WO number on create

Wizard step 1 cannot continue without type and a numeric WO number.
Remove the Mark as Add-On checkbox from the create wizard.
This commit is contained in:
Arthur Bassi 2026-08-25 22:51:29 -03:00
parent 61fbad7e1b
commit 3a85c8c9e3
6 changed files with 106 additions and 80 deletions

View file

@ -2,10 +2,9 @@ import { Check, ChevronLeft, ChevronRight, Lightbulb } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
WIZARD_STEPS, WIZARD_STEPS,
canContinueWizardStep1,
canCreateWorkOrderFromWizard, canCreateWorkOrderFromWizard,
isWizardStepValid, isWizardStepValid,
isWizardWoNumberValid, wizardStep1ContinueHint,
wizardStep2ContinueHint, wizardStep2ContinueHint,
type WorkOrderWizardDraft, type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard"; } from "@/domain/work-orders/types/work-order-wizard";
@ -22,11 +21,8 @@ type WizardFooterProps = {
}; };
function stepHint(step: number, draft: WorkOrderWizardDraft): string | null { function stepHint(step: number, draft: WorkOrderWizardDraft): string | null {
if (step === 1 && !canContinueWizardStep1(draft)) { if (step === 1) {
if (!isWizardWoNumberValid(draft.woNumber)) { return wizardStep1ContinueHint(draft);
return "Enter an alphanumeric WO number, or leave blank for SH generation";
}
return "Select severity to continue";
} }
if (step === 2) { if (step === 2) {
return wizardStep2ContinueHint(draft); return wizardStep2ContinueHint(draft);

View file

@ -4,6 +4,7 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order
import { import {
WO_TYPE_META, WO_TYPE_META,
WO_TYPES, WO_TYPES,
WIZARD_WO_NUMBER_MAX_LENGTH,
isWizardWoNumberValid, isWizardWoNumberValid,
type WOType, type WOType,
} from "@/domain/work-orders/types/work-order-wizard"; } from "@/domain/work-orders/types/work-order-wizard";
@ -231,28 +232,6 @@ function clearSchedulePatch(): Partial<WorkOrderWizardDraft> {
}; };
} }
function WizardAddOnOverrideField({
checked,
onChange,
}: {
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
aria-label="Mark as Add-On"
/>
<Text as="p" variant="description" sx={{ fontSize: 13, margin: 0 }}>
Mark as Add-On
</Text>
</label>
);
}
export function WizardStepTypeSchedule({ export function WizardStepTypeSchedule({
draft, draft,
users, users,
@ -262,13 +241,14 @@ export function WizardStepTypeSchedule({
onDuplicateWoNumber, onDuplicateWoNumber,
}: WizardStepTypeScheduleProps) { }: WizardStepTypeScheduleProps) {
const needsSeverity = typeNeedsSeverity(draft.type); const needsSeverity = typeNeedsSeverity(draft.type);
const typeError = showErrors && !draft.type;
const woNumberError = showErrors && !isWizardWoNumberValid(draft.woNumber); const woNumberError = showErrors && !isWizardWoNumberValid(draft.woNumber);
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId); const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<WizLabel optional>Type of WO</WizLabel> <WizLabel required>Type of WO</WizLabel>
<WoTypePicker <WoTypePicker
selected={draft.type} selected={draft.type}
onSelect={(t) => { onSelect={(t) => {
@ -278,6 +258,14 @@ export function WizardStepTypeSchedule({
}); });
}} }}
/> />
<Text
as="p"
variant="error"
when={Boolean(typeError)}
sx={{ fontSize: 11, marginTop: "6px" }}
>
Type of WO is required
</Text>
</div> </div>
{Boolean(needsSeverity) && ( {Boolean(needsSeverity) && (
@ -289,24 +277,31 @@ export function WizardStepTypeSchedule({
)} )}
<div> <div>
<WizLabel optional>WO Number</WizLabel> <WizLabel required>WO Number</WizLabel>
<input <input
type="text" type="text"
inputMode="numeric"
value={draft.woNumber} value={draft.woNumber}
onChange={(e) => onChange={(e) =>
onUpdate("woNumber", e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 20)) onUpdate(
"woNumber",
e.target.value.replace(/\D/g, "").slice(0, WIZARD_WO_NUMBER_MAX_LENGTH),
)
} }
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)} onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
placeholder="Leave blank for SH provisional number" placeholder="Enter WO number"
maxLength={20} maxLength={WIZARD_WO_NUMBER_MAX_LENGTH}
className={DLG_INPUT_CLS} className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE} style={DLG_INPUT_STYLE}
/> />
{Boolean(woNumberError) && ( <Text
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}> as="p"
WO number must be alphanumeric. variant="error"
</Text> when={Boolean(woNumberError)}
)} sx={{ fontSize: 11, marginTop: "6px" }}
>
{draft.woNumber.trim() ? "WO number must be up to 11 digits" : "WO number is required"}
</Text>
</div> </div>
<div> <div>
@ -338,13 +333,6 @@ export function WizardStepTypeSchedule({
</div> </div>
<WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} /> <WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
{Boolean(!hasWizardSchedule(draft)) && (
<WizardAddOnOverrideField
checked={Boolean(draft.isAddOnOverride)}
onChange={(value) => onUpdate("isAddOnOverride", value)}
/>
)}
</div> </div>
); );
} }

View file

@ -103,13 +103,14 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
isAddOnOverride: false, isAddOnOverride: false,
}; };
/** Blank numbers receive an SH provisional number from the backend; nonblank values are alphanumeric. */ export const WIZARD_WO_NUMBER_MAX_LENGTH = 11;
/** Create requires a WO number of 1–11 digits. */
export function isWizardWoNumberValid(woNumber: string): boolean { export function isWizardWoNumberValid(woNumber: string): boolean {
const trimmed = woNumber.trim(); const trimmed = woNumber.trim();
if (!trimmed) { return (
return true; trimmed.length > 0 && trimmed.length <= WIZARD_WO_NUMBER_MAX_LENGTH && /^\d+$/.test(trimmed)
} );
return trimmed.length <= 20 && /^[a-z0-9]+$/i.test(trimmed);
} }
/** Type is optional at creation; empty draft type defaults to PM. */ /** Type is optional at creation; empty draft type defaults to PM. */
@ -185,6 +186,9 @@ export const TIME_SLOTS: string[] = (() => {
})(); })();
export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean { export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
if (!draft.type) {
return false;
}
if (!isWizardWoNumberValid(draft.woNumber)) { if (!isWizardWoNumberValid(draft.woNumber)) {
return false; return false;
} }
@ -194,6 +198,22 @@ export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
return true; return true;
} }
export function wizardStep1ContinueHint(draft: WorkOrderWizardDraft): string | null {
if (canContinueWizardStep1(draft)) {
return null;
}
if (!draft.type) {
return "Select a work order type to continue";
}
if (!draft.woNumber.trim()) {
return "Enter a WO number to continue";
}
if (!isWizardWoNumberValid(draft.woNumber)) {
return "WO number must be up to 11 digits";
}
return "Select severity to continue";
}
export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean { export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean {
const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim())); const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim()));
return Boolean(hasSite && draft.pocName.trim() && draft.pocPhone.trim()); return Boolean(hasSite && draft.pocName.trim() && draft.pocPhone.trim());

View file

@ -48,7 +48,7 @@ describe("NewWoWizard duplicate wo number", () => {
/>, />,
); );
const woNumberInput = screen.getByPlaceholderText("Leave blank for SH provisional number"); const woNumberInput = screen.getByPlaceholderText("Enter WO number");
fireEvent.change(woNumberInput, { fireEvent.change(woNumberInput, {
target: { value: "12345678901" }, target: { value: "12345678901" },
}); });
@ -77,7 +77,7 @@ describe("NewWoWizard duplicate wo number", () => {
/>, />,
); );
const woNumberInput = screen.getByPlaceholderText("Leave blank for SH provisional number"); const woNumberInput = screen.getByPlaceholderText("Enter WO number");
fireEvent.change(woNumberInput, { fireEvent.change(woNumberInput, {
target: { value: "98765432109" }, target: { value: "98765432109" },
}); });

View file

@ -227,12 +227,17 @@ describe("WorkOrdersListPage board-core public interface", () => {
expect(screen.getByRole("dialog")).toBeInTheDocument(); expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument();
expect( expect(screen.getByPlaceholderText(/enter wo number/i)).toBeInTheDocument();
screen.getByPlaceholderText(/leave blank for sh provisional number/i), fireEvent.click(screen.getByRole("button", { name: /continue/i }));
).toBeInTheDocument(); expect(screen.getByText(/type of wo is required/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled(); expect(screen.getByText(/wo number is required/i)).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /scheduled preventive maintenance/i }));
fireEvent.change(screen.getByPlaceholderText(/enter wo number/i), {
target: { value: "12345678901" },
});
fireEvent.click(screen.getByRole("button", { name: /continue/i })); fireEvent.click(screen.getByRole("button", { name: /continue/i }));
expect(screen.queryByText(/wo number is required/i)).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: /location & service/i })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: /location & service/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^back$/i })); fireEvent.click(screen.getByRole("button", { name: /^back$/i }));

View file

@ -7,6 +7,7 @@ import {
canCreateWorkOrderFromWizard, canCreateWorkOrderFromWizard,
isWizardStepValid, isWizardStepValid,
isWizardWoNumberValid, isWizardWoNumberValid,
wizardStep1ContinueHint,
wizardStep2ContinueHint, wizardStep2ContinueHint,
} from "@/domain/work-orders/types/work-order-wizard"; } from "@/domain/work-orders/types/work-order-wizard";
@ -66,21 +67,21 @@ describe("mapWizardDraftToFormValues", () => {
}); });
describe("wizard step validation", () => { describe("wizard step validation", () => {
it("allows step 1 continue without a woNumber for backend SH generation", () => { it("blocks step 1 without a woNumber", () => {
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(true); expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(false);
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(true); expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(false);
}); });
it("allows step 1 without type when woNumber is set (type defaults to PM)", () => { it("blocks step 1 without type even when woNumber is set", () => {
const draft = { ...EMPTY_WIZARD_DRAFT, woNumber: "12345" }; const draft = { ...EMPTY_WIZARD_DRAFT, woNumber: "12345" };
expect(canContinueWizardStep1(draft)).toBe(true); expect(canContinueWizardStep1(draft)).toBe(false);
expect(isWizardStepValid(1, draft)).toBe(true); expect(isWizardStepValid(1, draft)).toBe(false);
}); });
it("allows step 1 when woNumber is missing even with type", () => { it("blocks step 1 without woNumber even when type is set", () => {
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const }; const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
expect(canContinueWizardStep1(draft)).toBe(true); expect(canContinueWizardStep1(draft)).toBe(false);
expect(isWizardStepValid(1, draft)).toBe(true); expect(isWizardStepValid(1, draft)).toBe(false);
}); });
it("allows step 1 when type and woNumber are set (schedule optional)", () => { it("allows step 1 when type and woNumber are set (schedule optional)", () => {
@ -183,7 +184,7 @@ describe("wizard step validation", () => {
const draft = { const draft = {
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,
type: "PM" as const, type: "PM" as const,
woNumber: "12345678901234567890", woNumber: "12345678901",
locationId: "99", locationId: "99",
siteCode: "BK5", siteCode: "BK5",
pocName: "Jordan", pocName: "Jordan",
@ -193,40 +194,56 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(2, draft)).toBe(true); expect(isWizardStepValid(2, draft)).toBe(true);
}); });
it("allows provisional SH-prefixed woNumber", () => { it("blocks SH-prefixed woNumber on create", () => {
const draft = { const draft = {
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "SH12345", woNumber: "SH12345",
}; };
expect(canContinueWizardStep1(draft)).toBe(true); expect(canContinueWizardStep1(draft)).toBe(false);
}); });
it("accepts blank woNumber for backend SH generation", () => { it("rejects blank woNumber", () => {
const draft = { const draft = {
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,
type: "PM" as const, type: "PM" as const,
woNumber: "", woNumber: "",
}; };
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true); expect(isWizardWoNumberValid(draft.woNumber)).toBe(false);
expect(canContinueWizardStep1(draft)).toBe(true); expect(canContinueWizardStep1(draft)).toBe(false);
}); });
it("accepts whitespace-only woNumber for backend SH generation", () => { it("rejects whitespace-only woNumber", () => {
const draft = { const draft = {
...EMPTY_WIZARD_DRAFT, ...EMPTY_WIZARD_DRAFT,
type: "PM" as const, type: "PM" as const,
woNumber: " ", woNumber: " ",
}; };
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true); expect(isWizardWoNumberValid(draft.woNumber)).toBe(false);
expect(canContinueWizardStep1(draft)).toBe(true); expect(canContinueWizardStep1(draft)).toBe(false);
}); });
it("rejects non-alphanumeric woNumber", () => { it("rejects non-digit woNumber", () => {
expect(isWizardWoNumberValid("!!!")).toBe(false); expect(isWizardWoNumberValid("!!!")).toBe(false);
expect(canContinueWizardStep1({ ...EMPTY_WIZARD_DRAFT, woNumber: "!!!" })).toBe(false); expect(canContinueWizardStep1({ ...EMPTY_WIZARD_DRAFT, type: "PM", woNumber: "!!!" })).toBe(
false,
);
}); });
it("accepts alphanumeric woNumber", () => { it("rejects woNumber longer than 11 digits", () => {
expect(isWizardWoNumberValid("ABC123")).toBe(true); expect(isWizardWoNumberValid("123456789012")).toBe(false);
});
it("accepts an 11-digit woNumber", () => {
expect(isWizardWoNumberValid("12345678901")).toBe(true);
});
it("hints type then WO number when step 1 is incomplete", () => {
expect(wizardStep1ContinueHint(EMPTY_WIZARD_DRAFT)).toBe(
"Select a work order type to continue",
);
expect(wizardStep1ContinueHint({ ...EMPTY_WIZARD_DRAFT, type: "PM" })).toBe(
"Enter a WO number to continue",
);
}); });
}); });