From 25837171a49a4966a504ad6cfe6467ab65cbd67a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Thu, 13 Aug 2026 11:04:48 -0300 Subject: [PATCH] feat(work-orders): duplicate WO number dialog on table and wizard (SH-189) (#90) * docs(work-orders): add pending BE contract checklist for design parity gaps * feat(work-orders): confirm dialog when duplicate wo number detected * fix(work-orders): show duplicate wo number dialog on wizard create * docs(work-orders): align pr description with jira and qa residuals * style(work-orders): prettier wizard duplicate dialog files * refactor(workorders): extract wizard state hook and list overlays * fix(work-orders): check duplicate numbers globally * fix(work-orders): exhaust fuzzy pages for exact WO number lookup Prevent accepting duplicates that fall outside the first fuzzy search page. * style(work-orders): format editable WO number cell for Prettier gate * fix(work-orders): 0-based WO search page and stale duplicate guard * fix(work-orders): sync draft WO# ref in effect for duplicate guard [SH-189] * fix(work-orders): extract WO number editor under max-lines gate Co-authored-by: Cursor * fix(work-orders): harden duplicate WO number checks [SH-189] * fix(work-orders): sync wizard refs in effects and exhaust duplicate WO lookup Co-authored-by: Cursor * fix(work-orders): extract hooks to satisfy max-lines governance gate Co-authored-by: Cursor * style(work-orders): format wizard state hook for Prettier gate Co-authored-by: Cursor * fix(work-orders): search one-character WO numbers for duplicates --------- Co-authored-by: Cursor --- .../list/duplicate-wo-number-dialog.tsx | 45 ++++ .../table/cells/editable-wo-number-cell.tsx | 217 ++++++++------- .../list/table/use-wo-table-inline-create.ts | 58 +++++ .../list/table/use-wo-table-row-handlers.ts | 6 +- .../table/wo-table-inline-identity-cells.tsx | 8 +- .../list/table/wo-table-inline-row-cells.tsx | 8 +- .../list/table/wo-table-inline-row.tsx | 8 +- .../_components/list/table/wo-table.tsx | 62 +++-- .../list/work-orders-list-wizard.tsx | 32 +++ .../_components/wizard/new-wo-wizard.tsx | 246 +++++++----------- .../wizard/use-new-wo-wizard-state.ts | 136 ++++++++++ .../wizard/use-wizard-duplicate-actions.ts | 100 +++++++ .../wizard/wizard-step-type-schedule.tsx | 3 + src/app/(protected)/workorders/index.tsx | 10 +- src/domain/work-orders/api/work-orders-api.ts | 42 +++ .../utils/find-duplicate-wo-number-row.ts | 16 ++ .../duplicate-wo-number-dialog.test.tsx | 58 +++++ .../new-wo-wizard-duplicate.test.tsx | 88 +++++++ ...se-wo-table-row-handlers-duplicate.test.ts | 43 +++ .../work-orders/api/work-orders-api.test.ts | 151 +++++++++++ .../find-duplicate-wo-number-row.test.ts | 19 ++ tmp/pr-descriptions/pr8-duplicate-dialog.md | 26 ++ 22 files changed, 1082 insertions(+), 300 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog.tsx create mode 100644 src/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create.ts create mode 100644 src/app/(protected)/workorders/_components/list/work-orders-list-wizard.tsx create mode 100644 src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts create mode 100644 src/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions.ts create mode 100644 src/domain/work-orders/utils/find-duplicate-wo-number-row.ts create mode 100644 src/test/app/(protected)/workorders/duplicate-wo-number-dialog.test.tsx create mode 100644 src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx create mode 100644 src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts create mode 100644 src/test/domain/work-orders/utils/find-duplicate-wo-number-row.test.ts create mode 100644 tmp/pr-descriptions/pr8-duplicate-dialog.md diff --git a/src/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog.tsx b/src/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog.tsx new file mode 100644 index 00000000..95decbe7 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog.tsx @@ -0,0 +1,45 @@ +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +type DuplicateWoNumberDialogProps = { + open: boolean; + existing: WorkOrderTableRow | null; + onDismiss: () => void; + onViewExisting: (row: WorkOrderTableRow) => void; +}; + +export function DuplicateWoNumberDialog({ + open, + existing, + onDismiss, + onViewExisting, +}: DuplicateWoNumberDialogProps) { + const woNumber = existing?.woNumber ?? ""; + + return ( + + Duplicate Work Order Number + + + Work order number {woNumber} is already assigned to another work order. View the existing + record or dismiss to keep editing. + + + + + + + + ); +} 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 ce4b3462..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 @@ -1,43 +1,30 @@ import { useEffect, useState } from "react"; - import { toast } from "react-toastify"; - +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; - +import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row"; import { HighlightText } from "./highlight-text"; type EditableWONumberCellProps = { value: string; - rescheduleCount: number; - carriedOver?: number; - q?: string; - currentId: string | number; - allOrders: WorkOrderTableRow[]; - onSave: (v: string) => void; - onDuplicateFound?: (existing: WorkOrderTableRow) => void; }; function CounterPill({ label, count }: { label: string; count: number }) { - if (count < 2) { - return null; - } - + if (count < 2) return null; return ( @@ -46,89 +33,134 @@ function CounterPill({ label, count }: { label: string; count: number }) { ); } +type WoNumberEditorProps = { + draft: string; + invalid: boolean; + isChecking: boolean; + onDraftChange: (value: string) => void; + onCommit: () => void; + onCancel: () => void; + onBlur: () => void; +}; + +function WoNumberEditor({ + draft, + invalid, + isChecking, + onDraftChange, + onCommit, + onCancel, + onBlur, +}: WoNumberEditorProps) { + return ( + onDraftChange(e.target.value.replace(/\D/g, "").slice(0, 11))} + onFocus={(e) => e.target.select()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onCommit(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + onBlur={onBlur} + placeholder="12345678901" + aria-label="Work order number" + aria-invalid={invalid} + className="font-mono focus:outline-none" + style={{ + fontSize: 13, + fontWeight: 700, + color: "var(--foreground)", + background: "var(--color-bg-surface)", + border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`, + boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`, + borderRadius: 4, + padding: "2px 4px", + width: 120, + }} + /> + ); +} + +async function resolveDuplicateWoNumber( + draft: string, + allOrders: WorkOrderTableRow[], + currentId: string | number, +) { + const local = findDuplicateWoNumberRow(draft, allOrders, currentId); + const remote = local ?? (await workOrdersApi.findByWoNumber(draft)); + return remote && String(remote.id) !== String(currentId) ? remote : undefined; +} + export function EditableWONumberCell({ value, - rescheduleCount, - carriedOver = 0, - q, - currentId, - allOrders, - onSave, - onDuplicateFound, }: EditableWONumberCellProps) { const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(value); + const [isChecking, setIsChecking] = useState(false); useEffect(() => { - if (!editing) { - setDraft(value); - } + if (!editing) setDraft(value); }, [value, editing]); - const findDuplicate = (d: string) => - allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId)); - const commit = () => { - if (draft === value) { + if (draft === value || isChecking) { setEditing(false); - return; } - if (draft.length !== 11) { toast("Work order number must be exactly 11 digits"); - return; } - - const duplicate = findDuplicate(draft); - - if (duplicate) { - onDuplicateFound?.(duplicate); - - setDraft(value); - - setEditing(false); - - return; - } - - onSave(draft); - - setEditing(false); + setIsChecking(true); + void (async () => { + try { + const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId); + if (duplicate) { + if (onDuplicateFound) { + onDuplicateFound(duplicate); + } else { + toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`); + } + setDraft(value); + return; + } + onSave(draft); + } catch { + toast.error("Unable to verify the work order number. Please try again."); + } finally { + setIsChecking(false); + setEditing(false); + } + })(); }; const cancel = () => { setDraft(value); - setEditing(false); }; const handleBlur = () => { + if (isChecking) return; if (draft !== value && draft.length === 11) { - const duplicate = findDuplicate(draft); - - if (duplicate) { - onDuplicateFound?.(duplicate); - - setDraft(value); - - setEditing(false); - - return; - } - - onSave(draft); + commit(); + return; } - setEditing(false); }; @@ -137,75 +169,34 @@ export function EditableWONumberCell({ return (
{editing ? ( - setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))} - onFocus={(e) => e.target.select()} - onClick={(e) => e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - - commit(); - } else if (e.key === "Escape") { - e.preventDefault(); - - cancel(); - } - }} + ) : ( { e.stopPropagation(); - setEditing(true); }} className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]" style={{ fontSize: 13, - fontWeight: 700, - color: value ? "var(--foreground)" : "var(--color-text-muted)", - borderRadius: 4, - padding: "2px 4px", }} > {value ? : "-"} )} - -
); diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create.ts new file mode 100644 index 00000000..d6097cb4 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-inline-create.ts @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { toast } from "react-toastify"; +import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { 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 type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; +import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input"; + +type UseWoTableInlineCreateArgs = { + tableData: ReturnTypeOfUseWorkOrdersTableData; + users: WorkOrderUser[]; + sites: LocationOption[]; + onDuplicateFound: (row: WorkOrderTableRow) => void; +}; + +export function useWoTableInlineCreate({ + tableData, + users, + sites, + onDuplicateFound, +}: UseWoTableInlineCreateArgs) { + const [isCheckingInlineDuplicate, setIsCheckingInlineDuplicate] = useState(false); + const createMutation = useCreateWorkOrderFromWizard(() => { + tableData.cancelDraft(); + }); + + 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") { + onDuplicateFound(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)); + })(); + }; + + return { + handleInlineSave, + saving: createMutation.isPending || isCheckingInlineDuplicate, + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts index 59aca8f5..c9626b3e 100644 --- a/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts +++ b/src/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers.ts @@ -19,6 +19,7 @@ type UseWoTableRowHandlersOptions = { onOpenMedia: (row: WorkOrderTableRow) => void; onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; onScheduleHighlight: (id: string | number | null) => void; + onDuplicateFound?: (existing: WorkOrderTableRow) => void; }; export function useWoTableRowHandlers({ @@ -30,6 +31,7 @@ export function useWoTableRowHandlers({ onOpenMedia, onPatch, onScheduleHighlight, + onDuplicateFound, }: UseWoTableRowHandlersOptions): WoTableRowHandlers { const { dragState: dragStateRef, @@ -97,8 +99,7 @@ export function useWoTableRowHandlers({ }); }, onDuplicateFound: (existing: WorkOrderTableRow) => { - tableData.setSelectedId(existing.id); - onOpenSlideOver(existing, false); + onDuplicateFound?.(existing); }, onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false), onPastDueAttempt: (id: string | number) => onScheduleHighlight(id), @@ -138,6 +139,7 @@ export function useWoTableRowHandlers({ onOpenMedia, onPatch, onScheduleHighlight, + onDuplicateFound, ], ); } diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx index f518a69d..7526cdeb 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx @@ -1,6 +1,9 @@ import type { RefObject } from "react"; import type { LocationOption } 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 { EditableWONumberCell } from "./cells/editable-wo-number-cell"; import { SiteCell } from "./cells/site-cell"; import { InlineCreateField } from "./inline-create-field"; @@ -16,6 +19,7 @@ type WoTableInlineIdentityCellsProps = { siteError: string; woShowError: boolean; onPatch: (patch: Partial) => void; + onDuplicateFound?: (existing: WorkOrderTableRow) => void; }; export function WoTableInlineIdentityCells({ @@ -29,6 +33,7 @@ export function WoTableInlineIdentityCells({ siteError, woShowError, onPatch, + onDuplicateFound, }: WoTableInlineIdentityCellsProps) { return ( <> @@ -74,6 +79,7 @@ export function WoTableInlineIdentityCells({ 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-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells.tsx index 0dfa7ba1..28b01db3 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 @@ -4,7 +4,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"; @@ -31,6 +34,7 @@ type WoTableInlineRowCellsProps = { siteError: string; woShowError: boolean; onPatch: (patch: Partial) => void; + onDuplicateFound?: (existing: WorkOrderTableRow) => void; }; function InlineMutedDashCell() { @@ -56,6 +60,7 @@ export function WoTableInlineRowCells({ siteError, woShowError, onPatch, + onDuplicateFound, }: WoTableInlineRowCellsProps) { const draftRow = toInlineDraftRow(draft); const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek); @@ -73,6 +78,7 @@ export function WoTableInlineRowCells({ siteError={siteError} woShowError={woShowError} onPatch={onPatch} + 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 816ed9e3..679a0c3b 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 { 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 { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row"; import { draftHasScheduleDate, @@ -32,6 +35,7 @@ type WoTableInlineRowProps = { onChange: (draft: WorkOrderTableDraft) => void; onSave: () => void; onCancel: () => void; + onDuplicateFound?: (existing: WorkOrderTableRow) => void; }; const PRE_SCHEDULE = new Set(["Incomplete", "Pending", ""]); @@ -60,6 +64,7 @@ export function WoTableInlineRow({ onChange, onSave, onCancel, + onDuplicateFound, }: WoTableInlineRowProps) { const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY; const missing = getInlineCreateMissing(draft); @@ -127,6 +132,7 @@ export function WoTableInlineRow({ siteError={inlineCreateSiteError(missing)} woShowError={woShowError} 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 94e019a7..c733ce89 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -9,11 +9,11 @@ import type { WorkOrderTablePatch, WorkOrderTableRow, } from "@/domain/work-orders/types/work-order-table-row"; -import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard"; +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"; -import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input"; import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup"; +import { useWoTableInlineCreate } from "./use-wo-table-inline-create"; import { useWoTableRowHandlers } from "./use-wo-table-row-handlers"; import { WoTableBody } from "./wo-table-body"; import { WoTableHead } from "./wo-table-head"; @@ -55,10 +55,14 @@ export function WoTable({ onPatch, }: WoTableProps) { const [dueDateHighlightId, setDueDateHighlightId] = useState(null); + const [duplicateRow, setDuplicateRow] = useState(null); const { data: locationSites = [] } = useLocationSites(); const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]); - const createMutation = useCreateWorkOrderFromWizard(() => { - tableData.cancelDraft(); + const { handleInlineSave, saving } = useWoTableInlineCreate({ + tableData, + users, + sites, + onDuplicateFound: setDuplicateRow, }); const handlers = useWoTableRowHandlers({ @@ -70,6 +74,7 @@ export function WoTable({ onOpenMedia, onPatch, onScheduleHighlight: setDueDateHighlightId, + onDuplicateFound: setDuplicateRow, }); const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => ( @@ -107,12 +112,11 @@ export function WoTable({ vendors={vendors} pmTypes={pmTypes} allRows={tableData.rows} - saving={createMutation.isPending} + saving={saving} onChange={tableData.setDraft} - onSave={() => - createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites)) - } + onSave={handleInlineSave} onCancel={tableData.cancelDraft} + onDuplicateFound={setDuplicateRow} /> ); @@ -151,21 +155,33 @@ export function WoTable({ } return ( - - + +
+ + +
+
+ + setDuplicateRow(null)} + onViewExisting={(row) => { + setDuplicateRow(null); + onOpenSlideOver(row, false); }} - > - - - - + /> + ); } diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-wizard.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-wizard.tsx new file mode 100644 index 00000000..c9ab1c6c --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-wizard.tsx @@ -0,0 +1,32 @@ +import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +type WorkOrdersListWizardProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + users: WorkOrderUser[]; + rows: WorkOrderTableRow[]; + onViewExisting: (row: WorkOrderTableRow) => void; +}; + +export function WorkOrdersListWizard({ + open, + onOpenChange, + users, + rows, + onViewExisting, +}: WorkOrdersListWizardProps) { + return ( + { + onOpenChange(false); + onViewExisting(row); + }} + /> + ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx index 6643256d..7b7d5326 100644 --- a/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx +++ b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx @@ -1,38 +1,23 @@ -import { useEffect, useMemo, useState } from "react"; import { Dialog } from "@mui/material"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { Text } from "@/components/ui/text"; -import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; -import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled"; -import { - getMockWoWizardLocationDetail, - MOCK_WO_WIZARD_LOCATIONS, - MOCK_WO_WIZARD_SERVICES, - MOCK_WO_WIZARD_USERS, - MOCK_WO_WIZARD_VENDORS, -} from "@/domain/work-orders/mocks/wo-wizard-mock-data"; +import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog"; import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; -import { - EMPTY_WIZARD_DRAFT, - WIZARD_STEPS, - type WorkOrderWizardDraft, -} from "@/domain/work-orders/types/work-order-wizard"; -import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; -import { - useWorkOrderLocations, - useWorkOrderVendors, -} 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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { WIZARD_STEPS } from "@/domain/work-orders/types/work-order-wizard"; import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer"; import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar"; import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service"; import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time"; +import { useNewWoWizardState } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state"; type NewWoWizardProps = { open: boolean; onOpenChange: (open: boolean) => void; users: WorkOrderUser[]; + existingRows?: WorkOrderTableRow[]; + onViewExistingWo?: (row: WorkOrderTableRow) => void; }; function WizardStepHeader({ step }: { step: number }) { @@ -75,152 +60,99 @@ function WizardStepHeader({ step }: { step: number }) { ); } -export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) { - const mockFallback = isWoWizardMockEnabled(); - const [step, setStep] = useState(1); - const [draft, setDraft] = useState({ - ...EMPTY_WIZARD_DRAFT, - status: "Incomplete", - }); - const [pocAutoFilledFor, setPocAutoFilledFor] = useState(""); - - const { data: apiLocations = [] } = useWorkOrderLocations(); - const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem"); - const isMockLocation = draft.locationId.startsWith("mock-"); - const { data: apiLocationDetail } = useLocationDetail( - isMockLocation ? undefined : draft.locationId || undefined, - ); - const mockLocationDetail = isMockLocation - ? getMockWoWizardLocationDetail(draft.locationId) - : undefined; - const locationDetail = mockLocationDetail ?? apiLocationDetail; - const { data: apiVendors = [] } = useWorkOrderVendors( - undefined, - isMockLocation ? undefined : locationDetail?.zipCode, - ); - const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false)); - - const wizardUsers = useMemo(() => { - if (users.length > 0) { - return users; - } - return mockFallback ? MOCK_WO_WIZARD_USERS : []; - }, [mockFallback, users]); - const locations = - apiLocations.length > 0 ? apiLocations : mockFallback ? MOCK_WO_WIZARD_LOCATIONS : []; - const problems = - apiProblems.length > 0 ? apiProblems : mockFallback ? MOCK_WO_WIZARD_SERVICES : []; - const vendors = apiVendors.length > 0 ? apiVendors : mockFallback ? MOCK_WO_WIZARD_VENDORS : []; - - useEffect(() => { - if (open) { - setStep(1); - setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); - setPocAutoFilledFor(""); - } - }, [open]); - - useEffect(() => { - if (!draft.locationId || !locationDetail) { - return; - } - if (pocAutoFilledFor === draft.locationId) { - return; - } - setDraft((current) => ({ - ...current, - pocName: locationDetail.contact ?? current.pocName, - pocPhone: locationDetail.phone ?? current.pocPhone, - })); - setPocAutoFilledFor(draft.locationId); - }, [draft.locationId, locationDetail, pocAutoFilledFor]); - - const setField = ( - key: K, - value: WorkOrderWizardDraft[K], - ) => { - setDraft((current) => ({ ...current, [key]: value })); - }; - - const patchDraft = (patch: Partial) => { - setDraft((current) => ({ ...current, ...patch })); - if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) { - setPocAutoFilledFor(""); - } - }; - - const handleCreate = () => createMutation.mutate(draft); +export function NewWoWizard({ + open, + onOpenChange, + users, + existingRows = [], + onViewExistingWo, +}: NewWoWizardProps) { + const wizard = useNewWoWizardState({ open, users, existingRows, onOpenChange }); return ( - onOpenChange(false)} - maxWidth={false} - slotProps={{ - ...dialogBlurBackdropSlotProps, - paper: { - className: "flex flex-col overflow-hidden p-0", - style: { - borderRadius: 12, - width: 768, - maxWidth: 768, - height: 680, - maxHeight: 680, + <> + onOpenChange(false)} + maxWidth={false} + slotProps={{ + ...dialogBlurBackdropSlotProps, + paper: { + className: "flex flex-col overflow-hidden p-0", + style: { + borderRadius: 12, + width: 768, + maxWidth: 768, + height: 680, + maxHeight: 680, + }, }, - }, - }} - > - Guided setup for creating a new work order + }} + > + Guided setup for creating a new work order -
- +
+ -
- +
+ - {step === 1 && ( - - )} + {wizard.step === 1 && ( + + )} - {step === 2 && ( - ({ id: p.id, value: p.value }))} - onUpdate={setField} - onPatch={patchDraft} - /> - )} + {wizard.step === 2 && ( + ({ id: p.id, value: p.value }))} + onUpdate={wizard.setField} + onPatch={wizard.patchDraft} + /> + )} - {step === 3 && ( - - )} + {wizard.step === 3 && ( + + )} +
-
- onOpenChange(false)} - onBack={() => setStep((s) => s - 1)} - onContinue={() => setStep((s) => s + 1)} - onCreate={handleCreate} - isCreating={createMutation.isPending} + onOpenChange(false)} + onBack={() => wizard.setStep((s) => s - 1)} + onContinue={() => wizard.setStep((s) => s + 1)} + onCreate={wizard.handleCreate} + isCreating={wizard.isCreating} + /> +
+ + wizard.setDuplicateRow(null)} + onViewExisting={(row) => { + wizard.setDuplicateRow(null); + onOpenChange(false); + onViewExistingWo?.(row); + }} /> -
+ ); } 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 new file mode 100644 index 00000000..748fb3c5 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-state.ts @@ -0,0 +1,136 @@ +import { useEffect, useMemo, useState } from "react"; +import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled"; +import { + getMockWoWizardLocationDetail, + MOCK_WO_WIZARD_LOCATIONS, + MOCK_WO_WIZARD_SERVICES, + MOCK_WO_WIZARD_USERS, + MOCK_WO_WIZARD_VENDORS, +} from "@/domain/work-orders/mocks/wo-wizard-mock-data"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; +import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; +import { + useWorkOrderLocations, + useWorkOrderVendors, +} 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 { useWizardDuplicateActions } from "./use-wizard-duplicate-actions"; + +type UseNewWoWizardStateArgs = { + open: boolean; + users: WorkOrderUser[]; + existingRows: WorkOrderTableRow[]; + onOpenChange: (open: boolean) => void; +}; + +export function useNewWoWizardState({ + open, + users, + existingRows, + onOpenChange, +}: UseNewWoWizardStateArgs) { + const mockFallback = isWoWizardMockEnabled(); + const [step, setStep] = useState(1); + const [draft, setDraft] = useState({ + ...EMPTY_WIZARD_DRAFT, + status: "Incomplete", + }); + const [pocAutoFilledFor, setPocAutoFilledFor] = useState(""); + + const { data: apiLocations = [] } = useWorkOrderLocations(); + const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem"); + const isMockLocation = draft.locationId.startsWith("mock-"); + const { data: apiLocationDetail } = useLocationDetail( + isMockLocation ? undefined : draft.locationId || undefined, + ); + const mockLocationDetail = isMockLocation + ? getMockWoWizardLocationDetail(draft.locationId) + : undefined; + const locationDetail = mockLocationDetail ?? apiLocationDetail; + const { data: apiVendors = [] } = useWorkOrderVendors( + undefined, + isMockLocation ? undefined : locationDetail?.zipCode, + ); + const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false)); + const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } = + useWizardDuplicateActions({ + open, + draft, + existingRows, + createMutation, + }); + + const wizardUsers = useMemo(() => { + if (users.length > 0) { + return users; + } + return mockFallback ? MOCK_WO_WIZARD_USERS : []; + }, [mockFallback, users]); + const locations = + apiLocations.length > 0 ? apiLocations : mockFallback ? MOCK_WO_WIZARD_LOCATIONS : []; + const problems = + apiProblems.length > 0 ? apiProblems : mockFallback ? MOCK_WO_WIZARD_SERVICES : []; + const vendors = apiVendors.length > 0 ? apiVendors : mockFallback ? MOCK_WO_WIZARD_VENDORS : []; + + useEffect(() => { + if (open) { + setStep(1); + setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); + setPocAutoFilledFor(""); + setDuplicateRow(null); + } + }, [open, setDuplicateRow]); + + useEffect(() => { + if (!draft.locationId || !locationDetail) { + return; + } + if (pocAutoFilledFor === draft.locationId) { + return; + } + setDraft((current) => ({ + ...current, + pocName: locationDetail.contact ?? current.pocName, + pocPhone: locationDetail.phone ?? current.pocPhone, + })); + setPocAutoFilledFor(draft.locationId); + }, [draft.locationId, locationDetail, pocAutoFilledFor]); + + const setField = ( + key: K, + value: WorkOrderWizardDraft[K], + ) => { + setDraft((current) => ({ ...current, [key]: value })); + }; + + const patchDraft = (patch: Partial) => { + setDraft((current) => ({ ...current, ...patch })); + if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) { + setPocAutoFilledFor(""); + } + }; + + return { + step, + setStep, + draft, + duplicateRow, + setDuplicateRow, + wizardUsers, + locations, + problems, + vendors, + locationDetail, + setField, + patchDraft, + handleDuplicateFound, + handleCreate, + isCreating, + }; +} diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions.ts new file mode 100644 index 00000000..0f015d2c --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from "react"; +import { toast } from "react-toastify"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; +import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row"; +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; + +type UseWizardDuplicateActionsArgs = { + open: boolean; + draft: WorkOrderWizardDraft; + existingRows: WorkOrderTableRow[]; + createMutation: { + mutate: (draft: WorkOrderWizardDraft) => void; + isPending: boolean; + }; +}; + +export function useWizardDuplicateActions({ + open, + draft, + existingRows, + createMutation, +}: UseWizardDuplicateActionsArgs) { + const [duplicateRow, setDuplicateRow] = useState(null); + const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false); + const openRef = useRef(open); + const draftRef = useRef(draft); + + useEffect(() => { + openRef.current = open; + }, [open]); + + useEffect(() => { + draftRef.current = draft; + }, [draft]); + + 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) => { + 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 = () => { + const woNumberAtSubmit = draft.woNumber; + void (async () => { + setIsCheckingDuplicate(true); + try { + const duplicate = await resolveDuplicate(woNumberAtSubmit); + // 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(draftRef.current); + } catch { + if (!openRef.current) { + return; + } + toast.error("Unable to verify the work order number. Please try again."); + } finally { + setIsCheckingDuplicate(false); + } + })(); + }; + + return { + duplicateRow, + setDuplicateRow, + handleDuplicateFound, + handleCreate, + isCreating: createMutation.isPending || isCheckingDuplicate, + }; +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index 51c6f0d3..7f35c2e7 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -117,6 +117,7 @@ type WizardStepTypeScheduleProps = { users: WorkOrderUser[]; onUpdate: (key: K, value: WorkOrderWizardDraft[K]) => void; onPatch: (patch: Partial) => void; + onDuplicateWoNumber?: (woNumber: string) => void; }; export function WizardStepTypeSchedule({ @@ -124,6 +125,7 @@ export function WizardStepTypeSchedule({ users, onUpdate, onPatch, + onDuplicateWoNumber, }: WizardStepTypeScheduleProps) { return (
@@ -138,6 +140,7 @@ export function WizardStepTypeSchedule({ type="text" value={draft.woNumber} onChange={(e) => onUpdate("woNumber", e.target.value)} + onBlur={() => onDuplicateWoNumber?.(draft.woNumber)} placeholder="e.g. 24818" maxLength={20} className={DLG_INPUT_CLS} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 672919ee..9036de10 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -9,8 +9,8 @@ import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_c import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header"; import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog"; +import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard"; import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; -import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard"; import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; import { useAuthContext } from "@/providers/auth-context"; @@ -91,7 +91,13 @@ export default function WorkOrdersListPage() { onPatch={page.patchField} /> - + page.handleOpenSlideOver(row, false)} + /> => { + const normalized = woNumber.trim(); + // Search every non-empty WO# — backend advanced search accepts 1-character queries, and + // wizard/create allow single-digit numbers (SH-189 must not bypass remote uniqueness). + if (!normalized) { + return undefined; + } + // Safety cap — if hit before exhausting results, fail closed (do not treat as unique). + const safetyMaxPages = 50; + + // `board/search` is fuzzy and paginated (0-based); exhaust pages for an exact WO#. + const pageSize = 100; + let page = 0; + let scanned = 0; + let totalCount = Number.POSITIVE_INFINITY; + + while (scanned < totalCount) { + if (page >= safetyMaxPages) { + throw new Error( + "Unable to verify the work order number: search result set exceeded the safety page limit.", + ); + } + const result = await workOrdersApi.searchBoard({ + search: normalized, + page, + pageSize, + }); + const exact = result.items.find((row) => row.woNumber === normalized); + if (exact) { + return exact; + } + scanned += result.items.length; + totalCount = result.totalCount; + if (result.items.length === 0) { + break; + } + page += 1; + } + + return undefined; + }, + getListByLocation: async ( locationId: string | number, search = "", diff --git a/src/domain/work-orders/utils/find-duplicate-wo-number-row.ts b/src/domain/work-orders/utils/find-duplicate-wo-number-row.ts new file mode 100644 index 00000000..0b4a0d52 --- /dev/null +++ b/src/domain/work-orders/utils/find-duplicate-wo-number-row.ts @@ -0,0 +1,16 @@ +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +export function findDuplicateWoNumberRow( + woNumber: string, + rows: WorkOrderTableRow[], + excludeId?: string | number, +): WorkOrderTableRow | undefined { + const normalized = woNumber.trim(); + if (normalized === "") { + return undefined; + } + + return rows.find( + (row) => row.woNumber === normalized && String(row.id) !== String(excludeId ?? ""), + ); +} diff --git a/src/test/app/(protected)/workorders/duplicate-wo-number-dialog.test.tsx b/src/test/app/(protected)/workorders/duplicate-wo-number-dialog.test.tsx new file mode 100644 index 00000000..5d126913 --- /dev/null +++ b/src/test/app/(protected)/workorders/duplicate-wo-number-dialog.test.tsx @@ -0,0 +1,58 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog"; +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; + +describe("DuplicateWoNumberDialog", () => { + it("shows the duplicate title and work order number when open", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Duplicate Work Order Number")).toBeInTheDocument(); + expect(screen.getByText(/12345678901/)).toBeInTheDocument(); + }); + + it("calls onViewExisting when View existing is clicked", () => { + const onViewExisting = vi.fn(); + + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "View existing" })); + + expect(onViewExisting).toHaveBeenCalledWith(existingRow); + }); + + it("calls onDismiss when Dismiss is clicked", () => { + const onDismiss = vi.fn(); + + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); 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 new file mode 100644 index 00000000..e1e6acb5 --- /dev/null +++ b/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx @@ -0,0 +1,88 @@ +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: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderLocations: () => ({ data: [] }), + useWorkOrderVendors: () => ({ data: [] }), +})); + +vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ + useLocationDetail: () => ({ data: undefined }), +})); + +vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({ + 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", async () => { + renderWithProviders( + , + ); + + const woNumberInput = screen.getByPlaceholderText("e.g. 24818"); + fireEvent.change(woNumberInput, { + target: { value: "12345678901" }, + }); + fireEvent.blur(woNumberInput); + + 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/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts new file mode 100644 index 00000000..df563025 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-wo-table-row-handlers-duplicate.test.ts @@ -0,0 +1,43 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +const existingRow = { id: "wo-99", woNumber: "12345678901" } as WorkOrderTableRow; + +function buildTableData() { + return { + dragState: { current: { dragId: null, overId: null } }, + setDragViz: vi.fn(), + reorderRows: vi.fn(), + endDrag: vi.fn(), + setUnschedOpen: vi.fn(), + triggerFlash: vi.fn(), + setSelectedId: vi.fn(), + } as never; +} + +describe("useWoTableRowHandlers onDuplicateFound", () => { + it("delegates to onDuplicateFound instead of opening the slide-over", () => { + const onDuplicateFound = vi.fn(); + const onOpenSlideOver = vi.fn(); + + const { result } = renderHook(() => + useWoTableRowHandlers({ + tableData: buildTableData(), + weekMonday: "2026-07-20", + onWeekChange: vi.fn(), + onOpenSlideOver, + onOpenMedia: vi.fn(), + onPatch: vi.fn(), + onScheduleHighlight: vi.fn(), + onDuplicateFound, + }), + ); + + result.current.onDuplicateFound?.(existingRow); + + expect(onDuplicateFound).toHaveBeenCalledWith(existingRow); + expect(onOpenSlideOver).not.toHaveBeenCalled(); + }); +}); 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 8b000890..a8fdf14d 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 @@ -298,6 +298,157 @@ describe("workOrdersApi.searchBoard", () => { }); }); +describe("workOrdersApi.findByWoNumber", () => { + beforeEach(() => { + apiGet.mockReset(); + }); + + it("searches globally without the active board filters", async () => { + apiGet.mockResolvedValue({ + items: [ + { + id: 505, + woNumber: "10000001235", + lifecycleStatusLabel: "Incomplete", + }, + ], + totalCount: 1, + page: 0, + pageSize: 100, + }); + + 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: "0", + pageSize: "100", + }); + expect(result).toMatchObject({ id: 505, woNumber: "10000001235" }); + }); + + it("pages through fuzzy search until the exact WO number is found", async () => { + apiGet + .mockResolvedValueOnce({ + items: Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + woNumber: `fuzzy-match-${index}`, + lifecycleStatusLabel: "Incomplete", + })), + totalCount: 101, + page: 0, + pageSize: 100, + }) + .mockResolvedValueOnce({ + items: [ + { + id: 999, + woNumber: "10000009999", + lifecycleStatusLabel: "Incomplete", + }, + ], + totalCount: 101, + page: 1, + pageSize: 100, + }); + + const result = await workOrdersApi.findByWoNumber("10000009999"); + + expect(apiGet).toHaveBeenCalledTimes(2); + const secondParams = apiGet.mock.calls[1]?.[1]?.searchParams as URLSearchParams; + expect(Object.fromEntries(secondParams)).toMatchObject({ + search: "10000009999", + page: "1", + pageSize: "100", + }); + expect(result).toMatchObject({ id: 999, woNumber: "10000009999" }); + }); + + it("skips remote search for empty WO numbers", async () => { + const result = await workOrdersApi.findByWoNumber(" "); + expect(apiGet).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it("finds a one-character WO number duplicate outside loaded board rows", async () => { + apiGet.mockResolvedValue({ + items: [ + { + id: 7, + woNumber: "9", + lifecycleStatusLabel: "Incomplete", + }, + ], + totalCount: 1, + page: 0, + pageSize: 100, + }); + + const result = await workOrdersApi.findByWoNumber("9"); + + expect(apiGet).toHaveBeenCalledTimes(1); + const searchParams = apiGet.mock.calls[0]?.[1]?.searchParams as URLSearchParams; + expect(Object.fromEntries(searchParams)).toEqual({ + search: "9", + page: "0", + pageSize: "100", + }); + expect(result).toMatchObject({ id: 7, woNumber: "9" }); + }); + + it("throws when the safety page limit is hit before exhausting 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, + }); + + await expect(workOrdersApi.findByWoNumber("10000005555")).rejects.toThrow(/safety page limit/i); + expect(apiGet).toHaveBeenCalledTimes(50); + }); + + it("returns undefined after exhausting all fuzzy search pages", async () => { + apiGet + .mockResolvedValueOnce({ + items: Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + woNumber: `fuzzy-match-${index}`, + lifecycleStatusLabel: "Incomplete", + })), + totalCount: 150, + page: 0, + pageSize: 100, + }) + .mockResolvedValueOnce({ + items: Array.from({ length: 50 }, (_, index) => ({ + id: index + 101, + woNumber: `fuzzy-match-${index + 100}`, + lifecycleStatusLabel: "Incomplete", + })), + totalCount: 150, + page: 1, + pageSize: 100, + }); + + const result = await workOrdersApi.findByWoNumber("10000005555"); + + expect(apiGet).toHaveBeenCalledTimes(2); + expect(result).toBeUndefined(); + }); +}); + describe("workOrdersApi.patchBoardField", () => { beforeEach(() => { apiPatchFn.mockReset(); diff --git a/src/test/domain/work-orders/utils/find-duplicate-wo-number-row.test.ts b/src/test/domain/work-orders/utils/find-duplicate-wo-number-row.test.ts new file mode 100644 index 00000000..f56dda8d --- /dev/null +++ b/src/test/domain/work-orders/utils/find-duplicate-wo-number-row.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { findDuplicateWoNumberRow } from "@/domain/work-orders/utils/find-duplicate-wo-number-row"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +const rows = [ + { id: "wo-1", woNumber: "12345678901" }, + { id: "wo-2", woNumber: "99999999999" }, +] as WorkOrderTableRow[]; + +describe("findDuplicateWoNumberRow", () => { + it("returns a matching row when the wo number already exists", () => { + expect(findDuplicateWoNumberRow("12345678901", rows)).toEqual(rows[0]); + }); + + it("ignores blank values and excluded ids", () => { + expect(findDuplicateWoNumberRow("", rows)).toBeUndefined(); + expect(findDuplicateWoNumberRow("12345678901", rows, "wo-1")).toBeUndefined(); + }); +}); diff --git a/tmp/pr-descriptions/pr8-duplicate-dialog.md b/tmp/pr-descriptions/pr8-duplicate-dialog.md new file mode 100644 index 00000000..242e05f7 --- /dev/null +++ b/tmp/pr-descriptions/pr8-duplicate-dialog.md @@ -0,0 +1,26 @@ +## Jira + +- [SH-189](https://luby-us.atlassian.net/browse/SH-189) + +## QA residual + +- P0 done: duplicate dialog also on **wizard create** (not only table cell). +- P1 polish: Cancel / View Existing copy tweaks. + +## Summary + +- Show a confirmation dialog when inline WO number editing **or wizard create** detects a duplicate instead of navigating immediately to the existing work order. +- Dialog title: **Duplicate Work Order Number**; actions: **View existing** (opens the existing WO via slide-over route) and **Dismiss** (closes the dialog and keeps the current row editable). +- Duplicate scan logic shared via `find-duplicate-wo-number-row`. + +## Test plan + +- [ ] Edit a WO number in the table to match an existing row → dialog opens with the duplicate number in the message. +- [ ] Click **Dismiss** → dialog closes; edited row keeps its original number. +- [ ] Repeat duplicate entry → click **View existing** → navigates to `/workorders/{id}` for the existing row. +- [ ] Wizard create with duplicate WO number → same dialog; **View existing** closes wizard and opens `/workorders/{id}`. + +## Out of scope + +- Server-side uniqueness (422) handling +- SH provisional numbering