fix(work-orders): harden CompDoc generate seed persist and blob revoke

This commit is contained in:
arthur.bassi 2026-08-05 10:54:33 -03:00
parent 7ea0d2c464
commit bfc230d421
6 changed files with 255 additions and 118 deletions

View file

@ -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<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
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<CompDocData>, status: CompDocSaveStatus): Promise<void> {
await onSave({ notes, generatedAt, ...extra }, status);
}
async function syncCompletedDateIfChanged(): Promise<void> {
if (editDateCompleted !== (row.completedDate || "")) {
await onDateCompletedChange?.(editDateCompleted);
}
}
async function syncAddressIfChanged(): Promise<void> {
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,
};
}

View file

@ -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<void>;
};
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<string | null>(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<CompDocData>, status: CompDocSaveStatus): Promise<void> {
await onSave({ notes, generatedAt, ...extra }, status);
}
async function syncCompletedDateIfChanged(): Promise<void> {
if (editDateCompleted !== (row.completedDate || "")) {
await onDateCompletedChange?.(editDateCompleted);
}
}
async function syncAddressIfChanged(): Promise<void> {
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,
};
}

View file

@ -13,6 +13,12 @@ type UploadMutate = (input: {
onProgress?: (percent: number) => void;
}) => Promise<unknown>;
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) {

View file

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

View file

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

View file

@ -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({