chore(work-orders): sync wizard create onto refreshed slide-over parent [SH-110]

This commit is contained in:
arthur.bassi 2026-08-06 10:05:54 -03:00
commit da21645e0f
11 changed files with 192 additions and 30 deletions

View file

@ -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 (
<div
@ -57,7 +62,7 @@ export function CompDocDialogFooter({
event.target.value = "";
}}
/>
{docState === "draft" && !isDocumentsReadOnly && (
{Boolean(showDraft && !isDocumentsReadOnly) && (
<CompDocDialogFooterDraftActions
rowVersion={rowVersion}
isUploading={isUploading}
@ -78,7 +83,7 @@ export function CompDocDialogFooter({
onDownloadPdf={onDownloadPdf}
/>
)}
{docState === "not-required" && (
{showNotRequired && (
<CompDocDialogFooterNotRequiredActions
isDocumentsReadOnly={isDocumentsReadOnly}
onUndo={onUndo}

View file

@ -62,8 +62,8 @@ export function useCompDocDialog({
const { data: locationDetail } = useLocationDetail(locId || undefined);
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
const uploadCompletionMutation = useUploadCompletionDoc(row.id);
const isUploading = uploadCompletionMutation.isPending;
const pdfInputRef = useRef<HTMLInputElement>(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<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) 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,

View file

@ -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);
}
})();
},

View file

@ -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 (
<div className="space-y-3">
@ -37,6 +41,8 @@ export function SlideOverCompletionMedia({
onUpload={onUploadMedia}
onCategorize={onCategorizeMedia}
onRemove={onRemoveMedia}
onRetry={onRetryMedia}
onCancel={onCancelMedia}
readOnly={isReadOnly}
pending={mediaPending}
/>

View file

@ -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}
/>
);
}

View file

@ -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,
};
}

View file

@ -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,

View file

@ -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}
/>

View file

@ -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 });
};

View file

@ -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();
});
});

View file

@ -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<HTMLInputElement | null>();
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(
<CompDocDialogFooter
docState="draft"
isDocumentsReadOnly={false}
rowVersion="1"
isUploading={false}
signOffAttachment="https://example.com/doc.pdf"
generatedAt=""
pdfInputRef={pdfInputRef}
{...handlers}
/>,
);
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(
<CompDocDialogFooter
docState="draft"
isDocumentsReadOnly={false}
rowVersion="1"
isUploading={false}
signOffAttachment={null}
generatedAt=""
pdfInputRef={pdfInputRef}
{...handlers}
/>,
);
expect(screen.getByRole("button", { name: /save draft/i })).toBeInTheDocument();
expect(screen.queryByText(/document on file/i)).not.toBeInTheDocument();
});
});