mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 08:33:14 +00:00
fix(work-orders): persist completedDate via board and media sh-116
leave EditWorkorder for completedDate; await mutations before draft saved toast; upload-then-categorize with MIME allowlist, progress, cancel, retry.
This commit is contained in:
parent
27c7c8df55
commit
509b3c3212
28 changed files with 688 additions and 313 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import { type MouseEvent, useMemo } from "react";
|
||||
import { type MouseEvent } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
|
||||
import { Image as ImageIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { LoadingRegion } from "@/components/ui/loading-region";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
import { MediaUploader } from "./media-uploader";
|
||||
|
||||
type MediaUploadModalProps = {
|
||||
row: WorkOrderTableRow;
|
||||
|
|
@ -25,23 +26,16 @@ type MediaUploadModalProps = {
|
|||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||
|
||||
const uploads = useMemo(
|
||||
() =>
|
||||
boardMediaItemsToMediaFiles(
|
||||
(detail?.media ?? []).filter(
|
||||
(item) =>
|
||||
item.category === "Before" ||
|
||||
item.category === "After" ||
|
||||
item.category === "Completion",
|
||||
),
|
||||
),
|
||||
[detail?.media],
|
||||
const { uploads, startUploads, retryUpload, removeLocal, isLocal } = useMediaUploadSession(
|
||||
detail,
|
||||
(input) => uploadMutation.mutateAsync(input),
|
||||
);
|
||||
|
||||
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||
const pending =
|
||||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
|
||||
return (
|
||||
|
|
@ -66,7 +60,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
|
||||
</div>
|
||||
<Text className="sr-only" variant="description">
|
||||
Upload photos and videos for this work order.
|
||||
Upload photos and videos for this work order, then assign Before, After, or Extra.
|
||||
</Text>
|
||||
|
||||
<div className="mt-4">
|
||||
|
|
@ -81,12 +75,17 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
) : (
|
||||
<MediaUploader
|
||||
uploads={uploads}
|
||||
onUpload={(files, category) => {
|
||||
for (const file of files) {
|
||||
uploadMutation.mutate({ file, category });
|
||||
onUpload={startUploads}
|
||||
onRemove={(id) => {
|
||||
if (isLocal(id)) {
|
||||
removeLocal(id);
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(id);
|
||||
}}
|
||||
onRemove={(id) => deleteMutation.mutate(id)}
|
||||
onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })}
|
||||
onRetry={retryUpload}
|
||||
onCancel={removeLocal}
|
||||
readOnly={isReadOnly}
|
||||
pending={pending}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,58 +1,23 @@
|
|||
import type { RefObject } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import { formatMediaSizeLimit } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const PHOTO_CATEGORIES: UploadableMediaCategory[] = ["Before", "After"];
|
||||
import {
|
||||
formatMediaSizeLimit,
|
||||
workOrderMediaAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
type MediaUploaderDropzoneProps = {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
category: UploadableMediaCategory;
|
||||
onCategoryChange: (category: UploadableMediaCategory) => void;
|
||||
showCategoryPicker: boolean;
|
||||
pending: boolean;
|
||||
onFilesSelected: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderDropzone({
|
||||
inputRef,
|
||||
category,
|
||||
onCategoryChange,
|
||||
showCategoryPicker,
|
||||
pending,
|
||||
onFilesSelected,
|
||||
}: MediaUploaderDropzoneProps) {
|
||||
return (
|
||||
<>
|
||||
{showCategoryPicker && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<label
|
||||
className="text-[11px]"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
htmlFor="media-upload-category"
|
||||
>
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="media-upload-category"
|
||||
value={category}
|
||||
disabled={pending}
|
||||
onChange={(event) => onCategoryChange(event.target.value as UploadableMediaCategory)}
|
||||
className="rounded border px-2 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
{PHOTO_CATEGORIES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
|
|
@ -74,13 +39,14 @@ export function MediaUploaderDropzone({
|
|||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
|
||||
upload
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
accept={workOrderMediaAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { RotateCcw, X } from "lucide-react";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { MediaUploaderGridItemPreview } from "./media-uploader-grid-item-preview";
|
||||
|
||||
type MediaUploaderGridItemChromeProps = {
|
||||
file: MediaFile;
|
||||
readOnly: boolean;
|
||||
pending: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
onRetry?: (id: string) => void;
|
||||
onCancel?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderGridItemChrome({
|
||||
file,
|
||||
readOnly,
|
||||
pending,
|
||||
onRemove,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: MediaUploaderGridItemChromeProps) {
|
||||
const uploading = file.uploadStatus === "uploading";
|
||||
const failed = file.uploadStatus === "failed";
|
||||
const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
aspectRatio: "1 / 1",
|
||||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
<MediaUploaderGridItemPreview file={file} />
|
||||
{Boolean(uploading) && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0"
|
||||
style={{ height: 4, background: "rgba(0,0,0,0.25)" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${file.progress ?? 0}%`,
|
||||
background: "var(--color-brand-primary, #2563eb)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(uploading && onCancel) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel?.(file.id)}
|
||||
title="Cancel upload"
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
{Boolean(failed && onRetry) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry?.(file.id)}
|
||||
title="Retry upload"
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: 4,
|
||||
left: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
</button>
|
||||
)}
|
||||
{Boolean(showDelete && !uploading) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(file.id)}
|
||||
title="Remove"
|
||||
disabled={pending}
|
||||
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
||||
const PURPOSE_OPTIONS: UploadableMediaCategory[] = ["Before", "After", "Extra"];
|
||||
|
||||
type MediaUploaderGridItemMetaProps = {
|
||||
file: MediaFile;
|
||||
readOnly: boolean;
|
||||
pending: boolean;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderGridItemMeta({
|
||||
file,
|
||||
readOnly,
|
||||
pending,
|
||||
onCategorize,
|
||||
}: MediaUploaderGridItemMetaProps) {
|
||||
const failed = file.uploadStatus === "failed";
|
||||
const needsCategory =
|
||||
!readOnly &&
|
||||
!file.isLegacy &&
|
||||
file.uploadStatus !== "uploading" &&
|
||||
file.uploadStatus !== "failed" &&
|
||||
Boolean(onCategorize);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 px-1 py-1">
|
||||
<div className="truncate" style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.name}
|
||||
</div>
|
||||
{Boolean(failed) && (
|
||||
<div style={{ fontSize: 10, color: "var(--color-danger, #b91c1c)" }}>
|
||||
{file.errorMessage || "Upload failed"}
|
||||
</div>
|
||||
)}
|
||||
{needsCategory ? (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
value={file.purpose ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as UploadableMediaCategory;
|
||||
if (value) onCategorize?.(file.id, value);
|
||||
}}
|
||||
className="w-full rounded border px-1 py-0.5 text-[10px]"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
<option value="">Assign purpose…</option>
|
||||
{PURPOSE_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.purpose || (file.isLegacy ? "Legacy" : "")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Paperclip,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
image: ImageIcon,
|
||||
video: Film,
|
||||
pdf: FileText,
|
||||
doc: FileIcon,
|
||||
other: Paperclip,
|
||||
};
|
||||
|
||||
type MediaUploaderGridItemPreviewProps = {
|
||||
file: MediaFile;
|
||||
};
|
||||
|
||||
export function MediaUploaderGridItemPreview({ file }: MediaUploaderGridItemPreviewProps) {
|
||||
const TypeIcon = FILETYPE_ICON[file.fileType];
|
||||
|
||||
if (file.fileType === "image") {
|
||||
return (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.fileType === "video") {
|
||||
return (
|
||||
<>
|
||||
<video src={file.url} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<Play size={18} style={{ color: "#fff" }} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +1,16 @@
|
|||
import {
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Paperclip,
|
||||
Play,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
image: ImageIcon,
|
||||
video: Film,
|
||||
pdf: FileText,
|
||||
doc: FileIcon,
|
||||
other: Paperclip,
|
||||
};
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { MediaUploaderGridItemChrome } from "./media-uploader-grid-item-chrome";
|
||||
import { MediaUploaderGridItemMeta } from "./media-uploader-grid-item-meta";
|
||||
|
||||
type MediaUploaderGridItemProps = {
|
||||
file: MediaFile;
|
||||
readOnly: boolean;
|
||||
pending: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
onRetry?: (id: string) => void;
|
||||
onCancel?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderGridItem({
|
||||
|
|
@ -30,67 +18,29 @@ export function MediaUploaderGridItem({
|
|||
readOnly,
|
||||
pending,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: MediaUploaderGridItemProps) {
|
||||
const TypeIcon = FILETYPE_ICON[file.fileType];
|
||||
const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
aspectRatio: "1 / 1",
|
||||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
{file.fileType === "image" ? (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : file.fileType === "video" ? (
|
||||
<>
|
||||
<video src={file.url} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<Play size={18} style={{ color: "#fff" }} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
||||
</span>
|
||||
)}
|
||||
{showDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(file.id)}
|
||||
title="Remove"
|
||||
disabled={pending}
|
||||
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||
style={{
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-1 py-1">
|
||||
<div className="truncate" style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
<MediaUploaderGridItemChrome
|
||||
file={file}
|
||||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onRemove={onRemove}
|
||||
onRetry={onRetry}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
<MediaUploaderGridItemMeta
|
||||
file={file}
|
||||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onCategorize={onCategorize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { MediaUploaderGridItem } from "./media-uploader-grid-item";
|
||||
|
||||
|
|
@ -6,6 +7,9 @@ type MediaUploaderGridProps = {
|
|||
readOnly: boolean;
|
||||
pending: boolean;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
onRetry?: (id: string) => void;
|
||||
onCancel?: (id: string) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderGrid({
|
||||
|
|
@ -13,6 +17,9 @@ export function MediaUploaderGrid({
|
|||
readOnly,
|
||||
pending,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: MediaUploaderGridProps) {
|
||||
if (uploads.length === 0) return null;
|
||||
|
||||
|
|
@ -25,6 +32,9 @@ export function MediaUploaderGrid({
|
|||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onRemove={onRemove}
|
||||
onCategorize={onCategorize}
|
||||
onRetry={onRetry}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,16 +6,27 @@ import {
|
|||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
|
||||
import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||
|
||||
export type MediaUploadRequest = {
|
||||
localId: string;
|
||||
file: File;
|
||||
signal: AbortSignal;
|
||||
onProgress: (percent: number) => void;
|
||||
};
|
||||
|
||||
type MediaUploaderProps = {
|
||||
uploads: MediaFile[];
|
||||
/** When provided, uploads go to the API with the selected Before/After category. */
|
||||
onUpload?: (files: File[], category: UploadableMediaCategory) => void;
|
||||
/** Upload-then-categorize: files only — category assigned after persist. */
|
||||
onUpload?: (requests: MediaUploadRequest[]) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
onRetry?: (localId: string) => void;
|
||||
onCancel?: (localId: string) => void;
|
||||
/** Legacy local-only mode (table modal fallback). */
|
||||
onChange?: (next: MediaFile[]) => void;
|
||||
readOnly?: boolean;
|
||||
|
|
@ -26,18 +37,25 @@ export function MediaUploader({
|
|||
uploads,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
pending = false,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [category, setCategory] = useState<UploadableMediaCategory>("Before");
|
||||
const [controllers, setControllers] = useState<Record<string, AbortController>>({});
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (readOnly || pending || !files || files.length === 0) return;
|
||||
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||
continue;
|
||||
|
|
@ -47,7 +65,20 @@ export function MediaUploader({
|
|||
if (accepted.length === 0) return;
|
||||
|
||||
if (onUpload) {
|
||||
onUpload(accepted, category);
|
||||
const nextControllers: Record<string, AbortController> = { ...controllers };
|
||||
const requests: MediaUploadRequest[] = accepted.map((file) => {
|
||||
const localId = `local-${crypto.randomUUID()}`;
|
||||
const controller = new AbortController();
|
||||
nextControllers[localId] = controller;
|
||||
return {
|
||||
localId,
|
||||
file,
|
||||
signal: controller.signal,
|
||||
onProgress: () => undefined,
|
||||
};
|
||||
});
|
||||
setControllers(nextControllers);
|
||||
onUpload(requests);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +90,7 @@ export function MediaUploader({
|
|||
const remove = (id: string) => {
|
||||
if (readOnly || pending) return;
|
||||
const target = uploads.find((u) => u.id === id);
|
||||
if (target && !canDeleteWorkOrderMedia(target)) return;
|
||||
if (target && !canDeleteWorkOrderMedia(target) && target.uploadStatus !== "failed") return;
|
||||
if (onRemove) {
|
||||
onRemove(id);
|
||||
return;
|
||||
|
|
@ -67,23 +98,24 @@ export function MediaUploader({
|
|||
onChange?.(uploads.filter((u) => u.id !== id));
|
||||
};
|
||||
|
||||
const cancel = (id: string) => {
|
||||
controllers[id]?.abort();
|
||||
onCancel?.(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!readOnly && (
|
||||
<MediaUploaderDropzone
|
||||
inputRef={inputRef}
|
||||
category={category}
|
||||
onCategoryChange={setCategory}
|
||||
showCategoryPicker={Boolean(onUpload)}
|
||||
pending={pending}
|
||||
onFilesSelected={addFiles}
|
||||
/>
|
||||
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
|
||||
)}
|
||||
<MediaUploaderGrid
|
||||
uploads={uploads}
|
||||
readOnly={readOnly}
|
||||
pending={pending}
|
||||
onRemove={remove}
|
||||
onCategorize={onCategorize}
|
||||
onRetry={onRetry}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -101,12 +101,37 @@ export const workOrderBoardDocumentsApi = {
|
|||
uploadMedia: async (
|
||||
workOrderId: string | number,
|
||||
file: File,
|
||||
category: UploadableMediaCategory,
|
||||
category?: UploadableMediaCategory,
|
||||
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
||||
): Promise<WorkOrderBoardMediaItem> => {
|
||||
const formData = buildMediaFormData(file, category);
|
||||
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
signal: options?.signal,
|
||||
onUploadProgress: options?.onProgress
|
||||
? (progress) => {
|
||||
options.onProgress?.(Math.round(progress.percent * 100));
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
},
|
||||
|
||||
updateMediaCategory: async (
|
||||
workOrderId: string | number,
|
||||
mediaId: string | number,
|
||||
category: UploadableMediaCategory,
|
||||
): Promise<WorkOrderBoardMediaItem> => {
|
||||
const formData = new FormData();
|
||||
formData.append("category", category);
|
||||
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
mapUpdateDispatchPayload,
|
||||
mapVendorDropdownItems,
|
||||
mapWorkOrderDetail,
|
||||
mapCompletedDateUpdateFormData,
|
||||
mapWorkOrderFormToCreatePayload,
|
||||
mapWorkOrderFormToUpdateFormData,
|
||||
mapWorkOrderTableRow,
|
||||
|
|
@ -308,14 +307,6 @@ export const workOrdersApi = {
|
|||
);
|
||||
},
|
||||
|
||||
/** Persist only CompletedDate via EditWorkorder — no GetWorkorderById prefetch. */
|
||||
updateCompletedDate: async (id: string | number, completedDate: string): Promise<void> => {
|
||||
await apiPostForm<{ message?: string; status?: string }>(
|
||||
API_PATHS.workOrder.editWorkorder,
|
||||
mapCompletedDateUpdateFormData(id, completedDate),
|
||||
);
|
||||
},
|
||||
|
||||
delete: async (id: string | number): Promise<void> => {
|
||||
await apiDelete(API_PATHS.workOrder.deleteWorkorder, {
|
||||
json: { Id: Number(id) },
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
|
|||
{ key: "vendorId", field: "vendorId", toValue: (value) => String(value) },
|
||||
{ key: "apptTime", field: "apptTime", toValue: (value) => value as string },
|
||||
{ key: "docStatus", field: "docStatus", toValue: (value) => mapDocStatusToApi(value as string) },
|
||||
{ key: "completedDate", field: "completedDate", toValue: (value) => value as string },
|
||||
{ key: "pm", field: "pm", toValue: (value) => value as string },
|
||||
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
|
||||
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
|
||||
|
|
|
|||
|
|
@ -119,9 +119,11 @@ export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrder
|
|||
|
||||
export type UploadableMediaCategory = "Before" | "After" | "Extra";
|
||||
|
||||
export function buildMediaFormData(file: File, category: UploadableMediaCategory): FormData {
|
||||
export function buildMediaFormData(file: File, category?: UploadableMediaCategory): FormData {
|
||||
const form = new FormData();
|
||||
form.append("category", category);
|
||||
if (category) {
|
||||
form.append("category", category);
|
||||
}
|
||||
form.append("file", file);
|
||||
return form;
|
||||
}
|
||||
|
|
@ -229,7 +231,12 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M
|
|||
fileType: classifyFileName(item.fileName, item.url),
|
||||
url: item.url,
|
||||
category: boardCategoryToMediaCategory(item.category),
|
||||
purpose:
|
||||
item.category === "Before" || item.category === "After" || item.category === "Extra"
|
||||
? item.category
|
||||
: null,
|
||||
isLegacy: item.isLegacy || Number(item.id) <= 0,
|
||||
uploadStatus: "done" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export type WorkOrderBoardEditableField =
|
|||
| "apptDate"
|
||||
| "apptTime"
|
||||
| "docStatus"
|
||||
| "completedDate"
|
||||
| "pm"
|
||||
| "serviceNotes"
|
||||
| "extraServices"
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@ export interface MediaFile {
|
|||
fileType: FileCategory;
|
||||
url: string;
|
||||
category: MediaCategory;
|
||||
/** Board purpose label (Before/After/Extra). Null/undefined = needs categorization. */
|
||||
purpose?: "Before" | "After" | "Extra" | null;
|
||||
/** True when media is a legacy Before/After/Completion column (not deletable). */
|
||||
isLegacy?: boolean;
|
||||
/** 0–100 while uploading; undefined when idle/complete. */
|
||||
progress?: number;
|
||||
uploadStatus?: "uploading" | "failed" | "done";
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export const MEDIA_CATEGORIES: MediaCategory[] = [
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ export interface UpdateWorkOrderCommentInput {
|
|||
|
||||
export interface UploadWorkOrderMediaInput {
|
||||
file: File;
|
||||
category?: UploadableMediaCategory;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
export interface UpdateWorkOrderMediaCategoryInput {
|
||||
mediaId: string | number;
|
||||
category: UploadableMediaCategory;
|
||||
}
|
||||
|
||||
|
|
@ -191,12 +198,12 @@ export function useUploadWorkOrderMedia(
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ file, category }: UploadWorkOrderMediaInput) =>
|
||||
workOrdersApi.uploadMedia(workOrderId, file, category),
|
||||
mutationFn: ({ file, category, signal, onProgress }: UploadWorkOrderMediaInput) =>
|
||||
workOrdersApi.uploadMedia(workOrderId, file, category, { signal, onProgress }),
|
||||
onSuccess: () => {
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
invalidateWorkOrders(queryClient);
|
||||
toast.success("File uploaded");
|
||||
toast.success("File uploaded — assign a category");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to upload file");
|
||||
|
|
@ -204,6 +211,25 @@ export function useUploadWorkOrderMedia(
|
|||
});
|
||||
}
|
||||
|
||||
export function useUpdateWorkOrderMediaCategory(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<WorkOrderBoardMediaItem, Error, UpdateWorkOrderMediaCategoryInput> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ mediaId, category }: UpdateWorkOrderMediaCategoryInput) =>
|
||||
workOrdersApi.updateMediaCategory(workOrderId, mediaId, category),
|
||||
onSuccess: () => {
|
||||
invalidateBoardDetail(queryClient, workOrderId);
|
||||
invalidateWorkOrders(queryClient);
|
||||
toast.success("Category saved");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update category");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWorkOrderMedia(
|
||||
workOrderId: string | number,
|
||||
): UseMutationResult<void, Error, string | number> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue