mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 06:32:05 +00:00
Merge remote-tracking branch 'origin/dev' into feat/SH-186-schedule-clear
This commit is contained in:
commit
15de3fca0a
39 changed files with 1580 additions and 243 deletions
|
|
@ -3,18 +3,8 @@ import { env } from "@/lib/env";
|
||||||
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||||
import queryClient from "@/lib/query/query-client";
|
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||||
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAuthLoginRequest(request: Request): boolean {
|
function isAuthLoginRequest(request: Request): boolean {
|
||||||
return request.url.includes(API_PATHS.authentication.login);
|
return request.url.includes(API_PATHS.authentication.login);
|
||||||
|
|
@ -51,7 +41,7 @@ export const api: KyInstance = ky.create({
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSessionAndRedirectToLogin();
|
expireSessionAndRedirectToLogin();
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
103
src/api/upload-form-with-progress.ts
Normal file
103
src/api/upload-form-with-progress.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||||
|
|
@ -9,9 +9,13 @@ import type {
|
||||||
useDeleteWorkOrderMedia,
|
useDeleteWorkOrderMedia,
|
||||||
useUpdateWorkOrderMediaCategory,
|
useUpdateWorkOrderMediaCategory,
|
||||||
useUploadWorkOrderMedia,
|
useUploadWorkOrderMedia,
|
||||||
|
UploadWorkOrderMediaInput,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import {
|
||||||
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
|
isCompletionSurfaceMedia,
|
||||||
|
isExtrasSurfaceMedia,
|
||||||
|
type MediaSurfaceOwnership,
|
||||||
|
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||||
|
|
||||||
type UseSlideOverMediaStateArgs = {
|
type UseSlideOverMediaStateArgs = {
|
||||||
detailForRow?: WorkOrderBoardDetail;
|
detailForRow?: WorkOrderBoardDetail;
|
||||||
|
|
@ -20,35 +24,108 @@ type UseSlideOverMediaStateArgs = {
|
||||||
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
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({
|
export function useSlideOverMediaState({
|
||||||
detailForRow,
|
detailForRow,
|
||||||
uploadMediaMutation,
|
uploadMediaMutation,
|
||||||
deleteMediaMutation,
|
deleteMediaMutation,
|
||||||
categorizeMediaMutation,
|
categorizeMediaMutation,
|
||||||
}: UseSlideOverMediaStateArgs) {
|
}: UseSlideOverMediaStateArgs) {
|
||||||
|
const workOrderId = detailForRow?.info.id;
|
||||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
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 } =
|
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||||
useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
|
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
||||||
|
|
||||||
const completionMedia = useMemo(
|
const completionMedia = useMemo(
|
||||||
() =>
|
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)),
|
||||||
uploads.filter(
|
[uploads, ownership],
|
||||||
(item) =>
|
|
||||||
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
|
|
||||||
item.uploadStatus === "uploading" ||
|
|
||||||
item.uploadStatus === "failed",
|
|
||||||
),
|
|
||||||
[uploads],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const extrasUploads = useMemo(
|
const extrasUploads = useMemo(
|
||||||
() =>
|
() =>
|
||||||
boardMediaItemsToMediaFiles(
|
boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) =>
|
||||||
(detailForRow?.media ?? []).filter(
|
isExtrasSurfaceMedia(item, ownership),
|
||||||
(item) => item.category === "Extra" || item.category === "Aveta",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
[detailForRow?.media],
|
[detailForRow?.media, ownership],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||||
|
|
@ -68,7 +145,11 @@ export function useSlideOverMediaState({
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
// 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 {
|
} finally {
|
||||||
setMediaBatchPending(false);
|
setMediaBatchPending(false);
|
||||||
|
|
@ -81,7 +162,20 @@ export function useSlideOverMediaState({
|
||||||
toast.error("Missing work order version. Refresh and try again.");
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
return;
|
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) => {
|
const removeMedia = (id: string) => {
|
||||||
|
|
|
||||||
|
|
@ -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<VendorDropdownItem[]>([]);
|
||||||
|
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(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);
|
||||||
|
if (selectedOption?.company !== nextCompany) {
|
||||||
|
setAddedVendors([]);
|
||||||
|
setPendingCreates({});
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ type UseVendorDialogSaveArgs = {
|
||||||
onSave: (patch: VendorPatch) => void;
|
onSave: (patch: VendorPatch) => void;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
setSaveError: (error: string | null) => void;
|
setSaveError: (error: string | null) => void;
|
||||||
|
canSubmit: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useVendorDialogSave({
|
export function useVendorDialogSave({
|
||||||
|
|
@ -33,6 +34,7 @@ export function useVendorDialogSave({
|
||||||
onSave,
|
onSave,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
setSaveError,
|
setSaveError,
|
||||||
|
canSubmit,
|
||||||
}: UseVendorDialogSaveArgs) {
|
}: UseVendorDialogSaveArgs) {
|
||||||
const resolveSaveVendor = () =>
|
const resolveSaveVendor = () =>
|
||||||
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
|
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
|
||||||
|
|
@ -44,6 +46,7 @@ export function useVendorDialogSave({
|
||||||
selectedVendor;
|
selectedVendor;
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
if (!canSubmit) return;
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
let saveVendor = resolveSaveVendor();
|
let saveVendor = resolveSaveVendor();
|
||||||
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
|
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
|
||||||
|
|
@ -65,8 +68,8 @@ export function useVendorDialogSave({
|
||||||
onSave({
|
onSave({
|
||||||
vendorId: saveVendor ? String(saveVendor.id) : "",
|
vendorId: saveVendor ? String(saveVendor.id) : "",
|
||||||
company: resolvedCompany || anchorCompanyName,
|
company: resolvedCompany || anchorCompanyName,
|
||||||
tech: saveVendor?.contactName ?? tech,
|
tech: selectedKey ? (saveVendor?.contactName ?? tech) : "",
|
||||||
techPhone: phone,
|
techPhone: selectedKey ? phone : "",
|
||||||
});
|
});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Wrench } from "lucide-react";
|
import { Building2, Wrench } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DLG_INPUT_CLS,
|
DLG_INPUT_CLS,
|
||||||
DLG_INPUT_STYLE,
|
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 { 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 { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
||||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
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 = {
|
type VendorDialogBodyProps = {
|
||||||
|
selectedCompany: string;
|
||||||
|
companyOptions: CompanyOption[];
|
||||||
selectedKey: string;
|
selectedKey: string;
|
||||||
technicianOptions: TechnicianOption[];
|
technicianOptions: TechnicianOption[];
|
||||||
anchorCompanyName: string;
|
anchorCompanyName: string;
|
||||||
|
|
@ -23,6 +28,7 @@ type VendorDialogBodyProps = {
|
||||||
resolvedCompany: string;
|
resolvedCompany: string;
|
||||||
selectedVendor: VendorDropdownItem | undefined;
|
selectedVendor: VendorDropdownItem | undefined;
|
||||||
saveError: string | null;
|
saveError: string | null;
|
||||||
|
onCompanyPick: (company: string) => void;
|
||||||
onPick: (key: string) => void;
|
onPick: (key: string) => void;
|
||||||
onPhoneChange: (phone: string) => void;
|
onPhoneChange: (phone: string) => void;
|
||||||
onNotesChange: (notes: string) => void;
|
onNotesChange: (notes: string) => void;
|
||||||
|
|
@ -34,6 +40,8 @@ type VendorDialogBodyProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VendorDialogBody({
|
export function VendorDialogBody({
|
||||||
|
selectedCompany,
|
||||||
|
companyOptions,
|
||||||
selectedKey,
|
selectedKey,
|
||||||
technicianOptions,
|
technicianOptions,
|
||||||
anchorCompanyName,
|
anchorCompanyName,
|
||||||
|
|
@ -44,6 +52,7 @@ export function VendorDialogBody({
|
||||||
resolvedCompany,
|
resolvedCompany,
|
||||||
selectedVendor,
|
selectedVendor,
|
||||||
saveError,
|
saveError,
|
||||||
|
onCompanyPick,
|
||||||
onPick,
|
onPick,
|
||||||
onPhoneChange,
|
onPhoneChange,
|
||||||
onNotesChange,
|
onNotesChange,
|
||||||
|
|
@ -53,17 +62,46 @@ export function VendorDialogBody({
|
||||||
<>
|
<>
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Technician</FieldLabel>
|
<FieldLabel required>Company</FieldLabel>
|
||||||
|
<WizardFieldSelect
|
||||||
|
value={selectedCompany}
|
||||||
|
placeholder="Select company …"
|
||||||
|
icon={
|
||||||
|
<Building2
|
||||||
|
size={14}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
searchPlaceholder="Search company…"
|
||||||
|
options={companyOptions.map((option) => ({
|
||||||
|
key: option.key,
|
||||||
|
label: option.label,
|
||||||
|
}))}
|
||||||
|
onPick={onCompanyPick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Boolean(resolvedCompany) && (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Company info</FieldLabel>
|
||||||
|
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Technician (optional)</FieldLabel>
|
||||||
<WizardFieldSelect
|
<WizardFieldSelect
|
||||||
value={selectedKey}
|
value={selectedKey}
|
||||||
placeholder="Search technician …"
|
placeholder={selectedCompany ? "Select technician …" : "Pick a company first"}
|
||||||
|
disabled={!selectedCompany}
|
||||||
icon={
|
icon={
|
||||||
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||||
}
|
}
|
||||||
searchPlaceholder="Search technician…"
|
searchPlaceholder="Search technician…"
|
||||||
options={technicianOptions.map((option) => ({
|
options={technicianOptions.map((option) => ({
|
||||||
key: option.key,
|
key: option.key,
|
||||||
label: option.label,
|
label: option.name,
|
||||||
}))}
|
}))}
|
||||||
onPick={onPick}
|
onPick={onPick}
|
||||||
/>
|
/>
|
||||||
|
|
@ -80,7 +118,7 @@ export function VendorDialogBody({
|
||||||
|
|
||||||
{Boolean(tech) && (
|
{Boolean(tech) && (
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Technician Phone</FieldLabel>
|
<FieldLabel>Technician Phone (optional)</FieldLabel>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={phone}
|
value={phone}
|
||||||
|
|
@ -91,15 +129,8 @@ export function VendorDialogBody({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Boolean(resolvedCompany) && (
|
|
||||||
<div>
|
|
||||||
<FieldLabel>Company</FieldLabel>
|
|
||||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Notes</FieldLabel>
|
<FieldLabel>Notes (optional)</FieldLabel>
|
||||||
<textarea
|
<textarea
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => onNotesChange(e.target.value)}
|
onChange={(e) => onNotesChange(e.target.value)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
export type VendorDialogSubmitSnapshot = {
|
||||||
|
company: string;
|
||||||
|
selectedKey: string;
|
||||||
|
phone: string;
|
||||||
|
notes: string;
|
||||||
|
pendingCreateCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VendorDialogCanSubmitInput = {
|
||||||
|
current: VendorDialogSubmitSnapshot;
|
||||||
|
baseline: VendorDialogSubmitSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function computeVendorDialogCanSubmit({
|
||||||
|
current,
|
||||||
|
baseline,
|
||||||
|
}: VendorDialogCanSubmitInput): boolean {
|
||||||
|
const valid = current.company.trim() !== "";
|
||||||
|
const hasNewTechs = current.pendingCreateCount > 0;
|
||||||
|
const dirty =
|
||||||
|
current.company !== baseline.company ||
|
||||||
|
current.selectedKey !== baseline.selectedKey ||
|
||||||
|
current.phone !== baseline.phone ||
|
||||||
|
current.pendingCreateCount !== baseline.pendingCreateCount;
|
||||||
|
return valid && (dirty || hasNewTechs);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ExternalLink, Mail, User, Wrench } from "lucide-react";
|
import { ExternalLink, Mail, MapPin } from "lucide-react";
|
||||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||||
|
|
@ -30,10 +30,7 @@ export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompany
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{Boolean(vendor?.contactName) && <Detail icon={User} text={vendor?.contactName ?? ""} />}
|
{Boolean(vendor?.address) && <Detail icon={MapPin} text={vendor?.address ?? ""} />}
|
||||||
{Boolean(vendor?.tradeSpecialties) && (
|
|
||||||
<Detail icon={Wrench} text={vendor?.tradeSpecialties ?? ""} />
|
|
||||||
)}
|
|
||||||
{Boolean(email) && <Detail icon={Mail} text={email} />}
|
{Boolean(email) && <Detail icon={Mail} text={email} />}
|
||||||
{vendor != null && Boolean(vendor.address) && (
|
{vendor != null && Boolean(vendor.address) && (
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ type VendorDialogFooterProps = {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
viewOnly?: boolean;
|
viewOnly?: boolean;
|
||||||
|
canSubmit?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lookDisabledSx = {
|
||||||
|
opacity: 0.5,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VendorDialogFooter({
|
export function VendorDialogFooter({
|
||||||
|
|
@ -12,6 +19,7 @@ export function VendorDialogFooter({
|
||||||
onCancel,
|
onCancel,
|
||||||
onSave,
|
onSave,
|
||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
|
canSubmit = false,
|
||||||
}: VendorDialogFooterProps) {
|
}: VendorDialogFooterProps) {
|
||||||
if (viewOnly) {
|
if (viewOnly) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -36,7 +44,13 @@ export function VendorDialogFooter({
|
||||||
<Button
|
<Button
|
||||||
className="min-h-0 px-3 py-1 text-[12px]"
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => void onSave()}
|
aria-disabled={!canSubmit && !isPending}
|
||||||
|
// why: AAP Save stays clickable; native disabled would change the no-op look-disabled gate.
|
||||||
|
sx={canSubmit || isPending ? undefined : lookDisabledSx}
|
||||||
|
onClick={() => {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
void onSave();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isPending ? "Saving…" : "Save"}
|
{isPending ? "Saving…" : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,44 @@ export type TechnicianOption = {
|
||||||
vendor?: VendorDropdownItem;
|
vendor?: VendorDropdownItem;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CompanyOption = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
vendor?: VendorDropdownItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildCompanyOptions(
|
||||||
|
vendors: VendorDropdownItem[],
|
||||||
|
currentCompany: string,
|
||||||
|
): CompanyOption[] {
|
||||||
|
const companies = new Map<string, CompanyOption>();
|
||||||
|
|
||||||
|
for (const vendor of vendors) {
|
||||||
|
const companyName = vendor.companyName.trim();
|
||||||
|
if (companyName && !companies.has(companyName)) {
|
||||||
|
companies.set(companyName, {
|
||||||
|
key: companyName,
|
||||||
|
label: companyName,
|
||||||
|
vendor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCompany && !companies.has(currentCompany)) {
|
||||||
|
companies.set(currentCompany, {
|
||||||
|
key: currentCompany,
|
||||||
|
label: currentCompany,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(companies.values());
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTechnicianOptions(
|
export function buildTechnicianOptions(
|
||||||
vendors: VendorDropdownItem[],
|
vendors: VendorDropdownItem[],
|
||||||
|
selectedCompany: string,
|
||||||
tech: string,
|
tech: string,
|
||||||
company: string,
|
currentCompany: string,
|
||||||
): TechnicianOption[] {
|
): TechnicianOption[] {
|
||||||
const options = new Map<string, TechnicianOption>();
|
const options = new Map<string, TechnicianOption>();
|
||||||
|
|
||||||
|
|
@ -25,6 +59,9 @@ export function buildTechnicianOptions(
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const vendor of vendors) {
|
for (const vendor of vendors) {
|
||||||
|
if (vendor.companyName !== selectedCompany) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const contact = vendor.contactName.trim() || vendor.companyName.trim();
|
const contact = vendor.contactName.trim() || vendor.companyName.trim();
|
||||||
add({
|
add({
|
||||||
key: String(vendor.id),
|
key: String(vendor.id),
|
||||||
|
|
@ -36,17 +73,16 @@ export function buildTechnicianOptions(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tech) {
|
if (tech && selectedCompany === currentCompany) {
|
||||||
const vendor =
|
const vendor =
|
||||||
vendors.find((v) => v.contactName === tech && v.companyName === company) ??
|
vendors.find((v) => v.contactName === tech && v.companyName === currentCompany) ??
|
||||||
vendors.find((v) => v.contactName === tech) ??
|
vendors.find((v) => v.contactName === tech);
|
||||||
vendors.find((v) => v.companyName === company);
|
|
||||||
const key = vendor ? String(vendor.id) : tech;
|
const key = vendor ? String(vendor.id) : tech;
|
||||||
add({
|
add({
|
||||||
key,
|
key,
|
||||||
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
label: tech,
|
||||||
name: tech,
|
name: tech,
|
||||||
company: company || tech,
|
company: currentCompany,
|
||||||
phone: "",
|
phone: "",
|
||||||
vendor,
|
vendor,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,11 @@
|
||||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
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 {
|
|
||||||
buildTechnicianOptions,
|
|
||||||
phoneForOption,
|
|
||||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
|
||||||
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
|
import { VendorDialogBody } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body";
|
||||||
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
|
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
|
||||||
import {
|
import { useVendorDialogForm } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form";
|
||||||
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 type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||||
|
|
||||||
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
export type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types";
|
||||||
|
|
@ -44,85 +33,15 @@ export function VendorDialog({
|
||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: VendorDialogProps) {
|
}: VendorDialogProps) {
|
||||||
const createVendor = useCreateVendor();
|
const form = useVendorDialogForm({
|
||||||
const [selectedKey, setSelectedKey] = useState(() =>
|
open,
|
||||||
resolveTechnicianKey(vendorId, tech, company, vendors),
|
|
||||||
);
|
|
||||||
const [t, setT] = useState(tech);
|
|
||||||
const [phone, setPhone] = useState(techPhone);
|
|
||||||
const [notes, setNotes] = useState("");
|
|
||||||
const [addedVendors, setAddedVendors] = useState<VendorDropdownItem[]>([]);
|
|
||||||
const [pendingCreates, setPendingCreates] = useState<Record<string, VendorFormValues>>({});
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const allVendors = useMemo(
|
|
||||||
() => mergeVendorDropdownItems(vendors, addedVendors),
|
|
||||||
[vendors, addedVendors],
|
|
||||||
);
|
|
||||||
|
|
||||||
const technicianOptions = useMemo(
|
|
||||||
() => buildTechnicianOptions(allVendors, tech, company),
|
|
||||||
[allVendors, tech, company],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
const key = resolveTechnicianKey(vendorId, tech, company, vendors);
|
|
||||||
setSelectedKey(key);
|
|
||||||
setT(tech);
|
|
||||||
setPhone(techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, tech, company)));
|
|
||||||
setNotes("");
|
|
||||||
setAddedVendors([]);
|
|
||||||
setPendingCreates({});
|
|
||||||
setSaveError(null);
|
|
||||||
}
|
|
||||||
// Intentionally omit technicianOptions: rebuilding options after Add must not clear staged drafts.
|
|
||||||
}, [open, tech, techPhone, vendors, company, vendorId]);
|
|
||||||
|
|
||||||
const selected = technicianOptions.find((option) => option.key === selectedKey);
|
|
||||||
const resolvedCompany = selectedKey ? (selected?.company ?? company) : company;
|
|
||||||
const selectedVendor =
|
|
||||||
selected?.vendor ?? allVendors.find((vendor) => String(vendor.id) === selectedKey);
|
|
||||||
|
|
||||||
const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({
|
|
||||||
company,
|
|
||||||
resolvedCompany,
|
|
||||||
selectedVendor,
|
|
||||||
vendors: allVendors,
|
|
||||||
});
|
|
||||||
|
|
||||||
const 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));
|
|
||||||
};
|
|
||||||
|
|
||||||
const 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { handleSave } = useVendorDialogSave({
|
|
||||||
allVendors,
|
|
||||||
pendingCreates,
|
|
||||||
selectedKey,
|
|
||||||
selectedVendor,
|
|
||||||
resolvedCompany,
|
|
||||||
anchorCompanyName,
|
|
||||||
tech: selected?.name ?? t,
|
|
||||||
phone,
|
|
||||||
createVendor,
|
|
||||||
onSave,
|
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
setSaveError,
|
vendorId,
|
||||||
|
company,
|
||||||
|
tech,
|
||||||
|
techPhone,
|
||||||
|
vendors,
|
||||||
|
onSave,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -134,43 +53,47 @@ export function VendorDialog({
|
||||||
...dialogBlurBackdropSlotProps,
|
...dialogBlurBackdropSlotProps,
|
||||||
paper: {
|
paper: {
|
||||||
className: "w-full",
|
className: "w-full",
|
||||||
style: { borderRadius: 12, maxWidth: 480 },
|
style: { borderRadius: 12, maxWidth: 560 },
|
||||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||||
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||||
Technician
|
Vendor
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<Text as="p" className="sr-only">
|
<Text as="p" className="sr-only">
|
||||||
Search and select the technician
|
Search and select the vendor company
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
|
<fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
|
||||||
<VendorDialogBody
|
<VendorDialogBody
|
||||||
selectedKey={selectedKey}
|
selectedCompany={form.selectedCompany}
|
||||||
technicianOptions={technicianOptions}
|
companyOptions={form.companyOptions}
|
||||||
anchorCompanyName={anchorCompanyName}
|
selectedKey={form.selectedKey}
|
||||||
companyVendorForAdd={viewOnly ? undefined : (companyVendor ?? undefined)}
|
technicianOptions={form.technicianOptions}
|
||||||
tech={selected?.name ?? t}
|
anchorCompanyName={form.anchorCompanyName}
|
||||||
phone={phone}
|
companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
|
||||||
notes={notes}
|
tech={form.selected?.name ?? form.t}
|
||||||
resolvedCompany={resolvedCompany}
|
phone={form.phone}
|
||||||
selectedVendor={selectedVendor}
|
notes={form.notes}
|
||||||
saveError={saveError}
|
resolvedCompany={form.resolvedCompany}
|
||||||
onPick={handlePick}
|
selectedVendor={form.selectedVendor}
|
||||||
onPhoneChange={setPhone}
|
saveError={form.saveError}
|
||||||
onNotesChange={setNotes}
|
onCompanyPick={form.handleCompanyPick}
|
||||||
onTechnicianCreated={handleTechnicianCreated}
|
onPick={form.handlePick}
|
||||||
|
onPhoneChange={form.setPhone}
|
||||||
|
onNotesChange={form.setNotes}
|
||||||
|
onTechnicianCreated={form.handleTechnicianCreated}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<VendorDialogFooter
|
<VendorDialogFooter
|
||||||
viewOnly={viewOnly}
|
viewOnly={viewOnly}
|
||||||
isPending={createVendor.isPending}
|
isPending={form.isPending}
|
||||||
|
canSubmit={form.canSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
onSave={handleSave}
|
onSave={form.handleSave}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -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 { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import {
|
import {
|
||||||
useDeleteWorkOrderMedia,
|
useDeleteWorkOrderMedia,
|
||||||
useUpdateWorkOrderMediaCategory,
|
|
||||||
useUploadWorkOrderMedia,
|
useUploadWorkOrderMedia,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
|
@ -35,14 +34,12 @@ function resolveWorkOrderVersion(
|
||||||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||||
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
|
||||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||||
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
||||||
|
|
||||||
const pending =
|
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
|
||||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||||
|
|
||||||
const requireVersion = (): string | null => {
|
const requireVersion = (): string | null => {
|
||||||
|
|
@ -69,14 +66,18 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
||||||
>
|
>
|
||||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ImageIcon size={16} className="shrink-0" style={{ color: "var(--foreground)" }} />
|
<ImageIcon size={18} className="shrink-0" style={{ color: "var(--foreground)" }} />
|
||||||
<DialogTitle className="p-0" style={{ fontSize: 15, color: "var(--color-text-primary)" }}>
|
<DialogTitle
|
||||||
|
sx={{ p: 0, fontSize: 16, lineHeight: 1.25, color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
Photos & Videos
|
Photos & Videos
|
||||||
</DialogTitle>
|
</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>
|
</div>
|
||||||
<Text className="sr-only" variant="description">
|
<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>
|
</Text>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|
@ -101,11 +102,6 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
||||||
if (!workOrderVersion) return;
|
if (!workOrderVersion) return;
|
||||||
deleteMutation.mutate({ mediaId: id, workOrderVersion });
|
deleteMutation.mutate({ mediaId: id, workOrderVersion });
|
||||||
}}
|
}}
|
||||||
onCategorize={(id, category) => {
|
|
||||||
const workOrderVersion = requireVersion();
|
|
||||||
if (!workOrderVersion) return;
|
|
||||||
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
|
|
||||||
}}
|
|
||||||
onRetry={retryUpload}
|
onRetry={retryUpload}
|
||||||
onCancel={cancelUpload}
|
onCancel={cancelUpload}
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ import {
|
||||||
type MediaUploaderDropzoneProps = {
|
type MediaUploaderDropzoneProps = {
|
||||||
inputRef: RefObject<HTMLInputElement | null>;
|
inputRef: RefObject<HTMLInputElement | null>;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
|
categorizeAfterUpload?: boolean;
|
||||||
onFilesSelected: (files: FileList | null) => void;
|
onFilesSelected: (files: FileList | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MediaUploaderDropzone({
|
export function MediaUploaderDropzone({
|
||||||
inputRef,
|
inputRef,
|
||||||
pending,
|
pending,
|
||||||
|
categorizeAfterUpload = false,
|
||||||
onFilesSelected,
|
onFilesSelected,
|
||||||
}: MediaUploaderDropzoneProps) {
|
}: MediaUploaderDropzoneProps) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -39,8 +41,8 @@ export function MediaUploaderDropzone({
|
||||||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
|
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||||
upload
|
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,13 @@ export function MediaUploaderGridItemMeta({
|
||||||
onCategorize,
|
onCategorize,
|
||||||
}: MediaUploaderGridItemMetaProps) {
|
}: MediaUploaderGridItemMetaProps) {
|
||||||
const failed = file.uploadStatus === "failed";
|
const failed = file.uploadStatus === "failed";
|
||||||
|
const managesPurpose = Boolean(onCategorize);
|
||||||
const needsCategory =
|
const needsCategory =
|
||||||
!readOnly &&
|
!readOnly &&
|
||||||
!file.isLegacy &&
|
!file.isLegacy &&
|
||||||
file.uploadStatus !== "uploading" &&
|
file.uploadStatus !== "uploading" &&
|
||||||
file.uploadStatus !== "failed" &&
|
file.uploadStatus !== "failed" &&
|
||||||
Boolean(onCategorize);
|
managesPurpose;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1 px-1 py-1">
|
<div className="space-y-1 px-1 py-1">
|
||||||
|
|
@ -34,10 +35,10 @@ export function MediaUploaderGridItemMeta({
|
||||||
{file.errorMessage || "Upload failed"}
|
{file.errorMessage || "Upload failed"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{needsCategory ? (
|
{needsCategory && (
|
||||||
<select
|
<select
|
||||||
aria-label={`Category for ${file.name}`}
|
aria-label={`Category for ${file.name}`}
|
||||||
value={file.purpose ?? ""}
|
value={file.purpose === "Extra" ? "" : (file.purpose ?? "")}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const value = event.target.value as UploadableMediaCategory;
|
const value = event.target.value as UploadableMediaCategory;
|
||||||
|
|
@ -57,7 +58,8 @@ export function MediaUploaderGridItemMeta({
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
)}
|
||||||
|
{Boolean(!needsCategory && managesPurpose) && (
|
||||||
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||||
{file.purpose || (file.isLegacy ? "Legacy" : "")}
|
{file.purpose || (file.isLegacy ? "Legacy" : "")}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,12 @@ export function MediaUploader({
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
|
<MediaUploaderDropzone
|
||||||
|
inputRef={inputRef}
|
||||||
|
pending={pending}
|
||||||
|
categorizeAfterUpload={Boolean(onCategorize)}
|
||||||
|
onFilesSelected={addFiles}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<MediaUploaderGrid
|
<MediaUploaderGrid
|
||||||
uploads={uploads}
|
uploads={uploads}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { API_PATHS } from "@/api/api-paths";
|
||||||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||||
import { api, apiGet, apiPost } from "@/api/api";
|
import { api, apiGet, apiPost } from "@/api/api";
|
||||||
import { handleApiResponse } from "@/api/handle-api-response";
|
import { handleApiResponse } from "@/api/handle-api-response";
|
||||||
|
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
|
||||||
import {
|
import {
|
||||||
BoardPatchConflictError,
|
BoardPatchConflictError,
|
||||||
BoardPatchValidationError,
|
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>) : {};
|
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||||
|
|
||||||
if (response.status === 409) {
|
if (status === 409) {
|
||||||
throw new BoardPatchConflictError(
|
throw new BoardPatchConflictError(
|
||||||
typeof record.message === "string"
|
typeof record.message === "string"
|
||||||
? record.message
|
? record.message
|
||||||
|
|
@ -45,14 +46,14 @@ function throwDocumentHttpError(response: Response, data: unknown): never {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 422) {
|
if (status === 422) {
|
||||||
throw new BoardPatchValidationError(
|
throw new BoardPatchValidationError(
|
||||||
typeof record.code === "string" ? record.code : "ValidationError",
|
typeof record.code === "string" ? record.code : "ValidationError",
|
||||||
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
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 = {
|
export const workOrderBoardDocumentsApi = {
|
||||||
|
|
@ -94,7 +95,7 @@ export const workOrderBoardDocumentsApi = {
|
||||||
return mapBoardDetailComment(handleApiResponse(data) ?? data);
|
return mapBoardDetailComment(handleApiResponse(data) ?? data);
|
||||||
}
|
}
|
||||||
|
|
||||||
throwDocumentHttpError(response, data);
|
throwDocumentHttpError(response.status, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** @deprecated Prefer createComment — modern board endpoint. */
|
/** @deprecated Prefer createComment — modern board endpoint. */
|
||||||
|
|
@ -119,21 +120,15 @@ export const workOrderBoardDocumentsApi = {
|
||||||
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
||||||
): Promise<WorkOrderBoardMediaItem> => {
|
): Promise<WorkOrderBoardMediaItem> => {
|
||||||
const formData = buildMediaFormData(file, category);
|
const formData = buildMediaFormData(file, category);
|
||||||
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
|
const { ok, status, data } = await uploadFormWithProgress(
|
||||||
body: formData,
|
API_PATHS.workOrder.media(workOrderId),
|
||||||
throwHttpErrors: false,
|
formData,
|
||||||
signal: options?.signal,
|
{ signal: options?.signal, onProgress: options?.onProgress },
|
||||||
onUploadProgress: options?.onProgress
|
);
|
||||||
? (progress) => {
|
if (ok) {
|
||||||
options.onProgress?.(Math.round(progress.percent * 100));
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
const data = await readResponseJson(response);
|
|
||||||
if (response.ok) {
|
|
||||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||||
}
|
}
|
||||||
throwDocumentHttpError(response, data);
|
throwDocumentHttpError(status, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateMediaCategory: async (
|
updateMediaCategory: async (
|
||||||
|
|
@ -160,7 +155,7 @@ export const workOrderBoardDocumentsApi = {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||||
}
|
}
|
||||||
throwDocumentHttpError(response, data);
|
throwDocumentHttpError(response.status, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteMedia: async (
|
deleteMedia: async (
|
||||||
|
|
@ -196,7 +191,7 @@ export const workOrderBoardDocumentsApi = {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throwDocumentHttpError(response, data);
|
throwDocumentHttpError(response.status, data);
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadCompletionDoc: async (
|
uploadCompletionDoc: async (
|
||||||
|
|
@ -224,6 +219,6 @@ export const workOrderBoardDocumentsApi = {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return mapCompletion(handleApiResponse(data) ?? data);
|
return mapCompletion(handleApiResponse(data) ?? data);
|
||||||
}
|
}
|
||||||
throwDocumentHttpError(response, data);
|
throwDocumentHttpError(response.status, data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,11 @@ export class BoardPatchValidationError extends Error {
|
||||||
this.code = code;
|
this.code = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** why: backend field names must never be exposed through user-facing board toasts. */
|
||||||
|
export function boardPatchValidationMessage(error: BoardPatchValidationError): string {
|
||||||
|
if (error.code === "DispatchRequired") {
|
||||||
|
return "Select a vendor company before saving.";
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
WorkOrderComment,
|
WorkOrderComment,
|
||||||
} from "@/domain/work-orders/types/work-order-detail";
|
} from "@/domain/work-orders/types/work-order-detail";
|
||||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||||
|
import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name";
|
||||||
import type {
|
import type {
|
||||||
FileCategory,
|
FileCategory,
|
||||||
MediaCategory,
|
MediaCategory,
|
||||||
|
|
@ -220,7 +221,7 @@ export function mapBoardDetailMediaItem(raw: unknown): WorkOrderBoardMediaItem {
|
||||||
id: String(id ?? (readString(item, "id") || cryptoRandomId())),
|
id: String(id ?? (readString(item, "id") || cryptoRandomId())),
|
||||||
category: mapMediaCategory(item.category ?? item.Category),
|
category: mapMediaCategory(item.category ?? item.Category),
|
||||||
url: readString(item, "url", "Url"),
|
url: readString(item, "url", "Url"),
|
||||||
fileName: readString(item, "fileName", "FileName") || "file",
|
fileName: toDisplayFileName(readString(item, "fileName", "FileName") || "file"),
|
||||||
uploadedAt: readString(item, "uploadedAt", "UploadedAt"),
|
uploadedAt: readString(item, "uploadedAt", "UploadedAt"),
|
||||||
isLegacy: isLegacyFlag || (id != null && id <= 0),
|
isLegacy: isLegacyFlag || (id != null && id <= 0),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
import {
|
import {
|
||||||
|
boardPatchValidationMessage,
|
||||||
BoardPatchConflictError,
|
BoardPatchConflictError,
|
||||||
BoardPatchValidationError,
|
BoardPatchValidationError,
|
||||||
} from "@/domain/work-orders/errors/board-patch-errors";
|
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||||
|
|
@ -161,7 +162,7 @@ export function useWorkOrderTableMutations(
|
||||||
toast.error(error.message || "Conflict — refreshed with latest data");
|
toast.error(error.message || "Conflict — refreshed with latest data");
|
||||||
} else if (error instanceof BoardPatchValidationError) {
|
} else if (error instanceof BoardPatchValidationError) {
|
||||||
clearPatch(id);
|
clearPatch(id);
|
||||||
toast.error(error.message);
|
toast.error(boardPatchValidationMessage(error));
|
||||||
} else {
|
} else {
|
||||||
clearPatch(id);
|
clearPatch(id);
|
||||||
toast.error(error.message || "Failed to update work order");
|
toast.error(error.message || "Failed to update work order");
|
||||||
|
|
|
||||||
10
src/domain/work-orders/utils/display-file-name.ts
Normal file
10
src/domain/work-orders/utils/display-file-name.ts
Normal 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;
|
||||||
|
}
|
||||||
44
src/domain/work-orders/utils/partition-slide-over-media.ts
Normal file
44
src/domain/work-orders/utils/partition-slide-over-media.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
/** Backend RequestSizeLimit for media and completion-doc uploads. */
|
/** Backend RequestSizeLimit for media and completion-doc uploads. */
|
||||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
|
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"]);
|
const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||||
|
|
||||||
|
|
@ -22,7 +28,7 @@ export function isWorkOrderMediaTypeAllowed(file: File): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workOrderMediaAcceptAttribute(): string {
|
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 {
|
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
|
||||||
|
|
|
||||||
12
src/lib/auth/expire-session.ts
Normal file
12
src/lib/auth/expire-session.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/test/api/upload-form-with-progress.test.ts
Normal file
184
src/test/api/upload-form-with-progress.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -61,7 +61,12 @@ describe("DialogCancelButton VendorDialog tokens", () => {
|
||||||
it("matches VendorDialogFooter Cancel classes (ghost + 12px compact padding)", () => {
|
it("matches VendorDialogFooter Cancel classes (ghost + 12px compact padding)", () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<>
|
<>
|
||||||
<VendorDialogFooter isPending={false} onCancel={() => undefined} onSave={() => undefined} />
|
<VendorDialogFooter
|
||||||
|
isPending={false}
|
||||||
|
canSubmit={false}
|
||||||
|
onCancel={() => undefined}
|
||||||
|
onSave={() => undefined}
|
||||||
|
/>
|
||||||
<DialogCancelButton />
|
<DialogCancelButton />
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -116,24 +116,42 @@ describe("MediaUploadModal API wiring", () => {
|
||||||
expect(screen.getByText("before.jpg")).toBeInTheDocument();
|
expect(screen.getByText("before.jpg")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByText("Photos & Videos")).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();
|
renderModal();
|
||||||
|
|
||||||
await waitFor(() => {
|
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), {
|
expect(screen.queryByLabelText(/category for before\.jpg/i)).not.toBeInTheDocument();
|
||||||
target: { value: "After" },
|
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",
|
mediaId: "11",
|
||||||
category: "After",
|
|
||||||
workOrderVersion: "DETAIL-VER==",
|
workOrderVersion: "DETAIL-VER==",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
|
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 { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
@ -85,6 +85,10 @@ function idleMutation() {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useSlideOverMediaState completion filter", () => {
|
describe("useSlideOverMediaState completion filter", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
|
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
|
||||||
const detail = detailWithMedia([
|
const detail = detailWithMedia([
|
||||||
{
|
{
|
||||||
|
|
@ -119,6 +123,14 @@ describe("useSlideOverMediaState completion filter", () => {
|
||||||
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||||
isLegacy: false,
|
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(() =>
|
const { result } = renderHook(() =>
|
||||||
|
|
@ -134,7 +146,9 @@ describe("useSlideOverMediaState completion filter", () => {
|
||||||
"Before",
|
"Before",
|
||||||
"After",
|
"After",
|
||||||
"Completion",
|
"Completion",
|
||||||
|
"Extra",
|
||||||
]);
|
]);
|
||||||
|
expect(result.current.completionMedia.map((item) => item.id)).toContain("10");
|
||||||
expect(
|
expect(
|
||||||
result.current.completionMedia.every((item) =>
|
result.current.completionMedia.every((item) =>
|
||||||
["Photo", "Video", "Report", "Other"].includes(item.category),
|
["Photo", "Video", "Report", "Other"].includes(item.category),
|
||||||
|
|
@ -142,6 +156,32 @@ describe("useSlideOverMediaState completion filter", () => {
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(result.current.extrasUploads).toHaveLength(1);
|
expect(result.current.extrasUploads).toHaveLength(1);
|
||||||
expect(result.current.extrasUploads[0]?.purpose).toBe("Extra");
|
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", () => {
|
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.map((item) => item.purpose)).toEqual(["Extra", "Aveta"]);
|
||||||
expect(result.current.extrasUploads[1]?.category).toBe("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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
computeVendorDialogCanSubmit,
|
||||||
|
type VendorDialogSubmitSnapshot,
|
||||||
|
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit";
|
||||||
|
|
||||||
|
const empty: VendorDialogSubmitSnapshot = {
|
||||||
|
company: "",
|
||||||
|
selectedKey: "",
|
||||||
|
phone: "",
|
||||||
|
notes: "",
|
||||||
|
pendingCreateCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const assigned: VendorDialogSubmitSnapshot = {
|
||||||
|
company: "Gateway Plumbing",
|
||||||
|
selectedKey: "10",
|
||||||
|
phone: "(314) 555-0100",
|
||||||
|
notes: "",
|
||||||
|
pendingCreateCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("computeVendorDialogCanSubmit", () => {
|
||||||
|
it("rejects an empty untouched assignment", () => {
|
||||||
|
expect(
|
||||||
|
computeVendorDialogCanSubmit({
|
||||||
|
current: empty,
|
||||||
|
baseline: empty,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an existing assignment with no field changes", () => {
|
||||||
|
expect(
|
||||||
|
computeVendorDialogCanSubmit({
|
||||||
|
current: assigned,
|
||||||
|
baseline: assigned,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows save after the technician key changes", () => {
|
||||||
|
expect(
|
||||||
|
computeVendorDialogCanSubmit({
|
||||||
|
current: { ...assigned, selectedKey: "11" },
|
||||||
|
baseline: assigned,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows save when a new technician is staged", () => {
|
||||||
|
expect(
|
||||||
|
computeVendorDialogCanSubmit({
|
||||||
|
current: { ...assigned, selectedKey: "draft-1", pendingCreateCount: 1 },
|
||||||
|
baseline: assigned,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects notes-only changes because notes are not persisted", () => {
|
||||||
|
expect(
|
||||||
|
computeVendorDialogCanSubmit({
|
||||||
|
current: { ...assigned, notes: "Call before arrival" },
|
||||||
|
baseline: assigned,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not allow notes-only save without a company or technician", () => {
|
||||||
|
expect(
|
||||||
|
computeVendorDialogCanSubmit({
|
||||||
|
current: { ...empty, notes: "Call before arrival" },
|
||||||
|
baseline: empty,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a company-only assignment", () => {
|
||||||
|
expect(
|
||||||
|
computeVendorDialogCanSubmit({
|
||||||
|
current: { ...empty, company: "Gateway Plumbing" },
|
||||||
|
baseline: empty,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a technician without a company", () => {
|
||||||
|
expect(
|
||||||
|
computeVendorDialogCanSubmit({
|
||||||
|
current: { ...empty, selectedKey: "10" },
|
||||||
|
baseline: empty,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
vi.mock("@/domain/vendors/use-cases/use-create-vendor", () => ({
|
||||||
|
useCreateVendor: () => ({
|
||||||
|
mutateAsync: vi.fn(),
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const jordan: VendorDropdownItem = {
|
||||||
|
id: 10,
|
||||||
|
companyName: "Gateway Plumbing",
|
||||||
|
contactName: "Jordan Lee",
|
||||||
|
tradeSpecialties: "Plumbing",
|
||||||
|
address: "1 Main St",
|
||||||
|
distanceMiles: 4.2,
|
||||||
|
email: "dispatch@gateway.test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const casey: VendorDropdownItem = {
|
||||||
|
...jordan,
|
||||||
|
id: 11,
|
||||||
|
contactName: "Casey New",
|
||||||
|
};
|
||||||
|
|
||||||
|
const avery: VendorDropdownItem = {
|
||||||
|
...jordan,
|
||||||
|
id: 12,
|
||||||
|
companyName: "Coastal HVAC",
|
||||||
|
contactName: "Avery Chen",
|
||||||
|
tradeSpecialties: "HVAC",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderAssigned(onSave = vi.fn()) {
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
vendorId={10}
|
||||||
|
company="Gateway Plumbing"
|
||||||
|
tech="Jordan Lee"
|
||||||
|
techPhone="(314) 555-0100"
|
||||||
|
vendors={[jordan, casey]}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return onSave;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VendorDialog save gate", () => {
|
||||||
|
it("does not persist when the empty modal is saved unchanged", () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
vendorId=""
|
||||||
|
company=""
|
||||||
|
tech=""
|
||||||
|
vendors={[]}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /pick a company first/i })).toBeDisabled();
|
||||||
|
const save = screen.getByRole("button", { name: /^save$/i });
|
||||||
|
expect(save).not.toBeDisabled();
|
||||||
|
expect(save).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(save).toHaveStyle({ opacity: "0.5", cursor: "not-allowed" });
|
||||||
|
fireEvent.click(save);
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not persist when an existing assignment is saved unchanged", () => {
|
||||||
|
const onSave = renderAssigned();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists after the selected technician changes", async () => {
|
||||||
|
const onSave = renderAssigned();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /jordan lee/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: /casey new/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
vendorId: "11",
|
||||||
|
tech: "Casey New",
|
||||||
|
company: "Gateway Plumbing",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists a company-only assignment without selecting a technician", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
vendorId=""
|
||||||
|
company=""
|
||||||
|
tech=""
|
||||||
|
vendors={[jordan, casey]}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /select company/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Gateway Plumbing" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
vendorId: "10",
|
||||||
|
company: "Gateway Plumbing",
|
||||||
|
tech: "",
|
||||||
|
techPhone: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only offers technicians from the selected company", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDialog
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
vendorId=""
|
||||||
|
company=""
|
||||||
|
tech=""
|
||||||
|
vendors={[jordan, casey, avery]}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /select company/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Coastal HVAC" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /select technician/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole("button", { name: "Avery Chen" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Jordan Lee" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Casey New" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not persist after a notes-only change", () => {
|
||||||
|
const onSave = renderAssigned();
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Contact preferences, availability, etc."), {
|
||||||
|
target: { value: "Gate code 12" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -78,7 +78,7 @@ describe("VendorDialog stable technician identity", () => {
|
||||||
|
|
||||||
// Draft stays staged: add form collapses and select still shows the colliding name.
|
// Draft stays staged: add form collapses and select still shows the colliding name.
|
||||||
expect(screen.getByRole("button", { name: /\+ add technician/i })).toBeTruthy();
|
expect(screen.getByRole("button", { name: /\+ add technician/i })).toBeTruthy();
|
||||||
expect(screen.getByText("Jordan Lee · Gateway Plumbing")).toBeTruthy();
|
expect(screen.getByRole("button", { name: "Jordan Lee" })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.change(screen.getByDisplayValue("3145550199"), {
|
fireEvent.change(screen.getByDisplayValue("3145550199"), {
|
||||||
target: { value: "(314) 555-9999" },
|
target: { value: "(314) 555-9999" },
|
||||||
|
|
@ -151,7 +151,7 @@ describe("VendorDialog stable technician identity", () => {
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
|
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
|
||||||
|
|
||||||
expect(screen.getByText("Casey New · Gateway Plumbing")).toBeTruthy();
|
expect(screen.getByRole("button", { name: "Casey New" })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
|
||||||
|
|
@ -175,4 +175,56 @@ describe("VendorDialog stable technician identity", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a staged draft when the same company is picked again", async () => {
|
||||||
|
const onSave = vi.fn();
|
||||||
|
mutateAsync.mockResolvedValue({
|
||||||
|
...createdVendor,
|
||||||
|
id: 88,
|
||||||
|
contactName: "Casey New",
|
||||||
|
phone: "3145550188",
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<VendorDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
vendorId={10}
|
||||||
|
company="Gateway Plumbing"
|
||||||
|
tech="Jordan Lee"
|
||||||
|
techPhone="(314) 555-0100"
|
||||||
|
vendors={[existingVendor]}
|
||||||
|
onSave={onSave}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /\+ add technician/i }));
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Full name"), {
|
||||||
|
target: { value: "Casey New" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("(000) 000-0000"), {
|
||||||
|
target: { value: "3145550188" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Gateway Plumbing" }));
|
||||||
|
const companyButtons = await screen.findAllByRole("button", { name: "Gateway Plumbing" });
|
||||||
|
fireEvent.click(companyButtons[companyButtons.length - 1]!);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mutateAsync).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
contactName: "Casey New",
|
||||||
|
phone: "3145550188",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
vendorId: "88",
|
||||||
|
tech: "Casey New",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -271,4 +271,28 @@ describe("CompDocCell slice affordances", () => {
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: /view doc/i })).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ const apiPatch = vi.fn();
|
||||||
const apiPatchFn = vi.fn();
|
const apiPatchFn = vi.fn();
|
||||||
const apiPostFn = vi.fn();
|
const apiPostFn = vi.fn();
|
||||||
const apiDeleteFn = 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", () => ({
|
vi.mock("@/api/api", () => ({
|
||||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||||
|
|
@ -654,44 +659,55 @@ describe("workOrdersApi.updateComment", () => {
|
||||||
|
|
||||||
describe("workOrdersApi.uploadMedia", () => {
|
describe("workOrdersApi.uploadMedia", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiPostFn.mockReset();
|
uploadFormFn.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POSTs multipart FormData with file and category", async () => {
|
it("POSTs multipart FormData with file and category, forwarding progress and signal", async () => {
|
||||||
apiPostFn.mockResolvedValue({
|
uploadFormFn.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
status: 201,
|
||||||
|
data: {
|
||||||
id: 12,
|
id: 12,
|
||||||
category: 3,
|
category: 3,
|
||||||
url: "https://cdn.example.com/extra.jpg",
|
url: "https://cdn.example.com/extra.jpg",
|
||||||
fileName: "extra.jpg",
|
fileName: "extra.jpg",
|
||||||
uploadedAt: "2026-07-16T12:00:00Z",
|
uploadedAt: "2026-07-16T12:00:00Z",
|
||||||
isLegacy: false,
|
isLegacy: false,
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const file = new File(["bytes"], "extra.jpg", { type: "image/jpeg" });
|
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(
|
expect(uploadFormFn).toHaveBeenCalledWith(API_PATHS.workOrder.media(10), expect.any(FormData), {
|
||||||
API_PATHS.workOrder.media(10),
|
signal,
|
||||||
expect.objectContaining({ throwHttpErrors: false }),
|
onProgress,
|
||||||
);
|
});
|
||||||
const body = apiPostFn.mock.calls[0]?.[1]?.body as FormData;
|
const body = uploadFormFn.mock.calls[0]?.[1] as FormData;
|
||||||
expect(body.get("category")).toBe("Extra");
|
expect(body.get("category")).toBe("Extra");
|
||||||
expect(body.get("file")).toBeInstanceOf(File);
|
expect(body.get("file")).toBeInstanceOf(File);
|
||||||
expect(item.id).toBe("12");
|
expect(item.id).toBe("12");
|
||||||
expect(item.category).toBe("Extra");
|
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 () => {
|
it("throws BoardPatchValidationError on 422", async () => {
|
||||||
apiPostFn.mockResolvedValue({
|
uploadFormFn.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 422,
|
status: 422,
|
||||||
json: async () => ({
|
data: {
|
||||||
code: "ReadOnly",
|
code: "ReadOnly",
|
||||||
message: "Work order is read-only.",
|
message: "Work order is read-only.",
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { BoardPatchValidationError } =
|
const { BoardPatchValidationError } =
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
it("maps numeric category and marks negative ids as legacy", () => {
|
||||||
const legacy = mapBoardDetailMediaItem({
|
const legacy = mapBoardDetailMediaItem({
|
||||||
id: -1,
|
id: -1,
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
describe("mapWorkOrderTableRow primaryDispatchStatus", () => {
|
||||||
it.each([
|
it.each([
|
||||||
["primaryDispatchStatus", "Refused"],
|
["primaryDispatchStatus", "Refused"],
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
||||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
import {
|
import {
|
||||||
applyAssignedToTableSave,
|
applyAssignedToTableSave,
|
||||||
|
applyVendorTableSave,
|
||||||
workOrderSavedMessage,
|
workOrderSavedMessage,
|
||||||
} from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
} from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
||||||
|
|
||||||
|
|
@ -94,4 +95,38 @@ describe("useWorkOrderTableMutations assigned-to toast", () => {
|
||||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||||
expect(toast.success).not.toHaveBeenCalled();
|
expect(toast.success).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces the raw DispatchRequired validation string with user-facing copy", async () => {
|
||||||
|
const { BoardPatchValidationError } =
|
||||||
|
await import("@/domain/work-orders/errors/board-patch-errors");
|
||||||
|
patchBoardField.mockRejectedValue(
|
||||||
|
new BoardPatchValidationError(
|
||||||
|
"DispatchRequired",
|
||||||
|
"A primary dispatch is required. Set vendorId first or provide primaryDispatchId.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useWorkOrderTableMutations({
|
||||||
|
onPatch: () => undefined,
|
||||||
|
clearPatch: () => undefined,
|
||||||
|
getRow: () => ROW,
|
||||||
|
}),
|
||||||
|
{ wrapper: makeWrapper() },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
applyVendorTableSave(result.current.patchField, 7, {
|
||||||
|
vendorId: "",
|
||||||
|
company: "",
|
||||||
|
tech: "",
|
||||||
|
techPhone: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(patchBoardField).toHaveBeenCalled());
|
||||||
|
expect(toast.error).toHaveBeenCalledWith("Select a vendor company before saving.");
|
||||||
|
expect(toast.error).not.toHaveBeenCalledWith(expect.stringContaining("primaryDispatchId"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
24
src/test/domain/work-orders/utils/display-file-name.test.ts
Normal file
24
src/test/domain/work-orders/utils/display-file-name.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -27,6 +27,7 @@ describe("work-order-media-limits", () => {
|
||||||
|
|
||||||
it("allows only JPG PNG MP4 MOV by MIME or extension", () => {
|
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/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.png", type: "image/png" } as File)).toBe(true);
|
||||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.mp4", type: "video/mp4" } 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(
|
expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue