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
parent 27c7c8df55
commit 509b3c3212
28 changed files with 688 additions and 313 deletions

View file

@ -15,8 +15,8 @@ type CompDocDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
onDateCompletedChange?: (date: string) => void;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
};
export function CompDocDialog({

View file

@ -23,8 +23,8 @@ type WorkOrdersListOverlaysProps = {
activeDocRow: WorkOrderTableRow | null;
onDocOpenChange: (open: boolean) => void;
completionDocs: Record<string, CompDocData>;
onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void;
onDateCompletedChange: (date: string) => void;
onSaveCompDoc: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange: (date: string) => void | Promise<void>;
activeMediaRow: WorkOrderTableRow | null;
onMediaOpenChange: (open: boolean) => void;
};
@ -71,8 +71,7 @@ export function WorkOrdersListOverlays({
open={!!activeDocRow}
onOpenChange={onDocOpenChange}
data={completionDocs[String(activeDocRow.id)]}
// docStatus persists via board PATCH (Yes|No|NotNeeded); completedDate via
// EditWorkorder. patchField serializes same-id saves so they never race.
// docStatus + completedDate persist via board PATCH (not legacy EditWorkorder).
// Notes/generatedAt stay client-local (no content endpoint on this branch).
onSave={onSaveCompDoc}
onDateCompletedChange={onDateCompletedChange}

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

@ -26,8 +26,8 @@ type UseCompDocDialogParams = {
row: WorkOrderTableRow;
open: boolean;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
onDateCompletedChange?: (date: string) => void;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
};
function buildAddressLine(site: { street: string; fullAddress: string }): string {
@ -102,13 +102,13 @@ export function useCompDocDialog({
[site, effectiveStreet],
);
function persist(extra: Partial<CompDocData>, status: CompDocSaveStatus) {
onSave({ notes, generatedAt, ...extra }, status);
async function persist(extra: Partial<CompDocData>, status: CompDocSaveStatus): Promise<void> {
await onSave({ notes, generatedAt, ...extra }, status);
}
function syncCompletedDateIfChanged() {
async function syncCompletedDateIfChanged(): Promise<void> {
if (editDateCompleted !== (row.completedDate || "")) {
onDateCompletedChange?.(editDateCompleted);
await onDateCompletedChange?.(editDateCompleted);
}
}
@ -118,44 +118,47 @@ export function useCompDocDialog({
if (next === current) return;
if (isMockLocation || !locId) return;
if (!locationDetail) {
toast.error("Location details are still loading. Try saving again.");
return;
throw new Error("Location details are still loading. Try saving again.");
}
try {
await locationsApi.update(locId, locationUpdatePayload(locationDetail, next));
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to update address");
}
await locationsApi.update(locId, locationUpdatePayload(locationDetail, next));
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
}
function handleMarkNotRequired() {
setDocState("not-required");
persist({}, "NN");
void persist({}, "NN");
}
function handleUndo() {
setDocState("draft");
persist({}, "No");
void persist({}, "No");
}
function handleSaveDraft() {
persist({}, "No");
syncCompletedDateIfChanged();
void syncAddressIfChanged();
showCompDocDraftSavedToast();
async function handleSaveDraft() {
try {
await persist({}, "No");
await syncCompletedDateIfChanged();
await syncAddressIfChanged();
showCompDocDraftSavedToast();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to save draft");
}
}
function handleGenerate() {
async function handleGenerate() {
const ts = fmtDocNow();
setGeneratedAt(ts);
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
syncCompletedDateIfChanged();
void syncAddressIfChanged();
try {
await persist({ generatedAt: ts }, "Yes");
await syncCompletedDateIfChanged();
await syncAddressIfChanged();
setGeneratedAt(ts);
setDocState("generated");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to generate document");
}
}
function handleDownloadPdf() {

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

@ -39,7 +39,7 @@ export default function WorkOrdersListPage() {
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
const { patchField } = useWorkOrderTableMutations({
const { patchField, patchFieldAsync } = useWorkOrderTableMutations({
onPatch: tableData.patchRow,
clearPatch: tableData.clearRowPatch,
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
@ -159,14 +159,14 @@ export default function WorkOrdersListPage() {
if (!open) setDocRow(null);
}}
completionDocs={completionDocs}
onSaveCompDoc={(data, status) => {
onSaveCompDoc={async (data, status) => {
if (!activeDocRow) return;
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
patchField(activeDocRow.id, { docStatus: status });
await patchFieldAsync(activeDocRow.id, { docStatus: status });
}}
onDateCompletedChange={(date) => {
onDateCompletedChange={async (date) => {
if (!activeDocRow) return;
patchField(activeDocRow.id, { completedDate: date });
await patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
activeMediaRow={activeMediaRow}
onMediaOpenChange={(open) => {

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

@ -33,10 +33,6 @@ export type WorkOrderTablePatchHandlers = {
type PatchResult =
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
async function persistCompletedDate(id: string | number, completedDate: string): Promise<void> {
await workOrdersApi.updateCompletedDate(id, completedDate);
}
async function applyBoardFieldOps(
id: string | number,
patch: WorkOrderTablePatch,
@ -79,7 +75,7 @@ async function applyBoardFieldOps(
return latest;
}
/** CompDoc completedDate uses EditWorkorder; board fields use PATCH …/board. */
/** Board fields (including completedDate) use PATCH …/board — never legacy EditWorkorder. */
export async function runWorkOrderTablePatch(
id: string | number,
patch: WorkOrderTablePatch,
@ -89,33 +85,8 @@ export async function runWorkOrderTablePatch(
return { kind: "local", patch };
}
const completedDate = patch.completedDate;
const boardPatch: WorkOrderTablePatch = { ...patch };
delete boardPatch.completedDate;
let boardRow: WorkOrderTableRow | undefined;
const hasBoardKeys = Object.keys(boardPatch).length > 0;
if (hasBoardKeys && !isLocalOnlyTablePatch(boardPatch)) {
boardRow = await applyBoardFieldOps(id, boardPatch, getRow);
} else if (!hasBoardKeys && completedDate === undefined) {
throw new BoardPatchValidationError(
"NoPersistableFields",
"These changes cannot be saved on the board. Refresh and edit a supported field.",
);
}
if (completedDate !== undefined) {
await persistCompletedDate(id, completedDate);
}
if (boardRow) {
return {
kind: "board",
row: completedDate !== undefined ? { ...boardRow, completedDate } : boardRow,
};
}
return { kind: "local", patch };
const boardRow = await applyBoardFieldOps(id, patch, getRow);
return { kind: "board", row: boardRow };
}
export function useWorkOrderTableMutations(
@ -179,8 +150,12 @@ export function useWorkOrderTableMutations(
patchMutation.mutate({ id, patch });
};
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
patchMutation.mutateAsync({ id, patch });
return {
patchField,
patchFieldAsync,
isPending: patchMutation.isPending,
};
}

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

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
@ -9,7 +9,11 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
}));
vi.mock("react-toastify", () => ({
toast: vi.fn(),
toast: { error: vi.fn(), success: vi.fn() },
}));
vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast", () => ({
showCompDocDraftSavedToast: vi.fn(),
}));
const SCHEDULED_ON = "2026-06-01";
@ -105,14 +109,16 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
fireEvent.change(dateInput!, { target: { value } });
}
it("Save draft emits completedDate without writing scheduledOn or lifecycle Completed", () => {
it("Save draft emits completedDate without writing scheduledOn or lifecycle Completed", async () => {
const row = baseRow();
const { patches } = renderDialog(row);
changeDateCompleted("2026-07-15");
fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15");
await waitFor(() => {
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-15");
});
expect(onSave).toHaveBeenCalledWith(expect.any(Object), "No");
expect(patches).toEqual([{ docStatus: "No" }, { completedDate: "2026-07-15" }]);
expect(patches.some((patch) => "scheduledOn" in patch)).toBe(false);
@ -121,14 +127,16 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
expect(row.status).toBe("Scheduled");
});
it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", () => {
it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", async () => {
const row = baseRow({ completedDate: "2026-07-01" });
const { patches } = renderDialog(row);
changeDateCompleted("2026-07-20");
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
await waitFor(() => {
expect(onDateCompletedChange).toHaveBeenCalledWith("2026-07-20");
});
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ generatedAt: expect.any(String) }),
"Yes",

View file

@ -4,8 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const uploadMutate = vi.fn();
const uploadMutateAsync = vi.fn();
const deleteMutate = vi.fn();
const categorizeMutate = vi.fn();
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({
@ -29,7 +30,12 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useUploadWorkOrderMedia: () => ({ mutate: uploadMutate, isPending: false }),
useUploadWorkOrderMedia: () => ({
mutate: vi.fn(),
mutateAsync: uploadMutateAsync,
isPending: false,
}),
useUpdateWorkOrderMediaCategory: () => ({ mutate: categorizeMutate, isPending: false }),
useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }),
}));
@ -82,8 +88,9 @@ function baseRow(): WorkOrderTableRow {
describe("MediaUploadModal API wiring", () => {
beforeEach(() => {
uploadMutate.mockReset();
uploadMutateAsync.mockReset();
deleteMutate.mockReset();
categorizeMutate.mockReset();
});
it("renders persisted media from board detail (refresh-safe)", async () => {
@ -101,6 +108,7 @@ describe("MediaUploadModal API wiring", () => {
expect(screen.getByText("before.jpg")).toBeInTheDocument();
});
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
expect(screen.getByLabelText(/category/i)).toBeInTheDocument();
expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
});
});

View file

@ -165,33 +165,10 @@ describe("workOrdersApi.update", () => {
});
});
describe("workOrdersApi.updateCompletedDate", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
apiPostForm.mockReset();
apiPostForm.mockResolvedValue({ message: "Updated Successfully", status: "200" });
});
it("posts only Id and CompletedDate to EditWorkorder without GetWorkorderById", async () => {
await workOrdersApi.updateCompletedDate(55, "2026-07-30");
expect(apiGet).not.toHaveBeenCalled();
expect(apiPost).not.toHaveBeenCalled();
expect(apiPostForm).toHaveBeenCalledTimes(1);
expect(apiPostForm).toHaveBeenCalledWith(
API_PATHS.workOrder.editWorkorder,
expect.any(FormData),
);
expect(API_PATHS.workOrder.editWorkorder).toBe("WorkOrder/EditWorkorder");
expect(API_PATHS.workOrder.getById).toBe("WorkOrder/GetWorkorderById");
const formData = apiPostForm.mock.calls[0]?.[1] as FormData;
expect(formData.get("Id")).toBe("55");
expect(formData.get("CompletedDate")).toBe("2026-07-30");
expect(formData.get("Status")).toBeNull();
expect(formData.get("WorkerOrderTitle")).toBeNull();
expect(formData.getAll("ContactIds")).toEqual([]);
describe("workOrdersApi completedDate board contract", () => {
it("does not expose updateCompletedDate — completedDate uses board PATCH", () => {
expect("updateCompletedDate" in workOrdersApi).toBe(false);
expect(API_PATHS.workOrder.boardPatch(55)).toBe("workorders/55/board");
});
});

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

@ -7,14 +7,12 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
const getById = vi.fn();
const update = vi.fn();
const updateCompletedDate = vi.fn();
const patchBoardField = vi.fn();
vi.mock("@/domain/work-orders/api/work-orders-api", () => ({
workOrdersApi: {
getById: (...args: unknown[]) => getById(...args),
update: (...args: unknown[]) => update(...args),
updateCompletedDate: (...args: unknown[]) => updateCompletedDate(...args),
patchBoardField: (...args: unknown[]) => patchBoardField(...args),
},
}));
@ -119,7 +117,6 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
beforeEach(() => {
getById.mockReset();
update.mockReset();
updateCompletedDate.mockReset();
patchBoardField.mockReset();
serverState = { ...BASE_DETAIL };
rowState = { ...BASE_ROW };
@ -130,7 +127,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
_id: unknown,
payload: { field: string; value?: string | null; workOrderVersion: string },
) => {
events.push("boardPatch:start");
events.push(`boardPatch:${payload.field}:start`);
await delay(5);
if (payload.field === "docStatus" && typeof payload.value === "string") {
const docStatus =
@ -142,17 +139,18 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
rowVersion: `${payload.workOrderVersion}-next`,
};
}
events.push("boardPatch:end");
if (payload.field === "completedDate" && typeof payload.value === "string") {
serverState = { ...serverState, completedDate: payload.value };
rowState = {
...rowState,
completedDate: payload.value,
rowVersion: `${payload.workOrderVersion}-date`,
};
}
events.push(`boardPatch:${payload.field}:end`);
return { ...rowState };
},
);
updateCompletedDate.mockImplementation(async (_id: unknown, completedDate: string) => {
events.push("updateCompletedDate:start");
await delay(5);
serverState = { ...serverState, completedDate };
events.push("updateCompletedDate:end");
});
});
it("persists docStatus via board PATCH with NotNeeded for UI NN", async () => {
@ -171,7 +169,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
result.current.patchField(42, { docStatus: "NN" });
});
await waitFor(() => expect(events).toContain("boardPatch:end"));
await waitFor(() => expect(events).toContain("boardPatch:docStatus:end"));
expect(patchBoardField).toHaveBeenCalledWith(42, {
field: "docStatus",
@ -179,12 +177,11 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
workOrderVersion: "AAAAAAA=",
});
expect(update).not.toHaveBeenCalled();
expect(updateCompletedDate).not.toHaveBeenCalled();
expect(getById).not.toHaveBeenCalled();
expect(serverState.docStatus).toBe("NN");
});
it("serializes board docStatus and completedDate without racing or GetWorkorderById", async () => {
it("serializes board docStatus and completedDate via PATCH without racing or EditWorkorder", async () => {
const onPatch = vi.fn();
const { result } = renderHook(
() =>
@ -201,19 +198,22 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
result.current.patchField(42, { completedDate: "2026-07-20" });
});
await waitFor(() => expect(events).toContain("updateCompletedDate:end"));
await waitFor(() => expect(events).toContain("boardPatch:end"));
await waitFor(() => expect(events).toContain("boardPatch:completedDate:end"));
await waitFor(() => expect(events).toContain("boardPatch:docStatus:end"));
expect(events).toEqual([
"boardPatch:start",
"boardPatch:end",
"updateCompletedDate:start",
"updateCompletedDate:end",
"boardPatch:docStatus:start",
"boardPatch:docStatus:end",
"boardPatch:completedDate:start",
"boardPatch:completedDate:end",
]);
expect(getById).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
expect(updateCompletedDate).toHaveBeenCalledWith(42, "2026-07-20");
expect(patchBoardField).toHaveBeenCalledWith(
42,
expect.objectContaining({ field: "completedDate", value: "2026-07-20" }),
);
expect(serverState.docStatus).toBe("Yes");
expect(serverState.completedDate).toBe("2026-07-20");

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