From 74e5c58e5decc80d842607c042cbec22f3bfb23c Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 7 Sep 2026 15:19:39 -0300 Subject: [PATCH 1/4] fix(work-orders): restore completion media uploads over HTTP/1.1 Use XHR for media progress so Ky does not stream the body on HTTP/1.1 (Vite proxy), and keep completion vs extra tiles from mixing after upload. --- src/api/api.ts | 16 +- src/api/upload-form-with-progress.ts | 103 ++++++++++ .../detail/use-slide-over-media-state.ts | 72 +++++-- .../_components/media/media-upload-modal.tsx | 22 +-- .../media/media-uploader-dropzone.tsx | 6 +- .../media/media-uploader-grid-item-meta.tsx | 8 +- .../_components/media/media-uploader.tsx | 7 +- .../api/work-order-board-documents-api.ts | 37 ++-- .../mappers/work-order-board-detail-mapper.ts | 3 +- .../work-orders/utils/display-file-name.ts | 10 + .../utils/partition-slide-over-media.ts | 44 +++++ .../utils/work-order-media-limits.ts | 10 +- src/lib/auth/expire-session.ts | 12 ++ .../api/upload-form-with-progress.test.ts | 184 ++++++++++++++++++ .../media-upload-modal-api.test.tsx | 34 +++- .../use-slide-over-media-state.test.tsx | 81 +++++++- ...ork-orders-board-core-affordances.test.tsx | 24 +++ .../work-orders/api/work-orders-api.test.ts | 44 +++-- .../work-order-board-detail-mapper.test.ts | 14 ++ .../work-order-table-row-mapper.test.ts | 19 ++ .../utils/display-file-name.test.ts | 24 +++ .../utils/partition-slide-over-media.test.ts | 79 ++++++++ .../utils/work-order-media-limits.test.ts | 1 + 23 files changed, 757 insertions(+), 97 deletions(-) create mode 100644 src/api/upload-form-with-progress.ts create mode 100644 src/domain/work-orders/utils/display-file-name.ts create mode 100644 src/domain/work-orders/utils/partition-slide-over-media.ts create mode 100644 src/lib/auth/expire-session.ts create mode 100644 src/test/api/upload-form-with-progress.test.ts create mode 100644 src/test/domain/work-orders/utils/display-file-name.test.ts create mode 100644 src/test/domain/work-orders/utils/partition-slide-over-media.test.ts diff --git a/src/api/api.ts b/src/api/api.ts index 51897b61..3cb8a7f0 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -3,18 +3,8 @@ import { env } from "@/lib/env"; import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url"; import { API_PATHS } from "@/api/api-paths"; import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error"; -import queryClient from "@/lib/query/query-client"; -import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast"; -import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; - -function clearSessionAndRedirectToLogin(): void { - clearAuth(); - stopQueryBroadcast(queryClient); - - if (typeof window !== "undefined" && window.location.pathname !== "/login") { - window.location.replace("/login"); - } -} +import { getAccessToken } from "@/lib/auth/auth-storage"; +import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; function isAuthLoginRequest(request: Request): boolean { return request.url.includes(API_PATHS.authentication.login); @@ -51,7 +41,7 @@ export const api: KyInstance = ky.create({ return response; } - clearSessionAndRedirectToLogin(); + expireSessionAndRedirectToLogin(); return response; }, ], diff --git a/src/api/upload-form-with-progress.ts b/src/api/upload-form-with-progress.ts new file mode 100644 index 00000000..90a2a254 --- /dev/null +++ b/src/api/upload-form-with-progress.ts @@ -0,0 +1,103 @@ +import { getAccessToken } from "@/lib/auth/auth-storage"; +import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; +import { env } from "@/lib/env"; +import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url"; + +export interface UploadFormResult { + ok: boolean; + status: number; + data: unknown; +} + +export interface UploadFormOptions { + signal?: AbortSignal; + onProgress?: (percent: number) => void; +} + +function parseJsonBody(text: string): unknown { + if (!text.trim()) { + return undefined; + } + + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +function createAbortError(): DOMException { + return new DOMException("The upload was aborted.", "AbortError"); +} + +/** + * hazard: Ky's `onUploadProgress` turns the body into a ReadableStream, and browsers only accept + * streamed request bodies over HTTP/2 — on HTTP/1.1 (Vite proxy, plain-HTTP hosts) the request dies + * as a network error before reaching the API. XHR reports progress on every transport. + */ +export function uploadFormWithProgress( + path: string, + body: FormData, + options?: UploadFormOptions, +): Promise { + return new Promise((resolve, reject) => { + if (options?.signal?.aborted) { + reject(createAbortError()); + return; + } + + const request = new XMLHttpRequest(); + request.open("POST", `${resolveApiBaseUrl(env.apiUrl)}/${path.replace(/^\/+/, "")}`); + request.setRequestHeader("Accept", "application/json"); + + const token = getAccessToken(); + if (token) { + request.setRequestHeader("Authorization", token); + } + + const abortRequest = () => { + request.abort(); + }; + options?.signal?.addEventListener("abort", abortRequest); + + const detachSignal = () => { + options?.signal?.removeEventListener("abort", abortRequest); + }; + + const { onProgress } = options ?? {}; + if (onProgress) { + request.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) { + onProgress(Math.round((event.loaded / event.total) * 100)); + } + }); + } + + request.addEventListener("load", () => { + detachSignal(); + const { status } = request; + + if (status === 401) { + expireSessionAndRedirectToLogin(); + } + + resolve({ + ok: status >= 200 && status < 300, + status, + data: parseJsonBody(request.responseText), + }); + }); + + request.addEventListener("error", () => { + detachSignal(); + reject(new Error("Upload failed. Check your connection and try again.")); + }); + + request.addEventListener("abort", () => { + detachSignal(); + reject(createAbortError()); + }); + + request.send(body); + }); +} 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 94b60262..fe011400 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,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, 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"; @@ -9,9 +9,12 @@ import type { useDeleteWorkOrderMedia, useUpdateWorkOrderMediaCategory, useUploadWorkOrderMedia, + UploadWorkOrderMediaInput, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; - -const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]); +import { + isCompletionSurfaceMedia, + isExtrasSurfaceMedia, +} from "@/domain/work-orders/utils/partition-slide-over-media"; type UseSlideOverMediaStateArgs = { detailForRow?: WorkOrderBoardDetail; @@ -20,6 +23,20 @@ type UseSlideOverMediaStateArgs = { categorizeMediaMutation: ReturnType; }; +function addOwnedId(previous: Set, id: string | undefined): Set { + if (!id) return previous; + const next = new Set(previous); + next.add(id); + return next; +} + +function dropOwnedId(previous: Set, id: string): Set { + if (!previous.has(id)) return previous; + const next = new Set(previous); + next.delete(id); + return next; +} + export function useSlideOverMediaState({ detailForRow, uploadMediaMutation, @@ -27,28 +44,38 @@ export function useSlideOverMediaState({ categorizeMediaMutation, }: UseSlideOverMediaStateArgs) { const [mediaBatchPending, setMediaBatchPending] = useState(false); + const [completionOwnedIds, setCompletionOwnedIds] = useState>(() => new Set()); + const [extraOwnedIds, setExtraOwnedIds] = useState>(() => new Set()); + + const persistCompletionUpload = useCallback( + async (input: UploadWorkOrderMediaInput) => { + const item = await uploadMediaMutation.mutateAsync(input); + setCompletionOwnedIds((previous) => addOwnedId(previous, item.id)); + setExtraOwnedIds((previous) => dropOwnedId(previous, item.id)); + return item; + }, + [uploadMediaMutation], + ); + const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } = - useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input)); + useMediaUploadSession(detailForRow, persistCompletionUpload); + + const ownership = useMemo( + () => ({ completionOwnedIds, extraOwnedIds }), + [completionOwnedIds, extraOwnedIds], + ); const completionMedia = useMemo( - () => - uploads.filter( - (item) => - COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") || - item.uploadStatus === "uploading" || - item.uploadStatus === "failed", - ), - [uploads], + () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)), + [uploads, ownership], ); const extrasUploads = useMemo( () => - boardMediaItemsToMediaFiles( - (detailForRow?.media ?? []).filter( - (item) => item.category === "Extra" || item.category === "Aveta", - ), + boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) => + isExtrasSurfaceMedia(item, ownership), ), - [detailForRow?.media], + [detailForRow?.media, ownership], ); const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); @@ -68,7 +95,9 @@ export function useSlideOverMediaState({ try { for (const file of files) { // Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH. - await uploadMediaMutation.mutateAsync({ file, category: "Extra" }); + const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" }); + setExtraOwnedIds((previous) => addOwnedId(previous, item.id)); + setCompletionOwnedIds((previous) => dropOwnedId(previous, item.id)); } } finally { setMediaBatchPending(false); @@ -81,6 +110,13 @@ export function useSlideOverMediaState({ toast.error("Missing work order version. Refresh and try again."); return; } + if (category === "Extra") { + setExtraOwnedIds((previous) => addOwnedId(previous, mediaId)); + setCompletionOwnedIds((previous) => dropOwnedId(previous, mediaId)); + } else { + setExtraOwnedIds((previous) => dropOwnedId(previous, mediaId)); + setCompletionOwnedIds((previous) => dropOwnedId(previous, mediaId)); + } categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion }); }; 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 ba84fa1c..e3aa23ee 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -12,7 +12,6 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; import { useDeleteWorkOrderMedia, - useUpdateWorkOrderMediaCategory, useUploadWorkOrderMedia, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; @@ -35,14 +34,12 @@ function resolveWorkOrderVersion( export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) { const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open); const uploadMutation = useUploadWorkOrderMedia(row.id); - const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id); const deleteMutation = useDeleteWorkOrderMedia(row.id); const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status); const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } = useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input)); - const pending = - uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending; + const pending = uploadMutation.isPending || deleteMutation.isPending; const handleClose = guardDialogClose(pending, () => onOpenChange(false)); const requireVersion = (): string | null => { @@ -69,14 +66,18 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr > e.stopPropagation()}>
- - + + Photos & Videos - #{row.woNumber} + + #{row.woNumber} +
- Upload photos and videos for this work order, then assign Before, After, or Extra. + Upload photos and videos for this work order.
@@ -101,11 +102,6 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr if (!workOrderVersion) return; deleteMutation.mutate({ mediaId: id, workOrderVersion }); }} - onCategorize={(id, category) => { - const workOrderVersion = requireVersion(); - if (!workOrderVersion) return; - categorizeMutation.mutate({ mediaId: id, category, workOrderVersion }); - }} onRetry={retryUpload} onCancel={cancelUpload} readOnly={isReadOnly} diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx index e6b3aa19..439b4693 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx @@ -8,12 +8,14 @@ import { type MediaUploaderDropzoneProps = { inputRef: RefObject; pending: boolean; + categorizeAfterUpload?: boolean; onFilesSelected: (files: FileList | null) => void; }; export function MediaUploaderDropzone({ inputRef, pending, + categorizeAfterUpload = false, onFilesSelected, }: MediaUploaderDropzoneProps) { return ( @@ -39,8 +41,8 @@ export function MediaUploaderDropzone({ {pending ? "Uploading…" : "Drag files here or click to browse"} - Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after - upload + Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file + {categorizeAfterUpload ? " · categorize after upload" : ""} @@ -34,7 +35,7 @@ export function MediaUploaderGridItemMeta({ {file.errorMessage || "Upload failed"}
)} - {needsCategory ? ( + {needsCategory && ( - ) : ( + )} + {Boolean(!needsCategory && managesPurpose) && (
{file.purpose || (file.isLegacy ? "Legacy" : "")}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx index 69f4c364..aed81332 100644 --- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx +++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx @@ -91,7 +91,12 @@ export function MediaUploader({ return (
{!readOnly && ( - + )} { } } -function throwDocumentHttpError(response: Response, data: unknown): never { +function throwDocumentHttpError(status: number, data: unknown): never { const record = typeof data === "object" && data !== null ? (data as Record) : {}; - if (response.status === 409) { + if (status === 409) { throw new BoardPatchConflictError( typeof record.message === "string" ? record.message @@ -45,14 +46,14 @@ function throwDocumentHttpError(response: Response, data: unknown): never { ); } - if (response.status === 422) { + if (status === 422) { throw new BoardPatchValidationError( typeof record.code === "string" ? record.code : "ValidationError", typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data), ); } - throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status); + throw new ApiError(mapHttpStatusToMessage(status, data), status); } export const workOrderBoardDocumentsApi = { @@ -94,7 +95,7 @@ export const workOrderBoardDocumentsApi = { return mapBoardDetailComment(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, /** @deprecated Prefer createComment — modern board endpoint. */ @@ -119,21 +120,15 @@ export const workOrderBoardDocumentsApi = { options?: { signal?: AbortSignal; onProgress?: (percent: number) => void }, ): Promise => { const formData = buildMediaFormData(file, category); - const response = await api.post(API_PATHS.workOrder.media(workOrderId), { - body: formData, - throwHttpErrors: false, - signal: options?.signal, - onUploadProgress: options?.onProgress - ? (progress) => { - options.onProgress?.(Math.round(progress.percent * 100)); - } - : undefined, - }); - const data = await readResponseJson(response); - if (response.ok) { + const { ok, status, data } = await uploadFormWithProgress( + API_PATHS.workOrder.media(workOrderId), + formData, + { signal: options?.signal, onProgress: options?.onProgress }, + ); + if (ok) { return mapBoardDetailMediaItem(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(status, data); }, updateMediaCategory: async ( @@ -160,7 +155,7 @@ export const workOrderBoardDocumentsApi = { if (response.ok) { return mapBoardDetailMediaItem(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, deleteMedia: async ( @@ -196,7 +191,7 @@ export const workOrderBoardDocumentsApi = { if (response.ok) { return; } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, uploadCompletionDoc: async ( @@ -224,6 +219,6 @@ export const workOrderBoardDocumentsApi = { if (response.ok) { return mapCompletion(handleApiResponse(data) ?? data); } - throwDocumentHttpError(response, data); + throwDocumentHttpError(response.status, data); }, }; diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index 950f8fd2..e2ae7411 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -12,6 +12,7 @@ import type { WorkOrderComment, } from "@/domain/work-orders/types/work-order-detail"; import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper"; +import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name"; import type { FileCategory, MediaCategory, @@ -220,7 +221,7 @@ export function mapBoardDetailMediaItem(raw: unknown): WorkOrderBoardMediaItem { id: String(id ?? (readString(item, "id") || cryptoRandomId())), category: mapMediaCategory(item.category ?? item.Category), url: readString(item, "url", "Url"), - fileName: readString(item, "fileName", "FileName") || "file", + fileName: toDisplayFileName(readString(item, "fileName", "FileName") || "file"), uploadedAt: readString(item, "uploadedAt", "UploadedAt"), isLegacy: isLegacyFlag || (id != null && id <= 0), }; diff --git a/src/domain/work-orders/utils/display-file-name.ts b/src/domain/work-orders/utils/display-file-name.ts new file mode 100644 index 00000000..65659c24 --- /dev/null +++ b/src/domain/work-orders/utils/display-file-name.ts @@ -0,0 +1,10 @@ +/** + * why: uploads are stored as `{guid}_{original name}`, so the raw storage name shown in media + * tiles reads as an id. The original name (and its extension) survive after the prefix. + */ +const STORED_NAME_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/i; + +export function toDisplayFileName(fileName: string): string { + const withoutPrefix = fileName.replace(STORED_NAME_PREFIX, "").trim(); + return withoutPrefix || fileName; +} diff --git a/src/domain/work-orders/utils/partition-slide-over-media.ts b/src/domain/work-orders/utils/partition-slide-over-media.ts new file mode 100644 index 00000000..5de1e4ad --- /dev/null +++ b/src/domain/work-orders/utils/partition-slide-over-media.ts @@ -0,0 +1,44 @@ +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; + +const COMPLETION_PURPOSES = new Set(["Before", "After", "Completion"]); + +export type MediaSurfaceOwnership = { + completionOwnedIds: ReadonlySet; + extraOwnedIds: ReadonlySet; +}; + +const EMPTY_OWNERSHIP: MediaSurfaceOwnership = { + completionOwnedIds: new Set(), + extraOwnedIds: new Set(), +}; + +function isPhotoOrVideo(item: MediaFile): boolean { + return item.fileType === "image" || item.fileType === "video"; +} + +function isInFlight(item: MediaFile): boolean { + return item.uploadStatus === "uploading" || item.uploadStatus === "failed"; +} + +/** Extra photo/video from Completion Doc POST (no category) until Extra is chosen. */ +export function isCompletionSurfaceMedia( + item: MediaFile, + ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, +): boolean { + if (isInFlight(item)) return true; + if (COMPLETION_PURPOSES.has(item.purpose ?? "")) return true; + if (ownership.completionOwnedIds.has(item.id)) return true; + if (ownership.extraOwnedIds.has(item.id)) return false; + return item.purpose === "Extra" && isPhotoOrVideo(item); +} + +export function isExtrasSurfaceMedia( + item: MediaFile, + ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP, +): boolean { + if (item.purpose === "Aveta") return true; + if (item.purpose !== "Extra") return false; + if (ownership.completionOwnedIds.has(item.id)) return false; + if (ownership.extraOwnedIds.has(item.id)) return true; + return !isPhotoOrVideo(item); +} diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts index 58cc358a..d93fb7cb 100644 --- a/src/domain/work-orders/utils/work-order-media-limits.ts +++ b/src/domain/work-orders/utils/work-order-media-limits.ts @@ -1,7 +1,13 @@ /** Backend RequestSizeLimit for media and completion-doc uploads. */ export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024; -const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]); +const ALLOWED_MIME_TYPES = new Set([ + "image/jpeg", + "image/jpg", + "image/png", + "video/mp4", + "video/quicktime", +]); const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]); @@ -22,7 +28,7 @@ export function isWorkOrderMediaTypeAllowed(file: File): boolean { } export function workOrderMediaAcceptAttribute(): string { - return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov"; + return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov"; } export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string { diff --git a/src/lib/auth/expire-session.ts b/src/lib/auth/expire-session.ts new file mode 100644 index 00000000..0c7c8900 --- /dev/null +++ b/src/lib/auth/expire-session.ts @@ -0,0 +1,12 @@ +import { clearAuth } from "@/lib/auth/auth-storage"; +import queryClient from "@/lib/query/query-client"; +import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast"; + +export function expireSessionAndRedirectToLogin(): void { + clearAuth(); + stopQueryBroadcast(queryClient); + + if (typeof window !== "undefined" && window.location.pathname !== "/login") { + window.location.replace("/login"); + } +} diff --git a/src/test/api/upload-form-with-progress.test.ts b/src/test/api/upload-form-with-progress.test.ts new file mode 100644 index 00000000..18a4f4dc --- /dev/null +++ b/src/test/api/upload-form-with-progress.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const expireSession = vi.fn(); + +vi.mock("@/lib/auth/expire-session", () => ({ + expireSessionAndRedirectToLogin: () => expireSession(), +})); + +import { uploadFormWithProgress } from "@/api/upload-form-with-progress"; + +type Listener = (event?: unknown) => void; + +class ListenerBag { + private readonly listeners = new Map(); + + addEventListener(type: string, listener: Listener): void { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + removeEventListener(type: string, listener: Listener): void { + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter((entry) => entry !== listener), + ); + } + + emit(type: string, event?: unknown): void { + for (const listener of [...(this.listeners.get(type) ?? [])]) { + listener(event); + } + } +} + +class FakeXhr extends ListenerBag { + static last: FakeXhr | undefined; + + readonly upload = new ListenerBag(); + readonly headers: Record = {}; + status = 0; + responseText = ""; + method = ""; + url = ""; + body: FormData | undefined; + abortCount = 0; + + constructor() { + super(); + FakeXhr.last = this; + } + + open(method: string, url: string): void { + this.method = method; + this.url = url; + } + + setRequestHeader(name: string, value: string): void { + this.headers[name] = value; + } + + send(body: FormData): void { + this.body = body; + } + + abort(): void { + this.abortCount += 1; + this.emit("abort"); + } + + respond(status: number, responseText = ""): void { + this.status = status; + this.responseText = responseText; + this.emit("load"); + } +} + +function currentXhr(): FakeXhr { + const instance = FakeXhr.last; + if (!instance) throw new Error("No XMLHttpRequest was created"); + return instance; +} + +function formWithFile(): FormData { + const body = new FormData(); + body.append("file", new File(["bytes"], "after.jpg", { type: "image/jpeg" })); + return body; +} + +describe("uploadFormWithProgress", () => { + const originalXhr = globalThis.XMLHttpRequest; + + beforeEach(() => { + expireSession.mockReset(); + FakeXhr.last = undefined; + globalThis.XMLHttpRequest = FakeXhr as unknown as typeof XMLHttpRequest; + localStorage.setItem("auth", JSON.stringify({ token: "abc123" })); + }); + + afterEach(() => { + globalThis.XMLHttpRequest = originalXhr; + localStorage.clear(); + }); + + it("POSTs the FormData with the stored bearer token and no streamed body", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + const request = currentXhr(); + + expect(request.method).toBe("POST"); + expect(request.url.endsWith("/api/workorders/10/media")).toBe(true); + expect(request.headers.Authorization).toBe("Bearer abc123"); + expect(request.body).toBeInstanceOf(FormData); + + request.respond(201, JSON.stringify({ data: { id: 12 } })); + + await expect(pending).resolves.toEqual({ + ok: true, + status: 201, + data: { data: { id: 12 } }, + }); + }); + + it("reports upload progress as whole percentages", async () => { + const onProgress = vi.fn(); + const pending = uploadFormWithProgress("workorders/10/media", formWithFile(), { onProgress }); + const request = currentXhr(); + + request.upload.emit("progress", { lengthComputable: true, loaded: 25, total: 200 }); + request.upload.emit("progress", { lengthComputable: false, loaded: 30, total: 200 }); + request.respond(200, "{}"); + await pending; + + expect(onProgress.mock.calls).toEqual([[13]]); + }); + + it("resolves non-2xx responses so callers can map API errors", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + currentXhr().respond( + 422, + JSON.stringify({ code: "ReadOnly", message: "Work order is closed." }), + ); + + await expect(pending).resolves.toEqual({ + ok: false, + status: 422, + data: { code: "ReadOnly", message: "Work order is closed." }, + }); + expect(expireSession).not.toHaveBeenCalled(); + }); + + it("expires the session on 401", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + currentXhr().respond(401); + + await expect(pending).resolves.toMatchObject({ ok: false, status: 401 }); + expect(expireSession).toHaveBeenCalledTimes(1); + }); + + it("rejects transport failures with an actionable message", async () => { + const pending = uploadFormWithProgress("workorders/10/media", formWithFile()); + currentXhr().emit("error"); + + await expect(pending).rejects.toThrow(/Upload failed/); + }); + + it("aborts the request when the caller signal aborts", async () => { + const controller = new AbortController(); + const pending = uploadFormWithProgress("workorders/10/media", formWithFile(), { + signal: controller.signal, + }); + + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(currentXhr().abortCount).toBe(1); + }); + + it("rejects immediately when the signal is already aborted", async () => { + await expect( + uploadFormWithProgress("workorders/10/media", formWithFile(), { + signal: AbortSignal.abort(), + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(FakeXhr.last).toBeUndefined(); + }); +}); 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 fd628353..b1fb4451 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 @@ -116,24 +116,42 @@ describe("MediaUploadModal API wiring", () => { expect(screen.getByText("before.jpg")).toBeInTheDocument(); }); expect(screen.getByText("Photos & Videos")).toBeInTheDocument(); - expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument(); - expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument(); }); - it("categorizes with workOrderVersion from detail.info.rowVersion", async () => { + it("offers no purpose picker — the table modal only uploads and removes", async () => { renderModal(); await waitFor(() => { - expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument(); + expect(screen.getByText("before.jpg")).toBeInTheDocument(); }); - fireEvent.change(screen.getByLabelText(/category for before\.jpg/i), { - target: { value: "After" }, + expect(screen.queryByLabelText(/category for before\.jpg/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument(); + expect(categorizeMutate).not.toHaveBeenCalled(); + }); + + it("shows only the file name under each tile — no purpose label", async () => { + renderModal(); + + await waitFor(() => { + expect(screen.getByText("before.jpg")).toBeInTheDocument(); }); - expect(categorizeMutate).toHaveBeenCalledWith({ + expect(screen.queryByText("Before")).not.toBeInTheDocument(); + expect(screen.queryByText("Extra")).not.toBeInTheDocument(); + }); + + it("removes media with workOrderVersion from detail.info.rowVersion", async () => { + renderModal(); + + await waitFor(() => { + expect(screen.getByText("before.jpg")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /^remove$/i })); + + expect(deleteMutate).toHaveBeenCalledWith({ mediaId: "11", - category: "After", workOrderVersion: "DETAIL-VER==", }); }); 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 d3504fbc..de7e96ff 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 @@ -1,4 +1,4 @@ -import { renderHook } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state"; import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; @@ -119,6 +119,14 @@ describe("useSlideOverMediaState completion filter", () => { uploadedAt: "2026-07-15T18:00:00.0000000Z", isLegacy: false, }, + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, ]); const { result } = renderHook(() => @@ -134,7 +142,9 @@ describe("useSlideOverMediaState completion filter", () => { "Before", "After", "Completion", + "Extra", ]); + expect(result.current.completionMedia.map((item) => item.id)).toContain("10"); expect( result.current.completionMedia.every((item) => ["Photo", "Video", "Report", "Other"].includes(item.category), @@ -142,6 +152,32 @@ describe("useSlideOverMediaState completion filter", () => { ).toBe(true); expect(result.current.extrasUploads).toHaveLength(1); expect(result.current.extrasUploads[0]?.purpose).toBe("Extra"); + expect(result.current.extrasUploads[0]?.id).toBe("4"); + }); + + it("keeps Extra photos on Completion Doc until Extra is assigned", () => { + const detail = detailWithMedia([ + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + ]); + + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detail, + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: idleMutation(), + }), + ); + + expect(result.current.completionMedia.map((item) => item.id)).toEqual(["10"]); + expect(result.current.extrasUploads).toHaveLength(0); }); it("includes Aveta-categorized extra documents in extrasUploads", () => { @@ -177,4 +213,47 @@ describe("useSlideOverMediaState completion filter", () => { expect(result.current.extrasUploads.map((item) => item.purpose)).toEqual(["Extra", "Aveta"]); expect(result.current.extrasUploads[1]?.category).toBe("Aveta"); }); + + it("moves an Extra photo to Extra Docs after assigning Extra", () => { + const detail = detailWithMedia([ + { + id: "10", + category: "Extra", + url: "https://cdn.example.com/site.jpg", + fileName: "site.jpg", + uploadedAt: "2026-07-15T18:00:00.0000000Z", + isLegacy: false, + }, + ]); + const categorizeMutate = vi.fn(); + const categorize = { + mutate: categorizeMutate, + mutateAsync: vi.fn().mockResolvedValue({}), + isPending: false, + } as never; + + const { result } = renderHook(() => + useSlideOverMediaState({ + detailForRow: detail, + uploadMediaMutation: idleMutation(), + deleteMediaMutation: idleMutation(), + categorizeMediaMutation: categorize, + }), + ); + + expect(result.current.completionMedia).toHaveLength(1); + expect(result.current.extrasUploads).toHaveLength(0); + + act(() => { + result.current.categorizeMedia("10", "Extra"); + }); + + expect(result.current.completionMedia).toHaveLength(0); + expect(result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]); + expect(categorizeMutate).toHaveBeenCalledWith({ + mediaId: "10", + category: "Extra", + workOrderVersion: "1", + }); + }); }); diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index e273324c..c466867d 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -271,4 +271,28 @@ describe("CompDocCell slice affordances", () => { expect(screen.getByRole("button", { name: /view doc/i })).toBeInTheDocument(); }); + + it("shows the uploaded file count for media work orders", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByRole("button", { name: /2 files/i })).toBeInTheDocument(); + }); + + it("shows a singular label with one file and the Media prompt with none", () => { + const { unmount } = renderWithProviders( + , + { withAuth: false }, + ); + expect(screen.getByRole("button", { name: /^1 file$/i })).toBeInTheDocument(); + unmount(); + + renderWithProviders( + , + { withAuth: false }, + ); + expect(screen.getByRole("button", { name: /^media$/i })).toBeInTheDocument(); + }); }); diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index d383aefc..7d7a3d1f 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -9,6 +9,11 @@ const apiPatch = vi.fn(); const apiPatchFn = vi.fn(); const apiPostFn = vi.fn(); const apiDeleteFn = vi.fn(); +const uploadFormFn = vi.fn(); + +vi.mock("@/api/upload-form-with-progress", () => ({ + uploadFormWithProgress: (...args: unknown[]) => uploadFormFn(...args), +})); vi.mock("@/api/api", () => ({ apiGet: (...args: unknown[]) => apiGet(...args), @@ -654,44 +659,55 @@ describe("workOrdersApi.updateComment", () => { describe("workOrdersApi.uploadMedia", () => { beforeEach(() => { - apiPostFn.mockReset(); + uploadFormFn.mockReset(); }); - it("POSTs multipart FormData with file and category", async () => { - apiPostFn.mockResolvedValue({ + it("POSTs multipart FormData with file and category, forwarding progress and signal", async () => { + uploadFormFn.mockResolvedValue({ ok: true, - json: async () => ({ + status: 201, + data: { id: 12, category: 3, url: "https://cdn.example.com/extra.jpg", fileName: "extra.jpg", uploadedAt: "2026-07-16T12:00:00Z", isLegacy: false, - }), + }, }); const file = new File(["bytes"], "extra.jpg", { type: "image/jpeg" }); - const item = await workOrdersApi.uploadMedia(10, file, "Extra"); + const onProgress = vi.fn(); + const signal = new AbortController().signal; + const item = await workOrdersApi.uploadMedia(10, file, "Extra", { signal, onProgress }); - expect(apiPostFn).toHaveBeenCalledWith( - API_PATHS.workOrder.media(10), - expect.objectContaining({ throwHttpErrors: false }), - ); - const body = apiPostFn.mock.calls[0]?.[1]?.body as FormData; + expect(uploadFormFn).toHaveBeenCalledWith(API_PATHS.workOrder.media(10), expect.any(FormData), { + signal, + onProgress, + }); + const body = uploadFormFn.mock.calls[0]?.[1] as FormData; expect(body.get("category")).toBe("Extra"); expect(body.get("file")).toBeInstanceOf(File); expect(item.id).toBe("12"); expect(item.category).toBe("Extra"); }); + it("does not upload through Ky, whose streamed body breaks HTTP/1.1 transports", async () => { + uploadFormFn.mockResolvedValue({ ok: true, status: 200, data: { id: 12, category: 3 } }); + + await workOrdersApi.uploadMedia(10, new File(["x"], "a.jpg", { type: "image/jpeg" }), "Before"); + + expect(apiPostFn).not.toHaveBeenCalled(); + }); + it("throws BoardPatchValidationError on 422", async () => { - apiPostFn.mockResolvedValue({ + uploadFormFn.mockResolvedValue({ ok: false, status: 422, - json: async () => ({ + data: { code: "ReadOnly", message: "Work order is read-only.", - }), + }, }); const { BoardPatchValidationError } = diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts index 21867fa4..7f34bcc8 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts @@ -236,6 +236,20 @@ describe("mapWorkOrderBoardDetail", () => { }); }); + it("shows the uploaded file name instead of the stored guid name", () => { + const stored = mapBoardDetailMediaItem({ + id: 30, + category: 3, + url: "https://cdn.example.com/7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_6370_1_Before.jpg", + fileName: "7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_6370_1_Before.jpg", + }); + expect(stored.fileName).toBe("6370_1_Before.jpg"); + expect(boardMediaItemsToMediaFiles([stored])[0]).toMatchObject({ + name: "6370_1_Before.jpg", + fileType: "image", + }); + }); + it("maps numeric category and marks negative ids as legacy", () => { const legacy = mapBoardDetailMediaItem({ id: -1, diff --git a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts index 81b8e3e8..0b0e78c5 100644 --- a/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-table-row-mapper.test.ts @@ -90,6 +90,25 @@ describe("mapWorkOrderTableRow avetaRequired", () => { }); }); +describe("mapWorkOrderTableRow mediaCount", () => { + it("maps the board mediaCount that feeds the CompDoc file count", () => { + const row = mapWorkOrderTableRow({ + id: 11, + workOrderType: WORK_ORDER_TYPE_API.Emergency, + mediaCount: 2, + }); + expect(row.mediaCount).toBe(2); + }); + + it("defaults mediaCount to zero when the board omits it", () => { + const row = mapWorkOrderTableRow({ + id: 12, + workOrderType: WORK_ORDER_TYPE_API.Emergency, + }); + expect(row.mediaCount).toBe(0); + }); +}); + describe("mapWorkOrderTableRow primaryDispatchStatus", () => { it.each([ ["primaryDispatchStatus", "Refused"], diff --git a/src/test/domain/work-orders/utils/display-file-name.test.ts b/src/test/domain/work-orders/utils/display-file-name.test.ts new file mode 100644 index 00000000..5c51f417 --- /dev/null +++ b/src/test/domain/work-orders/utils/display-file-name.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name"; + +describe("toDisplayFileName", () => { + it("drops the storage guid prefix and keeps the original name", () => { + expect(toDisplayFileName("7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_6370_1_Before.jpg")).toBe( + "6370_1_Before.jpg", + ); + }); + + it("matches the prefix regardless of guid casing", () => { + expect(toDisplayFileName("7A0CF3B5-3EEB-4C4F-A86E-8E2DA1B0C9F4_report.pdf")).toBe("report.pdf"); + }); + + it("keeps names that do not carry a guid prefix", () => { + expect(toDisplayFileName("before.jpg")).toBe("before.jpg"); + expect(toDisplayFileName("2026_summary_report.pdf")).toBe("2026_summary_report.pdf"); + }); + + it("falls back to the raw name when nothing follows the prefix", () => { + const onlyPrefix = "7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_"; + expect(toDisplayFileName(onlyPrefix)).toBe(onlyPrefix); + }); +}); diff --git a/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts new file mode 100644 index 00000000..077eff57 --- /dev/null +++ b/src/test/domain/work-orders/utils/partition-slide-over-media.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import { + isCompletionSurfaceMedia, + isExtrasSurfaceMedia, +} from "@/domain/work-orders/utils/partition-slide-over-media"; + +function file( + overrides: Partial & Pick, +): MediaFile { + return { + name: overrides.name ?? overrides.id, + url: "https://cdn.example.com/file", + category: "Photo", + uploadStatus: "done", + ...overrides, + }; +} + +describe("partition-slide-over-media", () => { + it("keeps unowned Extra photos on Completion Doc, not Extra Docs", () => { + const photo = file({ + id: "10", + fileType: "image", + purpose: "Extra", + name: "site.jpg", + }); + + expect(isCompletionSurfaceMedia(photo)).toBe(true); + expect(isExtrasSurfaceMedia(photo)).toBe(false); + }); + + it("keeps Extra PDFs on Extra Docs, not Completion Doc", () => { + const pdf = file({ + id: "4", + fileType: "pdf", + purpose: "Extra", + name: "extra.pdf", + category: "Other", + }); + + expect(isCompletionSurfaceMedia(pdf)).toBe(false); + expect(isExtrasSurfaceMedia(pdf)).toBe(true); + }); + + it("moves Extra photo to Extra Docs after explicit Extra ownership", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const ownership = { + completionOwnedIds: new Set(), + extraOwnedIds: new Set(["10"]), + }; + + expect(isCompletionSurfaceMedia(photo, ownership)).toBe(false); + expect(isExtrasSurfaceMedia(photo, ownership)).toBe(true); + }); + + it("keeps Extra photo on Completion Doc while completion-owned", () => { + const photo = file({ id: "10", fileType: "image", purpose: "Extra" }); + const ownership = { + completionOwnedIds: new Set(["10"]), + extraOwnedIds: new Set(), + }; + + expect(isCompletionSurfaceMedia(photo, ownership)).toBe(true); + expect(isExtrasSurfaceMedia(photo, ownership)).toBe(false); + }); + + it("keeps Before/After/Completion and in-flight tiles on Completion Doc", () => { + expect(isCompletionSurfaceMedia(file({ id: "1", fileType: "image", purpose: "Before" }))).toBe( + true, + ); + expect( + isCompletionSurfaceMedia({ + ...file({ id: "local-1", fileType: "image", purpose: null }), + uploadStatus: "uploading", + }), + ).toBe(true); + }); +}); diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts index 2ae1f827..49af4e90 100644 --- a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts +++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts @@ -27,6 +27,7 @@ describe("work-order-media-limits", () => { it("allows only JPG PNG MP4 MOV by MIME or extension", () => { expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe(true); + expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpg" } as File)).toBe(true); expect(isWorkOrderMediaTypeAllowed({ name: "a.png", type: "image/png" } as File)).toBe(true); expect(isWorkOrderMediaTypeAllowed({ name: "a.mp4", type: "video/mp4" } as File)).toBe(true); expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe( From 747515870e107dedbe64d7646d6c45b60d5e0b23 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 7 Sep 2026 20:48:30 -0300 Subject: [PATCH 2/4] fix(workorders): correct vendor save behavior --- .../table/cells/use-vendor-dialog-form.ts | 177 ++++++++++++++++++ .../table/cells/use-vendor-dialog-save.ts | 7 +- .../list/table/cells/vendor-dialog-body.tsx | 59 ++++-- .../table/cells/vendor-dialog-can-submit.ts | 27 +++ .../cells/vendor-dialog-company-card.tsx | 7 +- .../list/table/cells/vendor-dialog-footer.tsx | 15 +- .../list/table/cells/vendor-dialog-options.ts | 50 ++++- .../list/table/cells/vendor-dialog.tsx | 143 ++++---------- .../work-orders/errors/board-patch-errors.ts | 8 + .../use-work-order-table-mutations.ts | 3 +- .../uplifts/reject-revoke-dialogs.test.tsx | 7 +- .../vendor-dialog-can-submit.test.ts | 95 ++++++++++ .../vendor-dialog-save-gate.test.tsx | 158 ++++++++++++++++ .../vendor-dialog-stable-identity.test.tsx | 4 +- ...er-table-mutations-assigned-toast.test.tsx | 35 ++++ 15 files changed, 652 insertions(+), 143 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts create mode 100644 src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit.ts create mode 100644 src/test/app/(protected)/workorders/vendor-dialog-can-submit.test.ts create mode 100644 src/test/app/(protected)/workorders/vendor-dialog-save-gate.test.tsx diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts new file mode 100644 index 00000000..3bd9104b --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts @@ -0,0 +1,177 @@ +import { useEffect, useMemo, useState } from "react"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; +import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; +import { + buildCompanyOptions, + buildTechnicianOptions, + phoneForOption, +} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; +import { + mergeVendorDropdownItems, + resolveVendorCompanyAnchor, +} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor"; +import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key"; +import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save"; +import { computeVendorDialogCanSubmit } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit"; +import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types"; + +type UseVendorDialogFormArgs = { + open: boolean; + onOpenChange: (open: boolean) => void; + vendorId: string | number; + company: string; + tech: string; + techPhone: string; + vendors: VendorDropdownItem[]; + onSave: (patch: VendorPatch) => void; +}; + +export function useVendorDialogForm({ + open, + onOpenChange, + vendorId, + company, + tech, + techPhone, + vendors, + onSave, +}: UseVendorDialogFormArgs) { + const createVendor = useCreateVendor(); + const [selectedCompany, setSelectedCompany] = useState(company); + const [selectedKey, setSelectedKey] = useState(() => + resolveTechnicianKey(vendorId, tech, company, vendors), + ); + const [t, setT] = useState(tech); + const [phone, setPhone] = useState(techPhone); + const [notes, setNotes] = useState(""); + const [addedVendors, setAddedVendors] = useState([]); + const [pendingCreates, setPendingCreates] = useState>({}); + const [saveError, setSaveError] = useState(null); + + const allVendors = useMemo( + () => mergeVendorDropdownItems(vendors, addedVendors), + [vendors, addedVendors], + ); + + const companyOptions = useMemo( + () => buildCompanyOptions(allVendors, company), + [allVendors, company], + ); + + const technicianOptions = useMemo( + () => buildTechnicianOptions(allVendors, selectedCompany, tech, company), + [allVendors, selectedCompany, tech, company], + ); + + useEffect(() => { + if (open) { + const key = resolveTechnicianKey(vendorId, tech, company, vendors); + const initialOptions = buildTechnicianOptions(vendors, company, tech, company); + setSelectedCompany(company); + setSelectedKey(key); + setT(tech); + setPhone(techPhone || phoneForOption(tech, initialOptions)); + setNotes(""); + setAddedVendors([]); + setPendingCreates({}); + setSaveError(null); + } + // invariant: rebuilding options after Add must not clear staged technician drafts. + }, [open, tech, techPhone, vendors, company, vendorId]); + + const selected = technicianOptions.find((option) => option.key === selectedKey); + const resolvedCompany = selectedCompany; + const selectedVendor = + selected?.vendor ?? allVendors.find((vendor) => vendor.companyName === selectedCompany); + + const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({ + company: selectedCompany, + resolvedCompany, + selectedVendor, + vendors: allVendors, + }); + + const canSubmit = computeVendorDialogCanSubmit({ + current: { + company: selectedCompany, + selectedKey, + phone, + notes, + pendingCreateCount: Object.keys(pendingCreates).length, + }, + baseline: { + company, + selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors), + phone: + techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)), + notes: "", + pendingCreateCount: 0, + }, + }); + + const { handleSave } = useVendorDialogSave({ + allVendors, + pendingCreates, + selectedKey, + selectedVendor, + resolvedCompany, + anchorCompanyName, + tech: selected?.name ?? t, + phone, + createVendor, + onSave, + onOpenChange, + setSaveError, + canSubmit, + }); + + return { + companyOptions, + technicianOptions, + selected, + t, + phone, + notes, + resolvedCompany, + selectedVendor, + selectedCompany, + selectedKey, + anchorCompanyName, + companyVendor, + saveError, + canSubmit, + isPending: createVendor.isPending, + handleSave, + handleCompanyPick: (nextCompany: string) => { + const selectedOption = technicianOptions.find((option) => option.key === selectedKey); + setSelectedCompany(nextCompany); + setAddedVendors([]); + setPendingCreates({}); + if (selectedOption?.company !== nextCompany) { + setSelectedKey(""); + setT(""); + setPhone(""); + } + }, + handlePick: (key: string) => { + const option = technicianOptions.find((o) => o.key === key); + setSelectedKey(key); + setT(option?.name ?? key); + setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions)); + }, + handleTechnicianCreated: ( + created: VendorDropdownItem, + createdPhone: string, + payload: VendorFormValues, + ) => { + setAddedVendors((current) => [...current, created]); + setPendingCreates((current) => ({ ...current, [String(created.id)]: payload })); + setSelectedKey(String(created.id)); + setT(created.contactName); + setPhone(createdPhone); + }, + setPhone, + setNotes, + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts index 502714c6..619bc6c9 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts @@ -18,6 +18,7 @@ type UseVendorDialogSaveArgs = { onSave: (patch: VendorPatch) => void; onOpenChange: (open: boolean) => void; setSaveError: (error: string | null) => void; + canSubmit: boolean; }; export function useVendorDialogSave({ @@ -33,6 +34,7 @@ export function useVendorDialogSave({ onSave, onOpenChange, setSaveError, + canSubmit, }: UseVendorDialogSaveArgs) { const resolveSaveVendor = () => allVendors.find((vendor) => String(vendor.id) === selectedKey) ?? @@ -44,6 +46,7 @@ export function useVendorDialogSave({ selectedVendor; const handleSave = async () => { + if (!canSubmit) return; setSaveError(null); let saveVendor = resolveSaveVendor(); const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined; @@ -65,8 +68,8 @@ export function useVendorDialogSave({ onSave({ vendorId: saveVendor ? String(saveVendor.id) : "", company: resolvedCompany || anchorCompanyName, - tech: saveVendor?.contactName ?? tech, - techPhone: phone, + tech: selectedKey ? (saveVendor?.contactName ?? tech) : "", + techPhone: selectedKey ? phone : "", }); onOpenChange(false); }; diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx index 5b58de5e..928bfe51 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx @@ -1,4 +1,4 @@ -import { Wrench } from "lucide-react"; +import { Building2, Wrench } from "lucide-react"; import { DLG_INPUT_CLS, DLG_INPUT_STYLE, @@ -10,9 +10,14 @@ import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/ import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician"; import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; +import type { + CompanyOption, + TechnicianOption, +} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; type VendorDialogBodyProps = { + selectedCompany: string; + companyOptions: CompanyOption[]; selectedKey: string; technicianOptions: TechnicianOption[]; anchorCompanyName: string; @@ -23,6 +28,7 @@ type VendorDialogBodyProps = { resolvedCompany: string; selectedVendor: VendorDropdownItem | undefined; saveError: string | null; + onCompanyPick: (company: string) => void; onPick: (key: string) => void; onPhoneChange: (phone: string) => void; onNotesChange: (notes: string) => void; @@ -34,6 +40,8 @@ type VendorDialogBodyProps = { }; export function VendorDialogBody({ + selectedCompany, + companyOptions, selectedKey, technicianOptions, anchorCompanyName, @@ -44,6 +52,7 @@ export function VendorDialogBody({ resolvedCompany, selectedVendor, saveError, + onCompanyPick, onPick, onPhoneChange, onNotesChange, @@ -53,17 +62,46 @@ export function VendorDialogBody({ <>
- Technician + Company + + } + searchPlaceholder="Search company…" + options={companyOptions.map((option) => ({ + key: option.key, + label: option.label, + }))} + onPick={onCompanyPick} + /> +
+ + {Boolean(resolvedCompany) && ( +
+ Company info + +
+ )} + +
+ Technician (optional) } searchPlaceholder="Search technician…" options={technicianOptions.map((option) => ({ key: option.key, - label: option.label, + label: option.name, }))} onPick={onPick} /> @@ -80,7 +118,7 @@ export function VendorDialogBody({ {Boolean(tech) && (
- Technician Phone + Technician Phone (optional) )} - {Boolean(resolvedCompany) && ( -
- Company - -
- )} -
- Notes + Notes (optional)