mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): persist date before Generate and fix media retry cancel
Keep CompDoc from marking Generated until completedDate is saved. Bind retry AbortControllers to the uploader cancel map with upsert-by-localId.
This commit is contained in:
parent
9074841125
commit
c0d5e556f2
6 changed files with 220 additions and 42 deletions
|
|
@ -136,8 +136,8 @@ export function useCompDocDialog({
|
||||||
|
|
||||||
async function handleSaveDraft() {
|
async function handleSaveDraft() {
|
||||||
try {
|
try {
|
||||||
await persist({}, "No");
|
|
||||||
await syncDateIfChanged();
|
await syncDateIfChanged();
|
||||||
|
await persist({}, "No");
|
||||||
notifyDraftSaved();
|
notifyDraftSaved();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to save draft");
|
toast.error(error instanceof Error ? error.message : "Failed to save draft");
|
||||||
|
|
@ -147,8 +147,8 @@ export function useCompDocDialog({
|
||||||
async function handleGenerate() {
|
async function handleGenerate() {
|
||||||
const ts = fmtDocNow();
|
const ts = fmtDocNow();
|
||||||
try {
|
try {
|
||||||
await persist({ generatedAt: ts }, "Yes");
|
|
||||||
await syncDateIfChanged();
|
await syncDateIfChanged();
|
||||||
|
await persist({ generatedAt: ts }, "Yes");
|
||||||
setGeneratedAt(ts);
|
setGeneratedAt(ts);
|
||||||
setDocState("generated");
|
setDocState("generated");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ type UseCompDocPdfActionsArgs = {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
docFields: DocFields;
|
docFields: DocFields;
|
||||||
uploadCompletionMutation: ReturnType<typeof useUploadCompletionDoc>;
|
uploadCompletionMutation: ReturnType<typeof useUploadCompletionDoc>;
|
||||||
persist: (extra: Partial<CompDocData>, status: CompDocSaveStatus) => void | Promise<void>;
|
persist: (extra: Partial<CompDocData>, status: CompDocSaveStatus) => Promise<void>;
|
||||||
syncDateIfChanged: () => void | Promise<void>;
|
syncDateIfChanged: () => Promise<void>;
|
||||||
setGeneratedAt: (value: string) => void;
|
setGeneratedAt: (value: string) => void;
|
||||||
markGenerated: () => void;
|
markGenerated: () => void;
|
||||||
};
|
};
|
||||||
|
|
@ -67,11 +67,19 @@ export function useCompDocPdfActions({
|
||||||
{ file, workOrderVersion: rowVersion },
|
{ file, workOrderVersion: rowVersion },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
const ts = fmtDocNow();
|
void (async () => {
|
||||||
setGeneratedAt(ts);
|
const ts = fmtDocNow();
|
||||||
markGenerated();
|
try {
|
||||||
persist({ generatedAt: ts }, "Yes");
|
await syncDateIfChanged();
|
||||||
syncDateIfChanged();
|
await persist({ generatedAt: ts }, "Yes");
|
||||||
|
setGeneratedAt(ts);
|
||||||
|
markGenerated();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to finalize completion document",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ type MediaUploaderProps = {
|
||||||
onUpload?: (requests: MediaUploadRequest[]) => void;
|
onUpload?: (requests: MediaUploadRequest[]) => void;
|
||||||
onRemove?: (id: string) => void;
|
onRemove?: (id: string) => void;
|
||||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
onRetry?: (localId: string) => void;
|
onRetry?: (localId: string, signal: AbortSignal) => void;
|
||||||
onCancel?: (localId: string) => void;
|
onCancel?: (localId: string) => void;
|
||||||
/** Legacy local-only mode (table modal fallback). */
|
/** Legacy local-only mode (table modal fallback). */
|
||||||
onChange?: (next: MediaFile[]) => void;
|
onChange?: (next: MediaFile[]) => void;
|
||||||
|
|
@ -103,6 +103,13 @@ export function MediaUploader({
|
||||||
onCancel?.(id);
|
onCancel?.(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const retry = (id: string) => {
|
||||||
|
controllers[id]?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
setControllers((prev) => ({ ...prev, [id]: controller }));
|
||||||
|
onRetry?.(id, controller.signal);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
|
|
@ -114,7 +121,7 @@ export function MediaUploader({
|
||||||
pending={pending}
|
pending={pending}
|
||||||
onRemove={remove}
|
onRemove={remove}
|
||||||
onCategorize={onCategorize}
|
onCategorize={onCategorize}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry ? retry : undefined}
|
||||||
onCancel={cancel}
|
onCancel={cancel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
|
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||||
|
|
||||||
type LocalUpload = MediaFile & { sourceFile?: File };
|
type LocalUpload = MediaFile & { sourceFile?: File; signal?: AbortSignal };
|
||||||
|
|
||||||
type UploadMutate = (input: {
|
type UploadMutate = (input: {
|
||||||
file: File;
|
file: File;
|
||||||
|
|
@ -13,6 +13,26 @@ type UploadMutate = (input: {
|
||||||
onProgress?: (percent: number) => void;
|
onProgress?: (percent: number) => void;
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
|
|
||||||
|
function toLocalUpload(request: MediaUploadRequest): LocalUpload {
|
||||||
|
const classified = classifyUpload(request.file);
|
||||||
|
return {
|
||||||
|
id: request.localId,
|
||||||
|
name: request.file.name,
|
||||||
|
fileType: classified.fileType,
|
||||||
|
url: URL.createObjectURL(request.file),
|
||||||
|
category: classified.category,
|
||||||
|
purpose: null,
|
||||||
|
progress: 0,
|
||||||
|
uploadStatus: "uploading" as const,
|
||||||
|
sourceFile: request.file,
|
||||||
|
signal: request.signal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameAttempt(item: LocalUpload, request: MediaUploadRequest) {
|
||||||
|
return item.id === request.localId && item.signal === request.signal;
|
||||||
|
}
|
||||||
|
|
||||||
export function useMediaUploadSession(
|
export function useMediaUploadSession(
|
||||||
detail: WorkOrderBoardDetail | undefined,
|
detail: WorkOrderBoardDetail | undefined,
|
||||||
uploadMutateAsync: UploadMutate,
|
uploadMutateAsync: UploadMutate,
|
||||||
|
|
@ -33,23 +53,23 @@ export function useMediaUploadSession(
|
||||||
}, [serverUploads, localUploads]);
|
}, [serverUploads, localUploads]);
|
||||||
|
|
||||||
function startUploads(requests: MediaUploadRequest[]) {
|
function startUploads(requests: MediaUploadRequest[]) {
|
||||||
setLocalUploads((prev) => [
|
setLocalUploads((prev) => {
|
||||||
...prev,
|
const next = [...prev];
|
||||||
...requests.map((request) => {
|
for (const request of requests) {
|
||||||
const classified = classifyUpload(request.file);
|
const entry = toLocalUpload(request);
|
||||||
return {
|
const index = next.findIndex((item) => item.id === request.localId);
|
||||||
id: request.localId,
|
if (index >= 0) {
|
||||||
name: request.file.name,
|
const previous = next[index];
|
||||||
fileType: classified.fileType,
|
if (previous?.url.startsWith("blob:")) {
|
||||||
url: URL.createObjectURL(request.file),
|
URL.revokeObjectURL(previous.url);
|
||||||
category: classified.category,
|
}
|
||||||
purpose: null,
|
next[index] = entry;
|
||||||
progress: 0,
|
} else {
|
||||||
uploadStatus: "uploading" as const,
|
next.push(entry);
|
||||||
sourceFile: request.file,
|
}
|
||||||
};
|
}
|
||||||
}),
|
return next;
|
||||||
]);
|
});
|
||||||
|
|
||||||
for (const request of requests) {
|
for (const request of requests) {
|
||||||
void uploadMutateAsync({
|
void uploadMutateAsync({
|
||||||
|
|
@ -58,22 +78,22 @@ export function useMediaUploadSession(
|
||||||
onProgress: (percent) => {
|
onProgress: (percent) => {
|
||||||
setLocalUploads((prev) =>
|
setLocalUploads((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
item.id === request.localId ? { ...item, progress: percent } : item,
|
isSameAttempt(item, request) ? { ...item, progress: percent } : item,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setLocalUploads((prev) => prev.filter((item) => item.id !== request.localId));
|
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
setLocalUploads((prev) => prev.filter((item) => item.id !== request.localId));
|
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLocalUploads((prev) =>
|
setLocalUploads((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
item.id === request.localId
|
isSameAttempt(item, request)
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
uploadStatus: "failed",
|
uploadStatus: "failed",
|
||||||
|
|
@ -87,15 +107,14 @@ export function useMediaUploadSession(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryUpload(localId: string) {
|
function retryUpload(localId: string, signal: AbortSignal) {
|
||||||
const target = localUploads.find((item) => item.id === localId);
|
const target = localUploads.find((item) => item.id === localId);
|
||||||
if (!target?.sourceFile) return;
|
if (!target?.sourceFile) return;
|
||||||
const controller = new AbortController();
|
|
||||||
startUploads([
|
startUploads([
|
||||||
{
|
{
|
||||||
localId,
|
localId,
|
||||||
file: target.sourceFile,
|
file: target.sourceFile,
|
||||||
signal: controller.signal,
|
signal,
|
||||||
onProgress: () => undefined,
|
onProgress: () => undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,12 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
||||||
row={row}
|
row={row}
|
||||||
open
|
open
|
||||||
onOpenChange={vi.fn()}
|
onOpenChange={vi.fn()}
|
||||||
onSave={(data, status) => {
|
onSave={async (data, status) => {
|
||||||
onSave(data, status);
|
await onSave(data, status);
|
||||||
patches.push({ docStatus: status });
|
patches.push({ docStatus: status });
|
||||||
}}
|
}}
|
||||||
onDateCompletedChange={(date) => {
|
onDateCompletedChange={async (date) => {
|
||||||
onDateCompletedChange(date);
|
await onDateCompletedChange(date);
|
||||||
patches.push({ completedDate: date });
|
patches.push({ completedDate: date });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -128,7 +128,7 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
||||||
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15");
|
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15");
|
||||||
});
|
});
|
||||||
expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No");
|
expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No");
|
||||||
expect(patches).toEqual([{ docStatus: "No" }, { completedDate: "2026-07-15" }]);
|
expect(patches).toEqual([{ completedDate: "2026-07-15" }, { docStatus: "No" }]);
|
||||||
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
|
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
|
||||||
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
|
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
|
||||||
expect(row.scheduledOn).toBe(SCHEDULED_ON);
|
expect(row.scheduledOn).toBe(SCHEDULED_ON);
|
||||||
|
|
@ -149,10 +149,25 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
||||||
expect.objectContaining({ generatedAt: expect.any(String) }),
|
expect.objectContaining({ generatedAt: expect.any(String) }),
|
||||||
"Yes",
|
"Yes",
|
||||||
);
|
);
|
||||||
expect(patches).toEqual([{ docStatus: "Yes" }, { completedDate: "2026-07-20" }]);
|
expect(patches).toEqual([{ completedDate: "2026-07-20" }, { docStatus: "Yes" }]);
|
||||||
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
|
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
|
||||||
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
|
expect(patches.some((patch) => patch.status === "Completed")).toBe(false);
|
||||||
expect(row.scheduledOn).toBe(SCHEDULED_ON);
|
expect(row.scheduledOn).toBe(SCHEDULED_ON);
|
||||||
expect(row.status).toBe("Scheduled");
|
expect(row.status).toBe("Scheduled");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Generate keeps Draft and skips docStatus Yes when completedDate patch fails", async () => {
|
||||||
|
onDateCompletedChange.mockRejectedValueOnce(new Error("conflict"));
|
||||||
|
const row = baseRow({ completedDate: "2026-07-01" });
|
||||||
|
renderDialog(row);
|
||||||
|
|
||||||
|
changeDateCompleted("2026-07-20");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
|
||||||
|
});
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||||
|
|
||||||
|
function makeFile(name = "photo.jpg") {
|
||||||
|
return new File(["bytes"], name, { type: "image/jpeg" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useMediaUploadSession retry / cancel", () => {
|
||||||
|
it("retry upserts the same localId instead of appending a duplicate", async () => {
|
||||||
|
let rejectFirst!: (error: Error) => void;
|
||||||
|
const uploadMutateAsync = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
rejectFirst = reject;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockImplementation(() => new Promise(() => undefined));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
|
||||||
|
const firstController = new AbortController();
|
||||||
|
const localId = "local-1";
|
||||||
|
const file = makeFile();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.startUploads([
|
||||||
|
{
|
||||||
|
localId,
|
||||||
|
file,
|
||||||
|
signal: firstController.signal,
|
||||||
|
onProgress: () => undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
rejectFirst(new Error("network"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const retryController = new AbortController();
|
||||||
|
act(() => {
|
||||||
|
result.current.retryUpload(localId, retryController.signal);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads).toHaveLength(1);
|
||||||
|
expect(result.current.uploads[0]?.id).toBe(localId);
|
||||||
|
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
||||||
|
});
|
||||||
|
expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
|
||||||
|
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel after retry aborts the replacement upload and removes the local row", async () => {
|
||||||
|
let rejectFirst!: (error: Error) => void;
|
||||||
|
const uploadMutateAsync = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
rejectFirst = reject;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockImplementation(
|
||||||
|
({ signal }: { signal?: AbortSignal }) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
signal?.addEventListener("abort", () => {
|
||||||
|
const error = new Error("aborted");
|
||||||
|
error.name = "AbortError";
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
|
||||||
|
const localId = "local-retry";
|
||||||
|
const file = makeFile("retry.jpg");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.startUploads([
|
||||||
|
{
|
||||||
|
localId,
|
||||||
|
file,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
onProgress: () => undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
rejectFirst(new Error("upload failed"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const retryController = new AbortController();
|
||||||
|
act(() => {
|
||||||
|
result.current.retryUpload(localId, retryController.signal);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
// Mirrors MediaUploader.cancel: abort the mapped controller, then removeLocal.
|
||||||
|
retryController.abort();
|
||||||
|
result.current.removeLocal(localId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads).toHaveLength(0);
|
||||||
|
});
|
||||||
|
expect(retryController.signal.aborted).toBe(true);
|
||||||
|
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue