diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx index ce6f87a5..ce2fbdf9 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog.tsx @@ -15,8 +15,8 @@ type CompDocDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; data?: CompDocData; - onSave: (data: CompDocData, status: CompDocSaveStatus) => void; - onDateCompletedChange?: (date: string) => void; + onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise; + onDateCompletedChange?: (date: string) => void | Promise; }; export function CompDocDialog({ diff --git a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx index baa789d0..a38542d6 100644 --- a/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx +++ b/src/app/(protected)/workorders/_components/list/work-orders-list-overlays.tsx @@ -23,8 +23,8 @@ type WorkOrdersListOverlaysProps = { activeDocRow: WorkOrderTableRow | null; onDocOpenChange: (open: boolean) => void; completionDocs: Record; - onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void; - onDateCompletedChange: (date: string) => void; + onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise; + onDateCompletedChange: (date: string) => void | Promise; activeMediaRow: WorkOrderTableRow | null; onMediaOpenChange: (open: boolean) => void; }; @@ -71,8 +71,7 @@ export function WorkOrdersListOverlays({ open={!!activeDocRow} onOpenChange={onDocOpenChange} data={completionDocs[String(activeDocRow.id)]} - // docStatus persists via board PATCH (Yes|No|NotNeeded); completedDate via - // EditWorkorder. patchField serializes same-id saves so they never race. + // docStatus + completedDate persist via board PATCH (not legacy EditWorkorder). // Notes/generatedAt stay client-local (no content endpoint on this branch). onSave={onSaveCompDoc} onDateCompletedChange={onDateCompletedChange} diff --git a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx index c0ea3e58..4b6c10c1 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -1,20 +1,21 @@ -import { type MouseEvent, useMemo } from "react"; +import { type MouseEvent } from "react"; import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material"; import { Image as ImageIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; import { LoadingRegion } from "@/components/ui/loading-region"; import { Text } from "@/components/ui/text"; -import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader"; +import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useDeleteWorkOrderMedia, + useUpdateWorkOrderMediaCategory, useUploadWorkOrderMedia, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; import { guardDialogClose } from "@/lib/dialog-guard"; -import { MediaUploader } from "./media-uploader"; type MediaUploadModalProps = { row: WorkOrderTableRow; @@ -25,23 +26,16 @@ type MediaUploadModalProps = { export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) { const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open); const uploadMutation = useUploadWorkOrderMedia(row.id); + const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id); const deleteMutation = useDeleteWorkOrderMedia(row.id); const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status); - - const uploads = useMemo( - () => - boardMediaItemsToMediaFiles( - (detail?.media ?? []).filter( - (item) => - item.category === "Before" || - item.category === "After" || - item.category === "Completion", - ), - ), - [detail?.media], + const { uploads, startUploads, retryUpload, removeLocal, isLocal } = useMediaUploadSession( + detail, + (input) => uploadMutation.mutateAsync(input), ); - const pending = uploadMutation.isPending || deleteMutation.isPending; + const pending = + uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending; const handleClose = guardDialogClose(pending, () => onOpenChange(false)); return ( @@ -66,7 +60,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr #{row.woNumber} - Upload photos and videos for this work order. + Upload photos and videos for this work order, then assign Before, After, or Extra.
@@ -81,12 +75,17 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr ) : ( { - for (const file of files) { - uploadMutation.mutate({ file, category }); + onUpload={startUploads} + onRemove={(id) => { + if (isLocal(id)) { + removeLocal(id); + return; } + deleteMutation.mutate(id); }} - onRemove={(id) => deleteMutation.mutate(id)} + onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })} + onRetry={retryUpload} + onCancel={removeLocal} readOnly={isReadOnly} pending={pending} /> diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx index 9233d212..e6b3aa19 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx @@ -1,58 +1,23 @@ import type { RefObject } from "react"; import { Upload } from "lucide-react"; -import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; -import { formatMediaSizeLimit } from "@/domain/work-orders/utils/work-order-media-limits"; - -const PHOTO_CATEGORIES: UploadableMediaCategory[] = ["Before", "After"]; +import { + formatMediaSizeLimit, + workOrderMediaAcceptAttribute, +} from "@/domain/work-orders/utils/work-order-media-limits"; type MediaUploaderDropzoneProps = { inputRef: RefObject; - category: UploadableMediaCategory; - onCategoryChange: (category: UploadableMediaCategory) => void; - showCategoryPicker: boolean; pending: boolean; onFilesSelected: (files: FileList | null) => void; }; export function MediaUploaderDropzone({ inputRef, - category, - onCategoryChange, - showCategoryPicker, pending, onFilesSelected, }: MediaUploaderDropzoneProps) { return ( <> - {showCategoryPicker && ( -
- - -
- )} void; + onRetry?: (id: string) => void; + onCancel?: (id: string) => void; +}; + +export function MediaUploaderGridItemChrome({ + file, + readOnly, + pending, + onRemove, + onRetry, + onCancel, +}: MediaUploaderGridItemChromeProps) { + const uploading = file.uploadStatus === "uploading"; + const failed = file.uploadStatus === "failed"; + const showDelete = !readOnly && canDeleteWorkOrderMedia(file); + + return ( +
+ + {Boolean(uploading) && ( +
+
+
+ )} + {Boolean(uploading && onCancel) && ( + + )} + {Boolean(failed && onRetry) && ( + + )} + {Boolean(showDelete && !uploading) && ( + + )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx new file mode 100644 index 00000000..8b42c0f4 --- /dev/null +++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx @@ -0,0 +1,67 @@ +import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; + +const PURPOSE_OPTIONS: UploadableMediaCategory[] = ["Before", "After", "Extra"]; + +type MediaUploaderGridItemMetaProps = { + file: MediaFile; + readOnly: boolean; + pending: boolean; + onCategorize?: (id: string, category: UploadableMediaCategory) => void; +}; + +export function MediaUploaderGridItemMeta({ + file, + readOnly, + pending, + onCategorize, +}: MediaUploaderGridItemMetaProps) { + const failed = file.uploadStatus === "failed"; + const needsCategory = + !readOnly && + !file.isLegacy && + file.uploadStatus !== "uploading" && + file.uploadStatus !== "failed" && + Boolean(onCategorize); + + return ( +
+
+ {file.name} +
+ {Boolean(failed) && ( +
+ {file.errorMessage || "Upload failed"} +
+ )} + {needsCategory ? ( + + ) : ( +
+ {file.purpose || (file.isLegacy ? "Legacy" : "")} +
+ )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx new file mode 100644 index 00000000..3634fc1f --- /dev/null +++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx @@ -0,0 +1,52 @@ +import { + File as FileIcon, + FileText, + Film, + Image as ImageIcon, + Paperclip, + Play, +} from "lucide-react"; +import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media"; + +const FILETYPE_ICON: Record = { + image: ImageIcon, + video: Film, + pdf: FileText, + doc: FileIcon, + other: Paperclip, +}; + +type MediaUploaderGridItemPreviewProps = { + file: MediaFile; +}; + +export function MediaUploaderGridItemPreview({ file }: MediaUploaderGridItemPreviewProps) { + const TypeIcon = FILETYPE_ICON[file.fileType]; + + if (file.fileType === "image") { + return ( + {file.name} + ); + } + + if (file.fileType === "video") { + return ( + <> +
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index 106aa32b..11bbe349 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -6,16 +6,27 @@ import { canDeleteWorkOrderMedia, formatMediaSizeLimit, isWorkOrderFileTooLarge, + isWorkOrderMediaTypeAllowed, } from "@/domain/work-orders/utils/work-order-media-limits"; import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils"; import { MediaUploaderDropzone } from "./media-uploader-dropzone"; import { MediaUploaderGrid } from "./media-uploader-grid"; +export type MediaUploadRequest = { + localId: string; + file: File; + signal: AbortSignal; + onProgress: (percent: number) => void; +}; + type MediaUploaderProps = { uploads: MediaFile[]; - /** When provided, uploads go to the API with the selected Before/After category. */ - onUpload?: (files: File[], category: UploadableMediaCategory) => void; + /** Upload-then-categorize: files only — category assigned after persist. */ + onUpload?: (requests: MediaUploadRequest[]) => void; onRemove?: (id: string) => void; + onCategorize?: (id: string, category: UploadableMediaCategory) => void; + onRetry?: (localId: string) => void; + onCancel?: (localId: string) => void; /** Legacy local-only mode (table modal fallback). */ onChange?: (next: MediaFile[]) => void; readOnly?: boolean; @@ -26,18 +37,25 @@ export function MediaUploader({ uploads, onUpload, onRemove, + onCategorize, + onRetry, + onCancel, onChange, readOnly = false, pending = false, }: MediaUploaderProps) { const inputRef = useRef(null); - const [category, setCategory] = useState("Before"); + const [controllers, setControllers] = useState>({}); const addFiles = (files: FileList | null) => { if (readOnly || pending || !files || files.length === 0) return; const accepted: File[] = []; for (const file of Array.from(files)) { + if (!isWorkOrderMediaTypeAllowed(file)) { + toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`); + continue; + } if (isWorkOrderFileTooLarge(file)) { toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`); continue; @@ -47,7 +65,20 @@ export function MediaUploader({ if (accepted.length === 0) return; if (onUpload) { - onUpload(accepted, category); + const nextControllers: Record = { ...controllers }; + const requests: MediaUploadRequest[] = accepted.map((file) => { + const localId = `local-${crypto.randomUUID()}`; + const controller = new AbortController(); + nextControllers[localId] = controller; + return { + localId, + file, + signal: controller.signal, + onProgress: () => undefined, + }; + }); + setControllers(nextControllers); + onUpload(requests); return; } @@ -59,7 +90,7 @@ export function MediaUploader({ const remove = (id: string) => { if (readOnly || pending) return; const target = uploads.find((u) => u.id === id); - if (target && !canDeleteWorkOrderMedia(target)) return; + if (target && !canDeleteWorkOrderMedia(target) && target.uploadStatus !== "failed") return; if (onRemove) { onRemove(id); return; @@ -67,23 +98,24 @@ export function MediaUploader({ onChange?.(uploads.filter((u) => u.id !== id)); }; + const cancel = (id: string) => { + controllers[id]?.abort(); + onCancel?.(id); + }; + return (
{!readOnly && ( - + )}
); 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 128da982..1223abd2 100644 --- a/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts +++ b/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts @@ -26,8 +26,8 @@ type UseCompDocDialogParams = { row: WorkOrderTableRow; open: boolean; data?: CompDocData; - onSave: (data: CompDocData, status: CompDocSaveStatus) => void; - onDateCompletedChange?: (date: string) => void; + onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise; + onDateCompletedChange?: (date: string) => void | Promise; }; function buildAddressLine(site: { street: string; fullAddress: string }): string { @@ -102,13 +102,13 @@ export function useCompDocDialog({ [site, effectiveStreet], ); - function persist(extra: Partial, status: CompDocSaveStatus) { - onSave({ notes, generatedAt, ...extra }, status); + async function persist(extra: Partial, status: CompDocSaveStatus): Promise { + await onSave({ notes, generatedAt, ...extra }, status); } - function syncCompletedDateIfChanged() { + async function syncCompletedDateIfChanged(): Promise { if (editDateCompleted !== (row.completedDate || "")) { - onDateCompletedChange?.(editDateCompleted); + await onDateCompletedChange?.(editDateCompleted); } } @@ -118,44 +118,47 @@ export function useCompDocDialog({ if (next === current) return; if (isMockLocation || !locId) return; if (!locationDetail) { - toast.error("Location details are still loading. Try saving again."); - return; + throw new Error("Location details are still loading. Try saving again."); } - try { - 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 }); - } catch (error) { - toast.error(error instanceof Error ? error.message : "Failed to update address"); - } + 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"); - persist({}, "NN"); + void persist({}, "NN"); } function handleUndo() { setDocState("draft"); - persist({}, "No"); + void persist({}, "No"); } - function handleSaveDraft() { - persist({}, "No"); - syncCompletedDateIfChanged(); - void syncAddressIfChanged(); - showCompDocDraftSavedToast(); + async function handleSaveDraft() { + try { + await persist({}, "No"); + await syncCompletedDateIfChanged(); + await syncAddressIfChanged(); + showCompDocDraftSavedToast(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to save draft"); + } } - function handleGenerate() { + async function handleGenerate() { const ts = fmtDocNow(); - setGeneratedAt(ts); - setDocState("generated"); - persist({ generatedAt: ts }, "Yes"); - syncCompletedDateIfChanged(); - void syncAddressIfChanged(); + try { + await persist({ generatedAt: ts }, "Yes"); + await syncCompletedDateIfChanged(); + await syncAddressIfChanged(); + setGeneratedAt(ts); + setDocState("generated"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to generate document"); + } } function handleDownloadPdf() { diff --git a/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts new file mode 100644 index 00000000..aba709e2 --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts @@ -0,0 +1,113 @@ +import { useMemo, useState } from "react"; +import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader"; +import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; +import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils"; + +type LocalUpload = MediaFile & { sourceFile?: File }; + +type UploadMutate = (input: { + file: File; + signal?: AbortSignal; + onProgress?: (percent: number) => void; +}) => Promise; + +export function useMediaUploadSession( + detail: WorkOrderBoardDetail | undefined, + uploadMutateAsync: UploadMutate, +) { + const [localUploads, setLocalUploads] = useState([]); + + const serverUploads = useMemo( + () => boardMediaItemsToMediaFiles(detail?.media ?? []), + [detail?.media], + ); + + const uploads = useMemo(() => { + const serverIds = new Set(serverUploads.map((item) => item.id)); + const pendingLocals = localUploads.filter( + (item) => item.uploadStatus === "uploading" || item.uploadStatus === "failed", + ); + return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))]; + }, [serverUploads, localUploads]); + + function startUploads(requests: MediaUploadRequest[]) { + setLocalUploads((prev) => [ + ...prev, + ...requests.map((request) => { + const classified = classifyUpload(request.file); + return { + id: request.localId, + name: request.file.name, + fileType: classified.fileType, + url: URL.createObjectURL(request.file), + category: classified.category, + purpose: null, + progress: 0, + uploadStatus: "uploading" as const, + sourceFile: request.file, + }; + }), + ]); + + for (const request of requests) { + void uploadMutateAsync({ + file: request.file, + signal: request.signal, + onProgress: (percent) => { + setLocalUploads((prev) => + prev.map((item) => + item.id === request.localId ? { ...item, progress: percent } : item, + ), + ); + }, + }) + .then(() => { + setLocalUploads((prev) => prev.filter((item) => item.id !== request.localId)); + }) + .catch((error: Error) => { + if (error.name === "AbortError") { + setLocalUploads((prev) => prev.filter((item) => item.id !== request.localId)); + return; + } + setLocalUploads((prev) => + prev.map((item) => + item.id === request.localId + ? { + ...item, + uploadStatus: "failed", + errorMessage: error.message || "Upload failed", + progress: undefined, + } + : item, + ), + ); + }); + } + } + + function retryUpload(localId: string) { + const target = localUploads.find((item) => item.id === localId); + if (!target?.sourceFile) return; + const controller = new AbortController(); + startUploads([ + { + localId, + file: target.sourceFile, + signal: controller.signal, + onProgress: () => undefined, + }, + ]); + } + + function removeLocal(id: string) { + setLocalUploads((prev) => prev.filter((item) => item.id !== id)); + } + + function isLocal(id: string) { + return localUploads.some((item) => item.id === id); + } + + return { uploads, startUploads, retryUpload, removeLocal, isLocal }; +} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index da7c0483..9196a162 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -39,7 +39,7 @@ export default function WorkOrdersListPage() { const [completionDocs, setCompletionDocs] = useState>({}); const filters = useWorkOrdersListFilters(); const tableData = useWorkOrdersTableData(filters); - const { patchField } = useWorkOrderTableMutations({ + const { patchField, patchFieldAsync } = useWorkOrderTableMutations({ onPatch: tableData.patchRow, clearPatch: tableData.clearRowPatch, getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)), @@ -159,14 +159,14 @@ export default function WorkOrdersListPage() { if (!open) setDocRow(null); }} completionDocs={completionDocs} - onSaveCompDoc={(data, status) => { + onSaveCompDoc={async (data, status) => { if (!activeDocRow) return; setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data })); - patchField(activeDocRow.id, { docStatus: status }); + await patchFieldAsync(activeDocRow.id, { docStatus: status }); }} - onDateCompletedChange={(date) => { + onDateCompletedChange={async (date) => { if (!activeDocRow) return; - patchField(activeDocRow.id, { completedDate: date }); + await patchFieldAsync(activeDocRow.id, { completedDate: date }); }} activeMediaRow={activeMediaRow} onMediaOpenChange={(open) => { diff --git a/src/domain/work-orders/api/work-order-board-documents-api.ts b/src/domain/work-orders/api/work-order-board-documents-api.ts index 5532459f..85cf8e7c 100644 --- a/src/domain/work-orders/api/work-order-board-documents-api.ts +++ b/src/domain/work-orders/api/work-order-board-documents-api.ts @@ -101,12 +101,37 @@ export const workOrderBoardDocumentsApi = { uploadMedia: async ( workOrderId: string | number, file: File, - category: UploadableMediaCategory, + category?: UploadableMediaCategory, + options?: { signal?: AbortSignal; onProgress?: (percent: number) => void }, ): Promise => { const formData = buildMediaFormData(file, category); const response = await api.post(API_PATHS.workOrder.media(workOrderId), { body: formData, throwHttpErrors: false, + signal: options?.signal, + onUploadProgress: options?.onProgress + ? (progress) => { + options.onProgress?.(Math.round(progress.percent * 100)); + } + : undefined, + }); + const data = await readResponseJson(response); + if (response.ok) { + return mapBoardDetailMediaItem(handleApiResponse(data) ?? data); + } + throwDocumentHttpError(response, data); + }, + + updateMediaCategory: async ( + workOrderId: string | number, + mediaId: string | number, + category: UploadableMediaCategory, + ): Promise => { + const formData = new FormData(); + formData.append("category", category); + const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), { + body: formData, + throwHttpErrors: false, }); const data = await readResponseJson(response); if (response.ok) { diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts index c68874e7..7b0da992 100644 --- a/src/domain/work-orders/api/work-orders-api.ts +++ b/src/domain/work-orders/api/work-orders-api.ts @@ -18,7 +18,6 @@ import { mapUpdateDispatchPayload, mapVendorDropdownItems, mapWorkOrderDetail, - mapCompletedDateUpdateFormData, mapWorkOrderFormToCreatePayload, mapWorkOrderFormToUpdateFormData, mapWorkOrderTableRow, @@ -308,14 +307,6 @@ export const workOrdersApi = { ); }, - /** Persist only CompletedDate via EditWorkorder — no GetWorkorderById prefetch. */ - updateCompletedDate: async (id: string | number, completedDate: string): Promise => { - await apiPostForm<{ message?: string; status?: string }>( - API_PATHS.workOrder.editWorkorder, - mapCompletedDateUpdateFormData(id, completedDate), - ); - }, - delete: async (id: string | number): Promise => { await apiDelete(API_PATHS.workOrder.deleteWorkorder, { json: { Id: Number(id) }, 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 332c677d..fa3d05af 100644 --- a/src/domain/work-orders/mappers/expand-scalar-board-ops.ts +++ b/src/domain/work-orders/mappers/expand-scalar-board-ops.ts @@ -30,6 +30,7 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [ { key: "vendorId", field: "vendorId", toValue: (value) => String(value) }, { key: "apptTime", field: "apptTime", toValue: (value) => value as string }, { key: "docStatus", field: "docStatus", toValue: (value) => mapDocStatusToApi(value as string) }, + { key: "completedDate", field: "completedDate", toValue: (value) => value as string }, { key: "pm", field: "pm", toValue: (value) => value as string }, { key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) }, { key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string }, diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index fffd515b..77a00047 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -119,9 +119,11 @@ export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrder export type UploadableMediaCategory = "Before" | "After" | "Extra"; -export function buildMediaFormData(file: File, category: UploadableMediaCategory): FormData { +export function buildMediaFormData(file: File, category?: UploadableMediaCategory): FormData { const form = new FormData(); - form.append("category", category); + if (category) { + form.append("category", category); + } form.append("file", file); return form; } @@ -229,7 +231,12 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M fileType: classifyFileName(item.fileName, item.url), url: item.url, category: boardCategoryToMediaCategory(item.category), + purpose: + item.category === "Before" || item.category === "After" || item.category === "Extra" + ? item.category + : null, isLegacy: item.isLegacy || Number(item.id) <= 0, + uploadStatus: "done" as const, })); } diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 021fe1c0..22c9c043 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -116,6 +116,7 @@ export type WorkOrderBoardEditableField = | "apptDate" | "apptTime" | "docStatus" + | "completedDate" | "pm" | "serviceNotes" | "extraServices" diff --git a/src/domain/work-orders/types/work-order-media.ts b/src/domain/work-orders/types/work-order-media.ts index 68fd8faf..6d687275 100644 --- a/src/domain/work-orders/types/work-order-media.ts +++ b/src/domain/work-orders/types/work-order-media.ts @@ -9,8 +9,14 @@ export interface MediaFile { fileType: FileCategory; url: string; category: MediaCategory; + /** Board purpose label (Before/After/Extra). Null/undefined = needs categorization. */ + purpose?: "Before" | "After" | "Extra" | null; /** True when media is a legacy Before/After/Completion column (not deletable). */ isLegacy?: boolean; + /** 0–100 while uploading; undefined when idle/complete. */ + progress?: number; + uploadStatus?: "uploading" | "failed" | "done"; + errorMessage?: string; } export const MEDIA_CATEGORIES: MediaCategory[] = [ diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts index 82667f19..5b70c93a 100644 --- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts @@ -50,6 +50,13 @@ export interface UpdateWorkOrderCommentInput { export interface UploadWorkOrderMediaInput { file: File; + category?: UploadableMediaCategory; + signal?: AbortSignal; + onProgress?: (percent: number) => void; +} + +export interface UpdateWorkOrderMediaCategoryInput { + mediaId: string | number; category: UploadableMediaCategory; } @@ -191,12 +198,12 @@ export function useUploadWorkOrderMedia( const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ file, category }: UploadWorkOrderMediaInput) => - workOrdersApi.uploadMedia(workOrderId, file, category), + mutationFn: ({ file, category, signal, onProgress }: UploadWorkOrderMediaInput) => + workOrdersApi.uploadMedia(workOrderId, file, category, { signal, onProgress }), onSuccess: () => { invalidateBoardDetail(queryClient, workOrderId); invalidateWorkOrders(queryClient); - toast.success("File uploaded"); + toast.success("File uploaded — assign a category"); }, onError: (error: Error) => { toast.error(error.message || "Failed to upload file"); @@ -204,6 +211,25 @@ export function useUploadWorkOrderMedia( }); } +export function useUpdateWorkOrderMediaCategory( + workOrderId: string | number, +): UseMutationResult { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ mediaId, category }: UpdateWorkOrderMediaCategoryInput) => + workOrdersApi.updateMediaCategory(workOrderId, mediaId, category), + onSuccess: () => { + invalidateBoardDetail(queryClient, workOrderId); + invalidateWorkOrders(queryClient); + toast.success("Category saved"); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update category"); + }, + }); +} + export function useDeleteWorkOrderMedia( workOrderId: string | number, ): UseMutationResult { diff --git a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts index bf678231..3e85e821 100644 --- a/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts +++ b/src/domain/work-orders/use-cases/use-work-order-table-mutations.ts @@ -33,10 +33,6 @@ export type WorkOrderTablePatchHandlers = { type PatchResult = { kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow }; -async function persistCompletedDate(id: string | number, completedDate: string): Promise { - await workOrdersApi.updateCompletedDate(id, completedDate); -} - async function applyBoardFieldOps( id: string | number, patch: WorkOrderTablePatch, @@ -79,7 +75,7 @@ async function applyBoardFieldOps( return latest; } -/** CompDoc completedDate uses EditWorkorder; board fields use PATCH …/board. */ +/** Board fields (including completedDate) use PATCH …/board — never legacy EditWorkorder. */ export async function runWorkOrderTablePatch( id: string | number, patch: WorkOrderTablePatch, @@ -89,33 +85,8 @@ export async function runWorkOrderTablePatch( return { kind: "local", patch }; } - const completedDate = patch.completedDate; - const boardPatch: WorkOrderTablePatch = { ...patch }; - delete boardPatch.completedDate; - - let boardRow: WorkOrderTableRow | undefined; - const hasBoardKeys = Object.keys(boardPatch).length > 0; - if (hasBoardKeys && !isLocalOnlyTablePatch(boardPatch)) { - boardRow = await applyBoardFieldOps(id, boardPatch, getRow); - } else if (!hasBoardKeys && completedDate === undefined) { - throw new BoardPatchValidationError( - "NoPersistableFields", - "These changes cannot be saved on the board. Refresh and edit a supported field.", - ); - } - - if (completedDate !== undefined) { - await persistCompletedDate(id, completedDate); - } - - if (boardRow) { - return { - kind: "board", - row: completedDate !== undefined ? { ...boardRow, completedDate } : boardRow, - }; - } - - return { kind: "local", patch }; + const boardRow = await applyBoardFieldOps(id, patch, getRow); + return { kind: "board", row: boardRow }; } export function useWorkOrderTableMutations( @@ -179,8 +150,12 @@ export function useWorkOrderTableMutations( patchMutation.mutate({ id, patch }); }; + const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => + patchMutation.mutateAsync({ id, patch }); + return { patchField, + patchFieldAsync, isPending: patchMutation.isPending, }; } diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts index 4c513763..d79f45be 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -1,6 +1,10 @@ /** Backend RequestSizeLimit for media and completion-doc uploads. */ export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024; +const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]); + +const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]); + export function isWorkOrderFileTooLarge( file: File, maxBytes = MAX_WORK_ORDER_MEDIA_BYTES, @@ -8,6 +12,19 @@ export function isWorkOrderFileTooLarge( return file.size > maxBytes; } +export function isWorkOrderMediaTypeAllowed(file: File): boolean { + const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase()); + const name = file.name.toLowerCase(); + const dot = name.lastIndexOf("."); + const ext = dot >= 0 ? name.slice(dot) : ""; + const extOk = ALLOWED_EXTENSIONS.has(ext); + return mimeOk || extOk; +} + +export function workOrderMediaAcceptAttribute(): string { + return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov"; +} + export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string { return `${Math.round(maxBytes / (1024 * 1024))} MB`; } 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 3d9532d1..66699c5a 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,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +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 { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog"; @@ -9,7 +9,11 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ })); vi.mock("react-toastify", () => ({ - toast: vi.fn(), + toast: { error: vi.fn(), success: vi.fn() }, +})); + +vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast", () => ({ + showCompDocDraftSavedToast: vi.fn(), })); const SCHEDULED_ON = "2026-06-01"; @@ -105,14 +109,16 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => { fireEvent.change(dateInput!, { target: { value } }); } - it("Save draft emits completedDate without writing scheduledOn or lifecycle Completed", () => { + it("Save draft emits completedDate without writing scheduledOn or lifecycle Completed", async () => { const row = baseRow(); const { patches } = renderDialog(row); changeDateCompleted("2026-07-15"); fireEvent.click(screen.getByRole("button", { name: /save draft/i })); - expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15"); + await waitFor(() => { + expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15"); + }); expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No"); expect(patches).toEqual([{ docStatus: "No" }, { completedDate: "2026-07-15" }]); expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false); @@ -121,14 +127,16 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => { expect(row.status).toBe("Scheduled"); }); - it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", () => { + it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", async () => { const row = baseRow({ completedDate: "2026-07-01" }); const { patches } = renderDialog(row); changeDateCompleted("2026-07-20"); fireEvent.click(screen.getByRole("button", { name: /^generate$/i })); - expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20"); + await waitFor(() => { + expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20"); + }); expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ generatedAt: expect.any(String) }), "Yes", diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx index c47e4149..402afa30 100644 --- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx +++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx @@ -4,8 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -const uploadMutate = vi.fn(); +const uploadMutateAsync = vi.fn(); const deleteMutate = vi.fn(); +const categorizeMutate = vi.fn(); vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ useWorkOrderBoardDetail: () => ({ @@ -29,7 +30,12 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ })); vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ - useUploadWorkOrderMedia: () => ({ mutate: uploadMutate, isPending: false }), + useUploadWorkOrderMedia: () => ({ + mutate: vi.fn(), + mutateAsync: uploadMutateAsync, + isPending: false, + }), + useUpdateWorkOrderMediaCategory: () => ({ mutate: categorizeMutate, isPending: false }), useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }), })); @@ -82,8 +88,9 @@ function baseRow(): WorkOrderTableRow { describe("MediaUploadModal API wiring", () => { beforeEach(() => { - uploadMutate.mockReset(); + uploadMutateAsync.mockReset(); deleteMutate.mockReset(); + categorizeMutate.mockReset(); }); it("renders persisted media from board detail (refresh-safe)", async () => { @@ -101,6 +108,7 @@ describe("MediaUploadModal API wiring", () => { expect(screen.getByText("before.jpg")).toBeInTheDocument(); }); expect(screen.getByText("Photos & Videos")).toBeInTheDocument(); - expect(screen.getByLabelText(/category/i)).toBeInTheDocument(); + expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument(); }); }); diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 1cc433a1..302fa395 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -165,33 +165,10 @@ describe("workOrdersApi.update", () => { }); }); -describe("workOrdersApi.updateCompletedDate", () => { - beforeEach(() => { - apiGet.mockReset(); - apiPost.mockReset(); - apiPostForm.mockReset(); - apiPostForm.mockResolvedValue({ message: "Updated Successfully", status: "200" }); - }); - - it("posts only Id and CompletedDate to EditWorkorder without GetWorkorderById", async () => { - await workOrdersApi.updateCompletedDate(55, "2026-07-30"); - - expect(apiGet).not.toHaveBeenCalled(); - expect(apiPost).not.toHaveBeenCalled(); - expect(apiPostForm).toHaveBeenCalledTimes(1); - expect(apiPostForm).toHaveBeenCalledWith( - API_PATHS.workOrder.editWorkorder, - expect.any(FormData), - ); - expect(API_PATHS.workOrder.editWorkorder).toBe("WorkOrder/EditWorkorder"); - expect(API_PATHS.workOrder.getById).toBe("WorkOrder/GetWorkorderById"); - - const formData = apiPostForm.mock.calls[0]?.[1] as FormData; - expect(formData.get("Id")).toBe("55"); - expect(formData.get("CompletedDate")).toBe("2026-07-30"); - expect(formData.get("Status")).toBeNull(); - expect(formData.get("WorkerOrderTitle")).toBeNull(); - expect(formData.getAll("ContactIds")).toEqual([]); +describe("workOrdersApi completedDate board contract", () => { + it("does not expose updateCompletedDate — completedDate uses board PATCH", () => { + expect("updateCompletedDate" in workOrdersApi).toBe(false); + expect(API_PATHS.workOrder.boardPatch(55)).toBe("workorders/55/board"); }); }); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-completed-date.test.ts b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-completed-date.test.ts index 984c03a6..b9951795 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-completed-date.test.ts +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations-completed-date.test.ts @@ -1,35 +1,50 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -const getById = vi.fn(); -const update = vi.fn(); -const updateCompletedDate = vi.fn(); +const patchBoardField = vi.fn(); vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ workOrdersApi: { - getById: (...args: unknown[]) => getById(...args), - update: (...args: unknown[]) => update(...args), - updateCompletedDate: (...args: unknown[]) => updateCompletedDate(...args), - patchBoardField: vi.fn(), + getById: vi.fn(), + update: vi.fn(), + patchBoardField: (...args: unknown[]) => patchBoardField(...args), }, })); import { runWorkOrderTablePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; +const BASE_ROW = { + id: 99, + rowVersion: "AAAAAAA=", + completedDate: "", +} as WorkOrderTableRow; + describe("runWorkOrderTablePatch completedDate", () => { beforeEach(() => { - getById.mockReset(); - update.mockReset(); - updateCompletedDate.mockReset(); - updateCompletedDate.mockResolvedValue(undefined); + patchBoardField.mockReset(); + patchBoardField.mockResolvedValue({ + ...BASE_ROW, + completedDate: "2026-07-30", + rowVersion: "BBBBBBB=", + }); }); - it("persists completedDate via updateCompletedDate without getById", async () => { - const result = await runWorkOrderTablePatch(99, { completedDate: "2026-07-30" }, undefined); + it("persists completedDate via board PATCH (not EditWorkorder)", async () => { + const result = await runWorkOrderTablePatch( + 99, + { completedDate: "2026-07-30" }, + () => BASE_ROW, + ); - expect(getById).not.toHaveBeenCalled(); - expect(update).not.toHaveBeenCalled(); - expect(updateCompletedDate).toHaveBeenCalledTimes(1); - expect(updateCompletedDate).toHaveBeenCalledWith(99, "2026-07-30"); - expect(result).toEqual({ kind: "local", patch: { completedDate: "2026-07-30" } }); + expect(patchBoardField).toHaveBeenCalledTimes(1); + expect(patchBoardField).toHaveBeenCalledWith(99, { + field: "completedDate", + value: "2026-07-30", + workOrderVersion: "AAAAAAA=", + }); + expect(result).toEqual({ + kind: "board", + row: expect.objectContaining({ completedDate: "2026-07-30" }), + }); }); }); diff --git a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx index 7cd07dc3..f7b8fc16 100644 --- a/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -7,14 +7,12 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta const getById = vi.fn(); const update = vi.fn(); -const updateCompletedDate = vi.fn(); const patchBoardField = vi.fn(); vi.mock("@/domain/work-orders/api/work-orders-api", () => ({ workOrdersApi: { getById: (...args: unknown[]) => getById(...args), update: (...args: unknown[]) => update(...args), - updateCompletedDate: (...args: unknown[]) => updateCompletedDate(...args), patchBoardField: (...args: unknown[]) => patchBoardField(...args), }, })); @@ -119,7 +117,6 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { beforeEach(() => { getById.mockReset(); update.mockReset(); - updateCompletedDate.mockReset(); patchBoardField.mockReset(); serverState = { ...BASE_DETAIL }; rowState = { ...BASE_ROW }; @@ -130,7 +127,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { _id: unknown, payload: { field: string; value?: string | null; workOrderVersion: string }, ) => { - events.push("boardPatch:start"); + events.push(`boardPatch:${payload.field}:start`); await delay(5); if (payload.field === "docStatus" && typeof payload.value === "string") { const docStatus = @@ -142,17 +139,18 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { rowVersion: `${payload.workOrderVersion}-next`, }; } - events.push("boardPatch:end"); + if (payload.field === "completedDate" && typeof payload.value === "string") { + serverState = { ...serverState, completedDate: payload.value }; + rowState = { + ...rowState, + completedDate: payload.value, + rowVersion: `${payload.workOrderVersion}-date`, + }; + } + events.push(`boardPatch:${payload.field}:end`); return { ...rowState }; }, ); - - updateCompletedDate.mockImplementation(async (_id: unknown, completedDate: string) => { - events.push("updateCompletedDate:start"); - await delay(5); - serverState = { ...serverState, completedDate }; - events.push("updateCompletedDate:end"); - }); }); it("persists docStatus via board PATCH with NotNeeded for UI NN", async () => { @@ -171,7 +169,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { result.current.patchField(42, { docStatus: "NN" }); }); - await waitFor(() => expect(events).toContain("boardPatch:end")); + await waitFor(() => expect(events).toContain("boardPatch:docStatus:end")); expect(patchBoardField).toHaveBeenCalledWith(42, { field: "docStatus", @@ -179,12 +177,11 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { workOrderVersion: "AAAAAAA=", }); expect(update).not.toHaveBeenCalled(); - expect(updateCompletedDate).not.toHaveBeenCalled(); expect(getById).not.toHaveBeenCalled(); expect(serverState.docStatus).toBe("NN"); }); - it("serializes board docStatus and completedDate without racing or GetWorkorderById", async () => { + it("serializes board docStatus and completedDate via PATCH without racing or EditWorkorder", async () => { const onPatch = vi.fn(); const { result } = renderHook( () => @@ -201,19 +198,22 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => { result.current.patchField(42, { completedDate: "2026-07-20" }); }); - await waitFor(() => expect(events).toContain("updateCompletedDate:end")); - await waitFor(() => expect(events).toContain("boardPatch:end")); + await waitFor(() => expect(events).toContain("boardPatch:completedDate:end")); + await waitFor(() => expect(events).toContain("boardPatch:docStatus:end")); expect(events).toEqual([ - "boardPatch:start", - "boardPatch:end", - "updateCompletedDate:start", - "updateCompletedDate:end", + "boardPatch:docStatus:start", + "boardPatch:docStatus:end", + "boardPatch:completedDate:start", + "boardPatch:completedDate:end", ]); expect(getById).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); - expect(updateCompletedDate).toHaveBeenCalledWith(42, "2026-07-20"); + expect(patchBoardField).toHaveBeenCalledWith( + 42, + expect.objectContaining({ field: "completedDate", value: "2026-07-20" }), + ); expect(serverState.docStatus).toBe("Yes"); expect(serverState.completedDate).toBe("2026-07-20"); diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts index 932e74e4..2ae1f827 100644 --- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts +++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts @@ -3,6 +3,7 @@ import { canDeleteWorkOrderMedia, isWorkOrderDocumentsReadOnly, isWorkOrderFileTooLarge, + isWorkOrderMediaTypeAllowed, MAX_WORK_ORDER_MEDIA_BYTES, } from "@/domain/work-orders/utils/work-order-media-limits"; @@ -23,4 +24,17 @@ describe("work-order-media-limits", () => { expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true); expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false); }); + + it("allows only JPG PNG MP4 MOV by MIME or extension", () => { + expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe(true); + expect(isWorkOrderMediaTypeAllowed({ name: "a.png", type: "image/png" } as File)).toBe(true); + expect(isWorkOrderMediaTypeAllowed({ name: "a.mp4", type: "video/mp4" } as File)).toBe(true); + expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe( + true, + ); + expect(isWorkOrderMediaTypeAllowed({ name: "a.gif", type: "image/gif" } as File)).toBe(false); + expect(isWorkOrderMediaTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe( + false, + ); + }); });