mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
chore(work-orders): sync wizard create onto slide-over media review fixes
This commit is contained in:
commit
a7bce3bd48
17 changed files with 470 additions and 83 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue