mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
merge: sync feature/wo-wizard-board-create onto refreshed slide-over parent
This commit is contained in:
commit
162d47fe1a
11 changed files with 209 additions and 129 deletions
|
|
@ -11,13 +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 {
|
import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
DOC_STATE_CFG,
|
|
||||||
docStateOf,
|
|
||||||
fmtDocNow,
|
|
||||||
} 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 { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
|
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
|
||||||
|
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
|
||||||
|
|
||||||
type UseCompDocDialogArgs = {
|
type UseCompDocDialogArgs = {
|
||||||
row: WorkOrderTableRow;
|
row: WorkOrderTableRow;
|
||||||
|
|
@ -114,15 +111,21 @@ export function useCompDocDialog({
|
||||||
seaRep,
|
seaRep,
|
||||||
};
|
};
|
||||||
|
|
||||||
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
const { persist, syncDateIfChanged, refreshAuthoritativeState, handleSaveDraft, handleGenerate } =
|
||||||
await onSave({ notes, generatedAt, ...extra }, status);
|
useCompDocPersistFlow({
|
||||||
};
|
workOrderId: row.id,
|
||||||
|
notes,
|
||||||
const syncDateIfChanged = async () => {
|
generatedAt,
|
||||||
if (editDateCompleted !== (info.completedDate || "")) {
|
editDateCompleted,
|
||||||
await onDateCompletedChange?.(editDateCompleted);
|
currentCompletedDate: info.completedDate || "",
|
||||||
}
|
onSave,
|
||||||
};
|
onDateCompletedChange,
|
||||||
|
onGenerated: (ts) => {
|
||||||
|
setGeneratedAt(ts);
|
||||||
|
setDocState("generated");
|
||||||
|
},
|
||||||
|
onDraftSaved: notifyDraftSaved,
|
||||||
|
});
|
||||||
|
|
||||||
function handleMarkNotRequired() {
|
function handleMarkNotRequired() {
|
||||||
setDocState("not-required");
|
setDocState("not-required");
|
||||||
|
|
@ -134,28 +137,6 @@ export function useCompDocDialog({
|
||||||
void persist({}, "No");
|
void persist({}, "No");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveDraft() {
|
|
||||||
try {
|
|
||||||
await syncDateIfChanged();
|
|
||||||
await persist({}, "No");
|
|
||||||
notifyDraftSaved();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to save draft");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGenerate() {
|
|
||||||
const ts = fmtDocNow();
|
|
||||||
try {
|
|
||||||
await syncDateIfChanged();
|
|
||||||
await persist({ generatedAt: ts }, "Yes");
|
|
||||||
setGeneratedAt(ts);
|
|
||||||
setDocState("generated");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to generate document");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
|
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
|
||||||
info,
|
info,
|
||||||
signOffAttachment,
|
signOffAttachment,
|
||||||
|
|
@ -168,6 +149,7 @@ export function useCompDocDialog({
|
||||||
syncDateIfChanged,
|
syncDateIfChanged,
|
||||||
setGeneratedAt,
|
setGeneratedAt,
|
||||||
markGenerated: () => setDocState("generated"),
|
markGenerated: () => setDocState("generated"),
|
||||||
|
onPersistFailure: refreshAuthoritativeState,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ type UseCompDocPdfActionsArgs = {
|
||||||
syncDateIfChanged: () => Promise<void>;
|
syncDateIfChanged: () => Promise<void>;
|
||||||
setGeneratedAt: (value: string) => void;
|
setGeneratedAt: (value: string) => void;
|
||||||
markGenerated: () => void;
|
markGenerated: () => void;
|
||||||
|
onPersistFailure?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCompDocPdfActions({
|
export function useCompDocPdfActions({
|
||||||
|
|
@ -39,6 +40,7 @@ export function useCompDocPdfActions({
|
||||||
syncDateIfChanged,
|
syncDateIfChanged,
|
||||||
setGeneratedAt,
|
setGeneratedAt,
|
||||||
markGenerated,
|
markGenerated,
|
||||||
|
onPersistFailure,
|
||||||
}: UseCompDocPdfActionsArgs) {
|
}: UseCompDocPdfActionsArgs) {
|
||||||
function handleDownloadPdf() {
|
function handleDownloadPdf() {
|
||||||
if (signOffAttachment) {
|
if (signOffAttachment) {
|
||||||
|
|
@ -75,6 +77,7 @@ export function useCompDocPdfActions({
|
||||||
setGeneratedAt(ts);
|
setGeneratedAt(ts);
|
||||||
markGenerated();
|
markGenerated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
onPersistFailure?.();
|
||||||
toast.error(
|
toast.error(
|
||||||
error instanceof Error ? error.message : "Failed to finalize completion document",
|
error instanceof Error ? error.message : "Failed to finalize completion document",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
type UseCompDocPersistFlowArgs = {
|
||||||
|
workOrderId: string | number;
|
||||||
|
notes: string;
|
||||||
|
generatedAt: string;
|
||||||
|
editDateCompleted: string;
|
||||||
|
currentCompletedDate: string;
|
||||||
|
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||||
|
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||||
|
onGenerated: (generatedAt: string) => void;
|
||||||
|
onDraftSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCompDocPersistFlow({
|
||||||
|
workOrderId,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
editDateCompleted,
|
||||||
|
currentCompletedDate,
|
||||||
|
onSave,
|
||||||
|
onDateCompletedChange,
|
||||||
|
onGenerated,
|
||||||
|
onDraftSaved,
|
||||||
|
}: UseCompDocPersistFlowArgs) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const refreshAuthoritativeState = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.workOrders.detail(workOrderId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
||||||
|
await onSave({ notes, generatedAt, ...extra }, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncDateIfChanged = async () => {
|
||||||
|
if (editDateCompleted !== currentCompletedDate) {
|
||||||
|
await onDateCompletedChange?.(editDateCompleted);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleSaveDraft() {
|
||||||
|
try {
|
||||||
|
// Prerequisites first; docStatus last.
|
||||||
|
await syncDateIfChanged();
|
||||||
|
await persist({}, "No");
|
||||||
|
onDraftSaved();
|
||||||
|
} catch (error) {
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to save draft");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
const ts = fmtDocNow();
|
||||||
|
try {
|
||||||
|
// Prerequisites first; only mark Generated after completedDate persists.
|
||||||
|
await syncDateIfChanged();
|
||||||
|
await persist({ generatedAt: ts }, "Yes");
|
||||||
|
onGenerated(ts);
|
||||||
|
} catch (error) {
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to generate document");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
persist,
|
||||||
|
syncDateIfChanged,
|
||||||
|
refreshAuthoritativeState,
|
||||||
|
handleSaveDraft,
|
||||||
|
handleGenerate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -56,7 +56,6 @@ export function useSlideOverMediaState({
|
||||||
for (const request of requests) {
|
for (const request of requests) {
|
||||||
await uploadMediaMutation.mutateAsync({
|
await uploadMediaMutation.mutateAsync({
|
||||||
file: request.file,
|
file: request.file,
|
||||||
signal: request.signal,
|
|
||||||
onProgress: request.onProgress,
|
onProgress: request.onProgress,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,8 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
||||||
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
||||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||||
const { uploads, startUploads, retryUpload, removeLocal, isLocal } = useMediaUploadSession(
|
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||||
detail,
|
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
||||||
(input) => uploadMutation.mutateAsync(input),
|
|
||||||
);
|
|
||||||
|
|
||||||
const pending =
|
const pending =
|
||||||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
||||||
|
|
@ -85,7 +83,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
||||||
}}
|
}}
|
||||||
onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })}
|
onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })}
|
||||||
onRetry={retryUpload}
|
onRetry={retryUpload}
|
||||||
onCancel={removeLocal}
|
onCancel={cancelUpload}
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, useState } from "react";
|
import { useRef } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
import type { UploadableMediaCategory } 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";
|
||||||
|
|
@ -15,7 +15,6 @@ import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||||
export type MediaUploadRequest = {
|
export type MediaUploadRequest = {
|
||||||
localId: string;
|
localId: string;
|
||||||
file: File;
|
file: File;
|
||||||
signal: AbortSignal;
|
|
||||||
onProgress: (percent: number) => void;
|
onProgress: (percent: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -25,7 +24,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, signal: AbortSignal) => void;
|
onRetry?: (localId: string) => 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;
|
||||||
|
|
@ -45,7 +44,6 @@ export function MediaUploader({
|
||||||
pending = false,
|
pending = false,
|
||||||
}: MediaUploaderProps) {
|
}: MediaUploaderProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [controllers, setControllers] = useState<Record<string, AbortController>>({});
|
|
||||||
|
|
||||||
const addFiles = (files: FileList | null) => {
|
const addFiles = (files: FileList | null) => {
|
||||||
if (readOnly || pending || !files || files.length === 0) return;
|
if (readOnly || pending || !files || files.length === 0) return;
|
||||||
|
|
@ -65,19 +63,11 @@ export function MediaUploader({
|
||||||
if (accepted.length === 0) return;
|
if (accepted.length === 0) return;
|
||||||
|
|
||||||
if (onUpload) {
|
if (onUpload) {
|
||||||
const nextControllers: Record<string, AbortController> = { ...controllers };
|
const requests: MediaUploadRequest[] = accepted.map((file) => ({
|
||||||
const requests: MediaUploadRequest[] = accepted.map((file) => {
|
localId: `local-${crypto.randomUUID()}`,
|
||||||
const localId = `local-${crypto.randomUUID()}`;
|
file,
|
||||||
const controller = new AbortController();
|
onProgress: () => undefined,
|
||||||
nextControllers[localId] = controller;
|
}));
|
||||||
return {
|
|
||||||
localId,
|
|
||||||
file,
|
|
||||||
signal: controller.signal,
|
|
||||||
onProgress: () => undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setControllers(nextControllers);
|
|
||||||
onUpload(requests);
|
onUpload(requests);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -98,18 +88,6 @@ export function MediaUploader({
|
||||||
onChange?.(uploads.filter((u) => u.id !== id));
|
onChange?.(uploads.filter((u) => u.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancel = (id: string) => {
|
|
||||||
controllers[id]?.abort();
|
|
||||||
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 && (
|
||||||
|
|
@ -121,8 +99,8 @@ export function MediaUploader({
|
||||||
pending={pending}
|
pending={pending}
|
||||||
onRemove={remove}
|
onRemove={remove}
|
||||||
onCategorize={onCategorize}
|
onCategorize={onCategorize}
|
||||||
onRetry={onRetry ? retry : undefined}
|
onRetry={onRetry}
|
||||||
onCancel={cancel}
|
onCancel={onCancel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from "react";
|
import { 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";
|
||||||
|
|
@ -13,7 +13,13 @@ type UploadMutate = (input: {
|
||||||
onProgress?: (percent: number) => void;
|
onProgress?: (percent: number) => void;
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
|
|
||||||
function toLocalUpload(request: MediaUploadRequest): LocalUpload {
|
type SessionUploadRequest = {
|
||||||
|
localId: string;
|
||||||
|
file: File;
|
||||||
|
signal: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toLocalUpload(request: SessionUploadRequest): LocalUpload {
|
||||||
const classified = classifyUpload(request.file);
|
const classified = classifyUpload(request.file);
|
||||||
return {
|
return {
|
||||||
id: request.localId,
|
id: request.localId,
|
||||||
|
|
@ -29,7 +35,7 @@ function toLocalUpload(request: MediaUploadRequest): LocalUpload {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameAttempt(item: LocalUpload, request: MediaUploadRequest) {
|
function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
|
||||||
return item.id === request.localId && item.signal === request.signal;
|
return item.id === request.localId && item.signal === request.signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,6 +44,7 @@ export function useMediaUploadSession(
|
||||||
uploadMutateAsync: UploadMutate,
|
uploadMutateAsync: UploadMutate,
|
||||||
) {
|
) {
|
||||||
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
|
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
|
||||||
|
const controllersRef = useRef(new Map<string, AbortController>());
|
||||||
|
|
||||||
const serverUploads = useMemo(
|
const serverUploads = useMemo(
|
||||||
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
|
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
|
||||||
|
|
@ -52,10 +59,27 @@ export function useMediaUploadSession(
|
||||||
return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
|
return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
|
||||||
}, [serverUploads, localUploads]);
|
}, [serverUploads, localUploads]);
|
||||||
|
|
||||||
|
function clearController(localId: string) {
|
||||||
|
controllersRef.current.delete(localId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceController(localId: string): AbortController {
|
||||||
|
controllersRef.current.get(localId)?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllersRef.current.set(localId, controller);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
function startUploads(requests: MediaUploadRequest[]) {
|
function startUploads(requests: MediaUploadRequest[]) {
|
||||||
|
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
|
||||||
|
localId: request.localId,
|
||||||
|
file: request.file,
|
||||||
|
signal: replaceController(request.localId).signal,
|
||||||
|
}));
|
||||||
|
|
||||||
setLocalUploads((prev) => {
|
setLocalUploads((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
for (const request of requests) {
|
for (const request of sessionRequests) {
|
||||||
const entry = toLocalUpload(request);
|
const entry = toLocalUpload(request);
|
||||||
const index = next.findIndex((item) => item.id === request.localId);
|
const index = next.findIndex((item) => item.id === request.localId);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
|
|
@ -71,7 +95,7 @@ export function useMediaUploadSession(
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const request of requests) {
|
for (const request of sessionRequests) {
|
||||||
void uploadMutateAsync({
|
void uploadMutateAsync({
|
||||||
file: request.file,
|
file: request.file,
|
||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
|
|
@ -84,13 +108,16 @@ export function useMediaUploadSession(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
clearController(request.localId);
|
||||||
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
|
clearController(request.localId);
|
||||||
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep controller entry only while retryable; replaceController on retry.
|
||||||
setLocalUploads((prev) =>
|
setLocalUploads((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
isSameAttempt(item, request)
|
isSameAttempt(item, request)
|
||||||
|
|
@ -107,20 +134,32 @@ export function useMediaUploadSession(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryUpload(localId: string, signal: AbortSignal) {
|
function retryUpload(localId: string) {
|
||||||
const target = localUploads.find((item) => item.id === localId);
|
const target = localUploads.find((item) => item.id === localId);
|
||||||
if (!target?.sourceFile) return;
|
if (!target?.sourceFile) return;
|
||||||
startUploads([
|
startUploads([
|
||||||
{
|
{
|
||||||
localId,
|
localId,
|
||||||
file: target.sourceFile,
|
file: target.sourceFile,
|
||||||
signal,
|
|
||||||
onProgress: () => undefined,
|
onProgress: () => undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelUpload(localId: string) {
|
||||||
|
controllersRef.current.get(localId)?.abort();
|
||||||
|
clearController(localId);
|
||||||
|
setLocalUploads((prev) => {
|
||||||
|
const target = prev.find((item) => item.id === localId);
|
||||||
|
if (target?.url.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(target.url);
|
||||||
|
}
|
||||||
|
return prev.filter((item) => item.id !== localId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function removeLocal(id: string) {
|
function removeLocal(id: string) {
|
||||||
|
clearController(id);
|
||||||
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
|
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,5 +167,5 @@ export function useMediaUploadSession(
|
||||||
return localUploads.some((item) => item.id === id);
|
return localUploads.some((item) => item.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { uploads, startUploads, retryUpload, removeLocal, isLocal };
|
return { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -397,17 +397,6 @@ export function mapWorkOrderFormToUpdateFormData(
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Partial EditWorkorder payload — only Id + CompletedDate (avoids legacy GetWorkorderById). */
|
|
||||||
export function mapCompletedDateUpdateFormData(
|
|
||||||
id: string | number,
|
|
||||||
completedDate: string,
|
|
||||||
): FormData {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("Id", String(id));
|
|
||||||
appendIfPresent(formData, "CompletedDate", completedDate);
|
|
||||||
return formData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapLocationOption(raw: unknown): LocationOption {
|
export function mapLocationOption(raw: unknown): LocationOption {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
const name = readString(item, "name", "Name", "locationName", "LocationName");
|
const name = readString(item, "name", "Name", "locationName", "LocationName");
|
||||||
|
|
|
||||||
|
|
@ -170,4 +170,28 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Generate never writes docStatus Yes after a successful status-capable save when date fails first", async () => {
|
||||||
|
// Regression for the prior order (status then date): if date fails, Yes must not have been sent.
|
||||||
|
// With prerequisites-first ordering, date failure short-circuits before onSave("Yes").
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
onDateCompletedChange.mockImplementation(async () => {
|
||||||
|
callOrder.push("date");
|
||||||
|
throw new Error("completedDate conflict");
|
||||||
|
});
|
||||||
|
onSave.mockImplementation(async () => {
|
||||||
|
callOrder.push("status");
|
||||||
|
});
|
||||||
|
|
||||||
|
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).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(callOrder).toEqual(["date"]);
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
.mockImplementation(() => new Promise(() => undefined));
|
.mockImplementation(() => new Promise(() => undefined));
|
||||||
|
|
||||||
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
|
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
|
||||||
const firstController = new AbortController();
|
|
||||||
const localId = "local-1";
|
const localId = "local-1";
|
||||||
const file = makeFile();
|
const file = makeFile();
|
||||||
|
|
||||||
|
|
@ -29,7 +28,6 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
{
|
{
|
||||||
localId,
|
localId,
|
||||||
file,
|
file,
|
||||||
signal: firstController.signal,
|
|
||||||
onProgress: () => undefined,
|
onProgress: () => undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -39,6 +37,8 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
expect(result.current.uploads).toHaveLength(1);
|
expect(result.current.uploads).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
rejectFirst(new Error("network"));
|
rejectFirst(new Error("network"));
|
||||||
});
|
});
|
||||||
|
|
@ -47,9 +47,8 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
const retryController = new AbortController();
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.retryUpload(localId, retryController.signal);
|
result.current.retryUpload(localId);
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
@ -58,11 +57,15 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
||||||
});
|
});
|
||||||
expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
|
expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
|
||||||
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
|
const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
|
||||||
|
expect(retrySignal).toBeInstanceOf(AbortSignal);
|
||||||
|
expect(retrySignal).not.toBe(firstSignal);
|
||||||
|
expect(retrySignal.aborted).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancel after retry aborts the replacement upload and removes the local row", async () => {
|
it("fail → retry → cancel aborts the replacement upload and records no success", async () => {
|
||||||
let rejectFirst!: (error: Error) => void;
|
let rejectFirst!: (error: Error) => void;
|
||||||
|
let resolveRetry!: (value: unknown) => void;
|
||||||
const uploadMutateAsync = vi
|
const uploadMutateAsync = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementationOnce(
|
.mockImplementationOnce(
|
||||||
|
|
@ -73,7 +76,8 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
)
|
)
|
||||||
.mockImplementation(
|
.mockImplementation(
|
||||||
({ signal }: { signal?: AbortSignal }) =>
|
({ signal }: { signal?: AbortSignal }) =>
|
||||||
new Promise((_resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
resolveRetry = resolve;
|
||||||
signal?.addEventListener("abort", () => {
|
signal?.addEventListener("abort", () => {
|
||||||
const error = new Error("aborted");
|
const error = new Error("aborted");
|
||||||
error.name = "AbortError";
|
error.name = "AbortError";
|
||||||
|
|
@ -91,7 +95,6 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
{
|
{
|
||||||
localId,
|
localId,
|
||||||
file,
|
file,
|
||||||
signal: new AbortController().signal,
|
|
||||||
onProgress: () => undefined,
|
onProgress: () => undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -105,25 +108,28 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
const retryController = new AbortController();
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.retryUpload(localId, retryController.signal);
|
result.current.retryUpload(localId);
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
// Mirrors MediaUploader.cancel: abort the mapped controller, then removeLocal.
|
result.current.cancelUpload(localId);
|
||||||
retryController.abort();
|
|
||||||
result.current.removeLocal(localId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.uploads).toHaveLength(0);
|
expect(result.current.uploads).toHaveLength(0);
|
||||||
});
|
});
|
||||||
expect(retryController.signal.aborted).toBe(true);
|
expect(retrySignal.aborted).toBe(true);
|
||||||
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
|
// Cancelling must not leave a successful settle path.
|
||||||
|
expect(() => resolveRetry({})).not.toThrow();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
mapCompletedDateUpdateFormData,
|
|
||||||
mapWorkOrderFormToCreatePayload,
|
mapWorkOrderFormToCreatePayload,
|
||||||
mapWorkOrderFormToUpdateFormData,
|
mapWorkOrderFormToUpdateFormData,
|
||||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||||
|
|
@ -137,24 +136,3 @@ describe("mapWorkOrderFormToUpdateFormData", () => {
|
||||||
expect(formData.getAll("CategoryIds")).toEqual([]);
|
expect(formData.getAll("CategoryIds")).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mapCompletedDateUpdateFormData", () => {
|
|
||||||
it("builds FormData with only Id and CompletedDate", () => {
|
|
||||||
const formData = mapCompletedDateUpdateFormData(42, "2026-07-30");
|
|
||||||
|
|
||||||
expect(formData.get("Id")).toBe("42");
|
|
||||||
expect(formData.get("CompletedDate")).toBe("2026-07-30");
|
|
||||||
expect(formData.get("Status")).toBeNull();
|
|
||||||
expect(formData.get("WorkerOrderTitle")).toBeNull();
|
|
||||||
expect(formData.get("Description")).toBeNull();
|
|
||||||
expect(formData.getAll("ContactIds")).toEqual([]);
|
|
||||||
expect(formData.getAll("CategoryIds")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits empty CompletedDate", () => {
|
|
||||||
const formData = mapCompletedDateUpdateFormData(1, " ");
|
|
||||||
|
|
||||||
expect(formData.get("Id")).toBe("1");
|
|
||||||
expect(formData.get("CompletedDate")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue