mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix: satisfy SH-116 frontend gates
This commit is contained in:
parent
0c8a7f877f
commit
d6252552ca
6 changed files with 389 additions and 219 deletions
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
|
|
@ -84,43 +83,7 @@ function saveOwnership(
|
|||
}
|
||||
}
|
||||
|
||||
export function useSlideOverMediaState({
|
||||
detailForRow,
|
||||
row,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
}: UseSlideOverMediaStateArgs) {
|
||||
const workOrderId = detailForRow?.info.id ?? row?.id;
|
||||
const workOrderType = detailForRow?.info.type ?? row?.type;
|
||||
const {
|
||||
data: mediaPolicy,
|
||||
isError: mediaPolicyError,
|
||||
isLoading: mediaPolicyLoading,
|
||||
} = useWorkOrderMediaPolicy(workOrderId, workOrderId != null);
|
||||
const resumableCompletionUpload = useResumableWorkOrderMediaUpload(
|
||||
workOrderId ?? "",
|
||||
"CompletionMedia",
|
||||
mediaPolicy ?? undefined,
|
||||
);
|
||||
const policyCanUpload = mediaPolicy?.canUpload === true && !mediaPolicyError;
|
||||
const completionHasGallery =
|
||||
hasCompletionMediaGallery(detailForRow?.info.type) ||
|
||||
hasCompletionMediaGallery(mediaPolicy?.workOrderType) ||
|
||||
hasCompletionMediaGallery(row?.type);
|
||||
const emergencyMedia =
|
||||
isEmergencyReactiveWorkOrderType(mediaPolicy?.workOrderType) ||
|
||||
isEmergencyReactiveWorkOrderType(workOrderType);
|
||||
const emergencyMediaUnavailable = emergencyMedia && !policyCanUpload;
|
||||
const emergencyMediaUnavailableMessage =
|
||||
mediaPolicy?.canUpload === false
|
||||
? "Uploads are not available for this work order."
|
||||
: mediaPolicyError
|
||||
? "Upload permissions could not be loaded. Refresh and try again."
|
||||
: mediaPolicyLoading || !mediaPolicy
|
||||
? "Loading upload permissions…"
|
||||
: "Uploads are not available for this work order.";
|
||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||
function useMediaOwnership(workOrderId: string | number | undefined) {
|
||||
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
|
||||
loadOwnership(workOrderId),
|
||||
);
|
||||
|
|
@ -140,7 +103,27 @@ export function useSlideOverMediaState({
|
|||
[workOrderId],
|
||||
);
|
||||
|
||||
const persistCompletionUpload = useCallback(
|
||||
return { ownership, updateOwnership };
|
||||
}
|
||||
|
||||
type OwnershipUpdater = (update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => void;
|
||||
|
||||
type CompletionUploadArgs = {
|
||||
emergencyMedia: boolean;
|
||||
policyCanUpload: boolean;
|
||||
resumableCompletionUpload: ReturnType<typeof useResumableWorkOrderMediaUpload>;
|
||||
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||
updateOwnership: OwnershipUpdater;
|
||||
};
|
||||
|
||||
function usePersistCompletionUpload({
|
||||
emergencyMedia,
|
||||
policyCanUpload,
|
||||
resumableCompletionUpload,
|
||||
uploadMediaMutation,
|
||||
updateOwnership,
|
||||
}: CompletionUploadArgs) {
|
||||
return useCallback(
|
||||
async (input: UploadWorkOrderMediaInput): Promise<{ id: string }> => {
|
||||
if (emergencyMedia) {
|
||||
if (!policyCanUpload) {
|
||||
|
|
@ -175,9 +158,201 @@ export function useSlideOverMediaState({
|
|||
uploadMediaMutation,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
type SlideOverActionArgs = {
|
||||
detailForRow: WorkOrderBoardDetail | undefined;
|
||||
isLocal: (id: string) => boolean;
|
||||
removeLocal: (id: string) => void;
|
||||
updateOwnership: OwnershipUpdater;
|
||||
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
|
||||
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||
};
|
||||
|
||||
function useSlideOverMutationActions({
|
||||
detailForRow,
|
||||
isLocal,
|
||||
removeLocal,
|
||||
updateOwnership,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
}: SlideOverActionArgs) {
|
||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||
|
||||
const uploadExtraFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setMediaBatchPending(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
// Extra Docs stay on the legacy route and change category only by explicit PATCH.
|
||||
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setMediaBatchPending(false);
|
||||
}
|
||||
},
|
||||
[uploadMediaMutation, updateOwnership],
|
||||
);
|
||||
|
||||
const categorizeMedia = useCallback(
|
||||
(mediaId: string, category: UploadableMediaCategory) => {
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
categorizeMediaMutation.mutate(
|
||||
{ mediaId, category, workOrderVersion },
|
||||
{
|
||||
onSuccess: () => {
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||
extraOwnedIds:
|
||||
category === "Extra"
|
||||
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
|
||||
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[detailForRow?.info.rowVersion, categorizeMediaMutation, updateOwnership],
|
||||
);
|
||||
|
||||
const removeMedia = useCallback(
|
||||
(id: string) => {
|
||||
if (isLocal(id)) {
|
||||
removeLocal(id);
|
||||
return;
|
||||
}
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
},
|
||||
[detailForRow?.info.rowVersion, isLocal, removeLocal, deleteMediaMutation],
|
||||
);
|
||||
|
||||
return { mediaBatchPending, uploadExtraFiles, categorizeMedia, removeMedia };
|
||||
}
|
||||
|
||||
function hasAnyCompletionGallery(...types: Array<string | undefined>): boolean {
|
||||
return types.some(hasCompletionMediaGallery);
|
||||
}
|
||||
|
||||
function hasEmergencyMediaType(...types: Array<string | undefined>): boolean {
|
||||
return types.some(isEmergencyReactiveWorkOrderType);
|
||||
}
|
||||
|
||||
function getEmergencyUploaderOptions(
|
||||
emergencyMedia: boolean,
|
||||
limits: Parameters<typeof emergencyMediaSizeError>[1],
|
||||
) {
|
||||
if (!emergencyMedia) {
|
||||
return {
|
||||
emergencyMediaTypeAllowed: undefined,
|
||||
emergencyMediaTypeErrorMessage: undefined,
|
||||
emergencyMediaAcceptAttribute: undefined,
|
||||
emergencyMediaSizeError: undefined,
|
||||
emergencyMediaUploadHint: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
emergencyMediaTypeAllowed: (file: File) => classifyMediaKind(file) !== null,
|
||||
emergencyMediaTypeErrorMessage:
|
||||
"Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||
emergencyMediaAcceptAttribute: emergencyMediaAcceptAttribute(),
|
||||
emergencyMediaSizeError: (file: File) => emergencyMediaSizeError(file, limits),
|
||||
emergencyMediaUploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds",
|
||||
};
|
||||
}
|
||||
|
||||
function isAnyMediaMutationPending(...states: boolean[]): boolean {
|
||||
return states.some(Boolean);
|
||||
}
|
||||
|
||||
function getEmergencyMediaUnavailableMessage(
|
||||
canUpload: boolean | undefined,
|
||||
policyError: boolean,
|
||||
policyLoading: boolean,
|
||||
hasPolicy: boolean,
|
||||
): string {
|
||||
if (canUpload === false) return "Uploads are not available for this work order.";
|
||||
if (policyError) return "Upload permissions could not be loaded. Refresh and try again.";
|
||||
if (policyLoading || !hasPolicy) return "Loading upload permissions…";
|
||||
return "Uploads are not available for this work order.";
|
||||
}
|
||||
|
||||
function isPolicyUploadAllowed(canUpload: boolean | undefined, policyError: boolean): boolean {
|
||||
return canUpload === true && !policyError;
|
||||
}
|
||||
|
||||
function isEmergencyUploadUnavailable(emergencyMedia: boolean, policyCanUpload: boolean): boolean {
|
||||
return emergencyMedia && !policyCanUpload;
|
||||
}
|
||||
|
||||
export function useSlideOverMediaState({
|
||||
detailForRow,
|
||||
row,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
}: UseSlideOverMediaStateArgs) {
|
||||
const workOrderId = detailForRow?.info.id ?? row?.id;
|
||||
const workOrderType = detailForRow?.info.type ?? row?.type;
|
||||
const {
|
||||
data: mediaPolicy,
|
||||
isError: mediaPolicyError,
|
||||
isLoading: mediaPolicyLoading,
|
||||
} = useWorkOrderMediaPolicy(workOrderId, workOrderId != null);
|
||||
const resumableCompletionUpload = useResumableWorkOrderMediaUpload(
|
||||
workOrderId ?? "",
|
||||
"CompletionMedia",
|
||||
mediaPolicy ?? undefined,
|
||||
);
|
||||
const policyCanUpload = isPolicyUploadAllowed(mediaPolicy?.canUpload, mediaPolicyError);
|
||||
const completionHasGallery = hasAnyCompletionGallery(
|
||||
detailForRow?.info.type,
|
||||
mediaPolicy?.workOrderType,
|
||||
row?.type,
|
||||
);
|
||||
const emergencyMedia = hasEmergencyMediaType(mediaPolicy?.workOrderType, workOrderType);
|
||||
const emergencyMediaUnavailable = isEmergencyUploadUnavailable(emergencyMedia, policyCanUpload);
|
||||
const emergencyMediaUnavailableMessage = getEmergencyMediaUnavailableMessage(
|
||||
mediaPolicy?.canUpload,
|
||||
mediaPolicyError,
|
||||
mediaPolicyLoading,
|
||||
Boolean(mediaPolicy),
|
||||
);
|
||||
const { ownership, updateOwnership } = useMediaOwnership(workOrderId);
|
||||
const persistCompletionUpload = usePersistCompletionUpload({
|
||||
emergencyMedia,
|
||||
policyCanUpload,
|
||||
resumableCompletionUpload,
|
||||
uploadMediaMutation,
|
||||
updateOwnership,
|
||||
});
|
||||
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
||||
const { mediaBatchPending, uploadExtraFiles, categorizeMedia, removeMedia } =
|
||||
useSlideOverMutationActions({
|
||||
detailForRow,
|
||||
isLocal,
|
||||
removeLocal,
|
||||
updateOwnership,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
});
|
||||
|
||||
const completionMedia = useMemo(
|
||||
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership, completionHasGallery)),
|
||||
|
|
@ -193,68 +368,15 @@ export function useSlideOverMediaState({
|
|||
);
|
||||
|
||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||
const mediaPending =
|
||||
mediaBatchPending ||
|
||||
hasLocalUploading ||
|
||||
uploadMediaMutation.isPending ||
|
||||
resumableCompletionUpload.isPending ||
|
||||
deleteMediaMutation.isPending ||
|
||||
categorizeMediaMutation.isPending;
|
||||
|
||||
const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
|
||||
startUploads(requests);
|
||||
};
|
||||
|
||||
const uploadExtraFiles = async (files: File[]) => {
|
||||
setMediaBatchPending(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
||||
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setMediaBatchPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
categorizeMediaMutation.mutate(
|
||||
{ mediaId, category, workOrderVersion },
|
||||
{
|
||||
onSuccess: () => {
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||
extraOwnedIds:
|
||||
category === "Extra"
|
||||
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
|
||||
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeMedia = (id: string) => {
|
||||
if (isLocal(id)) {
|
||||
removeLocal(id);
|
||||
return;
|
||||
}
|
||||
const workOrderVersion = detailForRow?.info.rowVersion;
|
||||
if (!workOrderVersion) {
|
||||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
};
|
||||
const mediaPending = isAnyMediaMutationPending(
|
||||
mediaBatchPending,
|
||||
hasLocalUploading,
|
||||
uploadMediaMutation.isPending,
|
||||
resumableCompletionUpload.isPending,
|
||||
deleteMediaMutation.isPending,
|
||||
categorizeMediaMutation.isPending,
|
||||
);
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(emergencyMedia, mediaPolicy?.limits);
|
||||
|
||||
return {
|
||||
completionMedia,
|
||||
|
|
@ -263,20 +385,8 @@ export function useSlideOverMediaState({
|
|||
emergencyMedia,
|
||||
emergencyMediaUnavailable,
|
||||
emergencyMediaUnavailableMessage,
|
||||
emergencyMediaTypeAllowed: emergencyMedia
|
||||
? (file: File) => classifyMediaKind(file) !== null
|
||||
: undefined,
|
||||
emergencyMediaTypeErrorMessage: emergencyMedia
|
||||
? "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported."
|
||||
: undefined,
|
||||
emergencyMediaAcceptAttribute: emergencyMedia ? emergencyMediaAcceptAttribute() : undefined,
|
||||
emergencyMediaSizeError: emergencyMedia
|
||||
? (file: File) => emergencyMediaSizeError(file, mediaPolicy?.limits)
|
||||
: undefined,
|
||||
emergencyMediaUploadHint: emergencyMedia
|
||||
? "Photos up to 10 MB · Videos up to 100 MB and 90 seconds"
|
||||
: undefined,
|
||||
uploadMediaRequests,
|
||||
...emergencyUploaderOptions,
|
||||
uploadMediaRequests: startUploads,
|
||||
uploadExtraFiles,
|
||||
categorizeMedia,
|
||||
retryUpload,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,39 @@ function resolveWorkOrderVersion(
|
|||
return version || null;
|
||||
}
|
||||
|
||||
function isEmergencyReactive(row: WorkOrderTableRow, detailType?: string, policyType?: string) {
|
||||
return (
|
||||
isEmergencyReactiveWorkOrderType(detailType) ||
|
||||
isEmergencyReactiveWorkOrderType(policyType) ||
|
||||
isEmergencyReactiveWorkOrderType(row.type)
|
||||
);
|
||||
}
|
||||
|
||||
function emergencyPolicyMessage(
|
||||
canUpload: boolean | undefined,
|
||||
isError: boolean,
|
||||
isLoading: boolean,
|
||||
): string {
|
||||
if (canUpload === false) return "Uploads are not available for this work order.";
|
||||
if (isError) return "Upload permissions could not be loaded. Refresh and try again.";
|
||||
if (isLoading) return "Loading upload permissions…";
|
||||
return "Uploads are not available for this work order.";
|
||||
}
|
||||
|
||||
function getEmergencyUploaderOptions(
|
||||
isEmergency: boolean,
|
||||
limits: Parameters<typeof emergencyMediaSizeError>[1],
|
||||
) {
|
||||
if (!isEmergency) return {};
|
||||
return {
|
||||
fileSizeError: (file: File) => emergencyMediaSizeError(file, limits),
|
||||
uploadHint: "Photos up to 10 MB · Videos up to 100 MB and 90 seconds",
|
||||
typeAllowed: (file: File) => classifyMediaKind(file) !== null,
|
||||
typeErrorMessage: "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported.",
|
||||
acceptAttribute: emergencyMediaAcceptAttribute(),
|
||||
};
|
||||
}
|
||||
|
||||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||
const {
|
||||
|
|
@ -53,10 +86,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
policyError ? undefined : (policy ?? undefined),
|
||||
);
|
||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||
const isEmergency =
|
||||
isEmergencyReactiveWorkOrderType(detail?.info.type) ||
|
||||
isEmergencyReactiveWorkOrderType(policy?.workOrderType) ||
|
||||
isEmergencyReactiveWorkOrderType(row.type);
|
||||
const isEmergency = isEmergencyReactive(row, detail?.info.type, policy?.workOrderType);
|
||||
const uploadMutation = isEmergency ? resumableUploadMutation : legacyUploadMutation;
|
||||
const emergencyUploadUnavailable = isEmergency && (policy?.canUpload !== true || policyError);
|
||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||
|
|
@ -65,6 +95,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
|
||||
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
const emergencyUploaderOptions = getEmergencyUploaderOptions(isEmergency, policy?.limits);
|
||||
|
||||
const requireVersion = (): string | null => {
|
||||
const version = resolveWorkOrderVersion(detail?.info.rowVersion, row.rowVersion);
|
||||
|
|
@ -130,32 +161,14 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
onCancel={cancelUpload}
|
||||
readOnly={isReadOnly}
|
||||
uploadDisabled={emergencyUploadUnavailable}
|
||||
fileSizeError={
|
||||
isEmergency ? (file) => emergencyMediaSizeError(file, policy?.limits) : undefined
|
||||
}
|
||||
uploadHint={
|
||||
isEmergency ? "Photos up to 10 MB · Videos up to 100 MB and 90 seconds" : undefined
|
||||
}
|
||||
{...emergencyUploaderOptions}
|
||||
pending={pending}
|
||||
typeAllowed={isEmergency ? (file) => classifyMediaKind(file) !== null : undefined}
|
||||
typeErrorMessage={
|
||||
isEmergency
|
||||
? "Only photos (JPG, PNG, HEIC) and videos (MP4, MOV) are supported."
|
||||
: undefined
|
||||
}
|
||||
acceptAttribute={isEmergency ? emergencyMediaAcceptAttribute() : undefined}
|
||||
/>
|
||||
)}
|
||||
{emergencyUploadUnavailable && !isReadOnly && (
|
||||
<div role="status" className="mt-2">
|
||||
<Text variant="description">
|
||||
{policy?.canUpload === false
|
||||
? "Uploads are not available for this work order."
|
||||
: policyError
|
||||
? "Upload permissions could not be loaded. Refresh and try again."
|
||||
: policyLoading || !policy
|
||||
? "Loading upload permissions…"
|
||||
: "Uploads are not available for this work order."}
|
||||
{emergencyPolicyMessage(policy?.canUpload, policyError, policyLoading || !policy)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,106 @@ function viewMediaItem(item: VendorMediaItem, blob: Blob): void {
|
|||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
function VendorMediaItems({
|
||||
items,
|
||||
onView,
|
||||
}: {
|
||||
items: VendorMediaItem[];
|
||||
onView: (item: VendorMediaItem) => void;
|
||||
}) {
|
||||
return items.map((item) => (
|
||||
<article key={item.id} className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>File</strong>
|
||||
<span>
|
||||
{item.originalFileName} · {item.kind} · {formatBytes(item.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={!item.canView}
|
||||
onClick={() => onView(item)}
|
||||
>
|
||||
{item.canView ? "View" : "Unavailable"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
));
|
||||
}
|
||||
|
||||
function VendorUploadProgress({
|
||||
pending,
|
||||
progress,
|
||||
onCancel,
|
||||
}: {
|
||||
pending: boolean;
|
||||
progress: number | undefined;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
if (!pending) return null;
|
||||
return (
|
||||
<div role="status" aria-live="polite">
|
||||
{progress !== undefined ? (
|
||||
<>
|
||||
<progress max={100} value={progress} style={{ width: "100%" }} />
|
||||
<VendorPortalFeedback>Uploading… {progress}%</VendorPortalFeedback>
|
||||
</>
|
||||
) : (
|
||||
<VendorPortalFeedback>Preparing upload…</VendorPortalFeedback>
|
||||
)}
|
||||
<button type="button" className="vp-btn vp-btn-ghost" onClick={onCancel}>
|
||||
Cancel upload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VendorUploadError({
|
||||
error,
|
||||
canceled,
|
||||
locked,
|
||||
onRetry,
|
||||
}: {
|
||||
error: Error | null;
|
||||
canceled: boolean;
|
||||
locked: boolean;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
if (canceled) return <VendorPortalFeedback>Upload canceled.</VendorPortalFeedback>;
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div>
|
||||
<VendorPortalFeedback tone="error">{safeMediaUploadErrorMessage(error)}</VendorPortalFeedback>
|
||||
{!locked && error instanceof MediaUploadNetworkError && (
|
||||
<button type="button" className="vp-btn vp-btn-ghost" onClick={onRetry}>
|
||||
Retry upload
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VendorUploadButton({
|
||||
canUpload,
|
||||
pending,
|
||||
onClick,
|
||||
}: {
|
||||
canUpload: boolean;
|
||||
pending: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
if (!canUpload) return null;
|
||||
return (
|
||||
<div className="vp-actions">
|
||||
<button type="button" className="vp-btn vp-btn-primary" disabled={pending} onClick={onClick}>
|
||||
Upload photo or video
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSectionProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
|
@ -159,82 +259,27 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec
|
|||
<VendorPortalFeedback>No photos or videos uploaded yet.</VendorPortalFeedback>
|
||||
)}
|
||||
|
||||
{items.map((item) => (
|
||||
<article key={item.id} className="vp-uplift-row">
|
||||
<div className="vp-row">
|
||||
<strong>File</strong>
|
||||
<span>
|
||||
{item.originalFileName} · {item.kind} · {formatBytes(item.sizeBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={!item.canView}
|
||||
onClick={() => void handleView(item)}
|
||||
>
|
||||
{item.canView ? "View" : "Unavailable"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<VendorMediaItems items={items} onView={(item) => void handleView(item)} />
|
||||
<VendorUploadProgress
|
||||
pending={uploadMutation.isPending}
|
||||
progress={progress}
|
||||
onCancel={() => controllerRef.current?.abort()}
|
||||
/>
|
||||
<VendorUploadError
|
||||
error={uploadMutation.error}
|
||||
canceled={uploadCanceled}
|
||||
locked={locked}
|
||||
onRetry={() => startUpload(failedFileRef.current, true)}
|
||||
/>
|
||||
|
||||
{uploadMutation.isPending && (
|
||||
<div role="status" aria-live="polite">
|
||||
{progress !== undefined ? (
|
||||
<>
|
||||
<progress max={100} value={progress} style={{ width: "100%" }} />
|
||||
<VendorPortalFeedback>Uploading… {progress}%</VendorPortalFeedback>
|
||||
</>
|
||||
) : (
|
||||
<VendorPortalFeedback>Preparing upload…</VendorPortalFeedback>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
onClick={() => controllerRef.current?.abort()}
|
||||
>
|
||||
Cancel upload
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadCanceled && <VendorPortalFeedback>Upload canceled.</VendorPortalFeedback>}
|
||||
|
||||
{uploadMutation.isError && !uploadCanceled && (
|
||||
<div>
|
||||
<VendorPortalFeedback tone="error">
|
||||
{safeMediaUploadErrorMessage(uploadMutation.error)}
|
||||
</VendorPortalFeedback>
|
||||
{!locked && uploadMutation.error instanceof MediaUploadNetworkError && (
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-ghost"
|
||||
disabled={uploadMutation.isPending}
|
||||
onClick={() => startUpload(failedFileRef.current, true)}
|
||||
>
|
||||
Retry upload
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canUpload && (
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vp-btn vp-btn-primary"
|
||||
disabled={uploadMutation.isPending}
|
||||
onClick={() => {
|
||||
uploadMutation.reset();
|
||||
inputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
Upload photo or video
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<VendorUploadButton
|
||||
canUpload={canUpload}
|
||||
pending={uploadMutation.isPending}
|
||||
onClick={() => {
|
||||
uploadMutation.reset();
|
||||
inputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
|
||||
{locked && (
|
||||
<VendorPortalFeedback>
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ describe("resumable media upload engine", () => {
|
|||
.filter((entry) => entry.method === "PUT")
|
||||
.map((entry) => Number(/chunks\/(\d+)$/.exec(entry.url)?.[1]));
|
||||
expect(putIndices.filter((index) => index === 0)).toEqual([0]);
|
||||
expect(putIndices.at(-1)).toBe(1);
|
||||
expect(putIndices[putIndices.length - 1]).toBe(1);
|
||||
});
|
||||
|
||||
it("retries a failed create with the same idempotency key and never re-creates after chunk loss", async () => {
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ vi.mock("@/domain/work-orders/use-cases/use-resumable-work-order-media-upload",
|
|||
},
|
||||
}));
|
||||
|
||||
function baseRow(type = "Emergency"): WorkOrderTableRow {
|
||||
function baseRow(type: WorkOrderTableRow["type"] = "Emergency"): WorkOrderTableRow {
|
||||
return {
|
||||
id: 7,
|
||||
woNumber: "WO-7",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const policyState = vi.hoisted(() => ({
|
|||
isError: false,
|
||||
isLoading: true,
|
||||
}));
|
||||
const legacyUploadMutateAsync = vi.fn().mockResolvedValue({ id: "legacy" });
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-media-policy", () => ({
|
||||
useWorkOrderMediaPolicy: () => policyState,
|
||||
|
|
@ -96,13 +97,14 @@ function detailWithMedia(
|
|||
function idleMutation() {
|
||||
return {
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
mutateAsync: legacyUploadMutateAsync,
|
||||
isPending: false,
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("useSlideOverMediaState completion filter", () => {
|
||||
beforeEach(() => {
|
||||
legacyUploadMutateAsync.mockClear();
|
||||
sessionStorage.clear();
|
||||
policyState.data = undefined;
|
||||
policyState.isError = false;
|
||||
|
|
@ -370,7 +372,7 @@ describe("useSlideOverMediaState completion filter", () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(legacyUpload.mutateAsync).not.toHaveBeenCalled();
|
||||
expect(legacyUploadMutateAsync).not.toHaveBeenCalled();
|
||||
expect(result.current.completionMedia.some((item) => item.uploadStatus === "failed")).toBe(
|
||||
true,
|
||||
);
|
||||
|
|
@ -405,7 +407,7 @@ describe("useSlideOverMediaState completion filter", () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(legacyUpload.mutateAsync).not.toHaveBeenCalled();
|
||||
expect(legacyUploadMutateAsync).not.toHaveBeenCalled();
|
||||
expect(result.current.completionMedia[0]?.uploadStatus).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue