diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index de94ee7d..227af08b 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -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(() => 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; + uploadMediaMutation: ReturnType; + 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; + deleteMediaMutation: ReturnType; + categorizeMediaMutation: ReturnType; +}; + +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): boolean { + return types.some(hasCompletionMediaGallery); +} + +function hasEmergencyMediaType(...types: Array): boolean { + return types.some(isEmergencyReactiveWorkOrderType); +} + +function getEmergencyUploaderOptions( + emergencyMedia: boolean, + limits: Parameters[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, diff --git a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx index 58e1e722..7f824186 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -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[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 && (
- {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)}
)} diff --git a/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx b/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx index 240af621..45392523 100644 --- a/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx +++ b/src/app/v/[token]/dispatch/_components/vendor-media-section.tsx @@ -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) => ( +
+
+ File + + {item.originalFileName} · {item.kind} · {formatBytes(item.sizeBytes)} + +
+
+ +
+
+ )); +} + +function VendorUploadProgress({ + pending, + progress, + onCancel, +}: { + pending: boolean; + progress: number | undefined; + onCancel: () => void; +}) { + if (!pending) return null; + return ( +
+ {progress !== undefined ? ( + <> + + Uploading… {progress}% + + ) : ( + Preparing upload… + )} + +
+ ); +} + +function VendorUploadError({ + error, + canceled, + locked, + onRetry, +}: { + error: Error | null; + canceled: boolean; + locked: boolean; + onRetry: () => void; +}) { + if (canceled) return Upload canceled.; + if (!error) return null; + return ( +
+ {safeMediaUploadErrorMessage(error)} + {!locked && error instanceof MediaUploadNetworkError && ( + + )} +
+ ); +} + +function VendorUploadButton({ + canUpload, + pending, + onClick, +}: { + canUpload: boolean; + pending: boolean; + onClick: () => void; +}) { + if (!canUpload) return null; + return ( +
+ +
+ ); +} + export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSectionProps) { const inputRef = useRef(null); const controllerRef = useRef(null); @@ -159,82 +259,27 @@ export function VendorMediaSection({ token, dispatchId, locked }: VendorMediaSec No photos or videos uploaded yet. )} - {items.map((item) => ( -
-
- File - - {item.originalFileName} · {item.kind} · {formatBytes(item.sizeBytes)} - -
-
- -
-
- ))} + void handleView(item)} /> + controllerRef.current?.abort()} + /> + startUpload(failedFileRef.current, true)} + /> - {uploadMutation.isPending && ( -
- {progress !== undefined ? ( - <> - - Uploading… {progress}% - - ) : ( - Preparing upload… - )} - -
- )} - - {uploadCanceled && Upload canceled.} - - {uploadMutation.isError && !uploadCanceled && ( -
- - {safeMediaUploadErrorMessage(uploadMutation.error)} - - {!locked && uploadMutation.error instanceof MediaUploadNetworkError && ( - - )} -
- )} - - {canUpload && ( -
- -
- )} + { + uploadMutation.reset(); + inputRef.current?.click(); + }} + /> {locked && ( diff --git a/src/test/api/media-upload-engine.test.ts b/src/test/api/media-upload-engine.test.ts index e5245278..e06b9264 100644 --- a/src/test/api/media-upload-engine.test.ts +++ b/src/test/api/media-upload-engine.test.ts @@ -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 () => { diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx index c9d7b4d4..6fe189a3 100644 --- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx +++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx @@ -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", diff --git a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx index eb2a34b4..90ae47e0 100644 --- a/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx +++ b/src/test/app/(protected)/workorders/use-slide-over-media-state.test.tsx @@ -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"); }); });