From bfc230d4212f8d14d7f9be953b7115a6f2ce682a Mon Sep 17 00:00:00 2001 From: "arthur.bassi" Date: Wed, 5 Aug 2026 10:54:33 -0300 Subject: [PATCH] fix(work-orders): harden CompDoc generate seed persist and blob revoke --- .../_hooks/create-comp-doc-dialog-actions.ts | 170 ++++++++++++++++++ .../workorders/_hooks/use-comp-doc-dialog.ts | 140 ++++----------- .../_hooks/use-media-upload-session.ts | 34 +++- src/app/(protected)/workorders/index.tsx | 3 +- .../mappers/expand-scalar-board-ops.ts | 8 +- ...mp-doc-dialog-completion-contract.test.tsx | 18 ++ 6 files changed, 255 insertions(+), 118 deletions(-) create mode 100644 src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts diff --git a/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts b/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts new file mode 100644 index 00000000..343f9a8f --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions.ts @@ -0,0 +1,170 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { toast } from "react-toastify"; +import { showCompDocDraftSavedToast } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast"; +import { locationsApi } from "@/domain/locations/api/locations-api"; +import type { Location } from "@/domain/locations/types/location"; +import type { + CompDocData, + CompDocSaveStatus, + DocFields, + DocState, +} from "@/domain/work-orders/types/completion-doc"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html"; +import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state"; +import { queryKeys } from "@/infra/query-key/query-key"; + +type CompDocSite = { street: string; fullAddress: string }; + +export function buildCompDocAddressLine(site: CompDocSite): string { + if (site.street.trim()) return site.street.trim(); + return site.fullAddress !== "—" ? site.fullAddress : ""; +} + +function locationUpdatePayload(location: Location, address: string): Location { + return { + ...location, + address, + }; +} + +type CreateCompDocDialogActionsParams = { + row: WorkOrderTableRow; + notes: string; + generatedAt: string; + editDateCompleted: string; + editAddress: string; + docState: DocState; + docFields: DocFields; + site: CompDocSite; + locId: string; + isMockLocation: boolean; + locationDetail: Location | undefined; + queryClient: QueryClient; + onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise; + onDateCompletedChange?: (date: string) => void | Promise; + setDocState: (state: DocState) => void; + setGeneratedAt: (value: string) => void; +}; + +export function createCompDocDialogActions({ + row, + notes, + generatedAt, + editDateCompleted, + editAddress, + docState, + docFields, + site, + locId, + isMockLocation, + locationDetail, + queryClient, + onSave, + onDateCompletedChange, + setDocState, + setGeneratedAt, +}: CreateCompDocDialogActionsParams) { + async function persist(extra: Partial, status: CompDocSaveStatus): Promise { + await onSave({ notes, generatedAt, ...extra }, status); + } + + async function syncCompletedDateIfChanged(): Promise { + if (editDateCompleted !== (row.completedDate || "")) { + await onDateCompletedChange?.(editDateCompleted); + } + } + + async function syncAddressIfChanged(): Promise { + const next = editAddress.trim(); + const current = buildCompDocAddressLine(site); + if (next === current) return; + if (isMockLocation || !locId) return; + if (!locationDetail) { + throw new Error("Location details are still loading. Try saving again."); + } + + await locationsApi.update(locId, locationUpdatePayload(locationDetail, next)); + void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); + } + + function refreshAuthoritativeState() { + void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); + if (locId && !isMockLocation) { + void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) }); + void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); + } + } + + async function handleMarkNotRequired() { + const previous = docState; + setDocState("not-required"); + try { + await persist({}, "NN"); + } catch (error) { + setDocState(previous); + refreshAuthoritativeState(); + toast.error( + error instanceof Error ? error.message : "Failed to mark document as not required", + ); + } + } + + async function handleUndo() { + const previous = docState; + setDocState("draft"); + try { + await persist({}, "No"); + } catch (error) { + setDocState(previous); + refreshAuthoritativeState(); + toast.error(error instanceof Error ? error.message : "Failed to undo not-required"); + } + } + + async function handleSaveDraft() { + try { + await syncCompletedDateIfChanged(); + await syncAddressIfChanged(); + await persist({}, "No"); + showCompDocDraftSavedToast(); + } catch (error) { + refreshAuthoritativeState(); + toast.error(error instanceof Error ? error.message : "Failed to save draft"); + } + } + + async function handleGenerate() { + if (!editDateCompleted.trim()) { + toast.error("Date Work Completed is required to generate the document."); + return; + } + const ts = fmtDocNow(); + try { + // Prerequisites first; only mark Generated after completedDate/address persist. + await syncCompletedDateIfChanged(); + await syncAddressIfChanged(); + await persist({ generatedAt: ts }, "Yes"); + setGeneratedAt(ts); + setDocState("generated"); + } catch (error) { + // Keep Draft UI; refresh so any partially persisted date/address is authoritative. + refreshAuthoritativeState(); + toast.error(error instanceof Error ? error.message : "Failed to generate document"); + } + } + + function handleDownloadPdf() { + printDoc(buildDocHtml(row, { notes, generatedAt }, docFields)); + } + + return { + handleMarkNotRequired, + handleUndo, + handleSaveDraft, + handleGenerate, + handleDownloadPdf, + }; +} diff --git a/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts b/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts index fd7d139c..bf84f59d 100644 --- a/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts +++ b/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "react-toastify"; -import { showCompDocDraftSavedToast } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast"; -import { locationsApi } from "@/domain/locations/api/locations-api"; -import type { Location } from "@/domain/locations/types/location"; +import { + buildCompDocAddressLine, + createCompDocDialogActions, +} from "@/app/(protected)/workorders/_hooks/create-comp-doc-dialog-actions"; import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; import type { CompDocData, @@ -13,14 +13,8 @@ import type { } from "@/domain/work-orders/types/completion-doc"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data"; -import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html"; import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site"; -import { - DOC_STATE_CFG, - docStateOf, - fmtDocNow, -} from "@/domain/work-orders/utils/completion-doc-state"; -import { queryKeys } from "@/infra/query-key/query-key"; +import { DOC_STATE_CFG, docStateOf } from "@/domain/work-orders/utils/completion-doc-state"; type UseCompDocDialogParams = { row: WorkOrderTableRow; @@ -30,18 +24,6 @@ type UseCompDocDialogParams = { onDateCompletedChange?: (date: string) => void | Promise; }; -function buildAddressLine(site: { street: string; fullAddress: string }): string { - if (site.street.trim()) return site.street.trim(); - return site.fullAddress !== "—" ? site.fullAddress : ""; -} - -function locationUpdatePayload(location: Location, address: string): Location { - return { - ...location, - address, - }; -} - export function useCompDocDialog({ row, open, @@ -63,14 +45,22 @@ export function useCompDocDialog({ const [generatedAt, setGeneratedAt] = useState(""); const [editDateCompleted, setEditDateCompleted] = useState(""); const [editAddress, setEditAddress] = useState(""); + const seedKeyRef = useRef(null); + // Seed once per open/WO — row/detail refetches must not wipe in-progress edits. useEffect(() => { - if (!open) return; + if (!open) { + seedKeyRef.current = null; + return; + } + const key = String(row.id); + if (seedKeyRef.current === key) return; + seedKeyRef.current = key; setNotes(data?.notes ?? ""); setDocState(docStateOf(row.docStatus)); setGeneratedAt(data?.generatedAt ?? ""); setEditDateCompleted(row.completedDate || ""); - setEditAddress(buildAddressLine(resolveCompDocSite(row, locationDetail))); + setEditAddress(buildCompDocAddressLine(resolveCompDocSite(row, locationDetail))); }, [open, row, data?.notes, data?.generatedAt, locationDetail]); const amazonRep = row.pocName || ""; @@ -102,80 +92,24 @@ export function useCompDocDialog({ [site, effectiveStreet], ); - async function persist(extra: Partial, status: CompDocSaveStatus): Promise { - await onSave({ notes, generatedAt, ...extra }, status); - } - - async function syncCompletedDateIfChanged(): Promise { - if (editDateCompleted !== (row.completedDate || "")) { - await onDateCompletedChange?.(editDateCompleted); - } - } - - async function syncAddressIfChanged(): Promise { - const next = editAddress.trim(); - const current = buildAddressLine(site); - if (next === current) return; - if (isMockLocation || !locId) return; - if (!locationDetail) { - throw new Error("Location details are still loading. Try saving again."); - } - - await locationsApi.update(locId, locationUpdatePayload(locationDetail, next)); - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); - } - - function handleMarkNotRequired() { - setDocState("not-required"); - void persist({}, "NN"); - } - - function handleUndo() { - setDocState("draft"); - void persist({}, "No"); - } - - function refreshAuthoritativeState() { - void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); - if (locId && !isMockLocation) { - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - } - } - - async function handleSaveDraft() { - try { - await syncCompletedDateIfChanged(); - await syncAddressIfChanged(); - await persist({}, "No"); - showCompDocDraftSavedToast(); - } 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/address persist. - await syncCompletedDateIfChanged(); - await syncAddressIfChanged(); - await persist({ generatedAt: ts }, "Yes"); - setGeneratedAt(ts); - setDocState("generated"); - } catch (error) { - // Keep Draft UI; refresh so any partially persisted date/address is authoritative. - refreshAuthoritativeState(); - toast.error(error instanceof Error ? error.message : "Failed to generate document"); - } - } - - function handleDownloadPdf() { - printDoc(buildDocHtml(row, { notes, generatedAt }, docFields)); - } + const actions = createCompDocDialogActions({ + row, + notes, + generatedAt, + editDateCompleted, + editAddress, + docState, + docFields, + site, + locId, + isMockLocation, + locationDetail, + queryClient, + onSave, + onDateCompletedChange, + setDocState, + setGeneratedAt, + }); return { site: previewSite, @@ -192,10 +126,6 @@ export function useCompDocDialog({ cfg, isNotRequired, isGenerated, - handleMarkNotRequired, - handleUndo, - handleSaveDraft, - handleGenerate, - handleDownloadPdf, + ...actions, }; } diff --git a/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts index 69660f14..5f7a10e5 100644 --- a/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts +++ b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts @@ -13,6 +13,12 @@ type UploadMutate = (input: { onProgress?: (percent: number) => void; }) => Promise; +function revokeBlobUrl(url: string | undefined) { + if (url?.startsWith("blob:")) { + URL.revokeObjectURL(url); + } +} + function toLocalUpload(request: MediaUploadRequest): LocalUpload { const classified = classifyUpload(request.file); return { @@ -60,9 +66,7 @@ export function useMediaUploadSession( const index = next.findIndex((item) => item.id === request.localId); if (index >= 0) { const previous = next[index]; - if (previous?.url.startsWith("blob:")) { - URL.revokeObjectURL(previous.url); - } + revokeBlobUrl(previous?.url); next[index] = entry; } else { next.push(entry); @@ -84,11 +88,23 @@ export function useMediaUploadSession( }, }) .then(() => { - setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request))); + setLocalUploads((prev) => { + const dropped = prev.filter((item) => isSameAttempt(item, request)); + for (const item of dropped) { + revokeBlobUrl(item.url); + } + return prev.filter((item) => !isSameAttempt(item, request)); + }); }) .catch((error: Error) => { if (error.name === "AbortError") { - setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request))); + setLocalUploads((prev) => { + const dropped = prev.filter((item) => isSameAttempt(item, request)); + for (const item of dropped) { + revokeBlobUrl(item.url); + } + return prev.filter((item) => !isSameAttempt(item, request)); + }); return; } setLocalUploads((prev) => @@ -121,7 +137,13 @@ export function useMediaUploadSession( } function removeLocal(id: string) { - setLocalUploads((prev) => prev.filter((item) => item.id !== id)); + setLocalUploads((prev) => { + const dropped = prev.filter((item) => item.id === id); + for (const item of dropped) { + revokeBlobUrl(item.url); + } + return prev.filter((item) => item.id !== id); + }); } function isLocal(id: string) { diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 9196a162..d6f27def 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -161,8 +161,9 @@ export default function WorkOrdersListPage() { completionDocs={completionDocs} onSaveCompDoc={async (data, status) => { if (!activeDocRow) return; - setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data })); + // Persist board docStatus first so local generatedAt/notes never outrun the server. await patchFieldAsync(activeDocRow.id, { docStatus: status }); + setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data })); }} onDateCompletedChange={async (date) => { if (!activeDocRow) return; diff --git a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts index fa3d05af..6a67dd5d 100644 --- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts +++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts @@ -1,17 +1,13 @@ import type { WorkOrderBoardEditableField } from "@/domain/work-orders/types/work-order-board"; import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row"; +import { mapDocStatusToApi } from "@/domain/work-orders/mappers/doc-status-mapper"; export type BoardFieldOp = { field: WorkOrderBoardEditableField; value: string; }; -export function mapDocStatusToApi(value: string): string { - if (value === "NN") { - return "NotNeeded"; - } - return value; -} +export { mapDocStatusToApi }; type ScalarBoardOpRule = { key: keyof WorkOrderTablePatch; 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 5ee9134a..66bce349 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 @@ -1,6 +1,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; @@ -73,6 +74,7 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => { beforeEach(() => { onSave.mockReset(); onDateCompletedChange.mockReset(); + vi.mocked(toast.error).mockReset(); }); function renderDialog(row: WorkOrderTableRow) { @@ -148,6 +150,22 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => { expect(row.status).toBe("Scheduled"); }); + it("Generate keeps Draft and skips docStatus Yes when Date Work Completed is blank", async () => { + const row = baseRow({ completedDate: "" }); + renderDialog(row); + + fireEvent.click(screen.getByRole("button", { name: /^generate$/i })); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + "Date Work Completed is required to generate the document.", + ); + }); + expect(onDateCompletedChange).not.toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument(); + }); + it("Generate keeps Draft and skips docStatus Yes when completedDate patch fails", async () => { const row = baseRow({ completedDate: "2026-07-01" }); const client = new QueryClient({