mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): harden duplicate WO number checks [SH-189]
This commit is contained in:
parent
392ac86757
commit
db0b87eb8f
7 changed files with 107 additions and 48 deletions
|
|
@ -132,7 +132,11 @@ export function EditableWONumberCell({
|
||||||
try {
|
try {
|
||||||
const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId);
|
const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId);
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
onDuplicateFound?.(duplicate);
|
if (onDuplicateFound) {
|
||||||
|
onDuplicateFound(duplicate);
|
||||||
|
} else {
|
||||||
|
toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`);
|
||||||
|
}
|
||||||
setDraft(value);
|
setDraft(value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ import type {
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
import type {
|
||||||
|
WorkOrderTableDraft,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||||
import { DateCell } from "./cells/date-cell";
|
import { DateCell } from "./cells/date-cell";
|
||||||
|
|
@ -25,6 +28,7 @@ type WoTableInlineRowCellsProps = {
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
allRows: { id: string | number; woNumber: string }[];
|
allRows: { id: string | number; woNumber: string }[];
|
||||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||||
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WoTableInlineRowCells({
|
export function WoTableInlineRowCells({
|
||||||
|
|
@ -37,6 +41,7 @@ export function WoTableInlineRowCells({
|
||||||
pmTypes,
|
pmTypes,
|
||||||
allRows,
|
allRows,
|
||||||
onPatch,
|
onPatch,
|
||||||
|
onDuplicateFound,
|
||||||
}: WoTableInlineRowCellsProps) {
|
}: WoTableInlineRowCellsProps) {
|
||||||
const draftRow = toInlineDraftRow(draft);
|
const draftRow = toInlineDraftRow(draft);
|
||||||
|
|
||||||
|
|
@ -73,6 +78,7 @@ export function WoTableInlineRowCells({
|
||||||
currentId="draft"
|
currentId="draft"
|
||||||
allOrders={allRows as never[]}
|
allOrders={allRows as never[]}
|
||||||
onSave={(v) => onPatch({ woNumber: v })}
|
onSave={(v) => onPatch({ woNumber: v })}
|
||||||
|
onDuplicateFound={onDuplicateFound}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import type {
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
import type {
|
||||||
|
WorkOrderTableDraft,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
|
@ -16,54 +19,34 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
|
||||||
|
|
||||||
type WoTableInlineRowProps = {
|
type WoTableInlineRowProps = {
|
||||||
draft: WorkOrderTableDraft;
|
draft: WorkOrderTableDraft;
|
||||||
|
|
||||||
draftDayKey: string;
|
draftDayKey: string;
|
||||||
|
|
||||||
users: WorkOrderUser[];
|
users: WorkOrderUser[];
|
||||||
|
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
|
|
||||||
sitePlace?: { city: string; state: string };
|
sitePlace?: { city: string; state: string };
|
||||||
|
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
|
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
|
|
||||||
allRows: { id: string | number; woNumber: string }[];
|
allRows: { id: string | number; woNumber: string }[];
|
||||||
|
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
|
|
||||||
onChange: (draft: WorkOrderTableDraft) => void;
|
onChange: (draft: WorkOrderTableDraft) => void;
|
||||||
|
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WoTableInlineRow({
|
export function WoTableInlineRow({
|
||||||
draft,
|
draft,
|
||||||
|
|
||||||
draftDayKey,
|
draftDayKey,
|
||||||
|
|
||||||
users,
|
users,
|
||||||
|
|
||||||
sites,
|
sites,
|
||||||
|
|
||||||
sitePlace,
|
sitePlace,
|
||||||
|
|
||||||
vendors,
|
vendors,
|
||||||
|
|
||||||
pmTypes,
|
pmTypes,
|
||||||
|
|
||||||
allRows,
|
allRows,
|
||||||
|
|
||||||
saving = false,
|
saving = false,
|
||||||
|
|
||||||
onChange,
|
onChange,
|
||||||
|
|
||||||
onSave,
|
onSave,
|
||||||
|
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onDuplicateFound,
|
||||||
}: WoTableInlineRowProps) {
|
}: WoTableInlineRowProps) {
|
||||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||||
|
|
||||||
|
|
@ -77,11 +60,8 @@ export function WoTableInlineRow({
|
||||||
aria-busy={saving || undefined}
|
aria-busy={saving || undefined}
|
||||||
style={{
|
style={{
|
||||||
height: WO_TABLE_ROW_HEIGHT,
|
height: WO_TABLE_ROW_HEIGHT,
|
||||||
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
|
||||||
borderLeft: "2px solid var(--color-primary)",
|
borderLeft: "2px solid var(--color-primary)",
|
||||||
|
|
||||||
background: "var(--color-primary-tint)",
|
background: "var(--color-primary-tint)",
|
||||||
opacity: saving ? 0.7 : 1,
|
opacity: saving ? 0.7 : 1,
|
||||||
pointerEvents: saving ? "none" : undefined,
|
pointerEvents: saving ? "none" : undefined,
|
||||||
|
|
@ -103,6 +83,7 @@ export function WoTableInlineRow({
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
allRows={allRows}
|
allRows={allRows}
|
||||||
onPatch={patch}
|
onPatch={patch}
|
||||||
|
onDuplicateFound={onDuplicateFound}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<td style={{ padding: "0 4px" }}>
|
<td style={{ padding: "0 4px" }}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
|
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
|
||||||
import type {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
|
|
@ -9,7 +10,9 @@ import type {
|
||||||
WorkOrderTablePatch,
|
WorkOrderTablePatch,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||||
|
import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
|
||||||
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
||||||
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
||||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
|
|
@ -57,6 +60,7 @@ export function WoTable({
|
||||||
}: WoTableProps) {
|
}: WoTableProps) {
|
||||||
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
|
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
|
||||||
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
|
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
const [isCheckingInlineDuplicate, setIsCheckingInlineDuplicate] = useState(false);
|
||||||
const { data: locationSites = [] } = useLocationSites();
|
const { data: locationSites = [] } = useLocationSites();
|
||||||
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
||||||
const createMutation = useCreateWorkOrderFromWizard(() => {
|
const createMutation = useCreateWorkOrderFromWizard(() => {
|
||||||
|
|
@ -75,6 +79,31 @@ export function WoTable({
|
||||||
onDuplicateFound: setDuplicateRow,
|
onDuplicateFound: setDuplicateRow,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleInlineSave = () => {
|
||||||
|
void (async () => {
|
||||||
|
const draftSnapshot = tableData.draft;
|
||||||
|
const woNumber = draftSnapshot.woNumber.trim();
|
||||||
|
if (woNumber) {
|
||||||
|
setIsCheckingInlineDuplicate(true);
|
||||||
|
try {
|
||||||
|
const duplicate =
|
||||||
|
findDuplicateWoNumberRow(woNumber, tableData.rows, "draft") ??
|
||||||
|
(await workOrdersApi.findByWoNumber(woNumber));
|
||||||
|
if (duplicate && String(duplicate.id) !== "draft") {
|
||||||
|
setDuplicateRow(duplicate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Unable to verify the work order number. Please try again.");
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setIsCheckingInlineDuplicate(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites));
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||||
<WoTableRow
|
<WoTableRow
|
||||||
key={String(row.id)}
|
key={String(row.id)}
|
||||||
|
|
@ -109,12 +138,11 @@ export function WoTable({
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
allRows={tableData.rows}
|
allRows={tableData.rows}
|
||||||
saving={createMutation.isPending}
|
saving={createMutation.isPending || isCheckingInlineDuplicate}
|
||||||
onChange={tableData.setDraft}
|
onChange={tableData.setDraft}
|
||||||
onSave={() =>
|
onSave={handleInlineSave}
|
||||||
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
|
|
||||||
}
|
|
||||||
onCancel={tableData.cancelDraft}
|
onCancel={tableData.cancelDraft}
|
||||||
|
onDuplicateFound={setDuplicateRow}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,10 @@ export function useNewWoWizardState({
|
||||||
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
|
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
|
||||||
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
|
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
|
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
|
||||||
const draftWoNumberRef = useRef(draft.woNumber);
|
const openRef = useRef(open);
|
||||||
|
const draftRef = useRef(draft);
|
||||||
useEffect(() => {
|
openRef.current = open;
|
||||||
draftWoNumberRef.current = draft.woNumber;
|
draftRef.current = draft;
|
||||||
}, [draft.woNumber]);
|
|
||||||
|
|
||||||
const { data: apiLocations = [] } = useWorkOrderLocations();
|
const { data: apiLocations = [] } = useWorkOrderLocations();
|
||||||
const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem");
|
const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem");
|
||||||
|
|
@ -129,12 +128,23 @@ export function useNewWoWizardState({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicateFound = (woNumber: string) => {
|
const handleDuplicateFound = (woNumber: string) => {
|
||||||
void resolveDuplicate(woNumber).then((duplicate) => {
|
void (async () => {
|
||||||
// Compare against the latest draft — ignore stale lookups after the user edits.
|
try {
|
||||||
if (duplicate && woNumber === draftWoNumberRef.current) {
|
const duplicate = await resolveDuplicate(woNumber);
|
||||||
setDuplicateRow(duplicate);
|
// Ignore stale lookups after dismiss or after the user edits the WO#.
|
||||||
|
if (!openRef.current || woNumber !== draftRef.current.woNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (duplicate) {
|
||||||
|
setDuplicateRow(duplicate);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!openRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error("Unable to verify the work order number. Please try again.");
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
|
|
@ -143,16 +153,19 @@ export function useNewWoWizardState({
|
||||||
setIsCheckingDuplicate(true);
|
setIsCheckingDuplicate(true);
|
||||||
try {
|
try {
|
||||||
const duplicate = await resolveDuplicate(woNumberAtSubmit);
|
const duplicate = await resolveDuplicate(woNumberAtSubmit);
|
||||||
// Ignore stale lookups if the WO# changed while the request was in flight.
|
// Ignore after dismiss or if the WO# changed while the request was in flight.
|
||||||
if (woNumberAtSubmit !== draftWoNumberRef.current) {
|
if (!openRef.current || woNumberAtSubmit !== draftRef.current.woNumber) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
setDuplicateRow(duplicate);
|
setDuplicateRow(duplicate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createMutation.mutate(draft);
|
createMutation.mutate(draftRef.current);
|
||||||
} catch {
|
} catch {
|
||||||
|
if (!openRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error("Unable to verify the work order number. Please try again.");
|
toast.error("Unable to verify the work order number. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setIsCheckingDuplicate(false);
|
setIsCheckingDuplicate(false);
|
||||||
|
|
|
||||||
|
|
@ -193,17 +193,20 @@ export const workOrdersApi = {
|
||||||
|
|
||||||
findByWoNumber: async (woNumber: string): Promise<WorkOrderTableRow | undefined> => {
|
findByWoNumber: async (woNumber: string): Promise<WorkOrderTableRow | undefined> => {
|
||||||
const normalized = woNumber.trim();
|
const normalized = woNumber.trim();
|
||||||
if (normalized === "") {
|
// Skip empty / very short queries — fuzzy board/search would page endlessly for 1–2 chars.
|
||||||
|
const minLength = 3;
|
||||||
|
const maxPages = 5;
|
||||||
|
if (normalized.length < minLength) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `board/search` is fuzzy and paginated (0-based); exhaust pages until an exact WO# match.
|
// `board/search` is fuzzy and paginated (0-based); scan a bounded number of pages for an exact WO#.
|
||||||
const pageSize = 100;
|
const pageSize = 100;
|
||||||
let page = 0;
|
let page = 0;
|
||||||
let scanned = 0;
|
let scanned = 0;
|
||||||
let totalCount = Number.POSITIVE_INFINITY;
|
let totalCount = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
while (scanned < totalCount) {
|
while (scanned < totalCount && page < maxPages) {
|
||||||
const result = await workOrdersApi.searchBoard({
|
const result = await workOrdersApi.searchBoard({
|
||||||
search: normalized,
|
search: normalized,
|
||||||
page,
|
page,
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,30 @@ describe("workOrdersApi.findByWoNumber", () => {
|
||||||
});
|
});
|
||||||
expect(result).toMatchObject({ id: 999, woNumber: "10000009999" });
|
expect(result).toMatchObject({ id: 999, woNumber: "10000009999" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips remote search for short WO numbers", async () => {
|
||||||
|
const result = await workOrdersApi.findByWoNumber("12");
|
||||||
|
expect(apiGet).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps paging when scanning fuzzy board search results", async () => {
|
||||||
|
apiGet.mockResolvedValue({
|
||||||
|
items: Array.from({ length: 100 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
woNumber: `fuzzy-match-${index}`,
|
||||||
|
lifecycleStatusLabel: "Incomplete",
|
||||||
|
})),
|
||||||
|
totalCount: 10_000,
|
||||||
|
page: 0,
|
||||||
|
pageSize: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await workOrdersApi.findByWoNumber("10000005555");
|
||||||
|
|
||||||
|
expect(apiGet).toHaveBeenCalledTimes(5);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("workOrdersApi.patchBoardField", () => {
|
describe("workOrdersApi.patchBoardField", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue