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