(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) return;
+ 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 ?? "");
@@ -100,6 +109,7 @@ export function useCompDocDialog({
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,
@@ -127,14 +137,36 @@ export function useCompDocDialog({
onDraftSaved: notifyDraftSaved,
});
- function handleMarkNotRequired() {
+ async function handleMarkNotRequired() {
+ const previous = docState;
setDocState("not-required");
- void persist({}, "NN");
+ 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);
+ }
}
- function handleUndo() {
+ async function handleUndo() {
+ const previous = docState;
setDocState("draft");
- void persist({}, "No");
+ setIsFinalizing(true);
+ try {
+ await persist({}, "No");
+ } catch (error) {
+ setDocState(previous);
+ refreshAuthoritativeState();
+ toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
+ } finally {
+ setIsFinalizing(false);
+ }
}
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
@@ -150,6 +182,7 @@ export function useCompDocDialog({
setGeneratedAt,
markGenerated: () => setDocState("generated"),
onPersistFailure: refreshAuthoritativeState,
+ onFinalizingChange: setIsFinalizing,
});
return {
@@ -170,7 +203,7 @@ export function useCompDocDialog({
cfg,
isNotRequired,
isGenerated,
- isUploading,
+ isUploading: isBusy,
pdfInputRef,
handleMarkNotRequired,
handleUndo,
diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts
index 6e9feb91..5d3b6475 100644
--- a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts
+++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts
@@ -26,6 +26,7 @@ type UseCompDocPdfActionsArgs = {
setGeneratedAt: (value: string) => void;
markGenerated: () => void;
onPersistFailure?: () => void;
+ onFinalizingChange?: (finalizing: boolean) => void;
};
export function useCompDocPdfActions({
@@ -41,6 +42,7 @@ export function useCompDocPdfActions({
setGeneratedAt,
markGenerated,
onPersistFailure,
+ onFinalizingChange,
}: UseCompDocPdfActionsArgs) {
function handleDownloadPdf() {
if (signOffAttachment) {
@@ -69,6 +71,8 @@ export function useCompDocPdfActions({
{ file, workOrderVersion: rowVersion },
{
onSuccess: () => {
+ // Keep close disabled across the gap between upload settle and docStatus persist.
+ onFinalizingChange?.(true);
void (async () => {
const ts = fmtDocNow();
try {
@@ -81,6 +85,8 @@ export function useCompDocPdfActions({
toast.error(
error instanceof Error ? error.message : "Failed to finalize completion document",
);
+ } finally {
+ onFinalizingChange?.(false);
}
})();
},
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx
index 62e805c2..24e468d1 100644
--- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx
@@ -13,6 +13,8 @@ type SlideOverCompletionMediaProps = {
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
onRemoveMedia?: (id: string) => void;
+ onRetryMedia?: (localId: string) => void;
+ onCancelMedia?: (localId: string) => void;
};
export function SlideOverCompletionMedia({
@@ -22,6 +24,8 @@ export function SlideOverCompletionMedia({
onUploadMedia,
onCategorizeMedia,
onRemoveMedia,
+ onRetryMedia,
+ onCancelMedia,
}: SlideOverCompletionMediaProps) {
return (
@@ -37,6 +41,8 @@ export function SlideOverCompletionMedia({
onUpload={onUploadMedia}
onCategorize={onCategorizeMedia}
onRemove={onRemoveMedia}
+ onRetry={onRetryMedia}
+ onCancel={onCancelMedia}
readOnly={isReadOnly}
pending={mediaPending}
/>
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx
index cb444296..290ec603 100644
--- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx
@@ -30,6 +30,8 @@ type SlideOverCompletionTabProps = {
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
onRemoveMedia?: (id: string) => void;
+ onRetryMedia?: (localId: string) => void;
+ onCancelMedia?: (localId: string) => void;
onUploadCompletionPdf?: (file: File) => void;
onOpenCompletionDoc: () => void;
};
@@ -44,6 +46,8 @@ export function SlideOverCompletionTab({
onUploadMedia,
onCategorizeMedia,
onRemoveMedia,
+ onRetryMedia,
+ onCancelMedia,
onUploadCompletionPdf,
onOpenCompletionDoc,
}: SlideOverCompletionTabProps) {
@@ -61,6 +65,8 @@ export function SlideOverCompletionTab({
onUploadMedia={onUploadMedia}
onCategorizeMedia={onCategorizeMedia}
onRemoveMedia={onRemoveMedia}
+ onRetryMedia={onRetryMedia}
+ onCancelMedia={onCancelMedia}
/>
);
}
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 0220f747..821ff322 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
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
+import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
@@ -25,15 +26,18 @@ export function useSlideOverMediaState({
categorizeMediaMutation,
}: UseSlideOverMediaStateArgs) {
const [mediaBatchPending, setMediaBatchPending] = useState(false);
+ const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
+ useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
const completionMedia = useMemo(
() =>
- boardMediaItemsToMediaFiles(
- (detailForRow?.media ?? []).filter((item) =>
- COMPLETION_MEDIA_CATEGORIES.has(item.category),
- ),
+ uploads.filter(
+ (item) =>
+ COMPLETION_MEDIA_CATEGORIES.has(item.category) ||
+ item.uploadStatus === "uploading" ||
+ item.uploadStatus === "failed",
),
- [detailForRow?.media],
+ [uploads],
);
const extrasUploads = useMemo(
@@ -44,24 +48,16 @@ export function useSlideOverMediaState({
[detailForRow?.media],
);
+ const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
const mediaPending =
mediaBatchPending ||
+ hasLocalUploading ||
uploadMediaMutation.isPending ||
deleteMediaMutation.isPending ||
categorizeMediaMutation.isPending;
- const uploadMediaRequests = async (requests: MediaUploadRequest[]) => {
- setMediaBatchPending(true);
- try {
- for (const request of requests) {
- await uploadMediaMutation.mutateAsync({
- file: request.file,
- onProgress: request.onProgress,
- });
- }
- } finally {
- setMediaBatchPending(false);
- }
+ const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
+ startUploads(requests);
};
const uploadExtraFiles = async (files: File[]) => {
@@ -79,6 +75,14 @@ export function useSlideOverMediaState({
categorizeMediaMutation.mutate({ mediaId, category });
};
+ const removeMedia = (id: string) => {
+ if (isLocal(id)) {
+ removeLocal(id);
+ return;
+ }
+ deleteMediaMutation.mutate(id);
+ };
+
return {
completionMedia,
extrasUploads,
@@ -86,5 +90,8 @@ export function useSlideOverMediaState({
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,
+ retryUpload,
+ cancelUpload,
+ removeMedia,
};
}
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 7d6505c6..771cfb85 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
@@ -68,6 +68,9 @@ export function useWorkOrderSlideOver({
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,
+ retryUpload,
+ cancelUpload,
+ removeMedia,
} = useSlideOverMediaState({
detailForRow,
uploadMediaMutation,
@@ -123,6 +126,9 @@ export function useWorkOrderSlideOver({
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,
+ retryUpload,
+ cancelUpload,
+ removeMedia,
deleteMediaMutation,
uploadCompletionPdf,
showDetailSkeleton,
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 3f91d5f2..de9267fe 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
@@ -119,10 +119,12 @@ export function WorkOrderSlideOverTabs({
mediaPending={state.mediaPending}
completionPending={state.completionPending}
onUploadMedia={(requests) => {
- void state.uploadMediaRequests(requests);
+ state.uploadMediaRequests(requests);
}}
onCategorizeMedia={state.categorizeMedia}
- onRemoveMedia={(id) => state.deleteMediaMutation.mutate(id)}
+ onRemoveMedia={state.removeMedia}
+ onRetryMedia={state.retryUpload}
+ onCancelMedia={state.cancelUpload}
onUploadCompletionPdf={state.uploadCompletionPdf}
onOpenCompletionDoc={onOpenCompletionDoc}
/>
diff --git a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
index d46a2272..b2963c11 100644
--- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
+++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts
@@ -87,7 +87,13 @@ export function useWorkOrdersListPage() {
data: CompDocData,
status: CompDocSaveStatus,
) => {
- setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: data }));
+ // Persist PM comments + generatedAt in session state (no dedicated BE field yet)
+ // before patching board docStatus — Save draft / Generate / NN all share this path.
+ const nextData: CompDocData = {
+ notes: data.notes,
+ generatedAt: data.generatedAt,
+ };
+ setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData }));
await patchFieldAsync(row.id, { docStatus: status });
};
diff --git a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
index 5c10df5c..ff9d6ef2 100644
--- a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
+++ b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
@@ -194,4 +194,36 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
expect(callOrder).toEqual(["date"]);
expect(onSave).not.toHaveBeenCalled();
});
+
+ it("Save draft persists PM comments notes through onSave", async () => {
+ const row = baseRow();
+ renderDialog(row);
+
+ const notes = screen.getByPlaceholderText(/add findings/i);
+ fireEvent.change(notes, { target: { value: "No issues noted" } });
+ fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({ notes: "No issues noted" }),
+ "No",
+ );
+ });
+ });
+
+ it("Mark as not required rolls back UI when persist fails", async () => {
+ onSave.mockRejectedValueOnce(new Error("persist failed"));
+ const row = baseRow();
+ renderDialog(row);
+
+ fireEvent.click(screen.getByRole("button", { name: /mark as not required/i }));
+
+ await waitFor(() => {
+ expect(onSave).toHaveBeenCalledWith(expect.any(Object), "NN");
+ });
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /mark as not required/i })).toBeInTheDocument();
+ });
+ expect(screen.queryByRole("button", { name: /^undo$/i })).not.toBeInTheDocument();
+ });
});
diff --git a/src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx
new file mode 100644
index 00000000..49ced8be
--- /dev/null
+++ b/src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx
@@ -0,0 +1,53 @@
+import { createRef } from "react";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { CompDocDialogFooter } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer";
+
+describe("CompDocDialogFooter exclusive states", () => {
+ const pdfInputRef = createRef();
+ const handlers = {
+ onMarkNotRequired: vi.fn(),
+ onSaveDraft: vi.fn(),
+ onGenerate: vi.fn(),
+ onUndo: vi.fn(),
+ onDownloadPdf: vi.fn(),
+ onUploadPdf: vi.fn(),
+ };
+
+ it("does not render draft actions when a signed PDF is on file while docState is still draft", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByRole("button", { name: /save draft/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /mark as not required/i })).not.toBeInTheDocument();
+ expect(screen.getByText(/document on file/i)).toBeInTheDocument();
+ });
+
+ it("renders only draft actions for a clean draft without attachment", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: /save draft/i })).toBeInTheDocument();
+ expect(screen.queryByText(/document on file/i)).not.toBeInTheDocument();
+ });
+});