mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
Merge pull request #260 from Sea-Haven-Industries/fix/ab/sh-383-media-contract
fix(media): apply the SH-116 media contract to every upload surface
This commit is contained in:
commit
9f46a91cb9
32 changed files with 1351 additions and 150 deletions
2
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
2
e2e/vendors/vendor-uplift-workflow.spec.ts
vendored
|
|
@ -97,7 +97,7 @@ test("vendor uploads scanned evidence and submits a dispatch-scoped uplift", asy
|
|||
|
||||
await page.getByLabel("New total NTE").fill("125");
|
||||
await page.getByLabel("Reason").fill("Replacement compressor");
|
||||
await page.getByLabel("Evidence (PDF/JPG/PNG)").setInputFiles({
|
||||
await page.getByLabel("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)").setInputFiles({
|
||||
name: "estimate.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("%PDF-1.4\nmock estimate"),
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
|
||||
<MediaUploader
|
||||
uploads={media}
|
||||
mediaCounts={mediaCounts}
|
||||
onUpload={onUploadMedia}
|
||||
onCategorize={onCategorizeMedia}
|
||||
onRemove={onRemoveMedia}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
|
||||
type WorkOrderMediaKindCounts,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { SlideOverCompletionActions } from "./slide-over-completion-actions";
|
||||
import { SlideOverCompletionMedia } from "./slide-over-completion-media";
|
||||
|
|
@ -23,6 +24,7 @@ type SlideOverCompletionTabProps = {
|
|||
isReadOnly: boolean;
|
||||
completion?: WorkOrderBoardCompletion;
|
||||
media: MediaFile[];
|
||||
mediaCounts?: WorkOrderMediaKindCounts;
|
||||
mediaPending?: boolean;
|
||||
completionPending?: boolean;
|
||||
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
|
||||
|
|
@ -39,6 +41,7 @@ export function SlideOverCompletionTab({
|
|||
isReadOnly,
|
||||
completion,
|
||||
media,
|
||||
mediaCounts,
|
||||
mediaPending = false,
|
||||
completionPending = false,
|
||||
onUploadMedia,
|
||||
|
|
@ -59,6 +62,7 @@ export function SlideOverCompletionTab({
|
|||
<SlideOverCompletionMedia
|
||||
isReadOnly={isReadOnly}
|
||||
media={media}
|
||||
mediaCounts={mediaCounts}
|
||||
mediaPending={mediaPending}
|
||||
onUploadMedia={onUploadMedia}
|
||||
onCategorizeMedia={onCategorizeMedia}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
|
|
@ -18,12 +18,16 @@ import {
|
|||
hasExplicitAvetaFile,
|
||||
resolveExtraDocCategory,
|
||||
} from "@/domain/work-orders/utils/extra-docs-aveta";
|
||||
import {
|
||||
screenWorkOrderUploads,
|
||||
type WorkOrderUploadScreenResult,
|
||||
} from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
countWorkOrderMediaKinds,
|
||||
isWorkOrderExtraDocTypeAllowed,
|
||||
isWorkOrderFileTooLarge,
|
||||
workOrderExtraDocsAcceptAttribute,
|
||||
type WorkOrderMediaKindCounts,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
|
|
@ -53,11 +57,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<void> {
|
||||
const tab = window.open("about:blank", "_blank");
|
||||
if (!tab) {
|
||||
|
|
@ -176,27 +185,38 @@ export function SlideOverExtrasTab({
|
|||
pending = false,
|
||||
avetaRequired = false,
|
||||
workOrderId,
|
||||
mediaCounts,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
}: SlideOverExtrasTabProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const avetaAttached = hasExplicitAvetaFile(uploads);
|
||||
// One selection at a time: a second pick during the async screen would read the
|
||||
// same photo/video counts and could pass the per-work-order limit.
|
||||
const screeningRef = useRef(false);
|
||||
const [screening, setScreening] = useState(false);
|
||||
const busy = pending || screening;
|
||||
|
||||
const addFiles = (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(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||
continue;
|
||||
}
|
||||
accepted.push(file);
|
||||
const addFiles = async (files: FileList | null) => {
|
||||
if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return;
|
||||
// Snapshot synchronously: the input is reset right after this handler returns.
|
||||
const selected = Array.from(files);
|
||||
screeningRef.current = true;
|
||||
setScreening(true);
|
||||
let screened: WorkOrderUploadScreenResult;
|
||||
try {
|
||||
screened = await screenWorkOrderUploads(selected, {
|
||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||
isTypeAllowed: isWorkOrderExtraDocTypeAllowed,
|
||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||
});
|
||||
} finally {
|
||||
screeningRef.current = false;
|
||||
setScreening(false);
|
||||
}
|
||||
const { accepted, rejections } = screened;
|
||||
for (const message of rejections) toast.error(message);
|
||||
if (accepted.length > 0) {
|
||||
onUpload(accepted);
|
||||
}
|
||||
|
|
@ -228,12 +248,12 @@ export function SlideOverExtrasTab({
|
|||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
disabled={busy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
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={{
|
||||
|
|
@ -245,13 +265,13 @@ export function SlideOverExtrasTab({
|
|||
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||
<span
|
||||
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
|
||||
aria-busy={pending || undefined}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
{busy ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "}
|
||||
per file
|
||||
Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100
|
||||
MB and 90 s · Docs up to 50 MB
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
|
|
@ -260,9 +280,9 @@ export function SlideOverExtrasTab({
|
|||
accept={workOrderExtraDocsAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
addFiles(event.target.files);
|
||||
void addFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
>
|
||||
<SlideOverExtrasTab
|
||||
uploads={state.extrasUploads}
|
||||
mediaCounts={state.mediaCounts}
|
||||
workOrderId={infoSource?.id}
|
||||
avetaRequired={resolveSlideOverAvetaRequired(
|
||||
state.editing,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import type { RefObject } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
import {
|
||||
formatMediaSizeLimit,
|
||||
MAX_PHOTOS_PER_WORK_ORDER,
|
||||
MAX_VIDEOS_PER_WORK_ORDER,
|
||||
MAX_VIDEO_DURATION_SECONDS,
|
||||
MAX_WORK_ORDER_PHOTO_BYTES,
|
||||
MAX_WORK_ORDER_VIDEO_BYTES,
|
||||
workOrderMediaAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
|
|
@ -12,6 +16,12 @@ type MediaUploaderDropzoneProps = {
|
|||
onFilesSelected: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
function mediaContractHint(): string {
|
||||
const photoLimit = Math.round(MAX_WORK_ORDER_PHOTO_BYTES / 1_000_000);
|
||||
const videoLimit = Math.round(MAX_WORK_ORDER_VIDEO_BYTES / 1_000_000);
|
||||
return `Photos up to ${photoLimit} MB (max ${MAX_PHOTOS_PER_WORK_ORDER}) · Videos up to ${videoLimit} MB and ${MAX_VIDEO_DURATION_SECONDS} s (max ${MAX_VIDEOS_PER_WORK_ORDER})`;
|
||||
}
|
||||
|
||||
export function MediaUploaderDropzone({
|
||||
inputRef,
|
||||
pending,
|
||||
|
|
@ -41,7 +51,7 @@ 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().replace(" ", "")} per file
|
||||
Accepted: JPG, PNG, HEIC, MP4, MOV · {mediaContractHint()}
|
||||
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
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 {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
countWorkOrderMediaKinds,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
type WorkOrderMediaKindCounts,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
import {
|
||||
screenWorkOrderUploads,
|
||||
type WorkOrderUploadScreenResult,
|
||||
} from "@/domain/work-orders/utils/screen-work-order-uploads";
|
||||
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
|
||||
import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||
|
||||
|
|
@ -30,8 +34,12 @@ type MediaUploaderProps = {
|
|||
onChange?: (next: MediaFile[]) => 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.";
|
||||
|
||||
export function MediaUploader({
|
||||
uploads,
|
||||
onUpload,
|
||||
|
|
@ -42,24 +50,35 @@ export function MediaUploader({
|
|||
onChange,
|
||||
readOnly = false,
|
||||
pending = false,
|
||||
mediaCounts,
|
||||
}: MediaUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// One selection at a time: a second pick during the async screen would read the
|
||||
// same photo/video counts and could pass the per-work-order limit.
|
||||
const screeningRef = useRef(false);
|
||||
const [screening, setScreening] = useState(false);
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (readOnly || pending || !files || files.length === 0) return;
|
||||
const addFiles = async (files: FileList | null) => {
|
||||
if (readOnly || pending || screeningRef.current || !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;
|
||||
}
|
||||
accepted.push(file);
|
||||
// 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);
|
||||
screeningRef.current = true;
|
||||
setScreening(true);
|
||||
let screened: WorkOrderUploadScreenResult;
|
||||
try {
|
||||
screened = await screenWorkOrderUploads(selected, {
|
||||
existing: mediaCounts ?? countWorkOrderMediaKinds(uploads),
|
||||
isTypeAllowed: isWorkOrderMediaTypeAllowed,
|
||||
unsupportedTypeMessage: UNSUPPORTED_TYPE_MESSAGE,
|
||||
});
|
||||
} finally {
|
||||
screeningRef.current = false;
|
||||
setScreening(false);
|
||||
}
|
||||
const { accepted, rejections } = screened;
|
||||
for (const message of rejections) toast.error(message);
|
||||
if (accepted.length === 0) return;
|
||||
|
||||
if (onUpload) {
|
||||
|
|
@ -93,9 +112,9 @@ export function MediaUploader({
|
|||
{!readOnly && (
|
||||
<MediaUploaderDropzone
|
||||
inputRef={inputRef}
|
||||
pending={pending}
|
||||
pending={pending || screening}
|
||||
categorizeAfterUpload={Boolean(onCategorize)}
|
||||
onFilesSelected={addFiles}
|
||||
onFilesSelected={(files) => void addFiles(files)}
|
||||
/>
|
||||
)}
|
||||
<MediaUploaderGrid
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export function DispatchDetailSections({
|
|||
token={token}
|
||||
dispatchId={dispatchId}
|
||||
documents={data.documents ?? []}
|
||||
mediaCounts={data.mediaCounts}
|
||||
locked={locked}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
|
|
@ -68,6 +69,7 @@ export function DispatchDetailSections({
|
|||
dispatchId={dispatchId}
|
||||
currentNte={data.nteAmount}
|
||||
upliftRequests={data.upliftRequests ?? []}
|
||||
mediaCounts={data.mediaCounts}
|
||||
locked={locked}
|
||||
onChanged={refresh}
|
||||
pollDocument={pollEvidenceDocument}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
|
||||
import type { VendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
|
||||
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
|
@ -15,6 +16,7 @@ type UpliftRequestCardProps = {
|
|||
onWithdraw: (request: VendorPortalUpliftRequest) => void;
|
||||
onRevise: (request: VendorPortalUpliftRequest, values: UpliftFormValues) => void;
|
||||
onCancelRevise: () => void;
|
||||
mediaUsage?: VendorMediaUsage;
|
||||
};
|
||||
|
||||
const WITHDRAWABLE_STATUSES = new Set(["Pending", "ChangesRequested"]);
|
||||
|
|
@ -33,6 +35,7 @@ export function UpliftRequestCard({
|
|||
onWithdraw,
|
||||
onRevise,
|
||||
onCancelRevise,
|
||||
mediaUsage,
|
||||
}: UpliftRequestCardProps) {
|
||||
const [revising, setRevising] = useState(false);
|
||||
const canWithdraw = !locked && canAct && WITHDRAWABLE_STATUSES.has(request.status);
|
||||
|
|
@ -100,6 +103,7 @@ export function UpliftRequestCard({
|
|||
initialRequested={String(request.requestedNTE)}
|
||||
initialReason={request.vendorReason ?? ""}
|
||||
hasExistingEvidence={request.evidenceDocumentId != null}
|
||||
mediaUsage={mediaUsage}
|
||||
submitting={submitting}
|
||||
uploadingPercent={uploadingPercent}
|
||||
errorMessage=""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import {
|
||||
validateVendorDocument,
|
||||
VENDOR_UPLOAD_ACCEPT,
|
||||
type VendorMediaUsage,
|
||||
} from "@/domain/vendor-portal/lib/document-validation";
|
||||
import {
|
||||
isApprovalRequired,
|
||||
validateUpliftAmount,
|
||||
|
|
@ -13,6 +17,8 @@ type UpliftRequestFormProps = {
|
|||
initialRequested?: string;
|
||||
initialReason?: string;
|
||||
hasExistingEvidence?: boolean;
|
||||
/** Work-order photo/video usage for the evidence pre-check. */
|
||||
mediaUsage?: VendorMediaUsage;
|
||||
submitting: boolean;
|
||||
uploadingPercent: number | undefined;
|
||||
errorMessage: string;
|
||||
|
|
@ -26,6 +32,7 @@ export function UpliftRequestForm({
|
|||
initialRequested = "",
|
||||
initialReason = "",
|
||||
hasExistingEvidence = false,
|
||||
mediaUsage,
|
||||
submitting,
|
||||
uploadingPercent,
|
||||
errorMessage,
|
||||
|
|
@ -36,11 +43,15 @@ export function UpliftRequestForm({
|
|||
const [reason, setReason] = useState(initialReason);
|
||||
const [evidenceFile, setEvidenceFile] = useState<File | undefined>();
|
||||
const [formError, setFormError] = useState("");
|
||||
const [checkingEvidence, setCheckingEvidence] = useState(false);
|
||||
const latestSelection = useRef(0);
|
||||
|
||||
const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte);
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
// The picked file is not in state until its check settles; submitting now would validate the old one.
|
||||
if (checkingEvidence) return;
|
||||
const amount = Number.parseFloat(requested);
|
||||
const validationError = validateUpliftAmount(requested, currentNte, reason, {
|
||||
file: evidenceFile,
|
||||
|
|
@ -54,10 +65,15 @@ export function UpliftRequestForm({
|
|||
onSubmit({ amount, reason: reason.trim(), evidenceFile });
|
||||
};
|
||||
|
||||
const chooseFile = (file?: File) => {
|
||||
const validationMessage = file ? validateVendorDocument(file) : undefined;
|
||||
const chooseFile = async (file?: File) => {
|
||||
const selection = ++latestSelection.current;
|
||||
setCheckingEvidence(Boolean(file));
|
||||
const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined;
|
||||
// A newer pick may have settled while this one's video metadata was still loading.
|
||||
if (selection !== latestSelection.current) return;
|
||||
setFormError(validationMessage ?? "");
|
||||
setEvidenceFile(file);
|
||||
setCheckingEvidence(false);
|
||||
};
|
||||
|
||||
const shownError = errorMessage || formError;
|
||||
|
|
@ -112,14 +128,16 @@ export function UpliftRequestForm({
|
|||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>
|
||||
<label htmlFor={`uplift-evidence-${mode}`}>Evidence (PDF/JPG/PNG)</label>
|
||||
<label htmlFor={`uplift-evidence-${mode}`}>
|
||||
Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)
|
||||
</label>
|
||||
</strong>
|
||||
<input
|
||||
id={`uplift-evidence-${mode}`}
|
||||
type="file"
|
||||
className="vp-input"
|
||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
||||
onChange={(event) => chooseFile(event.target.files?.[0])}
|
||||
accept={VENDOR_UPLOAD_ACCEPT}
|
||||
onChange={(event) => void chooseFile(event.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -134,8 +152,14 @@ export function UpliftRequestForm({
|
|||
{shownError}
|
||||
</Text>
|
||||
<div className="vp-actions">
|
||||
<button type="submit" className="vp-btn vp-btn-primary" disabled={busy}>
|
||||
{busy ? "Submitting…" : mode === "revise" ? "Submit revision" : "Request uplift"}
|
||||
<button type="submit" className="vp-btn vp-btn-primary" disabled={busy || checkingEvidence}>
|
||||
{busy
|
||||
? "Submitting…"
|
||||
: checkingEvidence
|
||||
? "Checking evidence…"
|
||||
: mode === "revise"
|
||||
? "Submit revision"
|
||||
: "Request uplift"}
|
||||
</button>
|
||||
{mode === "revise" && (
|
||||
<button type="button" className="vp-btn vp-btn-ghost" onClick={onCancel} disabled={busy}>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
|||
import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key";
|
||||
import type {
|
||||
VendorPortalDocumentStatus,
|
||||
VendorPortalMediaCounts,
|
||||
VendorPortalUpliftRequest,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
|
||||
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
|
||||
import { vendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import { UpliftRequestCard } from "@/app/v/[token]/dispatch/_components/uplift-request-card";
|
||||
import {
|
||||
useVendorUpliftEvidence,
|
||||
|
|
@ -24,6 +26,7 @@ type UpliftRequestsSectionProps = {
|
|||
locked: boolean;
|
||||
onChanged: () => Promise<void>;
|
||||
pollDocument: (documentId: number) => Promise<VendorPortalDocumentStatus | undefined>;
|
||||
mediaCounts?: VendorPortalMediaCounts | null;
|
||||
};
|
||||
|
||||
type UseVendorUpliftActionsArgs = {
|
||||
|
|
@ -74,7 +77,7 @@ function useVendorUpliftActions({
|
|||
if (requiresApproval) {
|
||||
if (!values.evidenceFile) {
|
||||
setFormError(
|
||||
"Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.",
|
||||
"Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -168,7 +171,9 @@ export function UpliftRequestsSection({
|
|||
locked,
|
||||
onChanged,
|
||||
pollDocument,
|
||||
mediaCounts,
|
||||
}: UpliftRequestsSectionProps) {
|
||||
const mediaUsage = vendorMediaUsage(mediaCounts, "evidence");
|
||||
const evidence = useVendorUpliftEvidence({ pollDocument });
|
||||
const {
|
||||
submitting,
|
||||
|
|
@ -205,6 +210,7 @@ export function UpliftRequestsSection({
|
|||
onWithdraw={(r) => void handleWithdraw(r)}
|
||||
onRevise={(r, values) => void handleRevise(r, values)}
|
||||
onCancelRevise={evidence.reset}
|
||||
mediaUsage={mediaUsage}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -212,6 +218,7 @@ export function UpliftRequestsSection({
|
|||
<UpliftRequestForm
|
||||
mode="create"
|
||||
currentNte={currentNte}
|
||||
mediaUsage={mediaUsage}
|
||||
submitting={submitting}
|
||||
uploadingPercent={evidence.uploadingPercent}
|
||||
errorMessage={formError}
|
||||
|
|
|
|||
|
|
@ -3,17 +3,83 @@ import { useMutation } from "@tanstack/react-query";
|
|||
|
||||
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation";
|
||||
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import {
|
||||
validateVendorDocument,
|
||||
VENDOR_UPLOAD_ACCEPT,
|
||||
vendorMediaUsage,
|
||||
} from "@/domain/vendor-portal/lib/document-validation";
|
||||
import type {
|
||||
VendorPortalDocument,
|
||||
VendorPortalMediaCounts,
|
||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||
|
||||
type VendorPortalDocumentsProps = {
|
||||
token: string;
|
||||
dispatchId: number;
|
||||
documents: VendorPortalDocument[];
|
||||
locked: boolean;
|
||||
mediaCounts?: VendorPortalMediaCounts | null;
|
||||
onChanged: () => Promise<void>;
|
||||
};
|
||||
|
||||
type VendorPortalDocumentRowProps = {
|
||||
document: VendorPortalDocument;
|
||||
locked: boolean;
|
||||
uploading: boolean;
|
||||
onDownload: () => void;
|
||||
onReplace: () => void;
|
||||
};
|
||||
|
||||
function VendorPortalDocumentRow({
|
||||
document,
|
||||
locked,
|
||||
uploading,
|
||||
onDownload,
|
||||
onReplace,
|
||||
}: VendorPortalDocumentRowProps) {
|
||||
return (
|
||||
<article className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>File</strong>
|
||||
<span>
|
||||
{document.originalFileName} · v{document.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Security scan</strong>
|
||||
<span>{document.scanStatus}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Review</strong>
|
||||
<span>{document.reviewStatus}</span>
|
||||
</div>
|
||||
{Boolean(document.rejectionReason) && (
|
||||
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
|
||||
)}
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={!document.canDownload}
|
||||
onClick={onDownload}
|
||||
>
|
||||
{document.canDownload ? "Download" : "Quarantined"}
|
||||
</button>
|
||||
{!locked && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={uploading}
|
||||
onClick={onReplace}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
type UploadRequest = {
|
||||
file: File;
|
||||
replacementId?: number;
|
||||
|
|
@ -24,12 +90,15 @@ export function VendorPortalDocuments({
|
|||
dispatchId,
|
||||
documents,
|
||||
locked,
|
||||
mediaCounts,
|
||||
onChanged,
|
||||
}: VendorPortalDocumentsProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [replacementId, setReplacementId] = useState<number>();
|
||||
const [progress, setProgress] = useState<number>();
|
||||
const [validationError, setValidationError] = useState<string>();
|
||||
// Bumped on every new pick so a slower validation of an earlier file cannot upload it.
|
||||
const selectionRef = useRef(0);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
|
||||
|
|
@ -51,18 +120,27 @@ export function VendorPortalDocuments({
|
|||
});
|
||||
|
||||
const chooseFile = (documentId?: number) => {
|
||||
selectionRef.current += 1;
|
||||
setValidationError(undefined);
|
||||
uploadMutation.reset();
|
||||
setReplacementId(documentId);
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
const uploadSelectedFile = (file?: File) => {
|
||||
const uploadSelectedFile = async (file?: File) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validationMessage = validateVendorDocument(file);
|
||||
const selection = ++selectionRef.current;
|
||||
const replaced = documents.find((document) => document.id === replacementId);
|
||||
const validationMessage = await validateVendorDocument(
|
||||
file,
|
||||
vendorMediaUsage(mediaCounts, "completion", replaced?.contentType),
|
||||
);
|
||||
if (selection !== selectionRef.current) {
|
||||
return;
|
||||
}
|
||||
if (validationMessage) {
|
||||
setValidationError(validationMessage);
|
||||
return;
|
||||
|
|
@ -83,9 +161,9 @@ export function VendorPortalDocuments({
|
|||
ref={inputRef}
|
||||
type="file"
|
||||
aria-label="Choose completion document"
|
||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
||||
accept={VENDOR_UPLOAD_ACCEPT}
|
||||
hidden
|
||||
onChange={(event) => uploadSelectedFile(event.target.files?.[0])}
|
||||
onChange={(event) => void uploadSelectedFile(event.target.files?.[0])}
|
||||
/>
|
||||
|
||||
{documents.length === 0 && (
|
||||
|
|
@ -93,45 +171,14 @@ export function VendorPortalDocuments({
|
|||
)}
|
||||
|
||||
{documents.map((document) => (
|
||||
<article key={document.id} className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>File</strong>
|
||||
<span>
|
||||
{document.originalFileName} · v{document.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Security scan</strong>
|
||||
<span>{document.scanStatus}</span>
|
||||
</div>
|
||||
<div className="vp-row">
|
||||
<strong>Review</strong>
|
||||
<span>{document.reviewStatus}</span>
|
||||
</div>
|
||||
{Boolean(document.rejectionReason) && (
|
||||
<VendorPortalFeedback tone="error">{document.rejectionReason}</VendorPortalFeedback>
|
||||
)}
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={!document.canDownload}
|
||||
onClick={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
|
||||
>
|
||||
{document.canDownload ? "Download" : "Quarantined"}
|
||||
</button>
|
||||
{!locked && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={uploadMutation.isPending}
|
||||
onClick={() => chooseFile(document.id)}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
<VendorPortalDocumentRow
|
||||
key={document.id}
|
||||
document={document}
|
||||
locked={locked}
|
||||
uploading={uploadMutation.isPending}
|
||||
onDownload={() => void vendorPortalApi.downloadDocument(token, dispatchId, document)}
|
||||
onReplace={() => chooseFile(document.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{progress !== undefined && (
|
||||
|
|
@ -155,6 +202,10 @@ export function VendorPortalDocuments({
|
|||
>
|
||||
Upload document
|
||||
</button>
|
||||
<span className="vp-hint">
|
||||
Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and
|
||||
90 s · PDFs up to 10 MB
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -190,6 +190,13 @@
|
|||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.vp-hint {
|
||||
align-self: center;
|
||||
flex-basis: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.vp-checklist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,129 @@
|
|||
const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
|
||||
const ALLOWED_DOCUMENT_TYPES = new Set(["application/pdf", "image/jpeg", "image/png"]);
|
||||
import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||
|
||||
export function validateVendorDocument(file: File): string | undefined {
|
||||
if (!ALLOWED_DOCUMENT_TYPES.has(file.type)) {
|
||||
return "Only PDF, JPG, and PNG files are allowed.";
|
||||
/**
|
||||
* Client-confirmed media contract on the vendor portal: photos up to
|
||||
* 10 MB (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV).
|
||||
* PDF documents keep the previous 10 MB document cap.
|
||||
*/
|
||||
const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
|
||||
const MAX_PHOTO_BYTES = 10_000_000;
|
||||
const MAX_VIDEO_BYTES = 100_000_000;
|
||||
const MAX_VIDEO_DURATION_SECONDS = 90;
|
||||
|
||||
const UNSUPPORTED_TYPE_MESSAGE = "Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.";
|
||||
|
||||
/** File-picker filter for every vendor portal upload, matching the allowlist below. */
|
||||
export const VENDOR_UPLOAD_ACCEPT =
|
||||
".pdf,.jpg,.jpeg,.png,.heic,.mp4,.mov,application/pdf,image/jpeg,image/png,image/heic,video/mp4,video/quicktime";
|
||||
|
||||
type VendorUploadKind = "document" | "photo" | "video";
|
||||
|
||||
// Mirrors the server allowlist: an allowlisted browser type wins, otherwise the
|
||||
// extension decides (mobile browsers often send an empty or octet-stream type).
|
||||
const KIND_BY_MIME: Record<string, VendorUploadKind> = {
|
||||
"application/pdf": "document",
|
||||
"image/jpeg": "photo",
|
||||
"image/jpg": "photo",
|
||||
"image/png": "photo",
|
||||
"image/heic": "photo",
|
||||
"video/mp4": "video",
|
||||
"video/quicktime": "video",
|
||||
};
|
||||
|
||||
const KIND_BY_EXTENSION: Record<string, VendorUploadKind> = {
|
||||
pdf: "document",
|
||||
jpg: "photo",
|
||||
jpeg: "photo",
|
||||
png: "photo",
|
||||
heic: "photo",
|
||||
mp4: "video",
|
||||
mov: "video",
|
||||
};
|
||||
|
||||
function resolveKind(file: File): VendorUploadKind | null {
|
||||
const name = file.name.toLowerCase();
|
||||
const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : "";
|
||||
return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null;
|
||||
}
|
||||
|
||||
/** Photos/videos already counted against the work order for one upload, with the limits. */
|
||||
export type VendorMediaUsage = {
|
||||
photos: number;
|
||||
videos: number;
|
||||
maxPhotos: number;
|
||||
maxVideos: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The count basis the server uses for this upload. A completion upload replaces a
|
||||
* document: the latest one by default (already excluded in `completion*`), or the
|
||||
* one the vendor picked, which is not counted when it is the same kind. Evidence
|
||||
* uploads count everything. Undefined when the backend does not report counts.
|
||||
*/
|
||||
export function vendorMediaUsage(
|
||||
counts: VendorPortalMediaCounts | null | undefined,
|
||||
purpose: "completion" | "evidence",
|
||||
replacedContentType?: string,
|
||||
): VendorMediaUsage | undefined {
|
||||
if (!counts) return undefined;
|
||||
const limits = { maxPhotos: counts.maxPhotos, maxVideos: counts.maxVideos };
|
||||
if (purpose === "evidence") {
|
||||
return { ...limits, photos: counts.photos, videos: counts.videos };
|
||||
}
|
||||
if (replacedContentType === undefined) {
|
||||
return { ...limits, photos: counts.completionPhotos, videos: counts.completionVideos };
|
||||
}
|
||||
const replacedKind = KIND_BY_MIME[replacedContentType.toLowerCase()];
|
||||
return {
|
||||
...limits,
|
||||
photos: Math.max(0, counts.photos - (replacedKind === "photo" ? 1 : 0)),
|
||||
videos: Math.max(0, counts.videos - (replacedKind === "video" ? 1 : 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function countRejection(kind: VendorUploadKind, usage: VendorMediaUsage): string | undefined {
|
||||
if (kind === "photo" && usage.photos >= usage.maxPhotos) {
|
||||
return `A work order can have at most ${usage.maxPhotos} photos.`;
|
||||
}
|
||||
if (kind === "video" && usage.videos >= usage.maxVideos) {
|
||||
return `A work order can have at most ${usage.maxVideos} videos.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function validateVendorDocument(
|
||||
file: File,
|
||||
usage?: VendorMediaUsage,
|
||||
): Promise<string | undefined> {
|
||||
const kind = resolveKind(file);
|
||||
if (!kind) {
|
||||
return UNSUPPORTED_TYPE_MESSAGE;
|
||||
}
|
||||
|
||||
if (file.size > MAX_DOCUMENT_BYTES) {
|
||||
const countMessage = usage && countRejection(kind, usage);
|
||||
if (countMessage) {
|
||||
return countMessage;
|
||||
}
|
||||
|
||||
if (kind === "photo" && file.size > MAX_PHOTO_BYTES) {
|
||||
return "Photos must be 10 MB or smaller.";
|
||||
}
|
||||
|
||||
if (kind === "video") {
|
||||
if (file.size > MAX_VIDEO_BYTES) {
|
||||
return "Videos must be 100 MB or smaller.";
|
||||
}
|
||||
// Pre-check; the server enforces it too. Unreadable metadata never blocks.
|
||||
const duration = await probeVideoDurationSeconds(file);
|
||||
if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
|
||||
return `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "document" && file.size > MAX_DOCUMENT_BYTES) {
|
||||
return "Files must be 10 MB or smaller.";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export function validateUpliftAmount(
|
|||
return "Please include a reason for the uplift request.";
|
||||
}
|
||||
if (!options.file && !options.hasExisting) {
|
||||
return "Attach PDF, JPG, or PNG evidence — approval-required requests need evidence.";
|
||||
return "Attach PDF, JPG, PNG, HEIC, MP4, or MOV evidence — approval-required requests need evidence.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,16 @@ export interface VendorPortalDispatchSummary {
|
|||
refusedAt?: string;
|
||||
}
|
||||
|
||||
/** Counts for a new completion version exclude the document it replaces. */
|
||||
export interface VendorPortalMediaCounts {
|
||||
maxPhotos: number;
|
||||
maxVideos: number;
|
||||
photos: number;
|
||||
videos: number;
|
||||
completionPhotos: number;
|
||||
completionVideos: number;
|
||||
}
|
||||
|
||||
export interface VendorPortalDocument {
|
||||
id: number;
|
||||
originalFileName: string;
|
||||
|
|
@ -137,4 +147,6 @@ export interface VendorPortalDispatchDetail extends VendorPortalDispatchSummary
|
|||
upliftRequests: VendorPortalUpliftRequest[];
|
||||
signoffs: VendorPortalSignoff[];
|
||||
documents: VendorPortalDocument[];
|
||||
/** Per-work-order photo/video usage; absent until the backend reports it. */
|
||||
mediaCounts?: VendorPortalMediaCounts | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ function cryptoRandomId(): string {
|
|||
|
||||
function classifyFileName(fileName: string, url: string): FileCategory {
|
||||
const name = `${fileName} ${url}`.toLowerCase();
|
||||
if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
|
||||
if (/\.(jpe?g|png|heic|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) {
|
||||
|
|
|
|||
64
src/domain/work-orders/utils/screen-work-order-uploads.ts
Normal file
64
src/domain/work-orders/utils/screen-work-order-uploads.ts
Normal file
|
|
@ -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[] };
|
||||
|
||||
/**
|
||||
* 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<WorkOrderUploadScreenResult> {
|
||||
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 };
|
||||
}
|
||||
|
|
@ -1,20 +1,38 @@
|
|||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
/** Backend RequestSizeLimit for media uploads and Extra Docs. */
|
||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 200_000_000;
|
||||
/**
|
||||
* Client-confirmed media contract (2026-09-22): photos up to 10 MB
|
||||
* (JPEG/PNG/HEIC), videos up to 100 MB and 90 seconds (MP4/MOV), at most
|
||||
* 10 photos and 3 videos per work order. Applies to the Photos & Videos modal,
|
||||
* the Completion Doc → Media tab and the vendor portal upload. Duration is
|
||||
* pre-checked here and enforced by the server from the MP4/MOV header;
|
||||
* unreadable metadata never blocks. Documents
|
||||
* keep the 50 MB document cap; the signed completion PDF keeps its own cap.
|
||||
*/
|
||||
export const MAX_WORK_ORDER_PHOTO_BYTES = 10_000_000;
|
||||
export const MAX_WORK_ORDER_VIDEO_BYTES = 100_000_000;
|
||||
export const MAX_WORK_ORDER_DOCUMENT_BYTES = 50_000_000;
|
||||
|
||||
/** Backend RequestSizeLimit for completion-document uploads. */
|
||||
/** Backend RequestSizeLimit for completion-document (signed PDF) uploads. */
|
||||
export const MAX_WORK_ORDER_COMPLETION_DOC_BYTES = 50_000_000;
|
||||
|
||||
/** Largest single upload the media endpoint accepts (RequestSizeLimit ceiling). */
|
||||
export const MAX_WORK_ORDER_MEDIA_REQUEST_BYTES = MAX_WORK_ORDER_VIDEO_BYTES;
|
||||
|
||||
export const MAX_VIDEO_DURATION_SECONDS = 90;
|
||||
export const MAX_PHOTOS_PER_WORK_ORDER = 10;
|
||||
export const MAX_VIDEOS_PER_WORK_ORDER = 3;
|
||||
|
||||
const MEDIA_MIME_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/heic",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
]);
|
||||
|
||||
const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||
const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".heic", ".mp4", ".mov"]);
|
||||
|
||||
const EXTRA_DOC_MIME_TYPES = new Set([
|
||||
...MEDIA_MIME_TYPES,
|
||||
|
|
@ -25,6 +43,8 @@ const EXTRA_DOC_MIME_TYPES = new Set([
|
|||
|
||||
const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]);
|
||||
|
||||
export type WorkOrderUploadKind = "photo" | "video" | "document";
|
||||
|
||||
function fileExtension(file: File): string {
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
|
|
@ -37,11 +57,58 @@ function isTypeAllowed(file: File, mimes: Set<string>, extensions: Set<string>):
|
|||
return mimeOk || extOk;
|
||||
}
|
||||
|
||||
export function isWorkOrderFileTooLarge(
|
||||
file: File,
|
||||
maxBytes = MAX_WORK_ORDER_MEDIA_BYTES,
|
||||
): boolean {
|
||||
return file.size > maxBytes;
|
||||
const KIND_BY_MIME: Record<string, WorkOrderUploadKind> = {
|
||||
"image/jpeg": "photo",
|
||||
"image/jpg": "photo",
|
||||
"image/png": "photo",
|
||||
"image/heic": "photo",
|
||||
"video/mp4": "video",
|
||||
"video/quicktime": "video",
|
||||
};
|
||||
|
||||
const KIND_BY_EXTENSION: Record<string, WorkOrderUploadKind> = {
|
||||
".jpg": "photo",
|
||||
".jpeg": "photo",
|
||||
".png": "photo",
|
||||
".heic": "photo",
|
||||
".mp4": "video",
|
||||
".mov": "video",
|
||||
};
|
||||
|
||||
/**
|
||||
* Same rule as the server: an allowlisted browser type decides, otherwise the
|
||||
* extension (mobile browsers often send an empty, octet-stream or foreign type).
|
||||
*/
|
||||
export function resolveWorkOrderUploadKind(file: File): WorkOrderUploadKind {
|
||||
return (
|
||||
KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[fileExtension(file)] ?? "document"
|
||||
);
|
||||
}
|
||||
|
||||
function workOrderUploadKindLimit(kind: WorkOrderUploadKind): number {
|
||||
if (kind === "photo") return MAX_WORK_ORDER_PHOTO_BYTES;
|
||||
if (kind === "video") return MAX_WORK_ORDER_VIDEO_BYTES;
|
||||
return MAX_WORK_ORDER_DOCUMENT_BYTES;
|
||||
}
|
||||
|
||||
export function isWorkOrderFileTooLarge(file: File, maxBytes?: number): boolean {
|
||||
const limit = maxBytes ?? workOrderUploadKindLimit(resolveWorkOrderUploadKind(file));
|
||||
return file.size > limit;
|
||||
}
|
||||
|
||||
/** Stable per-kind rejection message; never echoes file metadata. */
|
||||
export function workOrderFileSizeRejection(file: File): string | null {
|
||||
const kind = resolveWorkOrderUploadKind(file);
|
||||
if (kind === "photo" && file.size > MAX_WORK_ORDER_PHOTO_BYTES) {
|
||||
return "Photos must be 10 MB or smaller.";
|
||||
}
|
||||
if (kind === "video" && file.size > MAX_WORK_ORDER_VIDEO_BYTES) {
|
||||
return "Videos must be 100 MB or smaller.";
|
||||
}
|
||||
if (kind === "document" && file.size > MAX_WORK_ORDER_DOCUMENT_BYTES) {
|
||||
return "Documents must be 50 MB or smaller.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
|
||||
|
|
@ -53,17 +120,34 @@ export function isWorkOrderExtraDocTypeAllowed(file: File): boolean {
|
|||
}
|
||||
|
||||
export function workOrderMediaAcceptAttribute(): string {
|
||||
return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
|
||||
return "image/jpeg,image/jpg,image/png,image/heic,video/mp4,video/quicktime,.jpg,.jpeg,.png,.heic,.mp4,.mov";
|
||||
}
|
||||
|
||||
export function workOrderExtraDocsAcceptAttribute(): string {
|
||||
return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`;
|
||||
}
|
||||
|
||||
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
|
||||
export function formatMediaSizeLimit(maxBytes: number): string {
|
||||
return `${Math.round(maxBytes / 1_000_000)} MB`;
|
||||
}
|
||||
|
||||
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; uploadStatus?: string }>,
|
||||
): WorkOrderMediaKindCounts {
|
||||
return uploads.reduce(
|
||||
(counts, item) => {
|
||||
if (item.isLegacy || item.uploadStatus === "failed") return counts;
|
||||
if (item.fileType === "image") counts.photos += 1;
|
||||
if (item.fileType === "video") counts.videos += 1;
|
||||
return counts;
|
||||
},
|
||||
{ photos: 0, videos: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
|
||||
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
||||
return isWorkOrderCompletionSurfaceReadOnly(status);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
MediaCategory,
|
||||
MediaFile,
|
||||
} from "@/domain/work-orders/types/work-order-media";
|
||||
import { resolveWorkOrderUploadKind } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
let mediaIdCounter = 0;
|
||||
|
||||
|
|
@ -16,6 +17,10 @@ export function classifyUpload(f: File): {
|
|||
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
|
||||
return { fileType: "pdf", category: "Report" };
|
||||
}
|
||||
// Phones often send an empty or octet-stream type; fall back to the extension.
|
||||
const kind = resolveWorkOrderUploadKind(f);
|
||||
if (kind === "photo") return { fileType: "image", category: "Photo" };
|
||||
if (kind === "video") return { fileType: "video", category: "Video" };
|
||||
return { fileType: "other", category: "Other" };
|
||||
}
|
||||
|
||||
|
|
|
|||
75
src/lib/probe-video-duration.ts
Normal file
75
src/lib/probe-video-duration.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Best-effort video duration probe for MP4/MOV. Reads the movie header
|
||||
* (`moov` → `mvhd`) the same way the server does, seeking over box headers so a
|
||||
* phone file with `moov` after a large `mdat` costs only a few small reads.
|
||||
* Resolves null when the header cannot be read — callers must not block the
|
||||
* upload in that case (the server enforces the limit from the same
|
||||
* header and also lets unreadable metadata through).
|
||||
*/
|
||||
const MAX_BOXES_PER_LEVEL = 1024;
|
||||
|
||||
type Box = { bodyStart: number; end: number };
|
||||
|
||||
async function readView(file: Blob, start: number, length: number): Promise<DataView | null> {
|
||||
if (start < 0 || length <= 0 || start + length > file.size) return null;
|
||||
const buffer = await file.slice(start, start + length).arrayBuffer();
|
||||
return buffer.byteLength === length ? new DataView(buffer) : null;
|
||||
}
|
||||
|
||||
function boxType(view: DataView): string {
|
||||
return String.fromCharCode(
|
||||
view.getUint8(4),
|
||||
view.getUint8(5),
|
||||
view.getUint8(6),
|
||||
view.getUint8(7),
|
||||
);
|
||||
}
|
||||
|
||||
async function findBox(file: Blob, start: number, end: number, type: string): Promise<Box | null> {
|
||||
let offset = start;
|
||||
for (let index = 0; index < MAX_BOXES_PER_LEVEL && offset + 8 <= end; index += 1) {
|
||||
const header = await readView(file, offset, Math.min(16, end - offset));
|
||||
if (!header) return null;
|
||||
let size = header.getUint32(0);
|
||||
let headerLength = 8;
|
||||
if (size === 1) {
|
||||
if (header.byteLength < 16) return null;
|
||||
const largeSize = header.getBigUint64(8);
|
||||
if (largeSize > BigInt(Number.MAX_SAFE_INTEGER)) return null;
|
||||
size = Number(largeSize);
|
||||
headerLength = 16;
|
||||
} else if (size === 0) {
|
||||
size = end - offset;
|
||||
}
|
||||
if (size < headerLength || offset + size > end) return null;
|
||||
if (boxType(header) === type) {
|
||||
return { bodyStart: offset + headerLength, end: offset + size };
|
||||
}
|
||||
offset += size;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readMovieHeaderSeconds(file: Blob, mvhd: Box): Promise<number | null> {
|
||||
const version = await readView(file, mvhd.bodyStart, 1);
|
||||
if (!version) return null;
|
||||
const isVersion1 = version.getUint8(0) === 1;
|
||||
const fields = await readView(file, mvhd.bodyStart, isVersion1 ? 32 : 20);
|
||||
if (!fields || mvhd.bodyStart + fields.byteLength > mvhd.end) return null;
|
||||
const timescale = fields.getUint32(isVersion1 ? 20 : 12);
|
||||
const duration = isVersion1 ? fields.getBigUint64(24) : BigInt(fields.getUint32(16));
|
||||
const unknown = isVersion1 ? 0xffff_ffff_ffff_ffffn : 0xffff_ffffn;
|
||||
if (timescale === 0 || duration === unknown) return null;
|
||||
return Number(duration) / timescale;
|
||||
}
|
||||
|
||||
export async function probeVideoDurationSeconds(file: Blob): Promise<number | null> {
|
||||
try {
|
||||
const moov = await findBox(file, 0, file.size, "moov");
|
||||
if (!moov) return null;
|
||||
const mvhd = await findBox(file, moov.bodyStart, moov.end, "mvhd");
|
||||
return mvhd ? await readMovieHeaderSeconds(file, mvhd) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -117,7 +117,9 @@ describe("MediaUploadModal API wiring", () => {
|
|||
});
|
||||
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
|
||||
screen.getByText(
|
||||
"Accepted: JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB (max 10) · Videos up to 100 MB and 90 s (max 3)",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,39 @@
|
|||
import { createRef } from "react";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "react-toastify";
|
||||
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone";
|
||||
import {
|
||||
MAX_WORK_ORDER_MEDIA_BYTES,
|
||||
workOrderMediaAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
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 gallery(photos: number, videos: number): MediaFile[] {
|
||||
const item = (id: string, fileType: "image" | "video") =>
|
||||
({ id, fileType, isLegacy: false }) as unknown as MediaFile;
|
||||
return [
|
||||
...Array.from({ length: photos }, (_, i) => item(`p${i + 1}`, "image")),
|
||||
...Array.from({ length: videos }, (_, i) => item(`v${i + 1}`, "video")),
|
||||
];
|
||||
}
|
||||
|
||||
function selectFiles(files: File[]) {
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
fireEvent.change(input as HTMLInputElement, { target: { files } });
|
||||
}
|
||||
|
||||
describe("MediaUploaderDropzone", () => {
|
||||
it("matches the AAP Photos accept copy and keeps it media-only", () => {
|
||||
it("shows the SH-116 media contract and keeps the picker media-only", () => {
|
||||
renderWithProviders(
|
||||
<MediaUploaderDropzone
|
||||
inputRef={createRef<HTMLInputElement>()}
|
||||
|
|
@ -23,28 +43,139 @@ describe("MediaUploaderDropzone", () => {
|
|||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 200MB per file"),
|
||||
screen.getByText(
|
||||
"Accepted: JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB (max 10) · Videos up to 100 MB and 90 s (max 3)",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/PDF/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/DOC/)).not.toBeInTheDocument();
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute());
|
||||
expect(input?.getAttribute("accept")).not.toContain(".pdf");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an oversized media file with the generic 200 MB toast", () => {
|
||||
renderWithProviders(<MediaUploader uploads={[]} />);
|
||||
describe("MediaUploader media contract (Completion Doc → Media, Photos & Videos modal)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(toast.error).mockClear();
|
||||
vi.mocked(probeVideoDurationSeconds).mockReset();
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
|
||||
});
|
||||
|
||||
const oversizedFile = {
|
||||
name: "mobile-clip.MP4",
|
||||
type: "application/octet-stream",
|
||||
size: MAX_WORK_ORDER_MEDIA_BYTES + 1,
|
||||
} as File;
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
it("accepts ~60 MB MP4 and MOV phone videos, including mobile MIME variants", async () => {
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, { target: { files: [oversizedFile] } });
|
||||
const videos = [
|
||||
phoneFile("clip.mp4", "video/mp4", 60 * MB),
|
||||
phoneFile("IMG_0001.MOV", "video/quicktime", 60 * MB),
|
||||
phoneFile("IMG_0002.MOV", "", 60 * MB),
|
||||
];
|
||||
selectFiles(videos);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('"mobile-clip.MP4" exceeds the 200 MB limit.');
|
||||
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
||||
expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual(videos);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts an octet-stream video with a video extension", async () => {
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||
|
||||
selectFiles([phoneFile("VID_0003.mp4", "application/octet-stream", 60 * MB)]);
|
||||
|
||||
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("rejects a 101 MB video with the generic message and no file name", async () => {
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||
|
||||
selectFiles([phoneFile("secret-site-clip.mp4", "video/mp4", 101 * MB)]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Videos must be 100 MB or smaller."),
|
||||
);
|
||||
expect(onUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an 11 MB photo", async () => {
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||
|
||||
selectFiles([phoneFile("IMG_0100.jpg", "image/jpeg", 11 * MB)]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Photos must be 10 MB or smaller."),
|
||||
);
|
||||
expect(onUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the 11th photo and the 4th video on a full work order", async () => {
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(<MediaUploader uploads={gallery(10, 3)} onUpload={onUpload} />);
|
||||
|
||||
selectFiles([
|
||||
phoneFile("IMG_0200.jpg", "image/jpeg", 2 * MB),
|
||||
phoneFile("IMG_0201.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 the whole work order's media when this tab shows only its share", async () => {
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(
|
||||
<MediaUploader uploads={[]} mediaCounts={{ photos: 10, videos: 3 }} onUpload={onUpload} />,
|
||||
);
|
||||
|
||||
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(<MediaUploader uploads={gallery(0, 2)} onUpload={onUpload} />);
|
||||
|
||||
const first = phoneFile("IMG_0301.MOV", "video/quicktime", 20 * MB);
|
||||
selectFiles([first, phoneFile("IMG_0302.MOV", "video/quicktime", 20 * MB)]);
|
||||
|
||||
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
||||
expect(onUpload.mock.calls[0][0].map((r: { file: File }) => r.file)).toEqual([first]);
|
||||
expect(toast.error).toHaveBeenCalledWith("A work order can have at most 3 videos.");
|
||||
});
|
||||
|
||||
it("rejects a video longer than 90 seconds when the duration is readable", async () => {
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(95);
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||
|
||||
selectFiles([phoneFile("IMG_0400.MOV", "video/quicktime", 40 * MB)]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("Videos must be 90 seconds or shorter."),
|
||||
);
|
||||
expect(onUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not block a video whose duration the browser cannot read", async () => {
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(<MediaUploader uploads={[]} onUpload={onUpload} />);
|
||||
|
||||
selectFiles([phoneFile("IMG_0500.MOV", "video/quicktime", 40 * MB)]);
|
||||
|
||||
await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -140,13 +140,15 @@ describe("SlideOverExtrasTab Aveta", () => {
|
|||
expect(getMediaContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Extra Docs accept copy with PDF DOC and 200MB", () => {
|
||||
it("shows Extra Docs accept copy with the SH-116 media contract", () => {
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={vi.fn()} onRemove={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 200MB per file"),
|
||||
screen.getByText(
|
||||
"Accepted: JPG, PNG, HEIC, MP4, MOV, PDF, DOC · Photos up to 10 MB · Videos up to 100 MB and 90 s · Docs up to 50 MB",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
expect(input?.getAttribute("accept")).toContain(".pdf");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
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(
|
||||
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={onUpload} onRemove={vi.fn()} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<SlideOverExtrasTab
|
||||
uploads={[]}
|
||||
mediaCounts={{ photos: 10, videos: 3 }}
|
||||
isReadOnly={false}
|
||||
onUpload={onUpload}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={onUpload} onRemove={vi.fn()} />,
|
||||
);
|
||||
|
||||
selectFiles([video]);
|
||||
|
||||
await waitFor(() => expect(onUpload).toHaveBeenCalledWith([video]));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a second pick while the first one is still being checked", async () => {
|
||||
let finishProbe: ((seconds: number) => void) | undefined;
|
||||
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishProbe = resolve;
|
||||
}),
|
||||
);
|
||||
const onUpload = vi.fn();
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab
|
||||
uploads={[]}
|
||||
mediaCounts={{ photos: 0, videos: 2 }}
|
||||
isReadOnly={false}
|
||||
onUpload={onUpload}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const first = phoneFile("IMG_0710.MOV", "video/quicktime", 20 * MB);
|
||||
const second = phoneFile("IMG_0711.MOV", "video/quicktime", 20 * MB);
|
||||
|
||||
selectFiles([first]);
|
||||
await waitFor(() => expect(finishProbe).toBeDefined());
|
||||
expect(document.querySelector('input[type="file"]')).toBeDisabled();
|
||||
selectFiles([second]);
|
||||
finishProbe?.(30);
|
||||
|
||||
await waitFor(() => expect(onUpload).toHaveBeenCalledOnce());
|
||||
expect(onUpload).toHaveBeenCalledWith([first]);
|
||||
expect(probeVideoDurationSeconds).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
|
||||
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||
|
||||
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
|
||||
|
||||
function renderForm(overrides: Partial<React.ComponentProps<typeof UpliftRequestForm>> = {}) {
|
||||
return render(
|
||||
|
|
@ -38,6 +41,70 @@ describe("UpliftRequestForm revise mode", () => {
|
|||
expect(screen.getByText(/must be greater than the current NTE/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refuses photo evidence when the work order already has 10 photos", async () => {
|
||||
renderForm({ mediaUsage: { photos: 10, videos: 0, maxPhotos: 10, maxVideos: 3 } });
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/evidence/i), {
|
||||
target: { files: [new File(["jpg"], "receipt.jpg", { type: "image/jpeg" })] },
|
||||
});
|
||||
|
||||
expect(await screen.findByText("A work order can have at most 10 photos.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers and accepts the same phone photo and video types as the upload allowlist", async () => {
|
||||
renderForm();
|
||||
|
||||
const input = screen.getByLabelText("Evidence (PDF, JPG, PNG, HEIC, MP4, MOV)");
|
||||
expect(input.getAttribute("accept")?.split(",")).toEqual(
|
||||
expect.arrayContaining([".pdf", ".jpg", ".png", ".heic", ".mp4", ".mov"]),
|
||||
);
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { files: [new File(["gif"], "clip.gif", { type: "image/gif" })] },
|
||||
});
|
||||
expect(
|
||||
await screen.findByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, {
|
||||
target: { files: [new File(["heic"], "site.heic", { type: "" })] },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText("Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed."),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("holds submit while a picked video is still being checked, then submits that video", async () => {
|
||||
let finishProbe: (seconds: number | null) => void = () => {};
|
||||
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
|
||||
() => new Promise((resolve) => (finishProbe = resolve)),
|
||||
);
|
||||
const onSubmit = vi.fn();
|
||||
renderForm({ initialRequested: "1200", initialReason: "Extra parts", onSubmit });
|
||||
|
||||
const video = new File(["mov"], "IMG_0001.MOV", { type: "video/quicktime" });
|
||||
fireEvent.change(screen.getByLabelText(/evidence/i), { target: { files: [video] } });
|
||||
|
||||
const checking = await screen.findByRole("button", { name: "Checking evidence…" });
|
||||
expect(checking).toBeDisabled();
|
||||
fireEvent.submit(checking.closest("form")!);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/approval-required requests need evidence/i)).not.toBeInTheDocument();
|
||||
|
||||
finishProbe(30);
|
||||
const submitButton = await screen.findByRole("button", { name: /submit revision/i });
|
||||
expect(submitButton).toBeEnabled();
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
amount: 1200,
|
||||
reason: "Extra parts",
|
||||
evidenceFile: video,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the current NTE threshold in the actionable error", () => {
|
||||
renderForm({ initialRequested: "900", currentNte: 1000 });
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||
import { VendorPortalDocuments } from "@/app/v/_components/vendor-portal-documents";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import type { VendorPortalDocument } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
|
||||
|
||||
const quarantinedDocument: VendorPortalDocument = {
|
||||
id: 12,
|
||||
originalFileName: "completion.pdf",
|
||||
|
|
@ -40,7 +43,7 @@ describe("VendorPortalDocuments", () => {
|
|||
});
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Only PDF, JPG, and PNG files are allowed.",
|
||||
"Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.",
|
||||
);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -62,6 +65,163 @@ describe("VendorPortalDocuments", () => {
|
|||
expect(screen.getByText("Processing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["animation.gif", "image/gif"],
|
||||
["clip.webm", "video/webm"],
|
||||
])("rejects %s (%s), which the server does not accept", async (name, type) => {
|
||||
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
|
||||
renderWithProviders(
|
||||
<VendorPortalDocuments
|
||||
token="portal-token"
|
||||
dispatchId={7}
|
||||
documents={[]}
|
||||
locked={false}
|
||||
onChanged={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Choose completion document"), {
|
||||
target: { files: [new File(["x"], name, { type })] },
|
||||
});
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Only PDF, JPG, PNG, HEIC, MP4, and MOV files are allowed.",
|
||||
);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("SH-116 media contract", () => {
|
||||
const MB = 1_000_000;
|
||||
const renderDocuments = () =>
|
||||
renderWithProviders(
|
||||
<VendorPortalDocuments
|
||||
token="portal-token"
|
||||
dispatchId={7}
|
||||
documents={[]}
|
||||
locked={false}
|
||||
onChanged={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
const choose = (file: File) =>
|
||||
fireEvent.change(screen.getByLabelText("Choose completion document"), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
|
||||
it("states the limit for every accepted kind, including PDFs", () => {
|
||||
renderDocuments();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Accepted: PDF, JPG, PNG, HEIC, MP4, MOV · Photos up to 10 MB · Videos up to 100 MB and 90 s · PDFs up to 10 MB",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uploads a ~60 MB phone MOV whose browser type is empty", async () => {
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(null);
|
||||
const upload = vi
|
||||
.spyOn(vendorPortalApi, "uploadDocument")
|
||||
.mockResolvedValue(quarantinedDocument);
|
||||
renderDocuments();
|
||||
|
||||
const video = { name: "IMG_0001.MOV", type: "", size: 60 * MB } as File;
|
||||
choose(video);
|
||||
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
|
||||
expect(upload.mock.calls[0][0].file).toBe(video);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ name: "clip.mp4", type: "video/mp4", size: 101 * MB },
|
||||
"Videos must be 100 MB or smaller.",
|
||||
],
|
||||
[
|
||||
{ name: "IMG_1.jpg", type: "image/jpeg", size: 11 * MB },
|
||||
"Photos must be 10 MB or smaller.",
|
||||
],
|
||||
])("rejects %o with a generic message", async (file, message) => {
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
|
||||
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
|
||||
renderDocuments();
|
||||
|
||||
choose(file as File);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(message);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Three videos already on the work order (dispatcher and vendor uploads together).
|
||||
const fullCounts = {
|
||||
maxPhotos: 10,
|
||||
maxVideos: 3,
|
||||
photos: 10,
|
||||
videos: 3,
|
||||
completionPhotos: 10,
|
||||
completionVideos: 3,
|
||||
};
|
||||
const videoDocument: VendorPortalDocument = {
|
||||
...quarantinedDocument,
|
||||
id: 21,
|
||||
originalFileName: "walkthrough.mp4",
|
||||
contentType: "video/mp4",
|
||||
};
|
||||
|
||||
it("rejects the 4th video and 11th photo counted across the whole work order", async () => {
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
|
||||
const upload = vi.spyOn(vendorPortalApi, "uploadDocument");
|
||||
renderWithProviders(
|
||||
<VendorPortalDocuments
|
||||
token="portal-token"
|
||||
dispatchId={7}
|
||||
documents={[]}
|
||||
mediaCounts={fullCounts}
|
||||
locked={false}
|
||||
onChanged={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
choose({ name: "IMG_0004.MOV", type: "video/quicktime", size: 20 * MB } as File);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"A work order can have at most 3 videos.",
|
||||
);
|
||||
choose({ name: "IMG_0011.jpg", type: "image/jpeg", size: 2 * MB } as File);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"A work order can have at most 10 photos.",
|
||||
),
|
||||
);
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a vendor replace one of the counted videos on a full work order", async () => {
|
||||
vi.mocked(probeVideoDurationSeconds).mockResolvedValue(30);
|
||||
const upload = vi.spyOn(vendorPortalApi, "uploadDocument").mockResolvedValue(videoDocument);
|
||||
renderWithProviders(
|
||||
<VendorPortalDocuments
|
||||
token="portal-token"
|
||||
dispatchId={7}
|
||||
documents={[videoDocument]}
|
||||
mediaCounts={fullCounts}
|
||||
locked={false}
|
||||
onChanged={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
|
||||
const replacement = { name: "walkthrough-v2.mp4", type: "video/mp4", size: 20 * MB } as File;
|
||||
choose(replacement);
|
||||
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
|
||||
expect(upload.mock.calls[0][0]).toMatchObject({ file: replacement, replacesDocumentId: 21 });
|
||||
});
|
||||
});
|
||||
|
||||
it("reports upload progress and refreshes the dispatch after success", async () => {
|
||||
const onChanged = vi.fn().mockResolvedValue(undefined);
|
||||
let finishUpload: (() => void) | undefined;
|
||||
|
|
@ -97,4 +257,40 @@ describe("VendorPortalDocuments", () => {
|
|||
finishUpload?.();
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("drops an earlier pick whose video check finishes after a newer pick", async () => {
|
||||
let finishProbe: ((seconds: number) => void) | undefined;
|
||||
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishProbe = resolve;
|
||||
}),
|
||||
);
|
||||
const upload = vi
|
||||
.spyOn(vendorPortalApi, "uploadDocument")
|
||||
.mockResolvedValue(quarantinedDocument);
|
||||
renderWithProviders(
|
||||
<VendorPortalDocuments
|
||||
token="portal-token"
|
||||
dispatchId={7}
|
||||
documents={[]}
|
||||
locked={false}
|
||||
onChanged={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
const input = screen.getByLabelText("Choose completion document");
|
||||
const video = { name: "IMG_0001.MOV", type: "video/quicktime", size: 20_000_000 } as File;
|
||||
const photo = { name: "IMG_0002.jpg", type: "image/jpeg", size: 2_000_000 } as File;
|
||||
|
||||
fireEvent.change(input, { target: { files: [video] } });
|
||||
await waitFor(() => expect(finishProbe).toBeDefined());
|
||||
fireEvent.change(input, { target: { files: [photo] } });
|
||||
await waitFor(() => expect(upload).toHaveBeenCalledOnce());
|
||||
finishProbe?.(30);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
expect(upload.mock.calls[0][0].file).toBe(photo);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
boardMediaItemsToMediaFiles,
|
||||
mapBoardDetailMediaItem,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import { countWorkOrderMediaKinds } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||
|
||||
describe("photo/video counting for the per-work-order limit", () => {
|
||||
it("counts a persisted HEIC photo after a refresh", () => {
|
||||
const heic = mapBoardDetailMediaItem({
|
||||
id: 41,
|
||||
category: 3,
|
||||
url: "https://cdn.example.com/IMG_0042.HEIC",
|
||||
fileName: "IMG_0042.HEIC",
|
||||
});
|
||||
const files = boardMediaItemsToMediaFiles([heic]);
|
||||
|
||||
expect(files[0]?.fileType).toBe("image");
|
||||
expect(countWorkOrderMediaKinds(files)).toEqual({ photos: 1, videos: 0 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ name: "IMG_0043.HEIC", type: "" }, "image"],
|
||||
[{ name: "IMG_0044.MOV", type: "" }, "video"],
|
||||
[{ name: "IMG_0045.jpg", type: "application/octet-stream" }, "image"],
|
||||
[{ name: "report.docx", type: "" }, "other"],
|
||||
])("classifies a local %o by extension when the browser type is missing", (file, fileType) => {
|
||||
expect(classifyUpload({ ...file, size: 1 } as File).fileType).toBe(fileType);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,20 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
countWorkOrderMediaKinds,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderDocumentsReadOnly,
|
||||
isWorkOrderExtraDocTypeAllowed,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
MAX_PHOTOS_PER_WORK_ORDER,
|
||||
MAX_VIDEO_DURATION_SECONDS,
|
||||
MAX_VIDEOS_PER_WORK_ORDER,
|
||||
MAX_WORK_ORDER_COMPLETION_DOC_BYTES,
|
||||
MAX_WORK_ORDER_MEDIA_BYTES,
|
||||
MAX_WORK_ORDER_PHOTO_BYTES,
|
||||
MAX_WORK_ORDER_VIDEO_BYTES,
|
||||
workOrderExtraDocsAcceptAttribute,
|
||||
workOrderFileSizeRejection,
|
||||
workOrderMediaAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
|
|
@ -25,12 +31,53 @@ describe("work-order-media-limits", () => {
|
|||
expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false);
|
||||
});
|
||||
|
||||
it("caps uploads at 200_000_000 bytes and formats decimal MB", () => {
|
||||
expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(200_000_000);
|
||||
expect(formatMediaSizeLimit()).toBe("200 MB");
|
||||
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES } as File)).toBe(false);
|
||||
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
|
||||
expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
|
||||
it("applies the SH-116 per-kind caps with stable generic messages", () => {
|
||||
const file = (name: string, type: string, size: number) => ({ name, type, size }) as File;
|
||||
|
||||
expect(MAX_WORK_ORDER_PHOTO_BYTES).toBe(10_000_000);
|
||||
expect(MAX_WORK_ORDER_VIDEO_BYTES).toBe(100_000_000);
|
||||
expect(MAX_VIDEO_DURATION_SECONDS).toBe(90);
|
||||
expect(MAX_PHOTOS_PER_WORK_ORDER).toBe(10);
|
||||
expect(MAX_VIDEOS_PER_WORK_ORDER).toBe(3);
|
||||
|
||||
expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 60_000_000))).toBeNull();
|
||||
expect(workOrderFileSizeRejection(file("IMG_1.MOV", "video/quicktime", 60_000_000))).toBeNull();
|
||||
expect(workOrderFileSizeRejection(file("IMG_1.MOV", "", 60_000_000))).toBeNull();
|
||||
expect(
|
||||
workOrderFileSizeRejection(file("clip.mp4", "application/octet-stream", 60_000_000)),
|
||||
).toBeNull();
|
||||
expect(workOrderFileSizeRejection(file("clip.mp4", "video/mp4", 101_000_000))).toBe(
|
||||
"Videos must be 100 MB or smaller.",
|
||||
);
|
||||
expect(workOrderFileSizeRejection(file("IMG_2.jpg", "image/jpeg", 11_000_000))).toBe(
|
||||
"Photos must be 10 MB or smaller.",
|
||||
);
|
||||
expect(workOrderFileSizeRejection(file("IMG_2.HEIC", "", 11_000_000))).toBe(
|
||||
"Photos must be 10 MB or smaller.",
|
||||
);
|
||||
// A foreign declared type never widens the class: this .jpg is still sized as a photo.
|
||||
expect(workOrderFileSizeRejection(file("IMG_3.jpg", "video/3gpp", 60_000_000))).toBe(
|
||||
"Photos must be 10 MB or smaller.",
|
||||
);
|
||||
expect(workOrderFileSizeRejection(file("scope.pdf", "application/pdf", 51_000_000))).toBe(
|
||||
"Documents must be 50 MB or smaller.",
|
||||
);
|
||||
expect(isWorkOrderFileTooLarge(file("clip.mp4", "video/mp4", MAX_WORK_ORDER_VIDEO_BYTES))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(formatMediaSizeLimit(MAX_WORK_ORDER_VIDEO_BYTES)).toBe("100 MB");
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
it("caps completion-document uploads at 50_000_000 bytes and formats the completion copy", () => {
|
||||
|
|
|
|||
93
src/test/lib/probe-video-duration.test.ts
Normal file
93
src/test/lib/probe-video-duration.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
|
||||
|
||||
function concat(...parts: Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ascii(text: string): Uint8Array {
|
||||
return Uint8Array.from(text, (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
function box(type: string, ...children: Uint8Array[]): Uint8Array {
|
||||
const body = concat(...children);
|
||||
const out = new Uint8Array(8 + body.length);
|
||||
new DataView(out.buffer).setUint32(0, out.length);
|
||||
out.set(ascii(type), 4);
|
||||
out.set(body, 8);
|
||||
return out;
|
||||
}
|
||||
|
||||
function movieHeader(timescale: number, duration: bigint, version1 = false): Uint8Array {
|
||||
const body = new Uint8Array(version1 ? 32 : 20);
|
||||
const view = new DataView(body.buffer);
|
||||
body[0] = version1 ? 1 : 0;
|
||||
if (version1) {
|
||||
view.setUint32(20, timescale);
|
||||
view.setBigUint64(24, duration);
|
||||
} else {
|
||||
view.setUint32(12, timescale);
|
||||
view.setUint32(16, Number(duration));
|
||||
}
|
||||
return box("mvhd", body);
|
||||
}
|
||||
|
||||
/** A phone-style file: ftyp, a large mdat, then moov at the end (not fast-start). */
|
||||
function mp4(timescale: number, duration: bigint, version1 = false): Uint8Array {
|
||||
return concat(
|
||||
box("ftyp", ascii("isom"), new Uint8Array(4)),
|
||||
box("mdat", new Uint8Array(4096)),
|
||||
box("moov", movieHeader(timescale, duration, version1)),
|
||||
);
|
||||
}
|
||||
|
||||
function asFile(bytes: Uint8Array, name = "IMG_0001.MOV"): File {
|
||||
return new File([new Uint8Array(bytes)], name, { type: "video/quicktime" });
|
||||
}
|
||||
|
||||
describe("probeVideoDurationSeconds", () => {
|
||||
it("reads the duration from a moov box after the media data", async () => {
|
||||
await expect(probeVideoDurationSeconds(asFile(mp4(600, 57_000n)))).resolves.toBe(95);
|
||||
});
|
||||
|
||||
it("reads a version 1 movie header", async () => {
|
||||
await expect(probeVideoDurationSeconds(asFile(mp4(1000, 30_500n, true)))).resolves.toBe(30.5);
|
||||
});
|
||||
|
||||
it("follows 64-bit box sizes", async () => {
|
||||
const mdat = new Uint8Array(16 + 512);
|
||||
const view = new DataView(mdat.buffer);
|
||||
view.setUint32(0, 1);
|
||||
mdat.set(ascii("mdat"), 4);
|
||||
view.setBigUint64(8, BigInt(mdat.length));
|
||||
const file = concat(
|
||||
box("ftyp", ascii("qt "), new Uint8Array(4)),
|
||||
mdat,
|
||||
box("moov", movieHeader(90_000, 90_000n * 120n)),
|
||||
);
|
||||
|
||||
await expect(probeVideoDurationSeconds(asFile(file))).resolves.toBe(120);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"no moov",
|
||||
concat(box("ftyp", ascii("isom"), new Uint8Array(4)), box("mdat", new Uint8Array(64))),
|
||||
],
|
||||
["truncated", mp4(600, 57_000n).slice(0, -10)],
|
||||
["zero timescale", mp4(0, 57_000n)],
|
||||
[
|
||||
"oversized box",
|
||||
concat(box("ftyp", new Uint8Array(8)), Uint8Array.of(0x7f, 0xff, 0xff, 0xff), ascii("moov")),
|
||||
],
|
||||
["not a video", ascii("%PDF-1.7 not a movie")],
|
||||
])("returns null for an unreadable file (%s)", async (_shape, bytes) => {
|
||||
await expect(probeVideoDurationSeconds(asFile(bytes))).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue