chore(work-orders): sync wizard create onto slide-over media review fixes

This commit is contained in:
Arthur Bassi 2026-08-07 13:37:23 -03:00
commit a7bce3bd48
17 changed files with 470 additions and 83 deletions

View file

@ -35,7 +35,9 @@ export function CompDocSidebar({
onNotesChange,
onDateCompletedChange,
}: CompDocSidebarProps) {
const cfg = DOC_STATE_CFG[docState];
const displayState: DocState =
isGenerated && docState !== "not-required" ? "generated" : docState;
const cfg = DOC_STATE_CFG[displayState];
const StateIcon = cfg.icon;
return (
@ -83,7 +85,7 @@ export function CompDocSidebar({
marginTop: 3,
}}
>
{docStatusCardDescription(docState, generatedAt)}
{docStatusCardDescription(displayState, generatedAt)}
</div>
</div>
</div>

View file

@ -0,0 +1,59 @@
import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import type { CompDocData, DocState } from "@/domain/work-orders/types/completion-doc";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import { docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
type UseCompDocDialogSeedArgs = {
open: boolean;
infoId: string | number;
infoDocStatus: CompletionDocStatus;
infoCompletedDate?: string | null;
completionDocStatus?: CompletionDocStatus | null;
data?: CompDocData;
setNotes: Dispatch<SetStateAction<string>>;
setDocState: Dispatch<SetStateAction<DocState>>;
setGeneratedAt: Dispatch<SetStateAction<string>>;
setEditDateCompleted: Dispatch<SetStateAction<string>>;
};
/** Prototype seeds once per open/WO session; detail refetches must not wipe edits. */
export function useCompDocDialogSeed({
open,
infoId,
infoDocStatus,
infoCompletedDate,
completionDocStatus,
data,
setNotes,
setDocState,
setGeneratedAt,
setEditDateCompleted,
}: UseCompDocDialogSeedArgs) {
const seedKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!open) {
seedKeyRef.current = null;
return;
}
const key = String(infoId);
if (seedKeyRef.current === key) return;
seedKeyRef.current = key;
setNotes(data?.notes ?? "");
setDocState(docStateOf(completionDocStatus || infoDocStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(infoCompletedDate || "");
}, [
open,
infoId,
infoDocStatus,
infoCompletedDate,
completionDocStatus,
data?.notes,
data?.generatedAt,
setNotes,
setDocState,
setGeneratedAt,
setEditDateCompleted,
]);
}

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { toast } from "react-toastify";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import type {
@ -11,8 +11,10 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
import { DOC_STATE_CFG } from "@/domain/work-orders/utils/completion-doc-state";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { useCompDocDialogSeed } from "./use-comp-doc-dialog-seed";
import { useCompDocNotRequiredActions } from "./use-comp-doc-not-required-actions";
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
@ -71,45 +73,31 @@ export function useCompDocDialog({
const signOffAttachment = completion?.signOffAttachment;
const rowVersion = info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]);
const [notes, setNotes] = useState("");
const [docState, setDocState] = useState<DocState>("draft");
const [generatedAt, setGeneratedAt] = useState("");
const [editDateCompleted, setEditDateCompleted] = useState("");
const seedKeyRef = useRef<string | null>(null);
// Prototype seeds once per open/WO session. Detail refetches must not wipe
// in-progress PM comments or date edits (Bugbot: dialog effect clears edits).
useEffect(() => {
if (!open) {
seedKeyRef.current = null;
return;
}
const key = String(info.id);
if (seedKeyRef.current === key) return;
seedKeyRef.current = key;
setNotes(data?.notes ?? "");
setDocState(docStateOf(completion?.docStatus || info.docStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(info.completedDate || "");
}, [
useCompDocDialogSeed({
open,
info.id,
info.docStatus,
info.completedDate,
completion?.docStatus,
data?.notes,
data?.generatedAt,
]);
infoId: info.id,
infoDocStatus: info.docStatus,
infoCompletedDate: info.completedDate,
completionDocStatus: completion?.docStatus,
data,
setNotes,
setDocState,
setGeneratedAt,
setEditDateCompleted,
});
const amazonRep = info.pocName || "";
const seaRep = info.tech || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
const isBusy = uploadCompletionMutation.isPending || isFinalizing;
const docFields: DocFields = {
street: site.street,
@ -137,37 +125,13 @@ export function useCompDocDialog({
onDraftSaved: notifyDraftSaved,
});
async function handleMarkNotRequired() {
const previous = docState;
setDocState("not-required");
setIsFinalizing(true);
try {
await persist({}, "NN");
} catch (error) {
setDocState(previous);
refreshAuthoritativeState();
toast.error(
error instanceof Error ? error.message : "Failed to mark document as not required",
);
} finally {
setIsFinalizing(false);
}
}
async function handleUndo() {
const previous = docState;
setDocState("draft");
setIsFinalizing(true);
try {
await persist({}, "No");
} catch (error) {
setDocState(previous);
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
} finally {
setIsFinalizing(false);
}
}
const { handleMarkNotRequired, handleUndo } = useCompDocNotRequiredActions({
docState,
setDocState,
persist,
refreshAuthoritativeState,
setIsFinalizing,
});
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
info,
@ -203,7 +167,7 @@ export function useCompDocDialog({
cfg,
isNotRequired,
isGenerated,
isUploading: isBusy,
isUploading: uploadCompletionMutation.isPending || isFinalizing,
pdfInputRef,
handleMarkNotRequired,
handleUndo,

View file

@ -0,0 +1,58 @@
import { toast } from "react-toastify";
import type {
CompDocData,
CompDocSaveStatus,
DocState,
} from "@/domain/work-orders/types/completion-doc";
type PersistFn = (data: Partial<CompDocData>, status: CompDocSaveStatus) => void | Promise<void>;
type UseCompDocNotRequiredActionsArgs = {
docState: DocState;
setDocState: (state: DocState) => void;
persist: PersistFn;
refreshAuthoritativeState: () => void;
setIsFinalizing: (value: boolean) => void;
};
export function useCompDocNotRequiredActions({
docState,
setDocState,
persist,
refreshAuthoritativeState,
setIsFinalizing,
}: UseCompDocNotRequiredActionsArgs) {
async function handleMarkNotRequired() {
const previous = docState;
setDocState("not-required");
setIsFinalizing(true);
try {
await persist({}, "NN");
} catch (error) {
setDocState(previous);
refreshAuthoritativeState();
toast.error(
error instanceof Error ? error.message : "Failed to mark document as not required",
);
} finally {
setIsFinalizing(false);
}
}
async function handleUndo() {
const previous = docState;
setDocState("draft");
setIsFinalizing(true);
try {
await persist({}, "No");
} catch (error) {
setDocState(previous);
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
} finally {
setIsFinalizing(false);
}
}
return { handleMarkNotRequired, handleUndo };
}

View file

@ -34,6 +34,7 @@ export function useSlideOverEditState({
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
@ -43,6 +44,7 @@ export function useSlideOverEditState({
setBaseline(null);
setEditing(false);
setShowUnsaved(false);
setUnsavedIntent(null);
return;
}
setDraft(row);
@ -50,6 +52,7 @@ export function useSlideOverEditState({
setEditing(editMode && (row.status as string) !== "Canceled");
setTab("info");
setShowUnsaved(false);
setUnsavedIntent(null);
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]);
@ -106,6 +109,7 @@ export function useSlideOverEditState({
const requestClose = () => {
if (closeDisabled) return;
if (editing && isDirty) {
setUnsavedIntent("close");
setShowUnsaved(true);
return;
}
@ -114,6 +118,7 @@ export function useSlideOverEditState({
const handleCancelEdit = () => {
if (isDirty) {
setUnsavedIntent("cancel-edit");
setShowUnsaved(true);
return;
}
@ -121,6 +126,17 @@ export function useSlideOverEditState({
setEditing(false);
};
const handleDiscard = () => {
const intent = unsavedIntent;
discard();
setUnsavedIntent(null);
// Close only when Discard was opened from panel close — never from cancel-edit.
// Skip when uploads block close (closeDisabled).
if (intent === "close" && !closeDisabled) {
onClose();
}
};
const startEditing = () => {
if (!infoSource) return;
setDraft(infoSource);
@ -138,6 +154,7 @@ export function useSlideOverEditState({
handleDraftChange,
commit,
discard,
handleDiscard,
requestClose,
handleCancelEdit,
startEditing,

View file

@ -10,7 +10,7 @@ import type {
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
const COMPLETION_MEDIA_CATEGORIES = new Set(["Before", "After", "Completion"]);
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
type UseSlideOverMediaStateArgs = {
detailForRow?: WorkOrderBoardDetail;
@ -33,7 +33,7 @@ export function useSlideOverMediaState({
() =>
uploads.filter(
(item) =>
COMPLETION_MEDIA_CATEGORIES.has(item.category) ||
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
item.uploadStatus === "uploading" ||
item.uploadStatus === "failed",
),

View file

@ -92,11 +92,23 @@ export function useWorkOrderSlideOver({
const uploadCompletionPdf = (file: File) => {
const version = infoSource?.rowVersion;
if (!version) {
if (!version || !row || !infoSource) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
uploadCompletionMutation.mutate({ file, workOrderVersion: version });
uploadCompletionMutation.mutate(
{ file, workOrderVersion: version },
{
onSuccess: () => {
// Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes.
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
if (!infoSource.completedDate) {
patch.completedDate = new Date().toISOString().slice(0, 10);
}
onSave(row.id, patch);
},
},
);
};
const showDetailSkeleton = isLoading && !detailForRow;

View file

@ -74,7 +74,7 @@ export function WorkOrderSlideOverTabs({
{state.detailForRow != null && (
<SlideOverCommentsTab
comments={state.detailForRow.comments}
isReadOnly={state.isCancelled}
isReadOnly={state.isDocumentsReadOnly}
isPending={state.commentMutation.isPending}
currentUserId={state.user?.id}
isUpdatePending={state.updateCommentMutation.isPending}

View file

@ -98,10 +98,7 @@ export function WorkOrderSlideOver({
onClose={state.requestClose}
onEdit={state.startEditing}
onSave={state.commit}
onDiscard={() => {
state.discard();
onClose();
}}
onDiscard={state.handleDiscard}
onKeepEditing={() => state.setShowUnsaved(false)}
/>
</Box>

View file

@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
@ -39,12 +39,40 @@ function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
return item.id === request.localId && item.signal === request.signal;
}
function revokeBlobUrl(url: string) {
if (url.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
export function useMediaUploadSession(
detail: WorkOrderBoardDetail | undefined,
uploadMutateAsync: UploadMutate,
) {
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
const controllersRef = useRef(new Map<string, AbortController>());
const workOrderId = detail?.info.id;
const previousWorkOrderIdRef = useRef(workOrderId);
// Drop local tiles and abort in-flight uploads when the bound WO changes.
// Skip the first bind (undefined → id) so an early upload is not cleared.
useEffect(() => {
if (previousWorkOrderIdRef.current === workOrderId) return;
const hadPrevious = previousWorkOrderIdRef.current != null;
previousWorkOrderIdRef.current = workOrderId;
if (!hadPrevious) return;
for (const controller of controllersRef.current.values()) {
controller.abort();
}
controllersRef.current.clear();
setLocalUploads((prev) => {
for (const item of prev) {
revokeBlobUrl(item.url);
}
return [];
});
}, [workOrderId]);
const serverUploads = useMemo(
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
@ -84,8 +112,8 @@ export function useMediaUploadSession(
const index = next.findIndex((item) => item.id === request.localId);
if (index >= 0) {
const previous = next[index];
if (previous?.url.startsWith("blob:")) {
URL.revokeObjectURL(previous.url);
if (previous) {
revokeBlobUrl(previous.url);
}
next[index] = entry;
} else {
@ -151,8 +179,8 @@ export function useMediaUploadSession(
clearController(localId);
setLocalUploads((prev) => {
const target = prev.find((item) => item.id === localId);
if (target?.url.startsWith("blob:")) {
URL.revokeObjectURL(target.url);
if (target) {
revokeBlobUrl(target.url);
}
return prev.filter((item) => item.id !== localId);
});

View file

@ -231,10 +231,7 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M
fileType: classifyFileName(item.fileName, item.url),
url: item.url,
category: boardCategoryToMediaCategory(item.category),
purpose:
item.category === "Before" || item.category === "After" || item.category === "Extra"
? item.category
: null,
purpose: item.category,
isLegacy: item.isLegacy || Number(item.id) <= 0,
uploadStatus: "done" as const,
}));

View file

@ -9,8 +9,8 @@ export interface MediaFile {
fileType: FileCategory;
url: string;
category: MediaCategory;
/** Board purpose label (Before/After/Extra). Null/undefined = needs categorization. */
purpose?: "Before" | "After" | "Extra" | null;
/** Board purpose label (Before/After/Extra/Completion). Null/undefined = needs categorization. */
purpose?: "Before" | "After" | "Extra" | "Completion" | null;
/** True when media is a legacy Before/After/Completion column (not deletable). */
isLegacy?: boolean;
/** 0–100 while uploading; undefined when idle/complete. */

View file

@ -23,8 +23,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
"serviceNotes",
"extraServices",
"vendorId",
"company",
"tech",
// company/tech are display companions; board persists vendor via vendorId only
"status",
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;

View file

@ -132,4 +132,75 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads).toHaveLength(0);
});
});
it("clears local uploads and aborts in-flight work when the bound WO changes", async () => {
const uploadMutateAsync = vi.fn().mockImplementation(
({ signal }: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
signal?.addEventListener("abort", () => {
const error = new Error("aborted");
error.name = "AbortError";
reject(error);
});
}),
);
const detailA = {
info: { id: 1 },
media: [],
comments: [],
audit: [],
completion: {
docStatus: "",
template: null,
signOffName: "",
signOffAttachment: "",
signOffSignature: "",
dispatchSignoffs: [],
},
} as unknown as Parameters<typeof useMediaUploadSession>[0];
const detailB = {
info: { id: 2 },
media: [],
comments: [],
audit: [],
completion: {
docStatus: "",
template: null,
signOffName: "",
signOffAttachment: "",
signOffSignature: "",
dispatchSignoffs: [],
},
} as unknown as Parameters<typeof useMediaUploadSession>[0];
const { result, rerender } = renderHook(
({ detail }) => useMediaUploadSession(detail, uploadMutateAsync),
{ initialProps: { detail: detailA } },
);
act(() => {
result.current.startUploads([
{
localId: "local-wo-a",
file: makeFile("a.jpg"),
onProgress: () => undefined,
},
]);
});
await waitFor(() => {
expect(result.current.uploads).toHaveLength(1);
});
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
rerender({ detail: detailB });
await waitFor(() => {
expect(result.current.uploads).toHaveLength(0);
});
expect(firstSignal.aborted).toBe(true);
});
});

View file

@ -0,0 +1,142 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 100,
woNumber: "100",
site: "SITE",
type: "Emergency",
dispatcherId: "d1",
dispatcherName: "Alex",
scheduledOn: "2026-07-16",
completedDate: "",
targetWeek: "",
dayGroup: "2026-07-16",
dayLabel: "Thu",
location: "Main",
locationId: 5,
pm: "",
serviceNotes: "",
extraServices: [],
vendorId: "v1",
company: "Acme",
tech: "Sam",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Incomplete",
docStatus: "",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "",
priority: "",
primaryDispatchId: null,
rowVersion: "1",
dispatchRowVersion: "",
flagColor: null,
...overrides,
};
}
function detailWithMedia(
media: WorkOrderBoardDetail["media"],
infoOverrides: Partial<WorkOrderTableRow> = {},
): WorkOrderBoardDetail {
return {
info: baseInfo(infoOverrides),
completion: {
docStatus: "",
template: null,
signOffName: "",
signOffAttachment: "",
signOffSignature: "",
dispatchSignoffs: [],
},
comments: [],
audit: [],
media,
};
}
function idleMutation() {
return {
mutate: vi.fn(),
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
} as never;
}
describe("useSlideOverMediaState completion filter", () => {
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
const detail = detailWithMedia([
{
id: "1",
category: "Before",
url: "https://cdn.example.com/before.jpg",
fileName: "before.jpg",
uploadedAt: "2026-07-15T18:00:00.0000000Z",
isLegacy: false,
},
{
id: "2",
category: "After",
url: "https://cdn.example.com/after.jpg",
fileName: "after.jpg",
uploadedAt: "2026-07-15T18:00:00.0000000Z",
isLegacy: false,
},
{
id: "3",
category: "Completion",
url: "https://cdn.example.com/completion.pdf",
fileName: "completion.pdf",
uploadedAt: "2026-07-15T18:00:00.0000000Z",
isLegacy: false,
},
{
id: "4",
category: "Extra",
url: "https://cdn.example.com/extra.pdf",
fileName: "extra.pdf",
uploadedAt: "2026-07-15T18:00:00.0000000Z",
isLegacy: false,
},
]);
const { result } = renderHook(() =>
useSlideOverMediaState({
detailForRow: detail,
uploadMediaMutation: idleMutation(),
deleteMediaMutation: idleMutation(),
categorizeMediaMutation: idleMutation(),
}),
);
expect(result.current.completionMedia.map((item) => item.purpose)).toEqual([
"Before",
"After",
"Completion",
]);
expect(
result.current.completionMedia.every((item) =>
["Photo", "Video", "Report", "Other"].includes(item.category),
),
).toBe(true);
expect(result.current.extrasUploads).toHaveLength(1);
expect(result.current.extrasUploads[0]?.purpose).toBe("Extra");
});
});

View file

@ -170,13 +170,40 @@ describe("mapWorkOrderBoardDetail", () => {
name: "before.jpg",
url: "https://cdn.example.com/before.jpg",
category: "Photo",
purpose: "Before",
fileType: "image",
isLegacy: false,
});
expect(files[1]?.category).toBe("Other");
expect(files[1]?.purpose).toBe("Extra");
expect(files[1]?.fileType).toBe("pdf");
});
it("maps After and Completion board categories to purpose for completion UI", () => {
const after = mapBoardDetailMediaItem({
id: 20,
category: 2,
url: "https://cdn.example.com/after.jpg",
fileName: "after.jpg",
});
const completion = mapBoardDetailMediaItem({
id: 21,
category: 4,
url: "https://cdn.example.com/completion.pdf",
fileName: "completion.pdf",
});
const files = boardMediaItemsToMediaFiles([after, completion]);
expect(files[0]).toMatchObject({
category: "Photo",
purpose: "After",
});
expect(files[1]).toMatchObject({
category: "Report",
purpose: "Completion",
fileType: "pdf",
});
});
it("maps numeric category and marks negative ids as legacy", () => {
const legacy = mapBoardDetailMediaItem({
id: -1,

View file

@ -58,9 +58,23 @@ describe("slide-over-draft", () => {
it("exposes only board-persistable edit keys", () => {
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
expect(SLIDE_OVER_EDIT_KEYS).toContain("status");
expect(SLIDE_OVER_EDIT_KEYS).toContain("vendorId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("pocName");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("company");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("tech");
});
it("tracks vendor via vendorId only (company/tech are display companions)", () => {
const row = baseRow();
const draft = baseRow({ company: "Other Co", tech: "Pat" });
expect(isSlideOverDraftDirty(draft, row)).toBe(false);
expect(buildSlideOverPatch(draft, row)).toEqual({});
const vendorDraft = baseRow({ vendorId: "v2", company: "Other Co", tech: "Pat" });
expect(isSlideOverDraftDirty(vendorDraft, row)).toBe(true);
expect(buildSlideOverPatch(vendorDraft, row)).toEqual({ vendorId: "v2" });
});
it("ignores POC-only edits for dirty state", () => {