mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
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.
This commit is contained in:
parent
cfcf6fa8f0
commit
e7fb8d6122
38 changed files with 1163 additions and 316 deletions
|
|
@ -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<void>;
|
||||
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function CompDocDialog({
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function notifyDraftSaved() {
|
||||
|
|
@ -114,38 +114,46 @@ export function useCompDocDialog({
|
|||
seaRep,
|
||||
};
|
||||
|
||||
const persist = (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
||||
onSave({ notes, generatedAt, ...extra }, status);
|
||||
const persist = async (extra: Partial<CompDocData>, 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({
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ type UseCompDocPdfActionsArgs = {
|
|||
generatedAt: string;
|
||||
docFields: DocFields;
|
||||
uploadCompletionMutation: ReturnType<typeof useUploadCompletionDoc>;
|
||||
persist: (extra: Partial<CompDocData>, status: CompDocSaveStatus) => void;
|
||||
syncDateIfChanged: () => void;
|
||||
persist: (extra: Partial<CompDocData>, status: CompDocSaveStatus) => void | Promise<void>;
|
||||
syncDateIfChanged: () => void | Promise<void>;
|
||||
setGeneratedAt: (value: string) => void;
|
||||
markGenerated: () => void;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<MediaUploader
|
||||
uploads={media}
|
||||
onUpload={onUploadMedia}
|
||||
onCategorize={onCategorizeMedia}
|
||||
onRemove={onRemoveMedia}
|
||||
readOnly={isReadOnly}
|
||||
pending={mediaPending}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useRef } from "react";
|
||||
import { Download, FileText } from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import 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 { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
|
@ -26,7 +27,8 @@ type SlideOverCompletionTabProps = {
|
|||
media: MediaFile[];
|
||||
mediaPending?: boolean;
|
||||
completionPending?: boolean;
|
||||
onUploadMedia?: (files: File[], category: UploadableMediaCategory) => 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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
workOrderMediaAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
|
|
@ -25,8 +27,6 @@ const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
|||
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"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit()} per file
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
accept={workOrderMediaAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads";
|
||||
import type {
|
||||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
|
||||
|
|
@ -14,12 +15,14 @@ type UseSlideOverMediaStateArgs = {
|
|||
detailForRow?: WorkOrderBoardDetail;
|
||||
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
|
||||
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
|
||||
</div>
|
||||
<Text className="sr-only" variant="description">
|
||||
Upload photos and videos for this work order.
|
||||
Upload photos and videos for this work order, then assign Before, After, or Extra.
|
||||
</Text>
|
||||
|
||||
<div className="mt-4">
|
||||
|
|
@ -81,12 +75,17 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
) : (
|
||||
<MediaUploader
|
||||
uploads={uploads}
|
||||
onUpload={(files, category) => {
|
||||
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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement | null>;
|
||||
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 && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<label
|
||||
className="text-[11px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
htmlFor="media-upload-category"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="media-upload-category"
|
||||
value={category}
|
||||
disabled={pending}
|
||||
onChange={(event) => onCategoryChange(event.target.value as UploadableMediaCategory)}
|
||||
className="rounded border px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
{PHOTO_CATEGORIES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
|
|
@ -74,13 +39,14 @@ export function MediaUploaderDropzone({
|
|||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
|
||||
upload
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
accept={workOrderMediaAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { RotateCcw, X } from "lucide-react";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { MediaUploaderGridItemPreview } from "./media-uploader-grid-item-preview";
|
||||
|
||||
type MediaUploaderGridItemChromeProps = {
|
||||
file: MediaFile;
|
||||
readOnly: boolean;
|
||||
pending: boolean;
|
||||
onRemove: (id: string) => 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 (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
aspectRatio: "1 / 1",
|
||||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
<MediaUploaderGridItemPreview file={file} />
|
||||
{Boolean(uploading) && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
style={{ height: 4, background: "rgba(0,0,0,0.25)" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${file.progress ?? 0}%`,
|
||||
background: "var(--color-brand-primary, #2563eb)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(uploading && onCancel) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel?.(file.id)}
|
||||
title="Cancel upload"
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
{Boolean(failed && onRetry) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry?.(file.id)}
|
||||
title="Retry upload"
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: 4,
|
||||
left: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
</button>
|
||||
)}
|
||||
{Boolean(showDelete && !uploading) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(file.id)}
|
||||
title="Remove"
|
||||
disabled={pending}
|
||||
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-1 px-1 py-1">
|
||||
<div className="truncate" style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.name}
|
||||
</div>
|
||||
{Boolean(failed) && (
|
||||
<div style={{ fontSize: 10, color: "var(--color-danger, #b91c1c)" }}>
|
||||
{file.errorMessage || "Upload failed"}
|
||||
</div>
|
||||
)}
|
||||
{needsCategory ? (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
value={file.purpose ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as UploadableMediaCategory;
|
||||
if (value) onCategorize?.(file.id, value);
|
||||
}}
|
||||
className="w-full rounded border px-1 py-0.5 text-[10px]"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
<option value="">Assign purpose…</option>
|
||||
{PURPOSE_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.purpose || (file.isLegacy ? "Legacy" : "")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<FileCategory, typeof ImageIcon> = {
|
||||
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 (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.fileType === "video") {
|
||||
return (
|
||||
<>
|
||||
<video src={file.url} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<Play size={18} style={{ color: "#fff" }} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +1,16 @@
|
|||
import {
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Paperclip,
|
||||
Play,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
image: ImageIcon,
|
||||
video: Film,
|
||||
pdf: FileText,
|
||||
doc: FileIcon,
|
||||
other: Paperclip,
|
||||
};
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { MediaUploaderGridItemChrome } from "./media-uploader-grid-item-chrome";
|
||||
import { MediaUploaderGridItemMeta } from "./media-uploader-grid-item-meta";
|
||||
|
||||
type MediaUploaderGridItemProps = {
|
||||
file: MediaFile;
|
||||
readOnly: boolean;
|
||||
pending: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
onRetry?: (id: string) => void;
|
||||
onCancel?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderGridItem({
|
||||
|
|
@ -30,67 +18,29 @@ export function MediaUploaderGridItem({
|
|||
readOnly,
|
||||
pending,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: MediaUploaderGridItemProps) {
|
||||
const TypeIcon = FILETYPE_ICON[file.fileType];
|
||||
const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
aspectRatio: "1 / 1",
|
||||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
{file.fileType === "image" ? (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : file.fileType === "video" ? (
|
||||
<>
|
||||
<video src={file.url} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<Play size={18} style={{ color: "#fff" }} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
||||
</span>
|
||||
)}
|
||||
{showDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(file.id)}
|
||||
title="Remove"
|
||||
disabled={pending}
|
||||
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-1 py-1">
|
||||
<div className="truncate" style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
<MediaUploaderGridItemChrome
|
||||
file={file}
|
||||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onRemove={onRemove}
|
||||
onRetry={onRetry}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
<MediaUploaderGridItemMeta
|
||||
file={file}
|
||||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onCategorize={onCategorize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { MediaUploaderGridItem } from "./media-uploader-grid-item";
|
||||
|
||||
|
|
@ -6,6 +7,9 @@ type MediaUploaderGridProps = {
|
|||
readOnly: boolean;
|
||||
pending: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
onRetry?: (id: string) => void;
|
||||
onCancel?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderGrid({
|
||||
|
|
@ -13,6 +17,9 @@ export function MediaUploaderGrid({
|
|||
readOnly,
|
||||
pending,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: MediaUploaderGridProps) {
|
||||
if (uploads.length === 0) return null;
|
||||
|
||||
|
|
@ -25,6 +32,9 @@ export function MediaUploaderGrid({
|
|||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onRemove={onRemove}
|
||||
onCategorize={onCategorize}
|
||||
onRetry={onRetry}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(null);
|
||||
const [category, setCategory] = useState<UploadableMediaCategory>("Before");
|
||||
const [controllers, setControllers] = useState<Record<string, AbortController>>({});
|
||||
|
||||
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<string, AbortController> = { ...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 (
|
||||
<div>
|
||||
{!readOnly && (
|
||||
<MediaUploaderDropzone
|
||||
inputRef={inputRef}
|
||||
category={category}
|
||||
onCategoryChange={setCategory}
|
||||
showCategoryPicker={Boolean(onUpload)}
|
||||
pending={pending}
|
||||
onFilesSelected={addFiles}
|
||||
/>
|
||||
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
|
||||
)}
|
||||
<MediaUploaderGrid
|
||||
uploads={uploads}
|
||||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onRemove={remove}
|
||||
onCategorize={onCategorize}
|
||||
onRetry={onRetry}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<unknown>;
|
||||
|
||||
export function useMediaUploadSession(
|
||||
detail: WorkOrderBoardDetail | undefined,
|
||||
uploadMutateAsync: UploadMutate,
|
||||
) {
|
||||
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
@ -22,7 +22,11 @@ export function useWorkOrdersListPage() {
|
|||
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderBoardMediaItem> => {
|
||||
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<WorkOrderBoardMediaItem> => {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
await apiPostForm<{ message?: string; status?: string }>(
|
||||
API_PATHS.workOrder.editWorkorder,
|
||||
mapCompletedDateUpdateFormData(id, completedDate),
|
||||
);
|
||||
},
|
||||
|
||||
delete: async (id: string | number): Promise<void> => {
|
||||
await apiDelete(API_PATHS.workOrder.deleteWorkorder, {
|
||||
json: { Id: Number(id) },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export type WorkOrderBoardEditableField =
|
|||
| "apptDate"
|
||||
| "apptTime"
|
||||
| "docStatus"
|
||||
| "completedDate"
|
||||
| "pm"
|
||||
| "serviceNotes"
|
||||
| "extraServices"
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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<WorkOrderBoardMediaItem, Error, UpdateWorkOrderMediaCategoryInput> {
|
||||
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<void, Error, string | number> {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
20
src/domain/work-orders/utils/work-order-patch-queue.ts
Normal file
20
src/domain/work-orders/utils/work-order-patch-queue.ts
Normal file
|
|
@ -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<string, Promise<unknown>>();
|
||||
|
||||
return function enqueue<T>(id: string | number, run: () => Promise<T>): Promise<T> {
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<QueryClientProvider client={client}>
|
||||
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue