From e7fb8d61227c8769415c6d7a7075e514e5d632cf Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 31 Jul 2026 12:24:31 -0300 Subject: [PATCH] fix(work-orders): persist completedDate via board and media sh-116 leave EditWorkorder for completedDate; await mutations before draft saved toast; upload-then-categorize with MIME allowlist, progress, cancel, retry. --- .../_components/comp-doc/comp-doc-dialog.tsx | 4 +- .../comp-doc/use-comp-doc-dialog.tsx | 42 ++-- .../comp-doc/use-comp-doc-pdf-actions.ts | 4 +- .../tabs/slide-over-completion-media.tsx | 10 +- .../detail/tabs/slide-over-completion-tab.tsx | 6 +- .../detail/tabs/slide-over-extras-tab.tsx | 12 +- .../detail/use-slide-over-media-state.ts | 53 ++++- .../detail/use-work-order-slide-over.ts | 26 +- .../detail/work-order-slide-over-tabs.tsx | 8 +- .../_components/media/media-upload-modal.tsx | 41 ++-- .../media/media-uploader-dropzone.tsx | 48 +--- .../media/media-uploader-grid-item-chrome.tsx | 113 +++++++++ .../media/media-uploader-grid-item-meta.tsx | 67 ++++++ .../media-uploader-grid-item-preview.tsx | 52 ++++ .../media/media-uploader-grid-item.tsx | 98 ++------ .../_components/media/media-uploader-grid.tsx | 10 + .../_components/media/media-uploader.tsx | 58 ++++- .../_hooks/use-media-upload-session.ts | 113 +++++++++ .../_hooks/use-work-orders-list-page.ts | 11 +- src/app/(protected)/workorders/index.tsx | 2 +- .../api/work-order-board-documents-api.ts | 27 ++- src/domain/work-orders/api/work-orders-api.ts | 9 - .../mappers/expand-scalar-board-ops.ts | 1 + .../mappers/work-order-board-detail-mapper.ts | 11 +- .../work-orders/types/work-order-board.ts | 1 + .../work-orders/types/work-order-media.ts | 6 + .../use-cases/use-work-order-mutations.ts | 32 ++- .../use-work-order-table-mutations.ts | 61 +---- .../utils/work-order-media-limits.ts | 17 ++ .../utils/work-order-patch-queue.ts | 20 ++ ...mp-doc-dialog-completion-contract.test.tsx | 20 +- .../media-upload-modal-api.test.tsx | 114 +++++++++ ...work-order-slide-over-failed-save.test.tsx | 1 + .../work-orders/api/work-orders-api.test.ts | 31 +-- ...der-table-mutations-completed-date.test.ts | 51 ++-- .../use-work-order-table-mutations.test.tsx | 223 ++++++++++++++++++ .../utils/work-order-media-limits.test.ts | 14 ++ .../utils/work-order-patch-queue.test.ts | 62 +++++ 38 files changed, 1163 insertions(+), 316 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item-chrome.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta.tsx create mode 100644 src/app/(protected)/workorders/_components/media/media-uploader-grid-item-preview.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-media-upload-session.ts create mode 100644 src/domain/work-orders/utils/work-order-patch-queue.ts create mode 100644 src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx create mode 100644 src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx create mode 100644 src/test/domain/work-orders/utils/work-order-patch-queue.test.ts 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 1aeeade8..f87fbb78 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 @@ -16,8 +16,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/comp-doc/use-comp-doc-dialog.tsx b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx index bc504e9a..5fcdc9a9 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-dialog.tsx @@ -23,8 +23,8 @@ type UseCompDocDialogArgs = { 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 notifyDraftSaved() { @@ -114,38 +114,46 @@ export function useCompDocDialog({ seaRep, }; - const persist = (extra: Partial, status: CompDocSaveStatus) => { - onSave({ notes, generatedAt, ...extra }, status); + const persist = async (extra: Partial, status: CompDocSaveStatus) => { + await onSave({ notes, generatedAt, ...extra }, status); }; - const syncDateIfChanged = () => { + const syncDateIfChanged = async () => { if (editDateCompleted !== (info.completedDate || "")) { - onDateCompletedChange?.(editDateCompleted); + await onDateCompletedChange?.(editDateCompleted); } }; function handleMarkNotRequired() { setDocState("not-required"); - persist({}, "NN"); + void persist({}, "NN"); } function handleUndo() { setDocState("draft"); - persist({}, "No"); + void persist({}, "No"); } - function handleSaveDraft() { - persist({}, "No"); - syncDateIfChanged(); - notifyDraftSaved(); + async function handleSaveDraft() { + try { + await persist({}, "No"); + await syncDateIfChanged(); + notifyDraftSaved(); + } 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"); - syncDateIfChanged(); + try { + await persist({ generatedAt: ts }, "Yes"); + await syncDateIfChanged(); + setGeneratedAt(ts); + setDocState("generated"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to generate document"); + } } const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({ diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts index 497fa679..9b95d1dd 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pdf-actions.ts @@ -21,8 +21,8 @@ type UseCompDocPdfActionsArgs = { generatedAt: string; docFields: DocFields; uploadCompletionMutation: ReturnType; - persist: (extra: Partial, status: CompDocSaveStatus) => void; - syncDateIfChanged: () => void; + persist: (extra: Partial, status: CompDocSaveStatus) => void | Promise; + syncDateIfChanged: () => void | Promise; setGeneratedAt: (value: string) => void; markGenerated: () => void; }; diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx index 5eafe394..62e805c2 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx @@ -1,5 +1,8 @@ import { Image as ImageIcon } from "lucide-react"; -import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader"; +import { + MediaUploader, + type MediaUploadRequest, +} from "@/app/(protected)/workorders/_components/media/media-uploader"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; @@ -7,7 +10,8 @@ type SlideOverCompletionMediaProps = { isReadOnly: boolean; media: MediaFile[]; mediaPending: boolean; - onUploadMedia?: (files: File[], category: UploadableMediaCategory) => void; + onUploadMedia?: (requests: MediaUploadRequest[]) => void; + onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void; onRemoveMedia?: (id: string) => void; }; @@ -16,6 +20,7 @@ export function SlideOverCompletionMedia({ media, mediaPending, onUploadMedia, + onCategorizeMedia, onRemoveMedia, }: SlideOverCompletionMediaProps) { return ( @@ -30,6 +35,7 @@ export function SlideOverCompletionMedia({ void; + onUploadMedia?: (requests: MediaUploadRequest[]) => void; + onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void; onRemoveMedia?: (id: string) => void; onUploadCompletionPdf?: (file: File) => void; onOpenCompletionDoc: () => void; @@ -40,6 +42,7 @@ export function SlideOverCompletionTab({ mediaPending = false, completionPending = false, onUploadMedia, + onCategorizeMedia, onRemoveMedia, onUploadCompletionPdf, onOpenCompletionDoc, @@ -56,6 +59,7 @@ export function SlideOverCompletionTab({ media={media} mediaPending={mediaPending} onUploadMedia={onUploadMedia} + onCategorizeMedia={onCategorizeMedia} onRemoveMedia={onRemoveMedia} /> ); diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx index 863551d8..b4c93318 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx @@ -15,6 +15,8 @@ import { canDeleteWorkOrderMedia, formatMediaSizeLimit, isWorkOrderFileTooLarge, + isWorkOrderMediaTypeAllowed, + workOrderMediaAcceptAttribute, } from "@/domain/work-orders/utils/work-order-media-limits"; const FILETYPE_ICON: Record = { @@ -25,8 +27,6 @@ const FILETYPE_ICON: Record = { other: Paperclip, }; -const ACCEPT = "image/*,video/*,.pdf,.doc,.docx"; - type SlideOverExtrasTabProps = { uploads: MediaFile[]; isReadOnly: boolean; @@ -48,6 +48,10 @@ export function SlideOverExtrasTab({ if (isReadOnly || 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; @@ -94,13 +98,13 @@ export function SlideOverExtrasTab({ {pending ? "Uploading…" : "Upload documents, photos or videos"} - Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit()} per file + Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file ; deleteMediaMutation: ReturnType; + categorizeMediaMutation: ReturnType; }; export function useSlideOverMediaState({ detailForRow, uploadMediaMutation, deleteMediaMutation, + categorizeMediaMutation, }: UseSlideOverMediaStateArgs) { const [mediaBatchPending, setMediaBatchPending] = useState(false); @@ -42,15 +45,47 @@ export function useSlideOverMediaState({ ); const mediaPending = - mediaBatchPending || uploadMediaMutation.isPending || deleteMediaMutation.isPending; + mediaBatchPending || + uploadMediaMutation.isPending || + deleteMediaMutation.isPending || + categorizeMediaMutation.isPending; - const uploadMediaFiles = async (files: File[], category: UploadableMediaCategory) => { - await runSequentialUploads( - files, - (file) => uploadMediaMutation.mutateAsync({ file, category }), - setMediaBatchPending, - ); + const uploadMediaRequests = async (requests: MediaUploadRequest[]) => { + setMediaBatchPending(true); + try { + for (const request of requests) { + await uploadMediaMutation.mutateAsync({ + file: request.file, + signal: request.signal, + onProgress: request.onProgress, + }); + } + } finally { + setMediaBatchPending(false); + } }; - return { completionMedia, extrasUploads, mediaPending, uploadMediaFiles }; + const uploadExtraFiles = async (files: File[]) => { + setMediaBatchPending(true); + try { + for (const file of files) { + await uploadMediaMutation.mutateAsync({ file, category: "Extra" }); + } + } finally { + setMediaBatchPending(false); + } + }; + + const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => { + categorizeMediaMutation.mutate({ mediaId, category }); + }; + + return { + completionMedia, + extrasUploads, + mediaPending, + uploadMediaRequests, + uploadExtraFiles, + categorizeMedia, + }; } diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts index 23f4e427..7d6505c6 100644 --- a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts +++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts @@ -9,6 +9,7 @@ import { useAddWorkOrderComment, useDeleteWorkOrderMedia, useUpdateWorkOrderComment, + useUpdateWorkOrderMediaCategory, useUploadCompletionDoc, useUploadWorkOrderMedia, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; @@ -47,6 +48,7 @@ export function useWorkOrderSlideOver({ const commentMutation = useAddWorkOrderComment(workOrderId ?? ""); const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? ""); const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? ""); + const categorizeMediaMutation = useUpdateWorkOrderMediaCategory(workOrderId ?? ""); const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? ""); const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? ""); @@ -59,13 +61,19 @@ export function useWorkOrderSlideOver({ const isCancelled = (infoSource?.status as string) === "Canceled"; const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status); - const { completionMedia, extrasUploads, mediaPending, uploadMediaFiles } = useSlideOverMediaState( - { - detailForRow, - uploadMediaMutation, - deleteMediaMutation, - }, - ); + const { + completionMedia, + extrasUploads, + mediaPending, + uploadMediaRequests, + uploadExtraFiles, + categorizeMedia, + } = useSlideOverMediaState({ + detailForRow, + uploadMediaMutation, + deleteMediaMutation, + categorizeMediaMutation, + }); const completionPending = uploadCompletionMutation.isPending; const closeDisabled = Boolean(saving || mediaPending || completionPending); @@ -112,7 +120,9 @@ export function useWorkOrderSlideOver({ mediaPending, completionPending, closeDisabled, - uploadMediaFiles, + uploadMediaRequests, + uploadExtraFiles, + categorizeMedia, deleteMediaMutation, uploadCompletionPdf, showDetailSkeleton, diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index db677926..3f91d5f2 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -10,7 +10,6 @@ import type { WorkOrderUser, } from "@/domain/work-orders/types/work-order"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over"; import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel"; @@ -119,9 +118,10 @@ export function WorkOrderSlideOverTabs({ media={state.completionMedia} mediaPending={state.mediaPending} completionPending={state.completionPending} - onUploadMedia={(files: File[], category: UploadableMediaCategory) => { - void state.uploadMediaFiles(files, category); + onUploadMedia={(requests) => { + void state.uploadMediaRequests(requests); }} + onCategorizeMedia={state.categorizeMedia} onRemoveMedia={(id) => state.deleteMediaMutation.mutate(id)} onUploadCompletionPdf={state.uploadCompletionPdf} onOpenCompletionDoc={onOpenCompletionDoc} @@ -143,7 +143,7 @@ export function WorkOrderSlideOverTabs({ isReadOnly={state.isDocumentsReadOnly} pending={state.mediaPending} onUpload={(files) => { - void state.uploadMediaFiles(files, "Extra"); + void state.uploadExtraFiles(files); }} onRemove={(id) => state.deleteMediaMutation.mutate(id)} /> 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-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/_hooks/use-work-orders-list-page.ts b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts index 205082cd..d46a2272 100644 --- a/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts +++ b/src/app/(protected)/workorders/_hooks/use-work-orders-list-page.ts @@ -22,7 +22,11 @@ export function useWorkOrdersListPage() { const [completionDocs, setCompletionDocs] = useState>({}); const filters = useWorkOrdersListFilters(); const tableData = useWorkOrdersTableData(filters); - const { patchField, isPending: isPatchPending } = useWorkOrderTableMutations({ + const { + patchField, + patchFieldAsync, + isPending: isPatchPending, + } = useWorkOrderTableMutations({ onPatch: tableData.patchRow, clearPatch: tableData.clearRowPatch, getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)), @@ -78,13 +82,13 @@ export function useWorkOrdersListPage() { const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null)); - const saveCompletionDoc = ( + const saveCompletionDoc = async ( row: WorkOrderTableRow, data: CompDocData, status: CompDocSaveStatus, ) => { setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: data })); - patchField(row.id, { docStatus: status }); + await patchFieldAsync(row.id, { docStatus: status }); }; return { @@ -101,6 +105,7 @@ export function useWorkOrdersListPage() { filters, tableData, patchField, + patchFieldAsync, isPatchPending, users, usersLoading, diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index e2d9fb58..489eb99e 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -149,7 +149,7 @@ export default function WorkOrdersListPage() { data={page.completionDocs[String(activeDocRow.id)]} onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)} onDateCompletedChange={(date) => - page.patchField(activeDocRow.id, { completedDate: date }) + page.patchFieldAsync(activeDocRow.id, { completedDate: date }) } /> )} 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 b2cb79bc..58ae0685 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 aff51807..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 @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "react-toastify"; import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; @@ -18,6 +19,7 @@ import type { } from "@/domain/work-orders/types/work-order-table-row"; import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils"; +import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue"; import { queryKeys } from "@/infra/query-key/query-key"; export type { WorkOrderTablePatch }; @@ -31,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, @@ -77,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, @@ -87,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( @@ -132,6 +105,7 @@ export function useWorkOrderTableMutations( const { onPatch, clearPatch, getRow } = handlers; const queryClient = useQueryClient(); + const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []); const invalidate = () => { void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }); @@ -139,7 +113,7 @@ export function useWorkOrderTableMutations( const patchMutation = useMutation({ mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => - runWorkOrderTablePatch(id, patch, getRow), + enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)), onMutate: async ({ id, patch }) => { onPatch(id, patch); }, @@ -172,25 +146,16 @@ export function useWorkOrderTableMutations( }, }); - const patchField = ( - id: string | number, - patch: WorkOrderTablePatch, - options?: { - onSuccess?: () => void; - onError?: (error: Error) => void; - }, - ) => { - patchMutation.mutate( - { id, patch }, - { - onSuccess: () => options?.onSuccess?.(), - onError: (error) => options?.onError?.(error), - }, - ); + const patchField = (id: string | number, patch: WorkOrderTablePatch) => { + 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/domain/work-orders/utils/work-order-patch-queue.ts b/src/domain/work-orders/utils/work-order-patch-queue.ts new file mode 100644 index 00000000..caf2e217 --- /dev/null +++ b/src/domain/work-orders/utils/work-order-patch-queue.ts @@ -0,0 +1,20 @@ +/** + * Serializes async work per work-order id so concurrent patches (e.g. a completion-doc + * status save firing alongside a completed-date save) never race two overlapping + * get-then-update round trips against the same record. Each call for a given id waits + * for the previous one to settle before starting its own fetch-and-update cycle. + */ +export function createWorkOrderPatchQueue() { + const chains = new Map>(); + + return function enqueue(id: string | number, run: () => Promise): Promise { + const key = String(id); + const previous = chains.get(key) ?? Promise.resolve(); + const settled = previous.catch(() => undefined).then(run); + chains.set( + key, + settled.catch(() => undefined), + ); + return settled; + }; +} 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 e58cbdc4..57a2df70 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"; @@ -17,7 +17,11 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ })); 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"; @@ -113,14 +117,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); @@ -129,14 +135,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 new file mode 100644 index 00000000..402afa30 --- /dev/null +++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx @@ -0,0 +1,114 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +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 uploadMutateAsync = vi.fn(); +const deleteMutate = vi.fn(); +const categorizeMutate = vi.fn(); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ + useWorkOrderBoardDetail: () => ({ + data: { + info: { status: "Scheduled" }, + media: [ + { + id: "11", + fileName: "before.jpg", + url: "https://example.com/before.jpg", + category: "Before", + isLegacy: false, + }, + ], + comments: [], + audit: [], + completion: {}, + }, + isLoading: false, + }), +})); + +vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ + useUploadWorkOrderMedia: () => ({ + mutate: vi.fn(), + mutateAsync: uploadMutateAsync, + isPending: false, + }), + useUpdateWorkOrderMediaCategory: () => ({ mutate: categorizeMutate, isPending: false }), + useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }), +})); + +function baseRow(): WorkOrderTableRow { + return { + id: 7, + woNumber: "WO-7", + site: "SEA1", + type: "Emergency", + dispatcherId: "u1", + dispatcherName: "Dispatcher", + scheduledOn: "2026-06-01", + completedDate: "", + targetWeek: "", + dayGroup: "", + dayLabel: "", + location: "Seattle", + locationId: "loc-1", + pm: "", + serviceNotes: "", + extraServices: [], + vendorId: "", + company: "", + tech: "", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Scheduled", + docStatus: "", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 1, + title: "Emergency", + priority: "Sev 1", + primaryDispatchId: null, + rowVersion: "", + dispatchRowVersion: "", + flagColor: null, + }; +} + +describe("MediaUploadModal API wiring", () => { + beforeEach(() => { + uploadMutateAsync.mockReset(); + deleteMutate.mockReset(); + categorizeMutate.mockReset(); + }); + + it("renders persisted media from board detail (refresh-safe)", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("before.jpg")).toBeInTheDocument(); + }); + expect(screen.getByText("Photos & Videos")).toBeInTheDocument(); + expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx index 0511dead..de960548 100644 --- a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx @@ -21,6 +21,7 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({ useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }), useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }), + useUpdateWorkOrderMediaCategory: () => ({ mutate: vi.fn(), isPending: false }), useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }), useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }), })); 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 ac1c359d..3df69544 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 @@ -164,33 +164,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 new file mode 100644 index 00000000..f7b8fc16 --- /dev/null +++ b/src/test/domain/work-orders/use-cases/use-work-order-table-mutations.test.tsx @@ -0,0 +1,223 @@ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; + +const getById = vi.fn(); +const update = 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), + patchBoardField: (...args: unknown[]) => patchBoardField(...args), + }, +})); + +vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } })); + +import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations"; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function makeWrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +const BASE_DETAIL: WorkOrderDetail = { + id: 42, + internalWONumber: "WO-42", + workOrderNumber: "WO-42", + title: "PM — SEA1", + location: "Seattle", + locationId: "loc-1", + locationZip: "98101", + assignedTo: "Dispatcher", + dueDate: "", + createdDate: "2026-06-01", + createdDateFull: "2026-06-01T00:00:00Z", + description: "", + priority: "Sev 3", + status: "Open", + po: "", + tt: "", + problem: "HVAC", + trade: "", + subTrade: "", + vendorNTE: "", + scheduledDate: "2026-06-01", + completedDate: "", + docStatus: "No", + source: "PM", + comments: [], + auditLog: [], + dispatches: [], +}; + +const BASE_ROW = { + id: 42, + woNumber: "WO-42", + site: "SEA1", + type: "PM", + dispatcherId: "d1", + dispatcherName: "Dispatcher", + scheduledOn: "2026-06-01", + completedDate: "", + targetWeek: "", + dayGroup: "", + dayLabel: "", + location: "Seattle", + locationId: "loc-1", + pm: "HVAC", + serviceNotes: "", + extraServices: [], + vendorId: "", + company: "", + tech: "", + techPhone: "", + apptStart: "", + apptEnd: "", + apptTime: "", + status: "Incomplete", + docStatus: "No", + pocName: "", + pocPhone: "", + pocNotes: "", + dueDate: "", + rescheduleCount: 0, + carriedOver: 0, + originalWeek: "", + originalDate: "", + isPastDue: false, + scheduleWeekOnly: false, + mediaCount: 0, + title: "PM — SEA1", + priority: "Sev 3", + primaryDispatchId: null, + rowVersion: "AAAAAAA=", + dispatchRowVersion: "", + flagColor: null, +} as WorkOrderTableRow; + +describe("useWorkOrderTableMutations completion-doc persistence", () => { + let serverState: WorkOrderDetail; + let events: string[]; + let rowState: WorkOrderTableRow; + + beforeEach(() => { + getById.mockReset(); + update.mockReset(); + patchBoardField.mockReset(); + serverState = { ...BASE_DETAIL }; + rowState = { ...BASE_ROW }; + events = []; + + patchBoardField.mockImplementation( + async ( + _id: unknown, + payload: { field: string; value?: string | null; workOrderVersion: string }, + ) => { + events.push(`boardPatch:${payload.field}:start`); + await delay(5); + if (payload.field === "docStatus" && typeof payload.value === "string") { + const docStatus = + payload.value === "NotNeeded" ? "NN" : (payload.value as WorkOrderDetail["docStatus"]); + serverState = { ...serverState, docStatus }; + rowState = { + ...rowState, + docStatus, + rowVersion: `${payload.workOrderVersion}-next`, + }; + } + 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 }; + }, + ); + }); + + it("persists docStatus via board PATCH with NotNeeded for UI NN", async () => { + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, { docStatus: "NN" }); + }); + + await waitFor(() => expect(events).toContain("boardPatch:docStatus:end")); + + expect(patchBoardField).toHaveBeenCalledWith(42, { + field: "docStatus", + value: "NotNeeded", + workOrderVersion: "AAAAAAA=", + }); + expect(update).not.toHaveBeenCalled(); + expect(getById).not.toHaveBeenCalled(); + expect(serverState.docStatus).toBe("NN"); + }); + + it("serializes board docStatus and completedDate via PATCH without racing or EditWorkorder", async () => { + const onPatch = vi.fn(); + const { result } = renderHook( + () => + useWorkOrderTableMutations({ + onPatch, + clearPatch: () => undefined, + getRow: () => rowState, + }), + { wrapper: makeWrapper() }, + ); + + act(() => { + result.current.patchField(42, { docStatus: "Yes" }); + result.current.patchField(42, { completedDate: "2026-07-20" }); + }); + + await waitFor(() => expect(events).toContain("boardPatch:completedDate:end")); + await waitFor(() => expect(events).toContain("boardPatch:docStatus:end")); + + expect(events).toEqual([ + "boardPatch:docStatus:start", + "boardPatch:docStatus:end", + "boardPatch:completedDate:start", + "boardPatch:completedDate:end", + ]); + + expect(getById).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(patchBoardField).toHaveBeenCalledWith( + 42, + expect.objectContaining({ field: "completedDate", value: "2026-07-20" }), + ); + + expect(serverState.docStatus).toBe("Yes"); + expect(serverState.completedDate).toBe("2026-07-20"); + expect(serverState.scheduledDate).toBe(BASE_DETAIL.scheduledDate); + expect(serverState.status).toBe(BASE_DETAIL.status); + }); +}); 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, + ); + }); }); diff --git a/src/test/domain/work-orders/utils/work-order-patch-queue.test.ts b/src/test/domain/work-orders/utils/work-order-patch-queue.test.ts new file mode 100644 index 00000000..c088e3c1 --- /dev/null +++ b/src/test/domain/work-orders/utils/work-order-patch-queue.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue"; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("createWorkOrderPatchQueue", () => { + it("serializes calls for the same id so the second never starts before the first settles", async () => { + const enqueue = createWorkOrderPatchQueue(); + const events: string[] = []; + + const first = enqueue(1, async () => { + events.push("first:start"); + await delay(20); + events.push("first:end"); + return "first"; + }); + const second = enqueue(1, async () => { + events.push("second:start"); + await delay(1); + events.push("second:end"); + return "second"; + }); + + await Promise.all([first, second]); + + expect(events).toEqual(["first:start", "first:end", "second:start", "second:end"]); + }); + + it("does not block work queued for a different id", async () => { + const enqueue = createWorkOrderPatchQueue(); + const events: string[] = []; + + const slow = enqueue(1, async () => { + events.push("slow:start"); + await delay(20); + events.push("slow:end"); + }); + const fast = enqueue(2, async () => { + events.push("fast:start"); + await delay(1); + events.push("fast:end"); + }); + + await Promise.all([slow, fast]); + + expect(events.indexOf("fast:end")).toBeLessThan(events.indexOf("slow:end")); + }); + + it("still runs the next task after a prior task rejects", async () => { + const enqueue = createWorkOrderPatchQueue(); + + await expect( + enqueue(1, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + await expect(enqueue(1, async () => "recovered")).resolves.toBe("recovered"); + }); +});