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..7908c1f8 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
@@ -15,6 +15,13 @@ type SlideOverCompletionMediaProps = {
onRemoveMedia?: (id: string) => void;
onRetryMedia?: (localId: string) => void;
onCancelMedia?: (localId: string) => void;
+ uploadDisabled?: boolean;
+ uploadUnavailableMessage?: string;
+ typeAllowed?: (file: File) => boolean;
+ typeErrorMessage?: string;
+ acceptAttribute?: string;
+ fileSizeError?: (file: File) => string | undefined;
+ uploadHint?: string;
};
export function SlideOverCompletionMedia({
@@ -26,6 +33,13 @@ export function SlideOverCompletionMedia({
onRemoveMedia,
onRetryMedia,
onCancelMedia,
+ uploadDisabled = false,
+ uploadUnavailableMessage,
+ typeAllowed,
+ typeErrorMessage,
+ acceptAttribute,
+ fileSizeError,
+ uploadHint,
}: SlideOverCompletionMediaProps) {
return (
@@ -45,8 +59,19 @@ export function SlideOverCompletionMedia({
onCancel={onCancelMedia}
readOnly={isReadOnly}
pending={mediaPending}
+ uploadDisabled={uploadDisabled}
+ typeAllowed={typeAllowed}
+ typeErrorMessage={typeErrorMessage}
+ acceptAttribute={acceptAttribute}
+ fileSizeError={fileSizeError}
+ uploadHint={uploadHint}
/>
+ {uploadDisabled && Boolean(uploadUnavailableMessage) && (
+
+ {uploadUnavailableMessage}
+
+ )}
);
}
diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx
index f88e3867..ef631d45 100644
--- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx
+++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab.tsx
@@ -30,6 +30,13 @@ type SlideOverCompletionTabProps = {
onRemoveMedia?: (id: string) => void;
onRetryMedia?: (localId: string) => void;
onCancelMedia?: (localId: string) => void;
+ uploadDisabled?: boolean;
+ uploadUnavailableMessage?: string;
+ typeAllowed?: (file: File) => boolean;
+ typeErrorMessage?: string;
+ acceptAttribute?: string;
+ fileSizeError?: (file: File) => string | undefined;
+ uploadHint?: string;
onUploadCompletionPdf?: (file: File) => void;
onOpenCompletionDoc: () => void;
};
@@ -46,6 +53,13 @@ export function SlideOverCompletionTab({
onRemoveMedia,
onRetryMedia,
onCancelMedia,
+ uploadDisabled,
+ uploadUnavailableMessage,
+ typeAllowed,
+ typeErrorMessage,
+ acceptAttribute,
+ fileSizeError,
+ uploadHint,
onUploadCompletionPdf,
onOpenCompletionDoc,
}: SlideOverCompletionTabProps) {
@@ -65,6 +79,13 @@ export function SlideOverCompletionTab({
onRemoveMedia={onRemoveMedia}
onRetryMedia={onRetryMedia}
onCancelMedia={onCancelMedia}
+ uploadDisabled={uploadDisabled}
+ uploadUnavailableMessage={uploadUnavailableMessage}
+ typeAllowed={typeAllowed}
+ typeErrorMessage={typeErrorMessage}
+ acceptAttribute={acceptAttribute}
+ fileSizeError={fileSizeError}
+ uploadHint={uploadHint}
/>
);
}
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 7352fefb..de94ee7d 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
@@ -5,6 +5,7 @@ import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-m
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type {
useDeleteWorkOrderMedia,
useUpdateWorkOrderMediaCategory,
@@ -19,10 +20,16 @@ import {
isExtrasSurfaceMedia,
type MediaSurfaceOwnership,
} from "@/domain/work-orders/utils/partition-slide-over-media";
-import { isEmergencyReactiveWorkOrderType } from "@/domain/work-orders/utils/emergency-media-limits";
+import {
+ classifyMediaKind,
+ emergencyMediaAcceptAttribute,
+ emergencyMediaSizeError,
+ isEmergencyReactiveWorkOrderType,
+} from "@/domain/work-orders/utils/emergency-media-limits";
type UseSlideOverMediaStateArgs = {
detailForRow?: WorkOrderBoardDetail;
+ row?: WorkOrderTableRow | null;
uploadMediaMutation: ReturnType;
deleteMediaMutation: ReturnType;
categorizeMediaMutation: ReturnType;
@@ -79,22 +86,40 @@ function saveOwnership(
export function useSlideOverMediaState({
detailForRow,
+ row,
uploadMediaMutation,
deleteMediaMutation,
categorizeMediaMutation,
}: UseSlideOverMediaStateArgs) {
- const workOrderId = detailForRow?.info.id;
- const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type);
- const { data: mediaPolicy } = useWorkOrderMediaPolicy(workOrderId, workOrderId != null);
+ const workOrderId = detailForRow?.info.id ?? row?.id;
+ const workOrderType = detailForRow?.info.type ?? row?.type;
+ const {
+ data: mediaPolicy,
+ isError: mediaPolicyError,
+ isLoading: mediaPolicyLoading,
+ } = useWorkOrderMediaPolicy(workOrderId, workOrderId != null);
const resumableCompletionUpload = useResumableWorkOrderMediaUpload(
workOrderId ?? "",
"CompletionMedia",
mediaPolicy ?? undefined,
);
- const policyCanUpload = mediaPolicy?.canUpload === true;
+ const policyCanUpload = mediaPolicy?.canUpload === true && !mediaPolicyError;
+ const completionHasGallery =
+ hasCompletionMediaGallery(detailForRow?.info.type) ||
+ hasCompletionMediaGallery(mediaPolicy?.workOrderType) ||
+ hasCompletionMediaGallery(row?.type);
const emergencyMedia =
isEmergencyReactiveWorkOrderType(mediaPolicy?.workOrderType) ||
- isEmergencyReactiveWorkOrderType(detailForRow?.info.type);
+ isEmergencyReactiveWorkOrderType(workOrderType);
+ const emergencyMediaUnavailable = emergencyMedia && !policyCanUpload;
+ const emergencyMediaUnavailableMessage =
+ mediaPolicy?.canUpload === false
+ ? "Uploads are not available for this work order."
+ : mediaPolicyError
+ ? "Upload permissions could not be loaded. Refresh and try again."
+ : mediaPolicyLoading || !mediaPolicy
+ ? "Loading upload permissions…"
+ : "Uploads are not available for this work order.";
const [mediaBatchPending, setMediaBatchPending] = useState(false);
const [ownership, setOwnershipState] = useState(() =>
loadOwnership(workOrderId),
@@ -235,6 +260,22 @@ export function useSlideOverMediaState({
completionMedia,
extrasUploads,
mediaPending,
+ emergencyMedia,
+ emergencyMediaUnavailable,
+ emergencyMediaUnavailableMessage,
+ emergencyMediaTypeAllowed: emergencyMedia
+ ? (file: File) => classifyMediaKind(file) !== null
+ : undefined,
+ emergencyMediaTypeErrorMessage: emergencyMedia
+ ? "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported."
+ : undefined,
+ emergencyMediaAcceptAttribute: emergencyMedia ? emergencyMediaAcceptAttribute() : undefined,
+ emergencyMediaSizeError: emergencyMedia
+ ? (file: File) => emergencyMediaSizeError(file, mediaPolicy?.limits)
+ : undefined,
+ emergencyMediaUploadHint: emergencyMedia
+ ? "Photos up to 10 MB · Videos up to 100 MB and 90 seconds"
+ : undefined,
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,
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..37792f32 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
@@ -119,6 +119,13 @@ export function useWorkOrderSlideOver({
completionMedia,
extrasUploads,
mediaPending,
+ emergencyMediaUnavailable,
+ emergencyMediaUnavailableMessage,
+ emergencyMediaTypeAllowed,
+ emergencyMediaTypeErrorMessage,
+ emergencyMediaAcceptAttribute,
+ emergencyMediaSizeError,
+ emergencyMediaUploadHint,
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,
@@ -127,6 +134,7 @@ export function useWorkOrderSlideOver({
removeMedia,
} = useSlideOverMediaState({
detailForRow,
+ row,
uploadMediaMutation,
deleteMediaMutation,
categorizeMediaMutation,
@@ -175,6 +183,13 @@ export function useWorkOrderSlideOver({
completionMedia,
extrasUploads,
mediaPending,
+ emergencyMediaUnavailable,
+ emergencyMediaUnavailableMessage,
+ emergencyMediaTypeAllowed,
+ emergencyMediaTypeErrorMessage,
+ emergencyMediaAcceptAttribute,
+ emergencyMediaSizeError,
+ emergencyMediaUploadHint,
completionPending,
closeDisabled,
uploadMediaRequests,
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..54513fc2 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
@@ -126,6 +126,13 @@ export function WorkOrderSlideOverTabs({
completion={state.detailForRow?.completion}
media={state.completionMedia}
mediaPending={state.mediaPending}
+ uploadDisabled={state.emergencyMediaUnavailable}
+ uploadUnavailableMessage={state.emergencyMediaUnavailableMessage}
+ typeAllowed={state.emergencyMediaTypeAllowed}
+ typeErrorMessage={state.emergencyMediaTypeErrorMessage}
+ acceptAttribute={state.emergencyMediaAcceptAttribute}
+ fileSizeError={state.emergencyMediaSizeError}
+ uploadHint={state.emergencyMediaUploadHint}
completionPending={state.completionPending}
onUploadMedia={(requests) => {
state.uploadMediaRequests(requests);
diff --git a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx
index 5dd4f65f..58e1e722 100644
--- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx
+++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx
@@ -19,6 +19,7 @@ import {
import {
classifyMediaKind,
emergencyMediaAcceptAttribute,
+ emergencyMediaSizeError,
isEmergencyReactiveWorkOrderType,
} from "@/domain/work-orders/utils/emergency-media-limits";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
@@ -40,19 +41,24 @@ function resolveWorkOrderVersion(
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
- const { data: policy } = useWorkOrderMediaPolicy(row.id, open);
+ const {
+ data: policy,
+ isError: policyError,
+ isLoading: policyLoading,
+ } = useWorkOrderMediaPolicy(row.id, open);
const legacyUploadMutation = useUploadWorkOrderMedia(row.id);
const resumableUploadMutation = useResumableWorkOrderMediaUpload(
row.id,
"WorkOrderTable",
- policy ?? undefined,
+ policyError ? undefined : (policy ?? undefined),
);
const deleteMutation = useDeleteWorkOrderMedia(row.id);
const isEmergency =
+ isEmergencyReactiveWorkOrderType(detail?.info.type) ||
isEmergencyReactiveWorkOrderType(policy?.workOrderType) ||
isEmergencyReactiveWorkOrderType(row.type);
const uploadMutation = isEmergency ? resumableUploadMutation : legacyUploadMutation;
- const emergencyUploadUnavailable = isEmergency && policy?.canUpload !== true;
+ const emergencyUploadUnavailable = isEmergency && (policy?.canUpload !== true || policyError);
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
@@ -124,6 +130,12 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
onCancel={cancelUpload}
readOnly={isReadOnly}
uploadDisabled={emergencyUploadUnavailable}
+ fileSizeError={
+ isEmergency ? (file) => emergencyMediaSizeError(file, policy?.limits) : undefined
+ }
+ uploadHint={
+ isEmergency ? "Photos up to 10 MB · Videos up to 100 MB and 90 seconds" : undefined
+ }
pending={pending}
typeAllowed={isEmergency ? (file) => classifyMediaKind(file) !== null : undefined}
typeErrorMessage={
@@ -137,9 +149,13 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
{emergencyUploadUnavailable && !isReadOnly && (
- {policy
+ {policy?.canUpload === false
? "Uploads are not available for this work order."
- : "Loading upload permissions…"}
+ : policyError
+ ? "Upload permissions could not be loaded. Refresh and try again."
+ : policyLoading || !policy
+ ? "Loading upload permissions…"
+ : "Uploads are not available for this work order."}
)}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx
index 21c57a61..1827d2b1 100644
--- a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx
@@ -8,6 +8,8 @@ import {
type MediaUploaderDropzoneProps = {
inputRef: RefObject;
pending: boolean;
+ disabled?: boolean;
+ helperText?: string;
categorizeAfterUpload?: boolean;
acceptAttribute?: string;
onFilesSelected: (files: FileList | null) => void;
@@ -16,6 +18,8 @@ type MediaUploaderDropzoneProps = {
export function MediaUploaderDropzone({
inputRef,
pending,
+ disabled = false,
+ helperText,
categorizeAfterUpload = false,
acceptAttribute,
onFilesSelected,
@@ -24,7 +28,7 @@ export function MediaUploaderDropzone({
<>
inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
@@ -43,7 +47,8 @@ export function MediaUploaderDropzone({
{pending ? "Uploading…" : "Drag files here or click to browse"}
- Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
+ {helperText ??
+ `Accepted: JPG, PNG, MP4, MOV · Max ${formatMediaSizeLimit().replace(" ", "")} per file`}
{categorizeAfterUpload ? " · categorize after upload" : ""}
@@ -53,7 +58,7 @@ export function MediaUploaderDropzone({
accept={acceptAttribute ?? workOrderMediaAcceptAttribute()}
multiple
hidden
- disabled={pending}
+ disabled={pending || disabled}
onChange={(e) => {
onFilesSelected(e.target.files);
e.target.value = "";
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx
index 3221a2a0..187d464c 100644
--- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx
+++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx
@@ -36,6 +36,8 @@ type MediaUploaderProps = {
typeAllowed?: (file: File) => boolean;
typeErrorMessage?: string;
acceptAttribute?: string;
+ fileSizeError?: (file: File) => string | undefined;
+ uploadHint?: string;
};
export function MediaUploader({
@@ -52,6 +54,8 @@ export function MediaUploader({
typeAllowed,
typeErrorMessage = "Unsupported file type.",
acceptAttribute,
+ fileSizeError,
+ uploadHint,
}: MediaUploaderProps) {
const inputRef = useRef(null);
@@ -65,6 +69,11 @@ export function MediaUploader({
toast.error(`${file.name} — ${typeErrorMessage}`);
continue;
}
+ const sizeError = fileSizeError?.(file);
+ if (sizeError) {
+ toast.error(sizeError);
+ continue;
+ }
if (isWorkOrderFileTooLarge(file)) {
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
continue;
@@ -104,7 +113,9 @@ export function MediaUploader({
{!readOnly && (
): MediaUploadLimits {
};
}
+export function emergencyMediaSizeError(
+ file: File,
+ limits?: Partial,
+): string | undefined {
+ const kind = classifyMediaKind(file);
+ if (!kind) return undefined;
+ const resolvedLimits = defaultLimits(limits);
+ return isMediaSizeWithinLimit(file, kind, resolvedLimits)
+ ? undefined
+ : mediaOverLimitNotice(file, kind, resolvedLimits);
+}
+
/**
* Validate one media file against the policy limits: kind, inclusive size caps and
* duration when it can be probed. A failed duration probe keeps the file eligible.
diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
index 01db34cd..c9d7b4d4 100644
--- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
+++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
@@ -1,17 +1,41 @@
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 { toast } from "react-toastify";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const uploadMutateAsync = vi.fn();
+const resumableUploadMutateAsync = vi.fn();
+const resumableUploadHookSpy = vi.hoisted(() => vi.fn());
const deleteMutate = vi.fn();
const categorizeMutate = vi.fn();
+const mediaQueryState = vi.hoisted(() => ({
+ detailType: "Emergency" as string | undefined,
+ policy: {
+ workOrderType: "Emergency",
+ canUpload: false,
+ limits: {
+ photoMaxBytes: 10_000_000,
+ videoMaxBytes: 100_000_000,
+ videoMaxSeconds: 90,
+ maxPhotos: 10,
+ maxVideos: 3,
+ },
+ remaining: { photos: 10, videos: 3 },
+ } as unknown,
+ isError: false,
+ isLoading: false,
+}));
+
+vi.mock("react-toastify", () => ({
+ toast: { error: vi.fn(), success: vi.fn() },
+}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({
data: {
- info: { status: "Scheduled", rowVersion: "DETAIL-VER==" },
+ info: { status: "Scheduled", rowVersion: "DETAIL-VER==", type: mediaQueryState.detailType },
media: [
{
id: "11",
@@ -41,31 +65,25 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
useWorkOrderMediaPolicy: () => ({
- data: {
- workOrderType: "Emergency",
- canUpload: false,
- limits: {
- photoMaxBytes: 10_000_000,
- videoMaxBytes: 100_000_000,
- videoMaxSeconds: 90,
- maxPhotos: 10,
- maxVideos: 3,
- },
- remaining: { photos: 10, videos: 3 },
- },
+ data: mediaQueryState.policy,
+ isError: mediaQueryState.isError,
+ isLoading: mediaQueryState.isLoading,
}),
}));
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
- useResumableWorkOrderMediaUpload: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useResumableWorkOrderMediaUpload: (...args: unknown[]) => {
+ resumableUploadHookSpy(...args);
+ return { mutateAsync: resumableUploadMutateAsync, isPending: false };
+ },
}));
-function baseRow(): WorkOrderTableRow {
+function baseRow(type = "Emergency"): WorkOrderTableRow {
return {
id: 7,
woNumber: "WO-7",
site: "SEA1",
- type: "Emergency",
+ type,
severity: 1,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
@@ -111,14 +129,14 @@ function baseRow(): WorkOrderTableRow {
};
}
-function renderModal() {
+function renderModal(rowType = "Emergency") {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
-
+
,
);
}
@@ -126,8 +144,26 @@ function renderModal() {
describe("MediaUploadModal API wiring", () => {
beforeEach(() => {
uploadMutateAsync.mockReset();
+ resumableUploadMutateAsync.mockReset();
+ resumableUploadHookSpy.mockClear();
deleteMutate.mockReset();
categorizeMutate.mockReset();
+ mediaQueryState.detailType = "Emergency";
+ mediaQueryState.policy = {
+ workOrderType: "Emergency",
+ canUpload: false,
+ limits: {
+ photoMaxBytes: 10_000_000,
+ videoMaxBytes: 100_000_000,
+ videoMaxSeconds: 90,
+ maxPhotos: 10,
+ maxVideos: 3,
+ },
+ remaining: { photos: 10, videos: 3 },
+ };
+ mediaQueryState.isError = false;
+ mediaQueryState.isLoading = false;
+ vi.mocked(toast.error).mockClear();
});
it("renders persisted media from board detail (refresh-safe)", async () => {
@@ -138,7 +174,7 @@ describe("MediaUploadModal API wiring", () => {
});
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
expect(
- screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
+ screen.getByText("Photos up to 10 MB · Videos up to 100 MB and 90 seconds"),
).toBeInTheDocument();
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
});
@@ -184,12 +220,72 @@ describe("MediaUploadModal API wiring", () => {
it("fails closed when Emergency policy says uploads are unavailable", async () => {
renderModal();
- const input = document.querySelector('input[type="file"]') as HTMLInputElement;
- expect(input.disabled).toBe(true);
+ expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true);
expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument();
+ expect(uploadMutateAsync).not.toHaveBeenCalled();
+ expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
+ expect(resumableUploadHookSpy).toHaveBeenCalledWith(7, "WorkOrderTable", expect.anything());
+ });
+
+ it("routes by the detailed work-order type when policy denies uploads", () => {
+ mediaQueryState.detailType = "Emergency";
+ mediaQueryState.policy = {
+ workOrderType: "Emergency",
+ canUpload: false,
+ limits: {
+ photoMaxBytes: 10_000_000,
+ videoMaxBytes: 100_000_000,
+ videoMaxSeconds: 90,
+ maxPhotos: 10,
+ maxVideos: 3,
+ },
+ remaining: { photos: 10, videos: 3 },
+ };
+ renderModal("PM");
+
+ expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true);
+ expect(screen.getByText("Uploads are not available for this work order.")).toBeInTheDocument();
+ expect(resumableUploadHookSpy).toHaveBeenCalledWith(7, "WorkOrderTable", expect.anything());
+ expect(uploadMutateAsync).not.toHaveBeenCalled();
+ expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
+ });
+
+ it("fails closed with a visible error when the Emergency policy request fails", () => {
+ mediaQueryState.policy = undefined;
+ mediaQueryState.isError = true;
+ renderModal("PM");
+
+ expect((document.querySelector('input[type="file"]') as HTMLInputElement).disabled).toBe(true);
+ expect(
+ screen.getByText("Upload permissions could not be loaded. Refresh and try again."),
+ ).toBeInTheDocument();
+ expect(uploadMutateAsync).not.toHaveBeenCalled();
+ expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
+ });
+
+ it("shows the Emergency photo cap notice before starting an upload", () => {
+ mediaQueryState.policy = {
+ workOrderType: "Emergency",
+ canUpload: true,
+ limits: {
+ photoMaxBytes: 10_000_000,
+ videoMaxBytes: 100_000_000,
+ videoMaxSeconds: 90,
+ maxPhotos: 10,
+ maxVideos: 3,
+ },
+ remaining: { photos: 10, videos: 3 },
+ };
+ renderModal();
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, {
- target: { files: [new File(["x"], "photo.jpg", { type: "image/jpeg" })] },
+ target: {
+ files: [new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" })],
+ },
});
+
+ expect(toast.error).toHaveBeenCalledWith('"photo.jpg" exceeds the photo limit (10 MB).');
+ expect(resumableUploadMutateAsync).not.toHaveBeenCalled();
expect(uploadMutateAsync).not.toHaveBeenCalled();
});
});
diff --git a/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx b/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx
index d87162fe..4f91f046 100644
--- a/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx
+++ b/src/test/app/(protected)/workorders/slide-over-completion-service-gate.test.tsx
@@ -1,8 +1,13 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
+import { toast } from "react-toastify";
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+vi.mock("react-toastify", () => ({
+ toast: { error: vi.fn(), success: vi.fn() },
+}));
+
function renderTab(pm: string, onOpenCompletionDoc = vi.fn()) {
const row = { id: "1", type: "PM", pm, docStatus: "No", rowVersion: "1" } as WorkOrderTableRow;
render(
@@ -43,4 +48,70 @@ describe("SlideOverCompletionTab service gate", () => {
fireEvent.click(button);
expect(onOpen).toHaveBeenCalledTimes(1);
});
+
+ it("renders disabled Emergency media upload with the policy failure message", () => {
+ const onUpload = vi.fn();
+ const row = {
+ id: "1",
+ type: "Emergency",
+ docStatus: "No",
+ rowVersion: "1",
+ } as WorkOrderTableRow;
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Upload permissions could not be loaded. Refresh and try again.",
+ );
+ expect(document.querySelector('input[type="file"]')).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: /drag files here or click to browse/i }),
+ ).toBeDisabled();
+ expect(onUpload).not.toHaveBeenCalled();
+ });
+
+ it("shows per-kind Emergency limits before starting an oversized photo upload", () => {
+ const onUpload = vi.fn();
+ const row = {
+ id: "1",
+ type: "Emergency",
+ docStatus: "No",
+ rowVersion: "1",
+ } as WorkOrderTableRow;
+ render(
+ true}
+ fileSizeError={(file) =>
+ file.size > 10_000_000 ? `"${file.name}" exceeds the photo limit (10 MB).` : undefined
+ }
+ uploadHint="Photos up to 10 MB · Videos up to 100 MB and 90 seconds"
+ onUploadMedia={onUpload}
+ onOpenCompletionDoc={vi.fn()}
+ />,
+ );
+
+ expect(
+ screen.getByText("Photos up to 10 MB · Videos up to 100 MB and 90 seconds"),
+ ).toBeInTheDocument();
+ fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
+ target: {
+ files: [new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" })],
+ },
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('"photo.jpg" exceeds the photo limit (10 MB).');
+ expect(onUpload).not.toHaveBeenCalled();
+ });
});
diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
index 9b9669f7..eb2a34b4 100644
--- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
+++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx
@@ -4,8 +4,14 @@ import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+const policyState = vi.hoisted(() => ({
+ data: undefined as unknown,
+ isError: false,
+ isLoading: true,
+}));
+
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
- useWorkOrderMediaPolicy: () => ({ data: undefined }),
+ useWorkOrderMediaPolicy: () => policyState,
}));
vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload", () => ({
@@ -98,6 +104,9 @@ function idleMutation() {
describe("useSlideOverMediaState completion filter", () => {
beforeEach(() => {
sessionStorage.clear();
+ policyState.data = undefined;
+ policyState.isError = false;
+ policyState.isLoading = true;
});
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
@@ -368,6 +377,87 @@ describe("useSlideOverMediaState completion filter", () => {
});
});
+ it("keeps the Emergency completion surface disabled with a visible policy error", async () => {
+ policyState.isError = true;
+ policyState.isLoading = false;
+ const legacyUpload = idleMutation();
+ const { result } = renderHook(() =>
+ useSlideOverMediaState({
+ detailForRow: detailWithMedia([], { type: "Emergency" }),
+ uploadMediaMutation: legacyUpload,
+ deleteMediaMutation: idleMutation(),
+ categorizeMediaMutation: idleMutation(),
+ }),
+ );
+
+ expect(result.current.emergencyMediaUnavailable).toBe(true);
+ expect(result.current.emergencyMediaUnavailableMessage).toBe(
+ "Upload permissions could not be loaded. Refresh and try again.",
+ );
+ act(() => {
+ result.current.uploadMediaRequests([
+ {
+ localId: "local-policy-error",
+ file: new File(["x"], "photo.jpg", { type: "image/jpeg" }),
+ onProgress: vi.fn(),
+ },
+ ]);
+ });
+
+ await waitFor(() => {
+ expect(legacyUpload.mutateAsync).not.toHaveBeenCalled();
+ expect(result.current.completionMedia[0]?.uploadStatus).toBe("failed");
+ });
+ });
+
+ it("uses the table row type to fail closed while work-order detail is loading", () => {
+ const { result } = renderHook(() =>
+ useSlideOverMediaState({
+ row: baseInfo({ type: "Reactive" }),
+ uploadMediaMutation: idleMutation(),
+ deleteMediaMutation: idleMutation(),
+ categorizeMediaMutation: idleMutation(),
+ }),
+ );
+
+ expect(result.current.emergencyMediaUnavailable).toBe(true);
+ expect(
+ result.current.emergencyMediaTypeAllowed?.(
+ new File(["x"], "clip.mov", { type: "video/quicktime" }),
+ ),
+ ).toBe(true);
+ });
+
+ it("exposes server-derived Emergency size caps to the slide-over uploader", () => {
+ policyState.data = {
+ workOrderType: "Reactive",
+ canUpload: true,
+ limits: {
+ photoMaxBytes: 10_000_000,
+ videoMaxBytes: 100_000_000,
+ videoMaxSeconds: 90,
+ maxPhotos: 10,
+ maxVideos: 3,
+ },
+ remaining: { photos: 10, videos: 3 },
+ };
+ policyState.isLoading = false;
+ const { result } = renderHook(() =>
+ useSlideOverMediaState({
+ detailForRow: detailWithMedia([], { type: "Reactive" }),
+ uploadMediaMutation: idleMutation(),
+ deleteMediaMutation: idleMutation(),
+ categorizeMediaMutation: idleMutation(),
+ }),
+ );
+
+ expect(
+ result.current.emergencyMediaSizeError?.(
+ new File([new Uint8Array(10_000_001)], "photo.jpg", { type: "image/jpeg" }),
+ ),
+ ).toBe('"photo.jpg" exceeds the photo limit (10 MB).');
+ });
+
it("reuses the same idempotency key when a failed media tile is retried", async () => {
const mutateAsync = vi
.fn()