fix(work-orders): send workOrderVersion on media categorize/delete

Align CompDoc media mutations with backend PR #47 so categorize and
delete no longer return 422 WorkOrderVersionRequired.
This commit is contained in:
Arthur Bassi 2026-08-11 14:52:41 -03:00
parent f66284d78c
commit 969f99a576
5 changed files with 148 additions and 12 deletions

View file

@ -1,6 +1,7 @@
import { type MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
import { Image as ImageIcon } from "lucide-react";
import { toast } from "react-toastify";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { LoadingRegion } from "@/components/ui/loading-region";
@ -23,6 +24,14 @@ type MediaUploadModalProps = {
onOpenChange: (open: boolean) => void;
};
function resolveWorkOrderVersion(
detailRowVersion: string | null | undefined,
rowVersion: string | null | undefined,
): string | null {
const version = detailRowVersion?.trim() || rowVersion?.trim() || "";
return version || null;
}
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
const uploadMutation = useUploadWorkOrderMedia(row.id);
@ -38,6 +47,15 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
const requireVersion = (): string | null => {
const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
if (!version) {
toast.error("Missing work order version. Refresh and try again.");
return null;
}
return version;
};
return (
<Dialog
open={open}
@ -81,9 +99,15 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
removeLocal(id);
return;
}
deleteMutation.mutate(id);
const workOrderVersion = requireVersion();
if (!workOrderVersion) return;
deleteMutation.mutate({ mediaId: id, workOrderVersion });
}}
onCategorize={(id, category) => {
const workOrderVersion = requireVersion();
if (!workOrderVersion) return;
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
}}
onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })}
onRetry={retryUpload}
onCancel={removeLocal}
readOnly={isReadOnly}

View file

@ -126,9 +126,18 @@ export const workOrderBoardDocumentsApi = {
workOrderId: string | number,
mediaId: string | number,
category: UploadableMediaCategory,
workOrderVersion: string,
): Promise<WorkOrderBoardMediaItem> => {
if (!workOrderVersion) {
throw new BoardPatchValidationError(
"WorkOrderVersionRequired",
"workOrderVersion is required to update media category.",
);
}
const formData = new FormData();
formData.append("category", category);
formData.append("workOrderVersion", workOrderVersion);
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
body: formData,
throwHttpErrors: false,
@ -140,7 +149,18 @@ export const workOrderBoardDocumentsApi = {
throwDocumentHttpError(response, data);
},
deleteMedia: async (workOrderId: string | number, mediaId: string | number): Promise<void> => {
deleteMedia: async (
workOrderId: string | number,
mediaId: string | number,
workOrderVersion: string,
): Promise<void> => {
if (!workOrderVersion) {
throw new BoardPatchValidationError(
"WorkOrderVersionRequired",
"workOrderVersion is required to delete media.",
);
}
const numericId = typeof mediaId === "number" ? mediaId : Number(mediaId);
if (!Number.isFinite(numericId) || numericId <= 0) {
throw new BoardPatchValidationError(
@ -150,6 +170,7 @@ export const workOrderBoardDocumentsApi = {
}
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
searchParams: { workOrderVersion },
throwHttpErrors: false,
});

View file

@ -58,6 +58,12 @@ export interface UploadWorkOrderMediaInput {
export interface UpdateWorkOrderMediaCategoryInput {
mediaId: string | number;
category: UploadableMediaCategory;
workOrderVersion: string;
}
export interface DeleteWorkOrderMediaInput {
mediaId: string | number;
workOrderVersion: string;
}
export interface UploadCompletionDocInput {
@ -217,8 +223,8 @@ export function useUpdateWorkOrderMediaCategory(
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ mediaId, category }: UpdateWorkOrderMediaCategoryInput) =>
workOrdersApi.updateMediaCategory(workOrderId, mediaId, category),
mutationFn: ({ mediaId, category, workOrderVersion }: UpdateWorkOrderMediaCategoryInput) =>
workOrdersApi.updateMediaCategory(workOrderId, mediaId, category, workOrderVersion),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
invalidateWorkOrders(queryClient);
@ -232,11 +238,12 @@ export function useUpdateWorkOrderMediaCategory(
export function useDeleteWorkOrderMedia(
workOrderId: string | number,
): UseMutationResult<void, Error, string | number> {
): UseMutationResult<void, Error, DeleteWorkOrderMediaInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (mediaId: string | number) => workOrdersApi.deleteMedia(workOrderId, mediaId),
mutationFn: ({ mediaId, workOrderVersion }: DeleteWorkOrderMediaInput) =>
workOrdersApi.deleteMedia(workOrderId, mediaId, workOrderVersion),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
invalidateWorkOrders(queryClient);

View file

@ -11,7 +11,7 @@ const categorizeMutate = vi.fn();
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({
data: {
info: { status: "Scheduled" },
info: { status: "Scheduled", rowVersion: "DETAIL-VER==" },
media: [
{
id: "11",
@ -80,7 +80,7 @@ function baseRow(): WorkOrderTableRow {
title: "Emergency",
priority: "Sev 1",
primaryDispatchId: null,
rowVersion: "",
rowVersion: "ROW-VER==",
dispatchRowVersion: "",
flagColor: null,
};
@ -111,4 +111,32 @@ describe("MediaUploadModal API wiring", () => {
expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
});
it("categorizes with workOrderVersion from detail.info.rowVersion", async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const { fireEvent } = await import("@testing-library/react");
render(
<QueryClientProvider client={client}>
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/category for before\.jpg/i), {
target: { value: "After" },
});
expect(categorizeMutate).toHaveBeenCalledWith({
mediaId: "11",
category: "After",
workOrderVersion: "DETAIL-VER==",
});
});
});

View file

@ -494,12 +494,55 @@ describe("workOrdersApi.uploadMedia", () => {
});
});
describe("workOrdersApi.updateMediaCategory", () => {
beforeEach(() => {
apiPatchFn.mockReset();
});
it("PATCHes FormData with category and workOrderVersion", async () => {
apiPatchFn.mockResolvedValue({
ok: true,
json: async () => ({
id: 12,
category: 1,
url: "https://cdn.example.com/before.jpg",
fileName: "before.jpg",
uploadedAt: "2026-07-16T12:00:00Z",
isLegacy: false,
}),
});
const item = await workOrdersApi.updateMediaCategory(10, 12, "Before", "AAAAAAA=");
expect(apiPatchFn).toHaveBeenCalledWith(
API_PATHS.workOrder.mediaItem(10, 12),
expect.objectContaining({ throwHttpErrors: false }),
);
const body = apiPatchFn.mock.calls[0]?.[1]?.body as FormData;
expect(body.get("category")).toBe("Before");
expect(body.get("workOrderVersion")).toBe("AAAAAAA=");
expect(item.category).toBe("Before");
});
it("rejects missing workOrderVersion client-side", async () => {
const { BoardPatchValidationError } =
await import("@/domain/work-orders/errors/board-patch-errors");
await expect(workOrdersApi.updateMediaCategory(10, 12, "After", "")).rejects.toMatchObject({
name: "BoardPatchValidationError",
code: "WorkOrderVersionRequired",
});
expect(apiPatchFn).not.toHaveBeenCalled();
expect(BoardPatchValidationError).toBeTruthy();
});
});
describe("workOrdersApi.deleteMedia", () => {
beforeEach(() => {
apiDeleteFn.mockReset();
});
it("DELETEs media item and accepts 204", async () => {
it("DELETEs media item with workOrderVersion query and accepts 204", async () => {
apiDeleteFn.mockResolvedValue({
ok: true,
status: 204,
@ -508,18 +551,31 @@ describe("workOrdersApi.deleteMedia", () => {
},
});
await workOrdersApi.deleteMedia(10, 12);
await workOrdersApi.deleteMedia(10, 12, "AAAAAAA=");
expect(apiDeleteFn).toHaveBeenCalledWith(API_PATHS.workOrder.mediaItem(10, 12), {
searchParams: { workOrderVersion: "AAAAAAA=" },
throwHttpErrors: false,
});
});
it("rejects missing workOrderVersion client-side", async () => {
const { BoardPatchValidationError } =
await import("@/domain/work-orders/errors/board-patch-errors");
await expect(workOrdersApi.deleteMedia(10, 12, "")).rejects.toMatchObject({
name: "BoardPatchValidationError",
code: "WorkOrderVersionRequired",
});
expect(apiDeleteFn).not.toHaveBeenCalled();
expect(BoardPatchValidationError).toBeTruthy();
});
it("rejects legacy media ids client-side", async () => {
const { BoardPatchValidationError } =
await import("@/domain/work-orders/errors/board-patch-errors");
await expect(workOrdersApi.deleteMedia(10, -1)).rejects.toBeInstanceOf(
await expect(workOrdersApi.deleteMedia(10, -1, "AAAAAAA=")).rejects.toBeInstanceOf(
BoardPatchValidationError,
);
expect(apiDeleteFn).not.toHaveBeenCalled();