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, onNotesChange,
onDateCompletedChange, onDateCompletedChange,
}: CompDocSidebarProps) { }: 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; const StateIcon = cfg.icon;
return ( return (
@ -83,7 +85,7 @@ export function CompDocSidebar({
marginTop: 3, marginTop: 3,
}} }}
> >
{docStatusCardDescription(docState, generatedAt)} {docStatusCardDescription(displayState, generatedAt)}
</div> </div>
</div> </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 { toast } from "react-toastify";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import type { 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 { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site"; 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 { 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 { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow"; import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
@ -71,45 +73,31 @@ export function useCompDocDialog({
const signOffAttachment = completion?.signOffAttachment; const signOffAttachment = completion?.signOffAttachment;
const rowVersion = info.rowVersion || row.rowVersion; const rowVersion = info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status); const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]); const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [docState, setDocState] = useState<DocState>("draft"); const [docState, setDocState] = useState<DocState>("draft");
const [generatedAt, setGeneratedAt] = useState(""); const [generatedAt, setGeneratedAt] = useState("");
const [editDateCompleted, setEditDateCompleted] = useState(""); const [editDateCompleted, setEditDateCompleted] = useState("");
const seedKeyRef = useRef<string | null>(null);
// Prototype seeds once per open/WO session. Detail refetches must not wipe useCompDocDialogSeed({
// 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 || "");
}, [
open, open,
info.id, infoId: info.id,
info.docStatus, infoDocStatus: info.docStatus,
info.completedDate, infoCompletedDate: info.completedDate,
completion?.docStatus, completionDocStatus: completion?.docStatus,
data?.notes, data,
data?.generatedAt, setNotes,
]); setDocState,
setGeneratedAt,
setEditDateCompleted,
});
const amazonRep = info.pocName || ""; const amazonRep = info.pocName || "";
const seaRep = info.tech || ""; const seaRep = info.tech || "";
const cfg = DOC_STATE_CFG[docState]; const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required"; const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated" || Boolean(signOffAttachment); const isGenerated = docState === "generated" || Boolean(signOffAttachment);
const isBusy = uploadCompletionMutation.isPending || isFinalizing;
const docFields: DocFields = { const docFields: DocFields = {
street: site.street, street: site.street,
@ -137,37 +125,13 @@ export function useCompDocDialog({
onDraftSaved: notifyDraftSaved, onDraftSaved: notifyDraftSaved,
}); });
async function handleMarkNotRequired() { const { handleMarkNotRequired, handleUndo } = useCompDocNotRequiredActions({
const previous = docState; docState,
setDocState("not-required"); setDocState,
setIsFinalizing(true); persist,
try { refreshAuthoritativeState,
await persist({}, "NN"); setIsFinalizing,
} 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 { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({ const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
info, info,
@ -203,7 +167,7 @@ export function useCompDocDialog({
cfg, cfg,
isNotRequired, isNotRequired,
isGenerated, isGenerated,
isUploading: isBusy, isUploading: uploadCompletionMutation.isPending || isFinalizing,
pdfInputRef, pdfInputRef,
handleMarkNotRequired, handleMarkNotRequired,
handleUndo, 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. */ /** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null); const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false); 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 — // 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. // not on every optimistic parent-row field update during a failed/in-flight save.
@ -43,6 +44,7 @@ export function useSlideOverEditState({
setBaseline(null); setBaseline(null);
setEditing(false); setEditing(false);
setShowUnsaved(false); setShowUnsaved(false);
setUnsavedIntent(null);
return; return;
} }
setDraft(row); setDraft(row);
@ -50,6 +52,7 @@ export function useSlideOverEditState({
setEditing(editMode && (row.status as string) !== "Canceled"); setEditing(editMode && (row.status as string) !== "Canceled");
setTab("info"); setTab("info");
setShowUnsaved(false); setShowUnsaved(false);
setUnsavedIntent(null);
// Intentionally identity-only: depending on `row` would absorb optimistic patches. // Intentionally identity-only: depending on `row` would absorb optimistic patches.
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate // eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]); }, [row?.id, editMode]);
@ -106,6 +109,7 @@ export function useSlideOverEditState({
const requestClose = () => { const requestClose = () => {
if (closeDisabled) return; if (closeDisabled) return;
if (editing && isDirty) { if (editing && isDirty) {
setUnsavedIntent("close");
setShowUnsaved(true); setShowUnsaved(true);
return; return;
} }
@ -114,6 +118,7 @@ export function useSlideOverEditState({
const handleCancelEdit = () => { const handleCancelEdit = () => {
if (isDirty) { if (isDirty) {
setUnsavedIntent("cancel-edit");
setShowUnsaved(true); setShowUnsaved(true);
return; return;
} }
@ -121,6 +126,17 @@ export function useSlideOverEditState({
setEditing(false); 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 = () => { const startEditing = () => {
if (!infoSource) return; if (!infoSource) return;
setDraft(infoSource); setDraft(infoSource);
@ -138,6 +154,7 @@ export function useSlideOverEditState({
handleDraftChange, handleDraftChange,
commit, commit,
discard, discard,
handleDiscard,
requestClose, requestClose,
handleCancelEdit, handleCancelEdit,
startEditing, startEditing,

View file

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

View file

@ -92,11 +92,23 @@ export function useWorkOrderSlideOver({
const uploadCompletionPdf = (file: File) => { const uploadCompletionPdf = (file: File) => {
const version = infoSource?.rowVersion; const version = infoSource?.rowVersion;
if (!version) { if (!version || !row || !infoSource) {
toast.error("Missing work order version. Refresh and try again."); toast.error("Missing work order version. Refresh and try again.");
return; 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; const showDetailSkeleton = isLoading && !detailForRow;

View file

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

View file

@ -98,10 +98,7 @@ export function WorkOrderSlideOver({
onClose={state.requestClose} onClose={state.requestClose}
onEdit={state.startEditing} onEdit={state.startEditing}
onSave={state.commit} onSave={state.commit}
onDiscard={() => { onDiscard={state.handleDiscard}
state.discard();
onClose();
}}
onKeepEditing={() => state.setShowUnsaved(false)} onKeepEditing={() => state.setShowUnsaved(false)}
/> />
</Box> </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 type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; 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; return item.id === request.localId && item.signal === request.signal;
} }
function revokeBlobUrl(url: string) {
if (url.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
export function useMediaUploadSession( export function useMediaUploadSession(
detail: WorkOrderBoardDetail | undefined, detail: WorkOrderBoardDetail | undefined,
uploadMutateAsync: UploadMutate, uploadMutateAsync: UploadMutate,
) { ) {
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]); const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
const controllersRef = useRef(new Map<string, AbortController>()); 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( const serverUploads = useMemo(
() => boardMediaItemsToMediaFiles(detail?.media ?? []), () => boardMediaItemsToMediaFiles(detail?.media ?? []),
@ -84,8 +112,8 @@ export function useMediaUploadSession(
const index = next.findIndex((item) => item.id === request.localId); const index = next.findIndex((item) => item.id === request.localId);
if (index >= 0) { if (index >= 0) {
const previous = next[index]; const previous = next[index];
if (previous?.url.startsWith("blob:")) { if (previous) {
URL.revokeObjectURL(previous.url); revokeBlobUrl(previous.url);
} }
next[index] = entry; next[index] = entry;
} else { } else {
@ -151,8 +179,8 @@ export function useMediaUploadSession(
clearController(localId); clearController(localId);
setLocalUploads((prev) => { setLocalUploads((prev) => {
const target = prev.find((item) => item.id === localId); const target = prev.find((item) => item.id === localId);
if (target?.url.startsWith("blob:")) { if (target) {
URL.revokeObjectURL(target.url); revokeBlobUrl(target.url);
} }
return prev.filter((item) => item.id !== localId); 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), fileType: classifyFileName(item.fileName, item.url),
url: item.url, url: item.url,
category: boardCategoryToMediaCategory(item.category), category: boardCategoryToMediaCategory(item.category),
purpose: purpose: item.category,
item.category === "Before" || item.category === "After" || item.category === "Extra"
? item.category
: null,
isLegacy: item.isLegacy || Number(item.id) <= 0, isLegacy: item.isLegacy || Number(item.id) <= 0,
uploadStatus: "done" as const, uploadStatus: "done" as const,
})); }));

View file

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

View file

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

View file

@ -132,4 +132,75 @@ describe("useMediaUploadSession retry / cancel", () => {
expect(result.current.uploads).toHaveLength(0); 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", name: "before.jpg",
url: "https://cdn.example.com/before.jpg", url: "https://cdn.example.com/before.jpg",
category: "Photo", category: "Photo",
purpose: "Before",
fileType: "image", fileType: "image",
isLegacy: false, isLegacy: false,
}); });
expect(files[1]?.category).toBe("Other"); expect(files[1]?.category).toBe("Other");
expect(files[1]?.purpose).toBe("Extra");
expect(files[1]?.fileType).toBe("pdf"); 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", () => { it("maps numeric category and marks negative ids as legacy", () => {
const legacy = mapBoardDetailMediaItem({ const legacy = mapBoardDetailMediaItem({
id: -1, id: -1,

View file

@ -58,9 +58,23 @@ describe("slide-over-draft", () => {
it("exposes only board-persistable edit keys", () => { it("exposes only board-persistable edit keys", () => {
expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber"); expect(SLIDE_OVER_EDIT_KEYS).toContain("woNumber");
expect(SLIDE_OVER_EDIT_KEYS).toContain("status"); 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("pocName");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId"); expect(SLIDE_OVER_EDIT_KEYS).not.toContain("locationId");
expect(SLIDE_OVER_EDIT_KEYS).not.toContain("techPhone"); 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", () => { it("ignores POC-only edits for dirty state", () => {