diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx index 5d137c2a..83d4297c 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -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; } diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index a9052764..193d46b7 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx @@ -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) => 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} /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index 60bc03f4..144d0af0 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -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} /> diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index cb5b8a9f..c055d4d7 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -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(null); const [duplicateRow, setDuplicateRow] = useState(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) => ( - createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites)) - } + onSave={handleInlineSave} onCancel={tableData.cancelDraft} + onDuplicateFound={setDuplicateRow} /> ); diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts index df7c5d32..871cb979 100644 --- a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts @@ -46,11 +46,10 @@ export function useNewWoWizardState({ const [pocAutoFilledFor, setPocAutoFilledFor] = useState(""); const [duplicateRow, setDuplicateRow] = useState(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); diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index 417e2fe4..4f5fa808 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -193,17 +193,20 @@ export const workOrdersApi = { findByWoNumber: async (woNumber: string): Promise => { 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, diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index b7cec34b..4d82981a 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -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", () => {