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:
Alexandre Brandizzi 2026-07-31 12:24:31 -03:00 • committed by arthur.bassi
parent cfcf6fa8f0
commit e7fb8d6122
38 changed files with 1163 additions and 316 deletions

View file

@ -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({

View file

@ -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({

View file

@ -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;
};

View file

@ -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}

View file

@ -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}
/>
);

View file

@ -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}

View file

@ -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,
};
}

View file

@ -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,

View file

@ -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)}
/>

View file

@ -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}
/>

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>
);

View file

@ -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 };
}

View file

@ -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,

View file

@ -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 })
}
/>
)}

View file

@ -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) {

View file

@ -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) },

View file

@ -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 },

View file

@ -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,
}));
}

View file

@ -116,6 +116,7 @@ export type WorkOrderBoardEditableField =
| "apptDate"
| "apptTime"
| "docStatus"
| "completedDate"
| "pm"
| "serviceNotes"
| "extraServices"

View file

@ -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[] = [

View file

@ -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> {

View file

@ -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,
};
}

View file

@ -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`;
}

View 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;
};
}

View file

@ -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",

View file

@ -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();
});
});

View file

@ -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 }),
}));

View file

@ -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");
});
});

View file

@ -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" }),
});
});
});

View file

@ -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);
});
});

View file

@ -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,
);
});
});

View file

@ -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");
});
});