diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx index 78b43895..02035224 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-sidebar.tsx @@ -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)} diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog-seed.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog-seed.ts new file mode 100644 index 00000000..d189971f --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog-seed.ts @@ -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>; + setDocState: Dispatch>; + setGeneratedAt: Dispatch>; + setEditDateCompleted: Dispatch>; +}; + +/** 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(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, + ]); +} diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx index 275f676b..365cf856 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx @@ -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("draft"); const [generatedAt, setGeneratedAt] = useState(""); const [editDateCompleted, setEditDateCompleted] = useState(""); - const seedKeyRef = useRef(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, diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-not-required-actions.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-not-required-actions.ts new file mode 100644 index 00000000..4159b926 --- /dev/null +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-not-required-actions.ts @@ -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, status: CompDocSaveStatus) => void | Promise; + +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 }; +} diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts index cb0e7fa2..ec74d498 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-edit-state.ts @@ -34,6 +34,7 @@ export function useSlideOverEditState({ /** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */ const [baseline, setBaseline] = useState(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, diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 821ff322..e0d8ed5a 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -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", ), diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts index 771cfb85..35c12c0d 100644 --- a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts +++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts @@ -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; diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index de9267fe..6d69148a 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -74,7 +74,7 @@ export function WorkOrderSlideOverTabs({ {state.detailForRow != null && ( { - state.discard(); - onClose(); - }} + onDiscard={state.handleDiscard} onKeepEditing={() => state.setShowUnsaved(false)} /> diff --git a/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts index d43b64c9..305c4f7b 100644 --- a/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts +++ b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts @@ -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([]); const controllersRef = useRef(new Map()); + 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); }); diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index 77a00047..97128ef3 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -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, })); diff --git a/src/domain/work-orders/types/work-order-media.ts b/src/domain/work-orders/types/work-order-media.ts index 6d687275..a5909850 100644 --- a/src/domain/work-orders/types/work-order-media.ts +++ b/src/domain/work-orders/types/work-order-media.ts @@ -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. */ diff --git a/src/domain/work-orders/utils/slide-over-draft.ts b/src/domain/work-orders/utils/slide-over-draft.ts index 9c8d897d..4bc824e3 100644 --- a/src/domain/work-orders/utils/slide-over-draft.ts +++ b/src/domain/work-orders/utils/slide-over-draft.ts @@ -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; diff --git a/src/test/app/(protected)/workorders/use-media-upload-session.test.tsx b/src/test/app/(protected)/workorders/use-media-upload-session.test.tsx index e93ae50a..4ac689fc 100644 --- a/src/test/app/(protected)/workorders/use-media-upload-session.test.tsx +++ b/src/test/app/(protected)/workorders/use-media-upload-session.test.tsx @@ -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[0]; + + const detailB = { + info: { id: 2 }, + media: [], + comments: [], + audit: [], + completion: { + docStatus: "", + template: null, + signOffName: "", + signOffAttachment: "", + signOffSignature: "", + dispatchSignoffs: [], + }, + } as unknown as Parameters[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); + }); }); diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx new file mode 100644 index 00000000..c5c03f6c --- /dev/null +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -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 { + 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 = {}, +): 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"); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts index 350df95f..3973cdc5 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts @@ -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, diff --git a/src/test/domain/work-orders/utils/slide-over-draft.test.ts b/src/test/domain/work-orders/utils/slide-over-draft.test.ts index 30f342b3..bc7ad080 100644 --- a/src/test/domain/work-orders/utils/slide-over-draft.test.ts +++ b/src/test/domain/work-orders/utils/slide-over-draft.test.ts @@ -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", () => {