chore(work-orders): merge dev and align SH-196 uplift contract

Merge latest dev, remove unused uplift mock store, document landed BE contract, and extract list page panels for maintainability gate.
This commit is contained in:
arthur.bassi 2026-08-13 14:49:57 -03:00
commit 4a07d5ca0c
25 changed files with 1189 additions and 511 deletions

View file

@ -4,23 +4,25 @@ Local checklist for FE branches off `dev`. Confirm with backend before shipping
## Board row / PATCH ## Board row / PATCH
| Field | Needed by | Notes | | Field | Needed by | Notes |
| -------------------------------------------------------- | ------------- | ---------------------------------------------------------------------- | | -------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------ |
| `completedDate` on GET `/board` and `/board/search` rows | PR1, PR4 | PATCH `field: "completedDate"` expected; confirm GET includes ISO date | | `completedDate` on GET `/board` and `/board/search` rows | PR1, PR4 | PATCH `field: "completedDate"` expected; confirm GET includes ISO date |
| `severity` (`1`–`5` \| `null`) on row, create, PATCH | PR2, PR5 | Required for Emergency/Reactive; PATCH `field: "severity"` | | `severity` (`1`–`5` \| `null`) on row, create, PATCH | PR2, PR5 | Required for Emergency/Reactive; PATCH `field: "severity"` |
| `upliftSummary` on row | PR3, PR4, PR5 | `{ hasUplift, pendingCount, primaryStatus? }` | | `upliftSummary` on row | PR3, PR4, PR5 | `{ hasUplift, pendingCount, primaryStatus?, amount? }` — **implemented (SH-196 BE)** |
| `hasPendingUplift` on row/detail | PR4 | Prefer server 422 on Completed / completion-doc when pending | | `hasPendingUplift` on row/detail | PR4 | Prefer server 422 on Completed / completion-doc when pending |
| `additionalContacts[]` | PR6 | `{ name, phone, notes? }[]` on create/detail/PATCH | | `additionalContacts[]` | PR6 | `{ name, phone, notes? }[]` on create/detail/PATCH |
| `isAddOn` | PR5 (filter) | Indicator, not a WO type (SH-126) | | `isAddOn` | PR5 (filter) | Indicator, not a WO type (SH-126) |
## Proposed WO-scoped uplift endpoints (confirm vs NTE `uplifts` queue) ## Proposed WO-scoped uplift endpoints (SH-196 — landed on backend `dev`)
| Method | Path | Body | | Method | Path | Body | Status |
| ------ | ------------------------------------------- | --------------------------------- | | ------ | ------------------------------------------- | --------------------------------- | --------------------------------------- |
| GET | `workorders/{id}/uplifts` | — | | GET | `workorders/{id}/uplifts` | — | Implemented (SH-196 BE) |
| POST | `workorders/{id}/uplifts` | `{ amount, notes, attachments? }` | | POST | `workorders/{id}/uplifts` | `{ amount, notes, attachments? }` | Implemented (attachments still FE stub) |
| POST | `workorders/{id}/uplifts/{upliftId}/cancel` | — | | POST | `workorders/{id}/uplifts/{upliftId}/cancel` | — | Implemented |
| POST | `workorders/{id}/uplifts/{upliftId}/revoke` | `{ reason? }` | | POST | `workorders/{id}/uplifts/{upliftId}/revoke` | `{ reason? }` | Implemented |
Board row `upliftSummary` (`{ hasUplift, pendingCount, primaryStatus?, amount? }`) is returned on GET `/board` and `/board/search` rows (SH-196 BE).
Statuses (design): `pending` \| `approved` \| `auto_approved` \| `rejected` \| `cancelled` \| `revoked`. Statuses (design): `pending` \| `approved` \| `auto_approved` \| `rejected` \| `cancelled` \| `revoked`.

View file

@ -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 (
<Dialog open={open} onClose={onDismiss} aria-labelledby="duplicate-wo-number-dialog-title">
<DialogTitle id="duplicate-wo-number-dialog-title">Duplicate Work Order Number</DialogTitle>
<DialogContent>
<Text variant="body">
Work order number {woNumber} is already assigned to another work order. View the existing
record or dismiss to keep editing.
</Text>
</DialogContent>
<DialogActions>
<Button onClick={onDismiss}>Dismiss</Button>
<Button
variant="contained"
disabled={existing == null}
onClick={() => {
if (existing != null) {
onViewExisting(existing);
}
}}
>
View existing
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -1,43 +1,30 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; 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 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"; import { HighlightText } from "./highlight-text";
type EditableWONumberCellProps = { type EditableWONumberCellProps = {
value: string; value: string;
rescheduleCount: number; rescheduleCount: number;
carriedOver?: number; carriedOver?: number;
q?: string; q?: string;
currentId: string | number; currentId: string | number;
allOrders: WorkOrderTableRow[]; allOrders: WorkOrderTableRow[];
onSave: (v: string) => void; onSave: (v: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
}; };
function CounterPill({ label, count }: { label: string; count: number }) { function CounterPill({ label, count }: { label: string; count: number }) {
if (count < 2) { if (count < 2) return null;
return null;
}
return ( return (
<span <span
title={label} title={label}
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold" className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={{ style={{
background: "var(--color-bg-muted)", background: "var(--color-bg-muted)",
color: "var(--color-text-secondary)", color: "var(--color-text-secondary)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
}} }}
> >
@ -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 (
<input
autoFocus
disabled={isChecking}
inputMode="numeric"
value={draft}
onChange={(e) => 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({ export function EditableWONumberCell({
value, value,
rescheduleCount, rescheduleCount,
carriedOver = 0, carriedOver = 0,
q, q,
currentId, currentId,
allOrders, allOrders,
onSave, onSave,
onDuplicateFound, onDuplicateFound,
}: EditableWONumberCellProps) { }: EditableWONumberCellProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
const [isChecking, setIsChecking] = useState(false);
useEffect(() => { useEffect(() => {
if (!editing) { if (!editing) setDraft(value);
setDraft(value);
}
}, [value, editing]); }, [value, editing]);
const findDuplicate = (d: string) =>
allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId));
const commit = () => { const commit = () => {
if (draft === value) { if (draft === value || isChecking) {
setEditing(false); setEditing(false);
return; return;
} }
if (draft.length !== 11) { if (draft.length !== 11) {
toast("Work order number must be exactly 11 digits"); toast("Work order number must be exactly 11 digits");
return; return;
} }
setIsChecking(true);
const duplicate = findDuplicate(draft); void (async () => {
try {
if (duplicate) { const duplicate = await resolveDuplicateWoNumber(draft, allOrders, currentId);
onDuplicateFound?.(duplicate); if (duplicate) {
if (onDuplicateFound) {
setDraft(value); onDuplicateFound(duplicate);
} else {
setEditing(false); toast.error(`WO #${duplicate.woNumber} is already assigned to another work order.`);
}
return; setDraft(value);
} return;
}
onSave(draft); onSave(draft);
} catch {
setEditing(false); toast.error("Unable to verify the work order number. Please try again.");
} finally {
setIsChecking(false);
setEditing(false);
}
})();
}; };
const cancel = () => { const cancel = () => {
setDraft(value); setDraft(value);
setEditing(false); setEditing(false);
}; };
const handleBlur = () => { const handleBlur = () => {
if (isChecking) return;
if (draft !== value && draft.length === 11) { if (draft !== value && draft.length === 11) {
const duplicate = findDuplicate(draft); commit();
return;
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
} }
setEditing(false); setEditing(false);
}; };
@ -137,75 +169,34 @@ export function EditableWONumberCell({
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{editing ? ( {editing ? (
<input <WoNumberEditor
autoFocus draft={draft}
inputMode="numeric" invalid={invalid}
value={draft} isChecking={isChecking}
onChange={(e) => setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))} onDraftChange={setDraft}
onFocus={(e) => e.target.select()} onCommit={commit}
onClick={(e) => e.stopPropagation()} onCancel={cancel}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
onBlur={handleBlur} onBlur={handleBlur}
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,
}}
/> />
) : ( ) : (
<span <span
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setEditing(true); setEditing(true);
}} }}
className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]" className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]"
style={{ style={{
fontSize: 13, fontSize: 13,
fontWeight: 700, fontWeight: 700,
color: value ? "var(--foreground)" : "var(--color-text-muted)", color: value ? "var(--foreground)" : "var(--color-text-muted)",
borderRadius: 4, borderRadius: 4,
padding: "2px 4px", padding: "2px 4px",
}} }}
> >
{value ? <HighlightText text={value} q={q} /> : "-"} {value ? <HighlightText text={value} q={q} /> : "-"}
</span> </span>
)} )}
<CounterPill label="Reschedules" count={rescheduleCount} /> <CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} /> <CounterPill label="Carried over" count={carriedOver} />
</div> </div>
); );

View file

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

View file

@ -20,6 +20,7 @@ type UseWoTableRowHandlersOptions = {
onOpenMedia: (row: WorkOrderTableRow) => void; onOpenMedia: (row: WorkOrderTableRow) => void;
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void; onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
onScheduleHighlight: (id: string | number | null) => void; onScheduleHighlight: (id: string | number | null) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
}; };
export function useWoTableRowHandlers({ export function useWoTableRowHandlers({
@ -32,6 +33,7 @@ export function useWoTableRowHandlers({
onOpenMedia, onOpenMedia,
onPatch, onPatch,
onScheduleHighlight, onScheduleHighlight,
onDuplicateFound,
}: UseWoTableRowHandlersOptions): WoTableRowHandlers { }: UseWoTableRowHandlersOptions): WoTableRowHandlers {
const { const {
dragState: dragStateRef, dragState: dragStateRef,
@ -105,8 +107,7 @@ export function useWoTableRowHandlers({
}); });
}, },
onDuplicateFound: (existing: WorkOrderTableRow) => { onDuplicateFound: (existing: WorkOrderTableRow) => {
tableData.setSelectedId(existing.id); onDuplicateFound?.(existing);
onOpenSlideOver(existing, false);
}, },
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false), onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id), onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
@ -147,6 +148,7 @@ export function useWoTableRowHandlers({
onOpenMedia, onOpenMedia,
onPatch, onPatch,
onScheduleHighlight, onScheduleHighlight,
onDuplicateFound,
], ],
); );
} }

View file

@ -1,6 +1,9 @@
import type { RefObject } from "react"; import type { RefObject } from "react";
import type { LocationOption } from "@/domain/work-orders/types/work-order"; 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 { EditableWONumberCell } from "./cells/editable-wo-number-cell";
import { SiteCell } from "./cells/site-cell"; import { SiteCell } from "./cells/site-cell";
import { InlineCreateField } from "./inline-create-field"; import { InlineCreateField } from "./inline-create-field";
@ -16,6 +19,7 @@ type WoTableInlineIdentityCellsProps = {
siteError: string; siteError: string;
woShowError: boolean; woShowError: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void; onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
}; };
export function WoTableInlineIdentityCells({ export function WoTableInlineIdentityCells({
@ -29,6 +33,7 @@ export function WoTableInlineIdentityCells({
siteError, siteError,
woShowError, woShowError,
onPatch, onPatch,
onDuplicateFound,
}: WoTableInlineIdentityCellsProps) { }: WoTableInlineIdentityCellsProps) {
return ( return (
<> <>
@ -74,6 +79,7 @@ export function WoTableInlineIdentityCells({
currentId="draft" currentId="draft"
allOrders={allRows as never[]} allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })} onSave={(v) => onPatch({ woNumber: v })}
onDuplicateFound={onDuplicateFound}
/> />
</InlineCreateField> </InlineCreateField>
</td> </td>

View file

@ -4,7 +4,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";
@ -31,6 +34,7 @@ type WoTableInlineRowCellsProps = {
siteError: string; siteError: string;
woShowError: boolean; woShowError: boolean;
onPatch: (patch: Partial<WorkOrderTableDraft>) => void; onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
}; };
function InlineMutedDashCell() { function InlineMutedDashCell() {
@ -56,6 +60,7 @@ export function WoTableInlineRowCells({
siteError, siteError,
woShowError, woShowError,
onPatch, onPatch,
onDuplicateFound,
}: WoTableInlineRowCellsProps) { }: WoTableInlineRowCellsProps) {
const draftRow = toInlineDraftRow(draft); const draftRow = toInlineDraftRow(draft);
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek); const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
@ -73,6 +78,7 @@ export function WoTableInlineRowCells({
siteError={siteError} siteError={siteError}
woShowError={woShowError} woShowError={woShowError}
onPatch={onPatch} onPatch={onPatch}
onDuplicateFound={onDuplicateFound}
/> />
<td className="group/cell" style={{ padding: "0 14px" }}> <td className="group/cell" style={{ padding: "0 14px" }}>

View file

@ -6,7 +6,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 { 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";
import { import {
draftHasScheduleDate, draftHasScheduleDate,
@ -32,6 +35,7 @@ type WoTableInlineRowProps = {
onChange: (draft: WorkOrderTableDraft) => void; onChange: (draft: WorkOrderTableDraft) => void;
onSave: () => void; onSave: () => void;
onCancel: () => void; onCancel: () => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
}; };
const PRE_SCHEDULE = new Set(["Incomplete", "Pending", ""]); const PRE_SCHEDULE = new Set(["Incomplete", "Pending", ""]);
@ -60,6 +64,7 @@ export function WoTableInlineRow({
onChange, onChange,
onSave, onSave,
onCancel, onCancel,
onDuplicateFound,
}: WoTableInlineRowProps) { }: WoTableInlineRowProps) {
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY; const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
const missing = getInlineCreateMissing(draft); const missing = getInlineCreateMissing(draft);
@ -127,6 +132,7 @@ export function WoTableInlineRow({
siteError={inlineCreateSiteError(missing)} siteError={inlineCreateSiteError(missing)}
woShowError={woShowError} woShowError={woShowError}
onPatch={patch} onPatch={patch}
onDuplicateFound={onDuplicateFound}
/> />
<td style={{ padding: "0 4px" }}> <td style={{ padding: "0 4px" }}>

View file

@ -9,11 +9,11 @@ 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 { 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 { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types"; import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup"; import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
import { useWoTableInlineCreate } from "./use-wo-table-inline-create";
import { useWoTableRowHandlers } from "./use-wo-table-row-handlers"; import { useWoTableRowHandlers } from "./use-wo-table-row-handlers";
import { WoTableBody } from "./wo-table-body"; import { WoTableBody } from "./wo-table-body";
import { WoTableHead } from "./wo-table-head"; import { WoTableHead } from "./wo-table-head";
@ -57,10 +57,14 @@ export function WoTable({
onPatch, onPatch,
}: WoTableProps) { }: WoTableProps) {
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null); const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(null);
const { data: locationSites = [] } = useLocationSites(); const { data: locationSites = [] } = useLocationSites();
const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]); const sitePlaceLookups = useMemo(() => buildSitePlaceLookups(locationSites), [locationSites]);
const createMutation = useCreateWorkOrderFromWizard(() => { const { handleInlineSave, saving } = useWoTableInlineCreate({
tableData.cancelDraft(); tableData,
users,
sites,
onDuplicateFound: setDuplicateRow,
}); });
const handlers = useWoTableRowHandlers({ const handlers = useWoTableRowHandlers({
@ -73,6 +77,7 @@ export function WoTable({
onOpenMedia, onOpenMedia,
onPatch, onPatch,
onScheduleHighlight: setDueDateHighlightId, onScheduleHighlight: setDueDateHighlightId,
onDuplicateFound: setDuplicateRow,
}); });
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => ( const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
@ -110,12 +115,11 @@ export function WoTable({
vendors={vendors} vendors={vendors}
pmTypes={pmTypes} pmTypes={pmTypes}
allRows={tableData.rows} allRows={tableData.rows}
saving={createMutation.isPending} saving={saving}
onChange={tableData.setDraft} onChange={tableData.setDraft}
onSave={() => onSave={handleInlineSave}
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
}
onCancel={tableData.cancelDraft} onCancel={tableData.cancelDraft}
onDuplicateFound={setDuplicateRow}
/> />
); );
@ -154,21 +158,33 @@ export function WoTable({
} }
return ( return (
<WoTableShell> <>
<table <WoTableShell>
style={{ <table
width: "100%", style={{
minWidth: WO_TABLE_MIN_WIDTH, width: "100%",
borderCollapse: "collapse", minWidth: WO_TABLE_MIN_WIDTH,
borderCollapse: "collapse",
}}
>
<WoTableHead />
<WoTableBody
tableData={tableData}
renderRow={renderRow}
renderInlineRow={renderInlineRow}
/>
</table>
</WoTableShell>
<DuplicateWoNumberDialog
open={duplicateRow != null}
existing={duplicateRow}
onDismiss={() => setDuplicateRow(null)}
onViewExisting={(row) => {
setDuplicateRow(null);
onOpenSlideOver(row, false);
}} }}
> />
<WoTableHead /> </>
<WoTableBody
tableData={tableData}
renderRow={renderRow}
renderInlineRow={renderInlineRow}
/>
</table>
</WoTableShell>
); );
} }

View file

@ -0,0 +1,95 @@
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
import type { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
type WorkOrdersListPagePanelsProps = {
page: ReturnType<typeof useWorkOrdersListPage>;
};
export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps) {
const { filters, tableData, activeDocRow, users, locations, vendors, pmTypes, vendorTechs } =
page;
return (
<>
<WorkOrdersListWizard
open={page.wizardOpen}
onOpenChange={page.setWizardOpen}
users={users}
rows={tableData.rows}
onViewExisting={(row) => page.handleOpenSlideOver(row, false)}
/>
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={locations}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
/>
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
users={users}
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}
onClose={page.handleCloseSlideOver}
/>
<WorkOrderCancelDialog
row={page.confirmCancel}
isPending={page.isPatchPending}
onDialogClose={page.cancelDialogClose}
onKeepWO={() => page.setConfirmCancel(null)}
onConfirm={page.handleConfirmCancel}
/>
{page.activeMediaRow != null && (
<MediaUploadModal
row={page.activeMediaRow}
open={Boolean(page.activeMediaRow)}
onOpenChange={(open) => {
if (!open) page.setMediaRow(null);
}}
/>
)}
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={Boolean(activeDocRow)}
onOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onDateCompletedChange={async (date) => {
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}
<WorkOrdersListOverlays
activeUpliftsRow={page.activeUpliftsRow}
onCloseUplifts={() => page.setUpliftsRow(null)}
/>
</>
);
}

View file

@ -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 (
<NewWoWizard
open={open}
onOpenChange={onOpenChange}
users={users}
existingRows={rows}
onViewExistingWo={(row) => {
onOpenChange(false);
onViewExisting(row);
}}
/>
);
}

View file

@ -1,38 +1,23 @@
import { useEffect, useMemo, useState } from "react";
import { Dialog } from "@mui/material"; import { Dialog } from "@mui/material";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; import { DuplicateWoNumberDialog } from "@/app/(protected)/workorders/_components/list/duplicate-wo-number-dialog";
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 { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
EMPTY_WIZARD_DRAFT, import { WIZARD_STEPS } from "@/domain/work-orders/types/work-order-wizard";
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 { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer"; import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar"; import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar";
import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service"; 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 { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time"; 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 = { type NewWoWizardProps = {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
users: WorkOrderUser[]; users: WorkOrderUser[];
existingRows?: WorkOrderTableRow[];
onViewExistingWo?: (row: WorkOrderTableRow) => void;
}; };
function WizardStepHeader({ step }: { step: number }) { function WizardStepHeader({ step }: { step: number }) {
@ -75,152 +60,99 @@ function WizardStepHeader({ step }: { step: number }) {
); );
} }
export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) { export function NewWoWizard({
const mockFallback = isWoWizardMockEnabled(); open,
const [step, setStep] = useState(1); onOpenChange,
const [draft, setDraft] = useState<WorkOrderWizardDraft>({ users,
...EMPTY_WIZARD_DRAFT, existingRows = [],
status: "Incomplete", onViewExistingWo,
}); }: NewWoWizardProps) {
const [pocAutoFilledFor, setPocAutoFilledFor] = useState(""); const wizard = useNewWoWizardState({ open, users, existingRows, onOpenChange });
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 = <K extends keyof WorkOrderWizardDraft>(
key: K,
value: WorkOrderWizardDraft[K],
) => {
setDraft((current) => ({ ...current, [key]: value }));
};
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
setDraft((current) => ({ ...current, ...patch }));
if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) {
setPocAutoFilledFor("");
}
};
const handleCreate = () => createMutation.mutate(draft);
return ( return (
<Dialog <>
open={open} <Dialog
onClose={() => onOpenChange(false)} open={open}
maxWidth={false} onClose={() => onOpenChange(false)}
slotProps={{ maxWidth={false}
...dialogBlurBackdropSlotProps, slotProps={{
paper: { ...dialogBlurBackdropSlotProps,
className: "flex flex-col overflow-hidden p-0", paper: {
style: { className: "flex flex-col overflow-hidden p-0",
borderRadius: 12, style: {
width: 768, borderRadius: 12,
maxWidth: 768, width: 768,
height: 680, maxWidth: 768,
maxHeight: 680, height: 680,
maxHeight: 680,
},
}, },
}, }}
}} >
> <span className="sr-only">Guided setup for creating a new work order</span>
<span className="sr-only">Guided setup for creating a new work order</span>
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<WizardSidebar step={step} draft={draft} /> <WizardSidebar step={wizard.step} draft={wizard.draft} />
<div <div
className="min-h-0 flex-1 overflow-y-auto" className="min-h-0 flex-1 overflow-y-auto"
style={{ padding: 32, background: "var(--color-bg-surface)" }} style={{ padding: 32, background: "var(--color-bg-surface)" }}
> >
<WizardStepHeader step={step} /> <WizardStepHeader step={wizard.step} />
{step === 1 && ( {wizard.step === 1 && (
<WizardStepTypeSchedule <WizardStepTypeSchedule
draft={draft} draft={wizard.draft}
users={wizardUsers} users={wizard.wizardUsers}
onUpdate={setField} onUpdate={wizard.setField}
onPatch={patchDraft} onPatch={wizard.patchDraft}
/> onDuplicateWoNumber={wizard.handleDuplicateFound}
)} />
)}
{step === 2 && ( {wizard.step === 2 && (
<WizardStepLocationService <WizardStepLocationService
draft={draft} draft={wizard.draft}
locations={locations} locations={wizard.locations}
locationDetail={locationDetail} locationDetail={wizard.locationDetail}
services={problems.map((p) => ({ id: p.id, value: p.value }))} services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))}
onUpdate={setField} onUpdate={wizard.setField}
onPatch={patchDraft} onPatch={wizard.patchDraft}
/> />
)} )}
{step === 3 && ( {wizard.step === 3 && (
<WizardStepVendorTime <WizardStepVendorTime
draft={draft} draft={wizard.draft}
vendors={vendors} vendors={wizard.vendors}
onUpdate={setField} onUpdate={wizard.setField}
onPatch={patchDraft} onPatch={wizard.patchDraft}
/> />
)} )}
</div>
</div> </div>
</div>
<WizardFooter <WizardFooter
step={step} step={wizard.step}
draft={draft} draft={wizard.draft}
onCancel={() => onOpenChange(false)} onCancel={() => onOpenChange(false)}
onBack={() => setStep((s) => s - 1)} onBack={() => wizard.setStep((s) => s - 1)}
onContinue={() => setStep((s) => s + 1)} onContinue={() => wizard.setStep((s) => s + 1)}
onCreate={handleCreate} onCreate={wizard.handleCreate}
isCreating={createMutation.isPending} isCreating={wizard.isCreating}
/>
</Dialog>
<DuplicateWoNumberDialog
open={wizard.duplicateRow != null}
existing={wizard.duplicateRow}
onDismiss={() => wizard.setDuplicateRow(null)}
onViewExisting={(row) => {
wizard.setDuplicateRow(null);
onOpenChange(false);
onViewExistingWo?.(row);
}}
/> />
</Dialog> </>
); );
} }

View file

@ -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<WorkOrderWizardDraft>({
...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 = <K extends keyof WorkOrderWizardDraft>(
key: K,
value: WorkOrderWizardDraft[K],
) => {
setDraft((current) => ({ ...current, [key]: value }));
};
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
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,
};
}

View file

@ -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<WorkOrderTableRow | null>(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,
};
}

View file

@ -117,6 +117,7 @@ type WizardStepTypeScheduleProps = {
users: WorkOrderUser[]; users: WorkOrderUser[];
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void; onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void; onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
onDuplicateWoNumber?: (woNumber: string) => void;
}; };
export function WizardStepTypeSchedule({ export function WizardStepTypeSchedule({
@ -124,6 +125,7 @@ export function WizardStepTypeSchedule({
users, users,
onUpdate, onUpdate,
onPatch, onPatch,
onDuplicateWoNumber,
}: WizardStepTypeScheduleProps) { }: WizardStepTypeScheduleProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -138,6 +140,7 @@ export function WizardStepTypeSchedule({
type="text" type="text"
value={draft.woNumber} value={draft.woNumber}
onChange={(e) => onUpdate("woNumber", e.target.value)} onChange={(e) => onUpdate("woNumber", e.target.value)}
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
placeholder="e.g. 24818" placeholder="e.g. 24818"
maxLength={20} maxLength={20}
className={DLG_INPUT_CLS} className={DLG_INPUT_CLS}

View file

@ -1,24 +1,18 @@
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header"; import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar"; 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 { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog"; import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; 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 { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useAuthContext } from "@/providers/auth-context"; import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() { export default function WorkOrdersListPage() {
const { user } = useAuthContext(); const { user } = useAuthContext();
const page = useWorkOrdersListPage(); const page = useWorkOrdersListPage();
const { filters, tableData, activeDocRow } = page; const { filters, tableData } = page;
return ( return (
<Box className="flex h-full flex-col overflow-hidden p-2.5"> <Box className="flex h-full flex-col overflow-hidden p-2.5">
@ -93,73 +87,7 @@ export default function WorkOrdersListPage() {
onPatch={page.patchField} onPatch={page.patchField}
/> />
<NewWoWizard open={page.wizardOpen} onOpenChange={page.setWizardOpen} users={page.users} /> <WorkOrdersListPagePanels page={page} />
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={page.locations}
users={page.users}
pmTypes={page.pmTypes}
vendorTechs={page.vendorTechs}
/>
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
users={page.users}
sites={page.locations}
vendors={page.vendors}
pmTypes={page.pmTypes}
allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}
onClose={page.handleCloseSlideOver}
/>
<WorkOrderCancelDialog
row={page.confirmCancel}
isPending={page.isPatchPending}
onDialogClose={page.cancelDialogClose}
onKeepWO={() => page.setConfirmCancel(null)}
onConfirm={page.handleConfirmCancel}
/>
{page.activeMediaRow != null && (
<MediaUploadModal
row={page.activeMediaRow}
open={Boolean(page.activeMediaRow)}
onOpenChange={(open) => {
if (!open) page.setMediaRow(null);
}}
/>
)}
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={Boolean(activeDocRow)}
onOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onDateCompletedChange={async (date) => {
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}
<WorkOrdersListOverlays
activeUpliftsRow={page.activeUpliftsRow}
onCloseUplifts={() => page.setUpliftsRow(null)}
/>
</Box> </Box>
); );
} }

View file

@ -191,6 +191,48 @@ export const workOrdersApi = {
}; };
}, },
findByWoNumber: async (woNumber: string): Promise<WorkOrderTableRow | undefined> => {
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 ( getListByLocation: async (
locationId: string | number, locationId: string | number,
search = "", search = "",

View file

@ -1,123 +0,0 @@
import type {
CreateWorkOrderUpliftInput,
UpliftSummary,
WorkOrderUplift,
} from "@/domain/work-orders/types/work-order-uplift";
const store = new Map<string, WorkOrderUplift[]>();
let nextId = 9000;
function keyFor(workOrderId: string | number): string {
return String(workOrderId);
}
function computeSummary(items: WorkOrderUplift[]): UpliftSummary {
if (items.length === 0) {
return { hasUplift: false, pendingCount: 0 };
}
const pendingCount = items.filter((item) => item.status === "pending").length;
const primary = items[0];
return {
hasUplift: true,
pendingCount,
primaryStatus: primary.status,
amount: primary.amount,
};
}
function seedIfEmpty(workOrderId: string | number): WorkOrderUplift[] {
const key = keyFor(workOrderId);
const existing = store.get(key);
if (existing) {
return existing;
}
const numericId = Number(workOrderId);
if (!Number.isNaN(numericId) && numericId % 3 === 0) {
const seeded: WorkOrderUplift[] = [
{
id: nextId++,
status: "pending",
amount: 450,
notes: "Additional coil cleaning required",
requestedAt: new Date().toISOString(),
requestedById: "mock-vendor",
requestedByName: "Mock Vendor",
decidedAt: "",
decidedByName: "",
decisionNote: "",
},
];
store.set(key, seeded);
return seeded;
}
store.set(key, []);
return [];
}
export const workOrderUpliftsMockStore = {
list(workOrderId: string | number): WorkOrderUplift[] {
return [...seedIfEmpty(workOrderId)];
},
create(workOrderId: string | number, input: CreateWorkOrderUpliftInput): WorkOrderUplift {
const items = seedIfEmpty(workOrderId);
const created: WorkOrderUplift = {
id: nextId++,
status: "pending",
amount: input.amount,
notes: input.notes,
requestedAt: new Date().toISOString(),
requestedById: "internal-user",
requestedByName: "Internal User",
decidedAt: "",
decidedByName: "",
decisionNote: "",
};
items.unshift(created);
store.set(keyFor(workOrderId), items);
return created;
},
cancel(workOrderId: string | number, upliftId: string | number): WorkOrderUplift {
const items = seedIfEmpty(workOrderId);
const target = items.find((item) => String(item.id) === String(upliftId));
if (!target) {
throw new Error("Uplift not found");
}
if (target.status !== "pending") {
throw new Error("Only pending uplifts can be cancelled");
}
target.status = "cancelled";
return target;
},
revoke(
workOrderId: string | number,
upliftId: string | number,
reason?: string,
): WorkOrderUplift {
const items = seedIfEmpty(workOrderId);
const target = items.find((item) => String(item.id) === String(upliftId));
if (!target) {
throw new Error("Uplift not found");
}
if (target.status !== "auto_approved" && target.status !== "approved") {
throw new Error("Only approved or auto-approved uplifts can be revoked");
}
if (target.status === "approved" && !reason?.trim()) {
throw new Error("Revoke reason is required for approved uplifts");
}
target.status = "revoked";
target.decisionNote = reason?.trim() ?? "";
target.decidedAt = new Date().toISOString();
return target;
},
summary(workOrderId: string | number): UpliftSummary {
return computeSummary(seedIfEmpty(workOrderId));
},
};

View file

@ -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 ?? ""),
);
}

View file

@ -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(
<DuplicateWoNumberDialog
open
existing={existingRow}
onDismiss={vi.fn()}
onViewExisting={vi.fn()}
/>,
);
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(
<DuplicateWoNumberDialog
open
existing={existingRow}
onDismiss={vi.fn()}
onViewExisting={onViewExisting}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "View existing" }));
expect(onViewExisting).toHaveBeenCalledWith(existingRow);
});
it("calls onDismiss when Dismiss is clicked", () => {
const onDismiss = vi.fn();
renderWithProviders(
<DuplicateWoNumberDialog
open
existing={existingRow}
onDismiss={onDismiss}
onViewExisting={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});

View file

@ -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(
<NewWoWizard
open
onOpenChange={vi.fn()}
users={[]}
existingRows={[existingRow]}
onViewExistingWo={vi.fn()}
/>,
);
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(
<NewWoWizard
open
onOpenChange={vi.fn()}
users={[]}
existingRows={[]}
onViewExistingWo={vi.fn()}
/>,
);
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");
});
});

View file

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

View file

@ -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", () => { describe("workOrdersApi.patchBoardField", () => {
beforeEach(() => { beforeEach(() => {
apiPatchFn.mockReset(); apiPatchFn.mockReset();

View file

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

View file

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