mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 00:02:06 +00:00
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:
parent
61fbad7e1b
commit
3a85c8c9e3
6 changed files with 106 additions and 80 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue