From b31616893033f05b89be007c4e20c3b1277181d8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 24 Sep 2026 22:29:20 -0300 Subject: [PATCH] fix(vendor-portal): pre-check the per-work-order photo/video limit The vendor completion upload and uplift evidence checked type, size and duration but not the 10-photo / 3-video work-order limit. Use the counts the dispatch detail now reports (shoc-backend#173), on the same basis as the server: a new completion version does not count the document it replaces. When the backend does not report counts yet, the server check still applies. --- .../_components/dispatch-detail-sections.tsx | 2 + .../_components/uplift-request-card.tsx | 4 ++ .../_components/uplift-request-form.tsx | 10 ++- .../_components/uplift-requests-section.tsx | 7 ++ .../v/_components/vendor-portal-documents.tsx | 18 ++++- .../vendor-portal/lib/document-validation.ts | 56 +++++++++++++++- .../vendor-portal/types/vendor-portal.ts | 12 ++++ src/test/app/v/uplift-request-form.test.tsx | 10 +++ .../app/v/vendor-portal-documents.test.tsx | 67 +++++++++++++++++++ 9 files changed, 180 insertions(+), 6 deletions(-) diff --git a/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx b/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx index 1cac5391..c31fc93b 100644 --- a/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx +++ b/src/app/v/[token]/dispatch/_components/dispatch-detail-sections.tsx @@ -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} diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx index c9f4845e..f9b6dd8f 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-request-card.tsx @@ -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="" diff --git a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx index f3ee9a48..63acfec8 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-request-form.tsx @@ -1,6 +1,9 @@ import { useRef, useState } from "react"; import { Text } from "@/components/ui/text"; -import { validateVendorDocument } from "@/domain/vendor-portal/lib/document-validation"; +import { + validateVendorDocument, + type VendorMediaUsage, +} from "@/domain/vendor-portal/lib/document-validation"; import { isApprovalRequired, validateUpliftAmount, @@ -13,6 +16,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 +31,7 @@ export function UpliftRequestForm({ initialRequested = "", initialReason = "", hasExistingEvidence = false, + mediaUsage, submitting, uploadingPercent, errorMessage, @@ -57,7 +63,7 @@ export function UpliftRequestForm({ const chooseFile = async (file?: File) => { const selection = ++latestSelection.current; - const validationMessage = file ? await validateVendorDocument(file) : undefined; + 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 ?? ""); diff --git a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx index 9fa98227..c8a10c01 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx @@ -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; pollDocument: (documentId: number) => Promise; + mediaCounts?: VendorPortalMediaCounts | null; }; type UseVendorUpliftActionsArgs = { @@ -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({ Promise; }; @@ -24,6 +31,7 @@ export function VendorPortalDocuments({ dispatchId, documents, locked, + mediaCounts, onChanged, }: VendorPortalDocumentsProps) { const inputRef = useRef(null); @@ -62,7 +70,11 @@ export function VendorPortalDocuments({ return; } - const validationMessage = await validateVendorDocument(file); + const replaced = documents.find((document) => document.id === replacementId); + const validationMessage = await validateVendorDocument( + file, + vendorMediaUsage(mediaCounts, "completion", replaced?.contentType), + ); if (validationMessage) { setValidationError(validationMessage); return; diff --git a/src/domain/vendor-portal/lib/document-validation.ts b/src/domain/vendor-portal/lib/document-validation.ts index bf059bca..d62aa918 100644 --- a/src/domain/vendor-portal/lib/document-validation.ts +++ b/src/domain/vendor-portal/lib/document-validation.ts @@ -1,3 +1,4 @@ +import type { VendorPortalMediaCounts } from "@/domain/vendor-portal/types/vendor-portal"; import { probeVideoDurationSeconds } from "@/lib/probe-video-duration"; /** @@ -42,12 +43,65 @@ function resolveKind(file: File): VendorUploadKind | null { return KIND_BY_MIME[file.type.toLowerCase()] ?? KIND_BY_EXTENSION[extension] ?? null; } -export async function validateVendorDocument(file: File): Promise { +/** 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 { const kind = resolveKind(file); if (!kind) { return UNSUPPORTED_TYPE_MESSAGE; } + 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."; } diff --git a/src/domain/vendor-portal/types/vendor-portal.ts b/src/domain/vendor-portal/types/vendor-portal.ts index d9853b07..75c3707a 100644 --- a/src/domain/vendor-portal/types/vendor-portal.ts +++ b/src/domain/vendor-portal/types/vendor-portal.ts @@ -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[]; + /** SH-116 per-work-order photo/video usage; absent until the backend reports it. */ + mediaCounts?: VendorPortalMediaCounts | null; } diff --git a/src/test/app/v/uplift-request-form.test.tsx b/src/test/app/v/uplift-request-form.test.tsx index 91cfc13e..84a612b6 100644 --- a/src/test/app/v/uplift-request-form.test.tsx +++ b/src/test/app/v/uplift-request-form.test.tsx @@ -38,6 +38,16 @@ 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("reports the current NTE threshold in the actionable error", () => { renderForm({ initialRequested: "900", currentNte: 1000 }); diff --git a/src/test/app/v/vendor-portal-documents.test.tsx b/src/test/app/v/vendor-portal-documents.test.tsx index d97b1134..cee758e8 100644 --- a/src/test/app/v/vendor-portal-documents.test.tsx +++ b/src/test/app/v/vendor-portal-documents.test.tsx @@ -143,6 +143,73 @@ describe("VendorPortalDocuments", () => { 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( + , + { 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( + , + { 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 () => {