Merge remote-tracking branch 'origin/dev' into fix/SH-181-vendor-save-gate

This commit is contained in:
Arthur Bassi 2026-09-09 17:33:58 -03:00
commit 54671baf5c
24 changed files with 875 additions and 100 deletions

View file

@ -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;
},
],

View file

@ -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<UploadFormResult> {
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);
});
}

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
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";
@ -9,9 +9,13 @@ 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,
type MediaSurfaceOwnership,
} from "@/domain/work-orders/utils/partition-slide-over-media";
type UseSlideOverMediaStateArgs = {
detailForRow?: WorkOrderBoardDetail;
@ -20,35 +24,108 @@ type UseSlideOverMediaStateArgs = {
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
};
function addOwnedId(previous: Set<string>, id: string | undefined): Set<string> {
if (!id) return previous;
const next = new Set(previous);
next.add(id);
return next;
}
function dropOwnedId(previous: Set<string>, id: string): Set<string> {
if (!previous.has(id)) return previous;
const next = new Set(previous);
next.delete(id);
return next;
}
const OWNERSHIP_STORAGE_PREFIX = "work-order-media-surface:";
function loadOwnership(workOrderId: string | number | undefined): MediaSurfaceOwnership {
if (workOrderId == null) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
try {
const stored = sessionStorage.getItem(`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`);
if (!stored) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
const parsed = JSON.parse(stored) as { completion?: unknown; extra?: unknown };
return {
completionOwnedIds: new Set(Array.isArray(parsed.completion) ? parsed.completion : []),
extraOwnedIds: new Set(Array.isArray(parsed.extra) ? parsed.extra : []),
};
} catch {
return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
}
}
function saveOwnership(
workOrderId: string | number | undefined,
ownership: MediaSurfaceOwnership,
): void {
if (workOrderId == null) return;
try {
sessionStorage.setItem(
`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`,
JSON.stringify({
completion: [...ownership.completionOwnedIds],
extra: [...ownership.extraOwnedIds],
}),
);
} catch {
return;
}
}
export function useSlideOverMediaState({
detailForRow,
uploadMediaMutation,
deleteMediaMutation,
categorizeMediaMutation,
}: UseSlideOverMediaStateArgs) {
const workOrderId = detailForRow?.info.id;
const [mediaBatchPending, setMediaBatchPending] = useState(false);
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
loadOwnership(workOrderId),
);
useEffect(() => {
setOwnershipState(loadOwnership(workOrderId));
}, [workOrderId]);
const updateOwnership = useCallback(
(update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => {
setOwnershipState((current) => {
const next = update(current);
saveOwnership(workOrderId, next);
return next;
});
},
[workOrderId],
);
const persistCompletionUpload = useCallback(
async (input: UploadWorkOrderMediaInput) => {
const item = await uploadMediaMutation.mutateAsync(input);
updateOwnership((current) => ({
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id),
extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), item.id),
}));
return item;
},
[updateOwnership, uploadMediaMutation],
);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
useMediaUploadSession(detailForRow, persistCompletionUpload);
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 +145,11 @@ 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" });
updateOwnership((current) => ({
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
}));
}
} finally {
setMediaBatchPending(false);
@ -81,7 +162,20 @@ export function useSlideOverMediaState({
toast.error("Missing work order version. Refresh and try again.");
return;
}
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
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) => {

View file

@ -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
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<ImageIcon size={16} className="shrink-0" style={{ color: "var(--foreground)" }} />
<DialogTitle className="p-0" style={{ fontSize: 15, color: "var(--color-text-primary)" }}>
<ImageIcon size={18} className="shrink-0" style={{ color: "var(--foreground)" }} />
<DialogTitle
sx={{ p: 0, fontSize: 16, lineHeight: 1.25, color: "var(--color-text-primary)" }}
>
Photos &amp; Videos
</DialogTitle>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-muted)" }}>
#{row.woNumber}
</span>
</div>
<Text className="sr-only" variant="description">
Upload photos and videos for this work order, then assign Before, After, or Extra.
Upload photos and videos for this work order.
</Text>
<div className="mt-4">
@ -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}

View file

@ -8,12 +8,14 @@ import {
type MediaUploaderDropzoneProps = {
inputRef: RefObject<HTMLInputElement | null>;
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"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
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" : ""}
</span>
</button>
<input

View file

@ -17,12 +17,13 @@ export function MediaUploaderGridItemMeta({
onCategorize,
}: MediaUploaderGridItemMetaProps) {
const failed = file.uploadStatus === "failed";
const managesPurpose = Boolean(onCategorize);
const needsCategory =
!readOnly &&
!file.isLegacy &&
file.uploadStatus !== "uploading" &&
file.uploadStatus !== "failed" &&
Boolean(onCategorize);
managesPurpose;
return (
<div className="space-y-1 px-1 py-1">
@ -34,10 +35,10 @@ export function MediaUploaderGridItemMeta({
{file.errorMessage || "Upload failed"}
</div>
)}
{needsCategory ? (
{needsCategory && (
<select
aria-label={`Category for ${file.name}`}
value={file.purpose ?? ""}
value={file.purpose === "Extra" ? "" : (file.purpose ?? "")}
disabled={pending}
onChange={(event) => {
const value = event.target.value as UploadableMediaCategory;
@ -57,7 +58,8 @@ export function MediaUploaderGridItemMeta({
</option>
))}
</select>
) : (
)}
{Boolean(!needsCategory && managesPurpose) && (
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
{file.purpose || (file.isLegacy ? "Legacy" : "")}
</div>

View file

@ -91,7 +91,12 @@ export function MediaUploader({
return (
<div>
{!readOnly && (
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
<MediaUploaderDropzone
inputRef={inputRef}
pending={pending}
categorizeAfterUpload={Boolean(onCategorize)}
onFilesSelected={addFiles}
/>
)}
<MediaUploaderGrid
uploads={uploads}

View file

@ -2,6 +2,7 @@ import { API_PATHS } from "@/api/api-paths";
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
import { api, apiGet, apiPost } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
import {
BoardPatchConflictError,
BoardPatchValidationError,
@ -33,10 +34,10 @@ async function readResponseJson(response: Response): Promise<unknown> {
}
}
function throwDocumentHttpError(response: Response, data: unknown): never {
function throwDocumentHttpError(status: number, data: unknown): never {
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
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<WorkOrderBoardMediaItem> => {
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);
},
};

View file

@ -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),
};

View file

@ -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;
}

View file

@ -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<string>;
extraOwnedIds: ReadonlySet<string>;
};
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);
}

View file

@ -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 {

View file

@ -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");
}
}

View file

@ -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<string, Listener[]>();
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<string, string> = {};
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();
});
});

View file

@ -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==",
});
});

View file

@ -0,0 +1,35 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MediaUploaderGridItemMeta } from "@/app/(protected)/workorders/_components/media/media-uploader-grid-item-meta";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
const extraPhoto: MediaFile = {
id: "10",
name: "site.jpg",
fileType: "image",
url: "https://cdn.example.com/site.jpg",
category: "Photo",
purpose: "Extra",
isLegacy: false,
};
describe("MediaUploaderGridItemMeta", () => {
it("allows an Extra photo to be explicitly assigned to Extra Docs", () => {
const onCategorize = vi.fn();
render(
<MediaUploaderGridItemMeta
file={extraPhoto}
readOnly={false}
pending={false}
onCategorize={onCategorize}
/>,
);
const category = screen.getByLabelText("Category for site.jpg");
expect(category).toHaveValue("");
fireEvent.change(category, { target: { value: "Extra" } });
expect(onCategorize).toHaveBeenCalledWith("10", "Extra");
});
});

View file

@ -1,5 +1,5 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, 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";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
@ -85,6 +85,10 @@ function idleMutation() {
}
describe("useSlideOverMediaState completion filter", () => {
beforeEach(() => {
sessionStorage.clear();
});
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
const detail = detailWithMedia([
{
@ -119,6 +123,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 +146,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 +156,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 +217,65 @@ describe("useSlideOverMediaState completion filter", () => {
expect(result.current.extrasUploads.map((item) => item.purpose)).toEqual(["Extra", "Aveta"]);
expect(result.current.extrasUploads[1]?.category).toBe("Aveta");
});
it("keeps an Extra photo in Extra Docs after assigning Extra and remounting", () => {
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((_input: unknown, options?: { onSuccess?: () => void }) =>
options?.onSuccess?.(),
);
const categorize = {
mutate: categorizeMutate,
mutateAsync: vi.fn().mockResolvedValue({}),
isPending: false,
} as never;
const { result, unmount } = 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",
},
expect.objectContaining({ onSuccess: expect.any(Function) }),
);
unmount();
const remounted = renderHook(() =>
useSlideOverMediaState({
detailForRow: detail,
uploadMediaMutation: idleMutation(),
deleteMediaMutation: idleMutation(),
categorizeMediaMutation: idleMutation(),
}),
);
expect(remounted.result.current.completionMedia).toHaveLength(0);
expect(remounted.result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]);
});
});

View file

@ -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(
<CompDocCell status="No" woType="Emergency" mediaCount={2} onOpenMedia={vi.fn()} />,
{ 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(
<CompDocCell status="No" woType="Emergency" mediaCount={1} onOpenMedia={vi.fn()} />,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: /^1 file$/i })).toBeInTheDocument();
unmount();
renderWithProviders(
<CompDocCell status="No" woType="Emergency" mediaCount={0} onOpenMedia={vi.fn()} />,
{ withAuth: false },
);
expect(screen.getByRole("button", { name: /^media$/i })).toBeInTheDocument();
});
});

View file

@ -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 } =

View file

@ -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,

View file

@ -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"],

View file

@ -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);
});
});

View file

@ -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<MediaFile> & Pick<MediaFile, "id" | "fileType" | "purpose">,
): 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<string>(),
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<string>(),
};
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);
});
});

View file

@ -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(