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 3681a3be..42537071 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 @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from "react"; +import { toast } from "react-toastify"; import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled"; import { getMockWoWizardLocationDetail, @@ -21,6 +22,7 @@ import { } from "@/domain/work-orders/use-cases/use-work-order-detail"; 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 { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; type UseNewWoWizardStateArgs = { open: boolean; @@ -43,6 +45,7 @@ export function useNewWoWizardState({ }); const [pocAutoFilledFor, setPocAutoFilledFor] = useState(""); const [duplicateRow, setDuplicateRow] = useState(null); + const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false); const { data: apiLocations = [] } = useWorkOrderLocations(); const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem"); @@ -110,22 +113,40 @@ export function useNewWoWizardState({ } }; - const resolveDuplicate = (woNumber: string) => findDuplicateWoNumberRow(woNumber, existingRows); + const resolveDuplicate = async (woNumber: string) => { + const localDuplicate = findDuplicateWoNumberRow(woNumber, existingRows); + if (localDuplicate) { + return localDuplicate; + } + + // The loaded board rows are only an immediate UX hint; the unfiltered API lookup is authoritative. + return workOrdersApi.findByWoNumber(woNumber); + }; const handleDuplicateFound = (woNumber: string) => { - const duplicate = resolveDuplicate(woNumber); - if (duplicate) { - setDuplicateRow(duplicate); - } + void resolveDuplicate(woNumber).then((duplicate) => { + if (duplicate && woNumber === draft.woNumber) { + setDuplicateRow(duplicate); + } + }); }; const handleCreate = () => { - const duplicate = resolveDuplicate(draft.woNumber); - if (duplicate) { - setDuplicateRow(duplicate); - return; - } - createMutation.mutate(draft); + void (async () => { + setIsCheckingDuplicate(true); + try { + const duplicate = await resolveDuplicate(draft.woNumber); + if (duplicate) { + setDuplicateRow(duplicate); + return; + } + createMutation.mutate(draft); + } catch { + toast.error("Unable to verify the work order number. Please try again."); + } finally { + setIsCheckingDuplicate(false); + } + })(); }; return { @@ -143,6 +164,6 @@ export function useNewWoWizardState({ patchDraft, handleDuplicateFound, handleCreate, - isCreating: createMutation.isPending, + isCreating: createMutation.isPending || isCheckingDuplicate, }; } diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index 7b0da992..4398a4f6 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -191,6 +191,22 @@ export const workOrdersApi = { }; }, + findByWoNumber: async (woNumber: string): Promise => { + const normalized = woNumber.trim(); + if (normalized === "") { + return undefined; + } + + // `board/search` without date or board filters searches the entire work-order collection. + const result = await workOrdersApi.searchBoard({ + search: normalized, + page: 1, + pageSize: 20, + }); + + return result.items.find((row) => row.woNumber === normalized); + }, + getListByLocation: async ( locationId: string | number, search = "", diff --git a/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx b/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx index de1187f6..e1e6acb5 100644 --- a/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx +++ b/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx @@ -1,10 +1,11 @@ -import { fireEvent, screen } from "@testing-library/react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { renderWithProviders } from "@/test/test-utils"; const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow; +const findByWoNumber = vi.fn(); vi.mock("@/domain/work-orders/use-cases/use-create-work-order-from-wizard", () => ({ useCreateWorkOrderFromWizard: () => ({ @@ -26,8 +27,14 @@ vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-ca useDropdownOptionsByCategory: () => ({ data: [] }), })); +vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ + workOrdersApi: { + findByWoNumber: (...args: unknown[]) => findByWoNumber(...args), + }, +})); + describe("NewWoWizard duplicate wo number", () => { - it("shows the duplicate dialog when the wizard wo number matches an existing row", () => { + it("shows the duplicate dialog when the wizard wo number matches an existing row", async () => { renderWithProviders( { }); fireEvent.blur(woNumberInput); - expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument(); + }); expect(screen.getByText(/is already assigned to another work order/i)).toBeInTheDocument(); }); + + it("finds a duplicate outside the loaded board rows", async () => { + const offPageRow = { + id: "wo-off-page", + woNumber: "98765432109", + } as WorkOrderTableRow; + findByWoNumber.mockResolvedValueOnce(offPageRow); + + renderWithProviders( + , + ); + + const woNumberInput = screen.getByPlaceholderText("e.g. 24818"); + fireEvent.change(woNumberInput, { + target: { value: "98765432109" }, + }); + fireEvent.blur(woNumberInput); + + await waitFor(() => { + expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument(); + }); + expect(findByWoNumber).toHaveBeenCalledWith("98765432109"); + }); }); 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 9466e814..3683edf9 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 @@ -291,6 +291,42 @@ describe("workOrdersApi.searchBoard", () => { }); }); +describe("workOrdersApi.findByWoNumber", () => { + beforeEach(() => { + apiGet.mockReset(); + apiGet.mockResolvedValue({ + items: [ + { + id: 505, + woNumber: "10000001235", + lifecycleStatusLabel: "Incomplete", + }, + ], + totalCount: 1, + page: 1, + pageSize: 20, + }); + }); + + it("searches globally without the active board filters", async () => { + const result = await workOrdersApi.findByWoNumber("10000001235"); + + expect(apiGet).toHaveBeenCalledWith( + API_PATHS.workOrder.boardSearch, + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }), + ); + const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams; + expect(Object.fromEntries(searchParams)).toEqual({ + search: "10000001235", + page: "1", + pageSize: "20", + }); + expect(result).toMatchObject({ id: 505, woNumber: "10000001235" }); + }); +}); + describe("workOrdersApi.patchBoardField", () => { beforeEach(() => { apiPatchFn.mockReset();