fix(work-orders): harden duplicate WO number checks [SH-189]

This commit is contained in:
Arthur Bassi 2026-08-10 13:46:01 -03:00
parent 392ac86757
commit db0b87eb8f
7 changed files with 107 additions and 48 deletions

View file

@ -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;
} }

View file

@ -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>

View file

@ -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" }}>

View file

@ -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}
/> />
); );

View file

@ -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);

View file

@ -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,

View file

@ -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", () => {