From 118d2042e62de360db3fe2e4d45f5a3ea9806e2b Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Wed, 5 Aug 2026 10:07:34 -0300 Subject: [PATCH 1/2] fix(work-orders): harden CompDoc state, persist rollback, and close gate --- .../comp-doc/comp-doc-dialog-footer.tsx | 11 ++-- .../comp-doc/use-comp-doc-dialog.tsx | 47 +++++++++++++--- .../comp-doc/use-comp-doc-pdf-actions.ts | 6 +++ .../_hooks/use-work-orders-list-page.ts | 8 ++- ...mp-doc-dialog-completion-contract.test.tsx | 32 +++++++++++ .../comp-doc-dialog-footer-exclusive.test.tsx | 53 +++++++++++++++++++ 6 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 src/test/app/(protected)/workorders/comp-doc-dialog-footer-exclusive.test.tsx diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx index 30c5ef8a..b21cbd05 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer.tsx @@ -35,8 +35,13 @@ export function CompDocDialogFooter({ onDownloadPdf, onUploadPdf, }: CompDocDialogFooterProps) { + // Exclusive footer branches (prototype: one docState at a time). A signed PDF + // on file implies Generated even if local docState has not caught up yet — never + // render Draft actions alongside Generated. + const showNotRequired = docState === "not-required"; const showGenerated = - (docState === "generated" || Boolean(signOffAttachment)) && docState !== "not-required"; + !showNotRequired && (docState === "generated" || Boolean(signOffAttachment)); + const showDraft = !showNotRequired && !showGenerated && docState === "draft"; return (
- {docState === "draft" && !isDocumentsReadOnly && ( + {Boolean(showDraft && !isDocumentsReadOnly) && ( )} - {docState === "not-required" && ( + {showNotRequired && ( (null); + const [isFinalizing, setIsFinalizing] = useState(false); const info = boardDetail?.info ?? row; const completion = boardDetail?.completion; @@ -78,9 +78,18 @@ export function useCompDocDialog({ const [docState, setDocState] = useState("draft"); const [generatedAt, setGeneratedAt] = useState(""); const [editDateCompleted, setEditDateCompleted] = useState(""); + const seedKeyRef = useRef(null); + // Prototype seeds once per open/WO session. Detail refetches must not wipe + // in-progress PM comments or date edits (Bugbot: dialog effect clears edits). useEffect(() => { - if (!open) 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/_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(); + }); +}); From 31dd9d5b7234dd684a107be51f921e384f5679e1 Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Thu, 6 Aug 2026 10:03:37 -0300 Subject: [PATCH 2/2] fix(work-orders): wire slide-over media retry and cancel [SH-116] --- .../tabs/slide-over-completion-media.tsx | 6 +++ .../detail/tabs/slide-over-completion-tab.tsx | 6 +++ .../detail/use-slide-over-media-state.ts | 41 +++++++++++-------- .../detail/use-work-order-slide-over.ts | 6 +++ .../detail/work-order-slide-over-tabs.tsx | 6 ++- 5 files changed, 46 insertions(+), 19 deletions(-) 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} />