mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 17:12:06 +00:00
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:
parent
f66284d78c
commit
969f99a576
5 changed files with 148 additions and 12 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { type MouseEvent } from "react";
|
import { type MouseEvent } from "react";
|
||||||
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
|
||||||
import { Image as ImageIcon } from "lucide-react";
|
import { Image as ImageIcon } from "lucide-react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import { LoadingRegion } from "@/components/ui/loading-region";
|
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||||
|
|
@ -23,6 +24,14 @@ type MediaUploadModalProps = {
|
||||||
onOpenChange: (open: boolean) => void;
|
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) {
|
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||||
|
|
@ -38,6 +47,15 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
||||||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
||||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|
@ -81,9 +99,15 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
||||||
removeLocal(id);
|
removeLocal(id);
|
||||||
return;
|
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}
|
onRetry={retryUpload}
|
||||||
onCancel={removeLocal}
|
onCancel={removeLocal}
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
|
|
|
||||||
|
|
@ -126,9 +126,18 @@ export const workOrderBoardDocumentsApi = {
|
||||||
workOrderId: string | number,
|
workOrderId: string | number,
|
||||||
mediaId: string | number,
|
mediaId: string | number,
|
||||||
category: UploadableMediaCategory,
|
category: UploadableMediaCategory,
|
||||||
|
workOrderVersion: string,
|
||||||
): Promise<WorkOrderBoardMediaItem> => {
|
): Promise<WorkOrderBoardMediaItem> => {
|
||||||
|
if (!workOrderVersion) {
|
||||||
|
throw new BoardPatchValidationError(
|
||||||
|
"WorkOrderVersionRequired",
|
||||||
|
"workOrderVersion is required to update media category.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("category", category);
|
formData.append("category", category);
|
||||||
|
formData.append("workOrderVersion", workOrderVersion);
|
||||||
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
|
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
|
||||||
body: formData,
|
body: formData,
|
||||||
throwHttpErrors: false,
|
throwHttpErrors: false,
|
||||||
|
|
@ -140,7 +149,18 @@ export const workOrderBoardDocumentsApi = {
|
||||||
throwDocumentHttpError(response, data);
|
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);
|
const numericId = typeof mediaId === "number" ? mediaId : Number(mediaId);
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
throw new BoardPatchValidationError(
|
throw new BoardPatchValidationError(
|
||||||
|
|
@ -150,6 +170,7 @@ export const workOrderBoardDocumentsApi = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
|
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
|
||||||
|
searchParams: { workOrderVersion },
|
||||||
throwHttpErrors: false,
|
throwHttpErrors: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,12 @@ export interface UploadWorkOrderMediaInput {
|
||||||
export interface UpdateWorkOrderMediaCategoryInput {
|
export interface UpdateWorkOrderMediaCategoryInput {
|
||||||
mediaId: string | number;
|
mediaId: string | number;
|
||||||
category: UploadableMediaCategory;
|
category: UploadableMediaCategory;
|
||||||
|
workOrderVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteWorkOrderMediaInput {
|
||||||
|
mediaId: string | number;
|
||||||
|
workOrderVersion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadCompletionDocInput {
|
export interface UploadCompletionDocInput {
|
||||||
|
|
@ -217,8 +223,8 @@ export function useUpdateWorkOrderMediaCategory(
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ mediaId, category }: UpdateWorkOrderMediaCategoryInput) =>
|
mutationFn: ({ mediaId, category, workOrderVersion }: UpdateWorkOrderMediaCategoryInput) =>
|
||||||
workOrdersApi.updateMediaCategory(workOrderId, mediaId, category),
|
workOrdersApi.updateMediaCategory(workOrderId, mediaId, category, workOrderVersion),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateBoardDetail(queryClient, workOrderId);
|
invalidateBoardDetail(queryClient, workOrderId);
|
||||||
invalidateWorkOrders(queryClient);
|
invalidateWorkOrders(queryClient);
|
||||||
|
|
@ -232,11 +238,12 @@ export function useUpdateWorkOrderMediaCategory(
|
||||||
|
|
||||||
export function useDeleteWorkOrderMedia(
|
export function useDeleteWorkOrderMedia(
|
||||||
workOrderId: string | number,
|
workOrderId: string | number,
|
||||||
): UseMutationResult<void, Error, string | number> {
|
): UseMutationResult<void, Error, DeleteWorkOrderMediaInput> {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (mediaId: string | number) => workOrdersApi.deleteMedia(workOrderId, mediaId),
|
mutationFn: ({ mediaId, workOrderVersion }: DeleteWorkOrderMediaInput) =>
|
||||||
|
workOrdersApi.deleteMedia(workOrderId, mediaId, workOrderVersion),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateBoardDetail(queryClient, workOrderId);
|
invalidateBoardDetail(queryClient, workOrderId);
|
||||||
invalidateWorkOrders(queryClient);
|
invalidateWorkOrders(queryClient);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const categorizeMutate = vi.fn();
|
||||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
useWorkOrderBoardDetail: () => ({
|
useWorkOrderBoardDetail: () => ({
|
||||||
data: {
|
data: {
|
||||||
info: { status: "Scheduled" },
|
info: { status: "Scheduled", rowVersion: "DETAIL-VER==" },
|
||||||
media: [
|
media: [
|
||||||
{
|
{
|
||||||
id: "11",
|
id: "11",
|
||||||
|
|
@ -80,7 +80,7 @@ function baseRow(): WorkOrderTableRow {
|
||||||
title: "Emergency",
|
title: "Emergency",
|
||||||
priority: "Sev 1",
|
priority: "Sev 1",
|
||||||
primaryDispatchId: null,
|
primaryDispatchId: null,
|
||||||
rowVersion: "",
|
rowVersion: "ROW-VER==",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
};
|
};
|
||||||
|
|
@ -111,4 +111,32 @@ describe("MediaUploadModal API wiring", () => {
|
||||||
expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
|
expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/category for before\.jpg/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==",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
describe("workOrdersApi.deleteMedia", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiDeleteFn.mockReset();
|
apiDeleteFn.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("DELETEs media item and accepts 204", async () => {
|
it("DELETEs media item with workOrderVersion query and accepts 204", async () => {
|
||||||
apiDeleteFn.mockResolvedValue({
|
apiDeleteFn.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 204,
|
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), {
|
expect(apiDeleteFn).toHaveBeenCalledWith(API_PATHS.workOrder.mediaItem(10, 12), {
|
||||||
|
searchParams: { workOrderVersion: "AAAAAAA=" },
|
||||||
throwHttpErrors: false,
|
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 () => {
|
it("rejects legacy media ids client-side", async () => {
|
||||||
const { BoardPatchValidationError } =
|
const { BoardPatchValidationError } =
|
||||||
await import("@/domain/work-orders/errors/board-patch-errors");
|
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,
|
BoardPatchValidationError,
|
||||||
);
|
);
|
||||||
expect(apiDeleteFn).not.toHaveBeenCalled();
|
expect(apiDeleteFn).not.toHaveBeenCalled();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue