diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx index 24e468d1..cd85ad9e 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-media.tsx @@ -5,10 +5,12 @@ import { } from "@/app/(protected)/workorders/_components/media/media-uploader"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import type { WorkOrderMediaKindCounts } from "@/domain/work-orders/utils/work-order-media-limits"; type SlideOverCompletionMediaProps = { isReadOnly: boolean; media: MediaFile[]; + mediaCounts?: WorkOrderMediaKindCounts; mediaPending: boolean; onUploadMedia?: (requests: MediaUploadRequest[]) => void; onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void; @@ -20,6 +22,7 @@ type SlideOverCompletionMediaProps = { export function SlideOverCompletionMedia({ isReadOnly, media, + mediaCounts, mediaPending, onUploadMedia, onCategorizeMedia, @@ -38,6 +41,7 @@ export function SlideOverCompletionMedia({
void; @@ -39,6 +41,7 @@ export function SlideOverCompletionTab({ isReadOnly, completion, media, + mediaCounts, mediaPending = false, completionPending = false, onUploadMedia, @@ -59,6 +62,7 @@ export function SlideOverCompletionTab({ = { @@ -52,11 +54,16 @@ type SlideOverExtrasTabProps = { pending?: boolean; avetaRequired?: boolean; workOrderId?: string | number; + /** Photos/videos on the whole work order; Extra Docs shows only its own share. */ + mediaCounts?: WorkOrderMediaKindCounts; onUpload: (files: File[]) => void; onRemove: (id: string) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void; }; +const UNSUPPORTED_TYPE_MESSAGE = + "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC."; + async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise { const tab = window.open("about:blank", "_blank"); if (!tab) { @@ -175,6 +182,7 @@ export function SlideOverExtrasTab({ pending = false, avetaRequired = false, workOrderId, + mediaCounts, onUpload, onRemove, onCategorize, @@ -182,23 +190,16 @@ export function SlideOverExtrasTab({ const inputRef = useRef(null); const avetaAttached = hasExplicitAvetaFile(uploads); - const addFiles = (files: FileList | null) => { + const addFiles = async (files: FileList | null) => { if (isReadOnly || pending || !files || files.length === 0) return; - const accepted: File[] = []; - for (const file of Array.from(files)) { - if (!isWorkOrderExtraDocTypeAllowed(file)) { - toast.error( - "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV. Documents: PDF, DOC.", - ); - continue; - } - const sizeRejection = workOrderFileSizeRejection(file); - if (sizeRejection) { - toast.error(sizeRejection); - continue; - } - accepted.push(file); - } + // Snapshot synchronously: the input is reset right after this handler returns. + const selected = Array.from(files); + const { accepted, rejections } = await screenWorkOrderUploads(selected, { + existing: mediaCounts ?? countWorkOrderMediaKinds(uploads), + isTypeAllowed: isWorkOrderExtraDocTypeAllowed, + unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE, + }); + for (const message of rejections) toast.error(message); if (accepted.length > 0) { onUpload(accepted); } @@ -235,7 +236,7 @@ export function SlideOverExtrasTab({ onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault(); - addFiles(event.dataTransfer.files); + void addFiles(event.dataTransfer.files); }} className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60" style={{ @@ -264,7 +265,7 @@ export function SlideOverExtrasTab({ hidden disabled={pending} onChange={(event) => { - addFiles(event.target.files); + void addFiles(event.target.files); event.target.value = ""; }} /> diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 5bde5578..d4e26b70 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -17,6 +17,7 @@ import { isExtrasSurfaceMedia, type MediaSurfaceOwnership, } from "@/domain/work-orders/utils/partition-slide-over-media"; +import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits"; type UseSlideOverMediaStateArgs = { detailForRow?: WorkOrderBoardDetail; @@ -130,6 +131,9 @@ export function useSlideOverMediaState({ [detailForRow?.media, ownership, completionHasGallery], ); + // Per-work-order photo/video counts span both tabs, so count the whole session. + const mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]); + const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); const mediaPending = mediaBatchPending || @@ -196,6 +200,7 @@ export function useSlideOverMediaState({ return { completionMedia, extrasUploads, + mediaCounts, mediaPending, uploadMediaRequests, uploadExtraFiles, diff --git a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts index f9263dd1..78e95ac2 100644 --- a/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts +++ b/src/app/(protected)/workorders/_components/detail/use-work-order-slide-over.ts @@ -118,6 +118,7 @@ export function useWorkOrderSlideOver({ const { completionMedia, extrasUploads, + mediaCounts, mediaPending, uploadMediaRequests, uploadExtraFiles, @@ -174,6 +175,7 @@ export function useWorkOrderSlideOver({ ...readFlags, completionMedia, extrasUploads, + mediaCounts, mediaPending, completionPending, closeDisabled, diff --git a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index 7337ace1..12d015fa 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -125,6 +125,7 @@ export function WorkOrderSlideOverTabs({ isReadOnly={state.isDocumentsReadOnly} completion={state.detailForRow?.completion} media={state.completionMedia} + mediaCounts={state.mediaCounts} mediaPending={state.mediaPending} completionPending={state.completionPending} onUploadMedia={(requests) => { @@ -151,6 +152,7 @@ export function WorkOrderSlideOverTabs({ > void; readOnly?: boolean; pending?: boolean; + /** Photos/videos on the whole work order when `uploads` is only one surface's share. */ + mediaCounts?: WorkOrderMediaKindCounts; }; const UNSUPPORTED_TYPE_MESSAGE = "Unsupported file type. Photos: JPG, PNG, HEIC. Videos: MP4, MOV."; -const PHOTO_COUNT_MESSAGE = `A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`; -const VIDEO_COUNT_MESSAGE = `A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`; -const VIDEO_DURATION_MESSAGE = `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`; - -async function isWithinContract(file: File, kind: WorkOrderUploadKind): Promise { - if (kind === "video") { - // SH-116: pre-check only; the server enforces it too. Unreadable metadata never blocks. - const duration = await probeVideoDurationSeconds(file); - if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { - toast.error(VIDEO_DURATION_MESSAGE); - return false; - } - } - return true; -} export function MediaUploader({ uploads, @@ -65,6 +47,7 @@ export function MediaUploader({ onChange, readOnly = false, pending = false, + mediaCounts, }: MediaUploaderProps) { const inputRef = useRef(null); @@ -74,32 +57,12 @@ export function MediaUploader({ // Snapshot synchronously: the caller resets the input value right after this // handler returns, which can detach a live FileList across awaits. const selected = Array.from(files); - const counts = countWorkOrderMediaKinds(uploads); - const accepted: File[] = []; - for (const file of selected) { - if (!isWorkOrderMediaTypeAllowed(file)) { - toast.error(UNSUPPORTED_TYPE_MESSAGE); - continue; - } - const sizeRejection = workOrderFileSizeRejection(file); - if (sizeRejection) { - toast.error(sizeRejection); - continue; - } - const kind = resolveWorkOrderUploadKind(file); - if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) { - toast.error(PHOTO_COUNT_MESSAGE); - continue; - } - if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) { - toast.error(VIDEO_COUNT_MESSAGE); - continue; - } - if (!(await isWithinContract(file, kind))) continue; - if (kind === "photo") counts.photos += 1; - if (kind === "video") counts.videos += 1; - accepted.push(file); - } + const { accepted, rejections } = await screenWorkOrderUploads(selected, { + existing: mediaCounts ?? countWorkOrderMediaKinds(uploads), + isTypeAllowed: isWorkOrderMediaTypeAllowed, + unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE, + }); + for (const message of rejections) toast.error(message); if (accepted.length === 0) return; if (onUpload) { diff --git a/src/domain/work-orders/utils/screen-work-order-uploads.ts b/src/domain/work-orders/utils/screen-work-order-uploads.ts new file mode 100644 index 00000000..f91a48a1 --- /dev/null +++ b/src/domain/work-orders/utils/screen-work-order-uploads.ts @@ -0,0 +1,64 @@ +import { + MAX_PHOTOS_PER_WORK_ORDER, + MAX_VIDEOS_PER_WORK_ORDER, + MAX_VIDEO_DURATION_SECONDS, + resolveWorkOrderUploadKind, + workOrderFileSizeRejection, + type WorkOrderMediaKindCounts, +} from "@/domain/work-orders/utils/work-order-media-limits"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; + +export type WorkOrderUploadScreen = { + /** Photos and videos already on the work order, across every surface. */ + existing: WorkOrderMediaKindCounts; + isTypeAllowed: (file: File) => boolean; + unsupportedTypeMessage: string; +}; + +export type WorkOrderUploadScreenResult = { accepted: File[]; rejections: string[] }; + +/** + * SH-116 browser pre-check shared by every dispatcher upload surface: type, size, + * per-work-order photo/video count (including earlier files in the same selection) + * and video duration. The server enforces the same contract; a video whose duration + * the browser cannot read is never blocked here. + */ +export async function screenWorkOrderUploads( + files: readonly File[], + { existing, isTypeAllowed, unsupportedTypeMessage }: WorkOrderUploadScreen, +): Promise { + const counts = { ...existing }; + const accepted: File[] = []; + const rejections: string[] = []; + for (const file of files) { + if (!isTypeAllowed(file)) { + rejections.push(unsupportedTypeMessage); + continue; + } + const sizeRejection = workOrderFileSizeRejection(file); + if (sizeRejection) { + rejections.push(sizeRejection); + continue; + } + const kind = resolveWorkOrderUploadKind(file); + if (kind === "photo" && counts.photos >= MAX_PHOTOS_PER_WORK_ORDER) { + rejections.push(`A work order can have at most ${MAX_PHOTOS_PER_WORK_ORDER} photos.`); + continue; + } + if (kind === "video" && counts.videos >= MAX_VIDEOS_PER_WORK_ORDER) { + rejections.push(`A work order can have at most ${MAX_VIDEOS_PER_WORK_ORDER} videos.`); + continue; + } + if (kind === "video") { + const duration = await probeVideoDurationSeconds(file); + if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) { + rejections.push(`Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`); + continue; + } + } + if (kind === "photo") counts.photos += 1; + if (kind === "video") counts.videos += 1; + accepted.push(file); + } + return { accepted, rejections }; +} diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts index e19640d5..824477ee 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -135,11 +135,11 @@ export type WorkOrderMediaKindCounts = { photos: number; videos: number }; /** Counts the gallery items the server's per-work-order counts cover (non-legacy rows). */ export function countWorkOrderMediaKinds( - uploads: Array<{ fileType?: string; isLegacy?: boolean }>, + uploads: Array<{ fileType?: string; isLegacy?: boolean; uploadStatus?: string }>, ): WorkOrderMediaKindCounts { return uploads.reduce( (counts, item) => { - if (item.isLegacy) return counts; + if (item.isLegacy || item.uploadStatus === "failed") return counts; if (item.fileType === "image") counts.photos += 1; if (item.fileType === "video") counts.videos += 1; return counts; diff --git a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx index 20a71923..9aae2d73 100644 --- a/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx +++ b/src/test/app/(protected)/workorders/media-uploader-dropzone.test.tsx @@ -126,6 +126,23 @@ describe("MediaUploader media contract (Completion Doc → Media, Photos & Video expect(onUpload).not.toHaveBeenCalled(); }); + it("counts the whole work order's media when this tab shows only its share", async () => { + const onUpload = vi.fn(); + renderWithProviders( + , + ); + + selectFiles([ + phoneFile("IMG_0210.jpg", "image/jpeg", 2 * MB), + phoneFile("IMG_0211.MOV", "video/quicktime", 20 * MB), + ]); + + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(2)); + expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos."); + expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos."); + expect(onUpload).not.toHaveBeenCalled(); + }); + it("counts files within one selection against the remaining slots", async () => { const onUpload = vi.fn(); renderWithProviders(); diff --git a/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx b/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx new file mode 100644 index 00000000..d319d350 --- /dev/null +++ b/src/test/app/(protected)/workorders/slide-over-extras-upload-contract.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "react-toastify"; +import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab"; +import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; +import { renderWithProviders } from "@/test/test-utils"; + +vi.mock("react-toastify", () => ({ toast: { error: vi.fn() } })); +vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() })); + +const MB = 1_000_000; + +function phoneFile(name: string, type: string, size: number): File { + return { name, type, size } as File; +} + +function selectFiles(files: File[]) { + const input = document.querySelector('input[type="file"]'); + fireEvent.change(input as HTMLInputElement, { target: { files } }); +} + +describe("SlideOverExtrasTab SH-116 media contract", () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear(); + vi.mocked(probeVideoDurationSeconds).mockReset(); + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30); + }); + + it("rejects a video longer than 90 seconds before upload", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95); + const onUpload = vi.fn(); + renderWithProviders( + , + ); + + selectFiles([phoneFile("IMG_0700.MOV", "video/quicktime", 40 * MB)]); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."), + ); + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("rejects the 11th photo and 4th video counted across the whole work order", async () => { + const onUpload = vi.fn(); + const report = phoneFile("inspection.pdf", "application/pdf", 2 * MB); + renderWithProviders( + , + ); + + selectFiles([ + phoneFile("IMG_0701.jpg", "image/jpeg", 2 * MB), + phoneFile("IMG_0702.MOV", "video/quicktime", 20 * MB), + report, + ]); + + await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1)); + expect(onUpload).toHaveBeenCalledWith([report]); + expect(toast.error).toHaveBeenCalledWith("A work order can have at most 10 photos."); + expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos."); + }); + + it("accepts a ~60 MB phone video whose duration the browser cannot read", async () => { + vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null); + const onUpload = vi.fn(); + const video = phoneFile("IMG_0703.MOV", "", 60 * MB); + renderWithProviders( + , + ); + + selectFiles([video]); + + await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video])); + expect(toast.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts index 02ace4bb..e1892159 100644 --- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts +++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts @@ -68,13 +68,14 @@ describe("work-order-media-limits", () => { expect(formatMediaSizeLimit(MAX_WORK_ORDER_VIDEO_BYTES)).toBe("100 MB"); }); - it("counts only non-legacy photos and videos toward the per-work-order limits", () => { + it("counts only non-legacy, non-failed photos and videos toward the per-work-order limits", () => { expect( countWorkOrderMediaKinds([ { fileType: "image" }, { fileType: "image", isLegacy: true }, { fileType: "video" }, { fileType: "document" }, + { fileType: "video", uploadStatus: "failed" }, ]), ).toEqual({ photos: 1, videos: 1 }); });