mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 17:53:11 +00:00
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:
commit
4a07d5ca0c
25 changed files with 1189 additions and 511 deletions
|
|
@ -4,23 +4,25 @@ Local checklist for FE branches off `dev`. Confirm with backend before shipping
|
|||
|
||||
## Board row / PATCH
|
||||
|
||||
| Field | Needed by | Notes |
|
||||
| -------------------------------------------------------- | ------------- | ---------------------------------------------------------------------- |
|
||||
| `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"` |
|
||||
| `upliftSummary` on row | PR3, PR4, PR5 | `{ hasUplift, pendingCount, primaryStatus? }` |
|
||||
| `hasPendingUplift` on row/detail | PR4 | Prefer server 422 on Completed / completion-doc when pending |
|
||||
| `additionalContacts[]` | PR6 | `{ name, phone, notes? }[]` on create/detail/PATCH |
|
||||
| `isAddOn` | PR5 (filter) | Indicator, not a WO type (SH-126) |
|
||||
| Field | Needed by | Notes |
|
||||
| -------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------ |
|
||||
| `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"` |
|
||||
| `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 |
|
||||
| `additionalContacts[]` | PR6 | `{ name, phone, notes? }[]` on create/detail/PATCH |
|
||||
| `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 |
|
||||
| ------ | ------------------------------------------- | --------------------------------- |
|
||||
| GET | `workorders/{id}/uplifts` | — |
|
||||
| POST | `workorders/{id}/uplifts` | `{ amount, notes, attachments? }` |
|
||||
| POST | `workorders/{id}/uplifts/{upliftId}/cancel` | — |
|
||||
| POST | `workorders/{id}/uplifts/{upliftId}/revoke` | `{ reason? }` |
|
||||
| Method | Path | Body | Status |
|
||||
| ------ | ------------------------------------------- | --------------------------------- | --------------------------------------- |
|
||||
| GET | `workorders/{id}/uplifts` | — | Implemented (SH-196 BE) |
|
||||
| POST | `workorders/{id}/uplifts` | `{ amount, notes, attachments? }` | Implemented (attachments still FE stub) |
|
||||
| POST | `workorders/{id}/uplifts/{upliftId}/cancel` | — | Implemented |
|
||||
| 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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<span
|
||||
title={label}
|
||||
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
|
||||
style={{
|
||||
background: "var(--color-bg-muted)",
|
||||
|
||||
color: "var(--color-text-secondary)",
|
||||
|
||||
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({
|
||||
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 (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
onChange={(e) => 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();
|
||||
}
|
||||
}}
|
||||
<WoNumberEditor
|
||||
draft={draft}
|
||||
invalid={invalid}
|
||||
isChecking={isChecking}
|
||||
onDraftChange={setDraft}
|
||||
onCommit={commit}
|
||||
onCancel={cancel}
|
||||
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
|
||||
onClick={(e) => {
|
||||
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 ? <HighlightText text={value} q={q} /> : "-"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<CounterPill label="Reschedules" count={rescheduleCount} />
|
||||
|
||||
<CounterPill label="Carried over" count={carriedOver} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -20,6 +20,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({
|
||||
|
|
@ -32,6 +33,7 @@ export function useWoTableRowHandlers({
|
|||
onOpenMedia,
|
||||
onPatch,
|
||||
onScheduleHighlight,
|
||||
onDuplicateFound,
|
||||
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
|
||||
const {
|
||||
dragState: dragStateRef,
|
||||
|
|
@ -105,8 +107,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),
|
||||
|
|
@ -147,6 +148,7 @@ export function useWoTableRowHandlers({
|
|||
onOpenMedia,
|
||||
onPatch,
|
||||
onScheduleHighlight,
|
||||
onDuplicateFound,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderTableDraft>) => 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}
|
||||
/>
|
||||
</InlineCreateField>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderTableDraft>) => 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}
|
||||
/>
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
<td style={{ padding: "0 4px" }}>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -57,10 +57,14 @@ export function WoTable({
|
|||
onPatch,
|
||||
}: WoTableProps) {
|
||||
const [dueDateHighlightId, setDueDateHighlightId] = useState<string | number | null>(null);
|
||||
const [duplicateRow, setDuplicateRow] = useState<WorkOrderTableRow | null>(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({
|
||||
|
|
@ -73,6 +77,7 @@ export function WoTable({
|
|||
onOpenMedia,
|
||||
onPatch,
|
||||
onScheduleHighlight: setDueDateHighlightId,
|
||||
onDuplicateFound: setDuplicateRow,
|
||||
});
|
||||
|
||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||
|
|
@ -110,12 +115,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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -154,21 +158,33 @@ export function WoTable({
|
|||
}
|
||||
|
||||
return (
|
||||
<WoTableShell>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
minWidth: WO_TABLE_MIN_WIDTH,
|
||||
borderCollapse: "collapse",
|
||||
<>
|
||||
<WoTableShell>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
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>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<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 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);
|
||||
export function NewWoWizard({
|
||||
open,
|
||||
onOpenChange,
|
||||
users,
|
||||
existingRows = [],
|
||||
onViewExistingWo,
|
||||
}: NewWoWizardProps) {
|
||||
const wizard = useNewWoWizardState({ open, users, existingRows, onOpenChange });
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => 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,
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<WizardSidebar step={step} draft={draft} />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<WizardSidebar step={wizard.step} draft={wizard.draft} />
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
style={{ padding: 32, background: "var(--color-bg-surface)" }}
|
||||
>
|
||||
<WizardStepHeader step={step} />
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
style={{ padding: 32, background: "var(--color-bg-surface)" }}
|
||||
>
|
||||
<WizardStepHeader step={wizard.step} />
|
||||
|
||||
{step === 1 && (
|
||||
<WizardStepTypeSchedule
|
||||
draft={draft}
|
||||
users={wizardUsers}
|
||||
onUpdate={setField}
|
||||
onPatch={patchDraft}
|
||||
/>
|
||||
)}
|
||||
{wizard.step === 1 && (
|
||||
<WizardStepTypeSchedule
|
||||
draft={wizard.draft}
|
||||
users={wizard.wizardUsers}
|
||||
onUpdate={wizard.setField}
|
||||
onPatch={wizard.patchDraft}
|
||||
onDuplicateWoNumber={wizard.handleDuplicateFound}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<WizardStepLocationService
|
||||
draft={draft}
|
||||
locations={locations}
|
||||
locationDetail={locationDetail}
|
||||
services={problems.map((p) => ({ id: p.id, value: p.value }))}
|
||||
onUpdate={setField}
|
||||
onPatch={patchDraft}
|
||||
/>
|
||||
)}
|
||||
{wizard.step === 2 && (
|
||||
<WizardStepLocationService
|
||||
draft={wizard.draft}
|
||||
locations={wizard.locations}
|
||||
locationDetail={wizard.locationDetail}
|
||||
services={wizard.problems.map((p) => ({ id: p.id, value: p.value }))}
|
||||
onUpdate={wizard.setField}
|
||||
onPatch={wizard.patchDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<WizardStepVendorTime
|
||||
draft={draft}
|
||||
vendors={vendors}
|
||||
onUpdate={setField}
|
||||
onPatch={patchDraft}
|
||||
/>
|
||||
)}
|
||||
{wizard.step === 3 && (
|
||||
<WizardStepVendorTime
|
||||
draft={wizard.draft}
|
||||
vendors={wizard.vendors}
|
||||
onUpdate={wizard.setField}
|
||||
onPatch={wizard.patchDraft}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WizardFooter
|
||||
step={step}
|
||||
draft={draft}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onBack={() => setStep((s) => s - 1)}
|
||||
onContinue={() => setStep((s) => s + 1)}
|
||||
onCreate={handleCreate}
|
||||
isCreating={createMutation.isPending}
|
||||
<WizardFooter
|
||||
step={wizard.step}
|
||||
draft={wizard.draft}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onBack={() => wizard.setStep((s) => s - 1)}
|
||||
onContinue={() => wizard.setStep((s) => s + 1)}
|
||||
onCreate={wizard.handleCreate}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -117,6 +117,7 @@ type WizardStepTypeScheduleProps = {
|
|||
users: WorkOrderUser[];
|
||||
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
|
||||
onDuplicateWoNumber?: (woNumber: string) => void;
|
||||
};
|
||||
|
||||
export function WizardStepTypeSchedule({
|
||||
|
|
@ -124,6 +125,7 @@ export function WizardStepTypeSchedule({
|
|||
users,
|
||||
onUpdate,
|
||||
onPatch,
|
||||
onDuplicateWoNumber,
|
||||
}: WizardStepTypeScheduleProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,18 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
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 { 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 { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
|
||||
import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels";
|
||||
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";
|
||||
|
||||
export default function WorkOrdersListPage() {
|
||||
const { user } = useAuthContext();
|
||||
const page = useWorkOrdersListPage();
|
||||
const { filters, tableData, activeDocRow } = page;
|
||||
const { filters, tableData } = page;
|
||||
|
||||
return (
|
||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||
|
|
@ -93,73 +87,7 @@ export default function WorkOrdersListPage() {
|
|||
onPatch={page.patchField}
|
||||
/>
|
||||
|
||||
<NewWoWizard open={page.wizardOpen} onOpenChange={page.setWizardOpen} users={page.users} />
|
||||
|
||||
<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)}
|
||||
/>
|
||||
<WorkOrdersListPagePanels page={page} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
locationId: string | number,
|
||||
search = "",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
16
src/domain/work-orders/utils/find-duplicate-wo-number-row.ts
Normal file
16
src/domain/work-orders/utils/find-duplicate-wo-number-row.ts
Normal 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 ?? ""),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
26
tmp/pr-descriptions/pr8-duplicate-dialog.md
Normal file
26
tmp/pr-descriptions/pr8-duplicate-dialog.md
Normal 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
|
||||
Loading…
Add table
Reference in a new issue