shoc-frontend-new/src/app/v/_components/vendor-portal-documents.tsx
Alexandre Brandizzi 44e96cf2a3 fix(media): apply the SH-116 media contract to every upload surface
Photos up to 10 MB (JPG/PNG/HEIC), videos up to 100 MB and 90 s (MP4/MOV),
at most 10 photos and 3 videos per work order, pre-validated with stable
generic messages on the Photos & Videos modal, the Completion Doc media tab,
Extra Docs and the vendor portal. Video duration is read from metadata when
the browser can; unreadable metadata never blocks. Mobile MIME variants
(empty type, octet-stream with a video extension, QuickTime) stay accepted.
The signed completion PDF keeps its 50 MB cap.
2026-09-24 20:59:26 -03:00

166 lines
5 KiB
TypeScript

import { useRef, useState } from "react";
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";
type VendorPortalDocumentsProps = {
token: string;
dispatchId: number;
documents: VendorPortalDocument[];
locked: boolean;
onChanged: () => Promise<void>;
};
type UploadRequest = {
file: File;
replacementId?: number;
};
export function VendorPortalDocuments({
token,
dispatchId,
documents,
locked,
onChanged,
}: VendorPortalDocumentsProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [replacementId, setReplacementId] = useState<number>();
const [progress, setProgress] = useState<number>();
const [validationError, setValidationError] = useState<string>();
const uploadMutation = useMutation({
mutationFn: ({ file, replacementId: requestedReplacementId }: UploadRequest) =>
vendorPortalApi.uploadDocument({
token,
dispatchId,
file,
replacesDocumentId: requestedReplacementId,
onProgress: setProgress,
}),
onSuccess: onChanged,
onSettled: () => {
setProgress(undefined);
setReplacementId(undefined);
if (inputRef.current) {
inputRef.current.value = "";
}
},
});
const chooseFile = (documentId?: number) => {
setValidationError(undefined);
uploadMutation.reset();
setReplacementId(documentId);
inputRef.current?.click();
};
const uploadSelectedFile = async (file?: File) => {
if (!file) {
return;
}
const validationMessage = await validateVendorDocument(file);
if (validationMessage) {
setValidationError(validationMessage);
return;
}
setValidationError(undefined);
setProgress(0);
uploadMutation.mutate({ file, replacementId });
};
const uploadError =
validationError ??
(uploadMutation.error ? "Unable to upload the document. Please try again." : undefined);
return (
<>
<input
ref={inputRef}
type="file"
aria-label="Choose completion document"
accept=".pdf,.jpg,.jpeg,.png,.heic,.mp4,.mov,application/pdf,image/jpeg,image/png,image/heic,video/mp4,video/quicktime"
hidden
onChange={(event) => void uploadSelectedFile(event.target.files?.[0])}
/>
{documents.length === 0 && (
<VendorPortalFeedback>No completion documents uploaded.</VendorPortalFeedback>
)}
{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>
))}
{progress !== undefined && (
<div role="status" aria-live="polite">
<progress max={100} value={progress} style={{ width: "100%" }} />
<VendorPortalFeedback>Uploading… {progress}%</VendorPortalFeedback>
</div>
)}
{Boolean(uploadError) && (
<VendorPortalFeedback tone="error">{uploadError}</VendorPortalFeedback>
)}
{!locked && (
<div className="vp-actions">
<button
type="button"
className="vp-btn vp-btn-primary"
disabled={uploadMutation.isPending}
onClick={() => chooseFile()}
>
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
</span>
</div>
)}
</>
);
}