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 {
|
||||
const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId);
|
||||
if (duplicate) {
|
||||
onDuplicateFound?.(duplicate);
|
||||
if (onDuplicateFound) {
|
||||
onDuplicateFound(duplicate);
|
||||
} else {
|
||||
toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`);
|
||||
}
|
||||
setDraft(value);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import type {
|
|||
VendorDropdownItem,
|
||||
WorkOrderUser,
|
||||
} 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 { ApptTimeCell } from "./cells/appt-time-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
|
|
@ -25,6 +28,7 @@ type WoTableInlineRowCellsProps = {
|
|||
pmTypes: string[];
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
export function WoTableInlineRowCells({
|
||||
|
|
@ -37,6 +41,7 @@ export function WoTableInlineRowCells({
|
|||
pmTypes,
|
||||
allRows,
|
||||
onPatch,
|
||||
onDuplicateFound,
|
||||
}: WoTableInlineRowCellsProps) {
|
||||
const draftRow = toInlineDraftRow(draft);
|
||||
|
||||
|
|
@ -73,6 +78,7 @@ export function WoTableInlineRowCells({
|
|||
currentId="draft"
|
||||
allOrders={allRows as never[]}
|
||||
onSave={(v) => onPatch({ woNumber: v })}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
</td>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import type {
|
|||
WorkOrderUser,
|
||||
} 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";
|
||||
|
||||
|
|
@ -16,54 +19,34 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
|
|||
|
||||
type WoTableInlineRowProps = {
|
||||
draft: WorkOrderTableDraft;
|
||||
|
||||
draftDayKey: string;
|
||||
|
||||
users: WorkOrderUser[];
|
||||
|
||||
sites: LocationOption[];
|
||||
|
||||
sitePlace?: { city: string; state: string };
|
||||
|
||||
vendors: VendorDropdownItem[];
|
||||
|
||||
pmTypes: string[];
|
||||
|
||||
allRows: { id: string | number; woNumber: string }[];
|
||||
|
||||
saving?: boolean;
|
||||
|
||||
onChange: (draft: WorkOrderTableDraft) => void;
|
||||
|
||||
onSave: () => void;
|
||||
|
||||
onCancel: () => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
};
|
||||
|
||||
export function WoTableInlineRow({
|
||||
draft,
|
||||
|
||||
draftDayKey,
|
||||
|
||||
users,
|
||||
|
||||
sites,
|
||||
|
||||
sitePlace,
|
||||
|
||||
vendors,
|
||||
|
||||
pmTypes,
|
||||
|
||||
allRows,
|
||||
|
||||
saving = false,
|
||||
|
||||
onChange,
|
||||
|
||||
onSave,
|
||||
|
||||
onCancel,
|
||||
onDuplicateFound,
|
||||
}: WoTableInlineRowProps) {
|
||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||
|
||||
|
|
@ -77,11 +60,8 @@ export function WoTableInlineRow({
|
|||
aria-busy={saving || undefined}
|
||||
style={{
|
||||
height: WO_TABLE_ROW_HEIGHT,
|
||||
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
|
||||
borderLeft: "2px solid var(--color-primary)",
|
||||
|
||||
background: "var(--color-primary-tint)",
|
||||
opacity: saving ? 0.7 : 1,
|
||||
pointerEvents: saving ? "none" : undefined,
|
||||
|
|
@ -103,6 +83,7 @@ export function WoTableInlineRow({
|
|||
pmTypes={pmTypes}
|
||||
allRows={allRows}
|
||||
onPatch={patch}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
/>
|
||||
|
||||
<td style={{ padding: "0 4px" }}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
|
||||
import type {
|
||||
LocationOption,
|
||||
|
|
@ -9,7 +10,9 @@ import type {
|
|||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} 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 { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row";
|
||||
import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
|
||||
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||
|
|
@ -57,6 +60,7 @@ export function WoTable({
|
|||
}: WoTableProps) {
|
||||
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
|
||||
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [isCheckingInlineDuplicate, setIsCheckingInlineDuplicate] = useState(false);
|
||||
const { data: locationSites = [] } = useLocationSites();
|
||||
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
|
||||
const createMutation = useCreateWorkOrderFromWizard(() => {
|
||||
|
|
@ -75,6 +79,31 @@ export function WoTable({
|
|||
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) => (
|
||||
<WoTableRow
|
||||
key={String(row.id)}
|
||||
|
|
@ -109,12 +138,11 @@ export function WoTable({
|
|||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
allRows={tableData.rows}
|
||||
saving={createMutation.isPending}
|
||||
saving={createMutation.isPending || isCheckingInlineDuplicate}
|
||||
onChange={tableData.setDraft}
|
||||
onSave={() =>
|
||||
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
|
||||
}
|
||||
onSave={handleInlineSave}
|
||||
onCancel={tableData.cancelDraft}
|
||||
onDuplicateFound={setDuplicateRow}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -46,11 +46,10 @@ export function useNewWoWizardState({
|
|||
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
|
||||
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
|
||||
const draftWoNumberRef = useRef(draft.woNumber);
|
||||
|
||||
useEffect(() => {
|
||||
draftWoNumberRef.current = draft.woNumber;
|
||||
}, [draft.woNumber]);
|
||||
const openRef = useRef(open);
|
||||
const draftRef = useRef(draft);
|
||||
openRef.current = open;
|
||||
draftRef.current = draft;
|
||||
|
||||
const { data: apiLocations = [] } = useWorkOrderLocations();
|
||||
const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem");
|
||||
|
|
@ -129,12 +128,23 @@ export function useNewWoWizardState({
|
|||
};
|
||||
|
||||
const handleDuplicateFound = (woNumber: string) => {
|
||||
void resolveDuplicate(woNumber).then((duplicate) => {
|
||||
// Compare against the latest draft — ignore stale lookups after the user edits.
|
||||
if (duplicate && woNumber === draftWoNumberRef.current) {
|
||||
setDuplicateRow(duplicate);
|
||||
void (async () => {
|
||||
try {
|
||||
const duplicate = await resolveDuplicate(woNumber);
|
||||
// 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 = () => {
|
||||
|
|
@ -143,16 +153,19 @@ export function useNewWoWizardState({
|
|||
setIsCheckingDuplicate(true);
|
||||
try {
|
||||
const duplicate = await resolveDuplicate(woNumberAtSubmit);
|
||||
// Ignore stale lookups if the WO# changed while the request was in flight.
|
||||
if (woNumberAtSubmit !== draftWoNumberRef.current) {
|
||||
// Ignore after dismiss or if the WO# changed while the request was in flight.
|
||||
if (!openRef.current || woNumberAtSubmit !== draftRef.current.woNumber) {
|
||||
return;
|
||||
}
|
||||
if (duplicate) {
|
||||
setDuplicateRow(duplicate);
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(draft);
|
||||
createMutation.mutate(draftRef.current);
|
||||
} catch {
|
||||
if (!openRef.current) {
|
||||
return;
|
||||
}
|
||||
toast.error("Unable to verify the work order number. Please try again.");
|
||||
} finally {
|
||||
setIsCheckingDuplicate(false);
|
||||
|
|
|
|||
|
|
@ -193,17 +193,20 @@ export const workOrdersApi = {
|
|||
|
||||
findByWoNumber: async (woNumber: string): Promise<WorkOrderTableRow | undefined> => {
|
||||
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;
|
||||
}
|
||||
|
||||
// `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;
|
||||
let page = 0;
|
||||
let scanned = 0;
|
||||
let totalCount = Number.POSITIVE_INFINITY;
|
||||
|
||||
while (scanned < totalCount) {
|
||||
while (scanned < totalCount && page < maxPages) {
|
||||
const result = await workOrdersApi.searchBoard({
|
||||
search: normalized,
|
||||
page,
|
||||
|
|
|
|||
|
|
@ -363,6 +363,30 @@ describe("workOrdersApi.findByWoNumber", () => {
|
|||
});
|
||||
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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue