mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 08:33:14 +00:00
Merge branch 'dev' into feat/sh-298-sentry
Three conflicts, all where dev refactored code this branch had instrumented: - api.ts — dev extracted the session-expiry helper into lib/auth/expire-session. Took dev's import, dropped the now-duplicate local copy, kept the tracing import. - work-order-board-documents-api.ts — dev replaced the ky upload with uploadFormWithProgress, an XHR path that exists because ky's onUploadProgress streams the body and browsers refuse that over HTTP/1.1. Kept dev's helper and wrapped it in traceHttpOperation so the upload stays instrumented; neither change is lost. - work-orders-api.test.ts — kept both mock surfaces, since the merged work-orders-api calls apiRequestRaw while other code uses the ky instance. getMediaContent arrived from dev calling `api.get` directly, which this file no longer imports; routed it through apiRequestRaw like its siblings, which also brings it under tracing.
This commit is contained in:
commit
fdd97c24f9
97 changed files with 4489 additions and 544 deletions
|
|
@ -37,6 +37,8 @@ export const API_PATHS = {
|
|||
media: (id: string | number) => `workorders/${id}/media`,
|
||||
mediaItem: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}`,
|
||||
mediaContent: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}/content`,
|
||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
||||
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
|
||||
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
|
||||
|
|
|
|||
|
|
@ -3,20 +3,10 @@ import { env } from "@/lib/env";
|
|||
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
|
||||
import queryClient from "@/lib/query/query-client";
|
||||
import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast";
|
||||
import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage";
|
||||
import { getAccessToken } from "@/lib/auth/auth-storage";
|
||||
import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
|
||||
function clearSessionAndRedirectToLogin(): void {
|
||||
clearAuth();
|
||||
stopQueryBroadcast(queryClient);
|
||||
|
||||
if (typeof window !== "undefined" && window.location.pathname !== "/login") {
|
||||
window.location.replace("/login");
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthLoginRequest(request: Request): boolean {
|
||||
return request.url.includes(API_PATHS.authentication.login);
|
||||
}
|
||||
|
|
@ -52,7 +42,7 @@ export const api: KyInstance = ky.create({
|
|||
return response;
|
||||
}
|
||||
|
||||
clearSessionAndRedirectToLogin();
|
||||
expireSessionAndRedirectToLogin();
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import {
|
||||
|
|
@ -20,9 +21,9 @@ import {
|
|||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderExtraDocTypeAllowed,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
workOrderMediaAcceptAttribute,
|
||||
workOrderExtraDocsAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
|
|
@ -43,21 +44,43 @@ type SlideOverExtrasTabProps = {
|
|||
isReadOnly: boolean;
|
||||
pending?: boolean;
|
||||
avetaRequired?: boolean;
|
||||
workOrderId?: string | number;
|
||||
onUpload: (files: File[]) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
};
|
||||
|
||||
async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise<void> {
|
||||
const tab = window.open("about:blank", "_blank");
|
||||
if (!tab) {
|
||||
toast.error("Unable to open this document. Please allow pop-ups and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
tab.opener = null;
|
||||
tab.location.href = objectUrl;
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
} catch {
|
||||
tab.close();
|
||||
toast.error("Unable to open this document.");
|
||||
}
|
||||
}
|
||||
|
||||
function ExtraDocRow({
|
||||
file,
|
||||
isReadOnly,
|
||||
pending,
|
||||
workOrderId,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
}: {
|
||||
file: MediaFile;
|
||||
isReadOnly: boolean;
|
||||
pending: boolean;
|
||||
workOrderId?: string | number;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
}) {
|
||||
|
|
@ -75,15 +98,20 @@ function ExtraDocRow({
|
|||
className="shrink-0"
|
||||
style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-0 flex-1 truncate hover:underline"
|
||||
<button
|
||||
type="button"
|
||||
disabled={workOrderId == null}
|
||||
onClick={() => {
|
||||
if (workOrderId == null) {
|
||||
return;
|
||||
}
|
||||
void openExtraDocContent(workOrderId, file.id);
|
||||
}}
|
||||
className="min-w-0 flex-1 truncate text-left hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ fontSize: 12, color: "var(--foreground)" }}
|
||||
>
|
||||
{file.name}
|
||||
</a>
|
||||
</button>
|
||||
{showCategory ? (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
|
|
@ -134,6 +162,7 @@ export function SlideOverExtrasTab({
|
|||
isReadOnly,
|
||||
pending = false,
|
||||
avetaRequired = false,
|
||||
workOrderId,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
|
|
@ -145,8 +174,8 @@ export function SlideOverExtrasTab({
|
|||
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
||||
if (!isWorkOrderExtraDocTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
|
|
@ -193,7 +222,7 @@ export function SlideOverExtrasTab({
|
|||
event.preventDefault();
|
||||
addFiles(event.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 100,
|
||||
border: "1px dashed var(--color-border)",
|
||||
|
|
@ -208,13 +237,14 @@ export function SlideOverExtrasTab({
|
|||
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "}
|
||||
per file
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={workOrderMediaAcceptAttribute()}
|
||||
accept={workOrderExtraDocsAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
|
|
@ -238,6 +268,7 @@ export function SlideOverExtrasTab({
|
|||
file={file}
|
||||
isReadOnly={isReadOnly}
|
||||
pending={pending}
|
||||
workOrderId={workOrderId}
|
||||
onRemove={onRemove}
|
||||
onCategorize={onCategorize}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||
import { DocBadge } from "./slide-over-doc-badge";
|
||||
|
||||
|
|
@ -31,6 +32,75 @@ type SlideOverInfoTabEditViewProps = {
|
|||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
function SlideOverScheduleOnField({
|
||||
draft,
|
||||
onDraftChange,
|
||||
}: {
|
||||
draft: WorkOrderTableRow;
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
}) {
|
||||
const hasSchedule =
|
||||
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
|
||||
|
||||
return (
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
|
||||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onSave={(scheduledOn) =>
|
||||
onDraftChange({
|
||||
scheduledOn,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: scheduledOn ? "" : draft.targetWeek,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
isPastDue: false,
|
||||
...deriveDayFields(scheduledOn),
|
||||
})
|
||||
}
|
||||
onSaveWeek={(weekStart) =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
targetWeek: weekStart,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
isPastDue: false,
|
||||
...deriveDayFields(""),
|
||||
})
|
||||
}
|
||||
onSaveRange={(start, end) =>
|
||||
onDraftChange({
|
||||
scheduledOn: start,
|
||||
scheduleEnd: end,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isPastDue: false,
|
||||
...deriveDayFields(start),
|
||||
})
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isPastDue: false,
|
||||
...deriveDayFields(""),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlideOverInfoTabEditView({
|
||||
draft,
|
||||
users,
|
||||
|
|
@ -99,23 +169,7 @@ export function SlideOverInfoTabEditView({
|
|||
/>
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Schedule On">
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
|
||||
onSave={(scheduledOn) =>
|
||||
onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
|
||||
}
|
||||
onSaveWeek={(weekStart) =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
targetWeek: weekStart,
|
||||
scheduleWeekOnly: true,
|
||||
isPastDue: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SlideOverScheduleOnField draft={draft} onDraftChange={onDraftChange} />
|
||||
</SlideOverInfoRow>
|
||||
<SlideOverInfoRow label="Due Date">
|
||||
<DueDateCell
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||
|
|
@ -9,9 +9,13 @@ import type {
|
|||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
UploadWorkOrderMediaInput,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
|
||||
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
|
||||
import {
|
||||
isCompletionSurfaceMedia,
|
||||
isExtrasSurfaceMedia,
|
||||
type MediaSurfaceOwnership,
|
||||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||
|
||||
type UseSlideOverMediaStateArgs = {
|
||||
detailForRow?: WorkOrderBoardDetail;
|
||||
|
|
@ -20,35 +24,108 @@ type UseSlideOverMediaStateArgs = {
|
|||
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||
};
|
||||
|
||||
function addOwnedId(previous: Set<string>, id: string | undefined): Set<string> {
|
||||
if (!id) return previous;
|
||||
const next = new Set(previous);
|
||||
next.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
function dropOwnedId(previous: Set<string>, id: string): Set<string> {
|
||||
if (!previous.has(id)) return previous;
|
||||
const next = new Set(previous);
|
||||
next.delete(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
const OWNERSHIP_STORAGE_PREFIX = "work-order-media-surface:";
|
||||
|
||||
function loadOwnership(workOrderId: string | number | undefined): MediaSurfaceOwnership {
|
||||
if (workOrderId == null) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
try {
|
||||
const stored = sessionStorage.getItem(`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`);
|
||||
if (!stored) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
const parsed = JSON.parse(stored) as { completion?: unknown; extra?: unknown };
|
||||
return {
|
||||
completionOwnedIds: new Set(Array.isArray(parsed.completion) ? parsed.completion : []),
|
||||
extraOwnedIds: new Set(Array.isArray(parsed.extra) ? parsed.extra : []),
|
||||
};
|
||||
} catch {
|
||||
return { completionOwnedIds: new Set(), extraOwnedIds: new Set() };
|
||||
}
|
||||
}
|
||||
|
||||
function saveOwnership(
|
||||
workOrderId: string | number | undefined,
|
||||
ownership: MediaSurfaceOwnership,
|
||||
): void {
|
||||
if (workOrderId == null) return;
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`,
|
||||
JSON.stringify({
|
||||
completion: [...ownership.completionOwnedIds],
|
||||
extra: [...ownership.extraOwnedIds],
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSlideOverMediaState({
|
||||
detailForRow,
|
||||
uploadMediaMutation,
|
||||
deleteMediaMutation,
|
||||
categorizeMediaMutation,
|
||||
}: UseSlideOverMediaStateArgs) {
|
||||
const workOrderId = detailForRow?.info.id;
|
||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
|
||||
loadOwnership(workOrderId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOwnershipState(loadOwnership(workOrderId));
|
||||
}, [workOrderId]);
|
||||
|
||||
const updateOwnership = useCallback(
|
||||
(update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => {
|
||||
setOwnershipState((current) => {
|
||||
const next = update(current);
|
||||
saveOwnership(workOrderId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[workOrderId],
|
||||
);
|
||||
|
||||
const persistCompletionUpload = useCallback(
|
||||
async (input: UploadWorkOrderMediaInput) => {
|
||||
const item = await uploadMediaMutation.mutateAsync(input);
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
return item;
|
||||
},
|
||||
[updateOwnership, uploadMediaMutation],
|
||||
);
|
||||
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
|
||||
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
||||
|
||||
const completionMedia = useMemo(
|
||||
() =>
|
||||
uploads.filter(
|
||||
(item) =>
|
||||
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
|
||||
item.uploadStatus === "uploading" ||
|
||||
item.uploadStatus === "failed",
|
||||
),
|
||||
[uploads],
|
||||
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)),
|
||||
[uploads, ownership],
|
||||
);
|
||||
|
||||
const extrasUploads = useMemo(
|
||||
() =>
|
||||
boardMediaItemsToMediaFiles(
|
||||
(detailForRow?.media ?? []).filter(
|
||||
(item) => item.category === "Extra" || item.category === "Aveta",
|
||||
),
|
||||
boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) =>
|
||||
isExtrasSurfaceMedia(item, ownership),
|
||||
),
|
||||
[detailForRow?.media],
|
||||
[detailForRow?.media, ownership],
|
||||
);
|
||||
|
||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||
|
|
@ -68,7 +145,11 @@ export function useSlideOverMediaState({
|
|||
try {
|
||||
for (const file of files) {
|
||||
// Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH.
|
||||
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id),
|
||||
extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id),
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
setMediaBatchPending(false);
|
||||
|
|
@ -81,7 +162,20 @@ export function useSlideOverMediaState({
|
|||
toast.error("Missing work order version. Refresh and try again.");
|
||||
return;
|
||||
}
|
||||
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
|
||||
categorizeMediaMutation.mutate(
|
||||
{ mediaId, category, workOrderVersion },
|
||||
{
|
||||
onSuccess: () => {
|
||||
updateOwnership((current) => ({
|
||||
completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId),
|
||||
extraOwnedIds:
|
||||
category === "Extra"
|
||||
? addOwnedId(new Set(current.extraOwnedIds), mediaId)
|
||||
: dropOwnedId(new Set(current.extraOwnedIds), mediaId),
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeMedia = (id: string) => {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export function WorkOrderSlideOverTabs({
|
|||
>
|
||||
<SlideOverExtrasTab
|
||||
uploads={state.extrasUploads}
|
||||
workOrderId={infoSource?.id}
|
||||
avetaRequired={resolveSlideOverAvetaRequired(
|
||||
state.editing,
|
||||
infoSource?.avetaRequired,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/w
|
|||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import { resolveCreationStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
|
||||
export function buildInlineDraftCreateInput(
|
||||
draft: WorkOrderTableDraft,
|
||||
|
|
@ -36,13 +37,19 @@ export function buildInlineDraftCreateInput(
|
|||
vendorNotes: "",
|
||||
apptStart: draft.apptStart,
|
||||
apptEnd: draft.apptEnd,
|
||||
status: draft.status || "Incomplete",
|
||||
status: resolveCreationStatus({
|
||||
status: draft.status || "Incomplete",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
scheduleWeekOnly,
|
||||
}),
|
||||
pocName: draft.pocName,
|
||||
pocPhone: draft.pocPhone,
|
||||
pocNotes: draft.pocNotes,
|
||||
additionalContacts: draft.additionalContacts ?? [],
|
||||
scheduleWeekOnly,
|
||||
targetWeek: draft.targetWeek || "",
|
||||
scheduleEnd: draft.scheduleEnd || "",
|
||||
schedMode: draft.schedMode,
|
||||
isAddOnOverride: draft.isAddOnOverride,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
|||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1.5 rounded transition-opacity hover:opacity-80"
|
||||
className="group/cell flex w-full min-w-0 items-center gap-1.5 rounded transition-opacity hover:opacity-80"
|
||||
style={
|
||||
highlight
|
||||
? {
|
||||
|
|
@ -43,13 +43,13 @@ export function AssignedToCell({ dispatcherId, users, highlight, onSave }: Assig
|
|||
{selected ? (
|
||||
<DispatcherPill user={selected} />
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
|
||||
<Users
|
||||
size={13}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
Select dispatcher
|
||||
<span className="truncate whitespace-nowrap">Select dispatcher</span>
|
||||
</span>
|
||||
)}
|
||||
<HoverChevron />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { CalendarDays, Calendar as CalendarIcon, CalendarRange } from "lucide-react";
|
||||
import { fmtDateRange, fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
|
||||
type DateCellValueArgs = {
|
||||
showRange: boolean;
|
||||
showWeek: boolean;
|
||||
value: string;
|
||||
scheduleEnd: string;
|
||||
targetWeek: string;
|
||||
q?: string;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
export function DateCellValue({
|
||||
showRange,
|
||||
showWeek,
|
||||
value,
|
||||
scheduleEnd,
|
||||
targetWeek,
|
||||
q,
|
||||
highlight,
|
||||
}: DateCellValueArgs) {
|
||||
if (showRange) {
|
||||
return (
|
||||
<>
|
||||
<CalendarRange size={11} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
{fmtDateRange(value, scheduleEnd)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (showWeek) {
|
||||
return (
|
||||
<>
|
||||
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||
{fmtWeekOf(targetWeek)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<CalendarIcon
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
{value ? (
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { useState } from "react";
|
||||
import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { DateCellValue } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell-value";
|
||||
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
|
||||
import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { HighlightText } from "./highlight-text";
|
||||
import { useWizardDateFieldState } from "@/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state";
|
||||
import { WizardDateModeTabs } from "@/app/(protected)/workorders/_components/wizard/wizard-date-mode-tabs";
|
||||
import { WizardDateRangePanel } from "@/app/(protected)/workorders/_components/wizard/wizard-date-range-panel";
|
||||
import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
type DateCellProps = {
|
||||
value: string;
|
||||
|
|
@ -15,6 +18,9 @@ type DateCellProps = {
|
|||
targetWeek?: string;
|
||||
onSaveWeek?: (weekStart: string) => void;
|
||||
defaultMode?: "week" | "date";
|
||||
scheduleEnd?: string;
|
||||
schedMode?: WizardSchedMode;
|
||||
onSaveRange?: (start: string, end: string) => void;
|
||||
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
|
||||
onClear?: () => void;
|
||||
/** Screen-reader-only status appended inside the button so the accessible name keeps the visible date and adds durable state. */
|
||||
|
|
@ -30,14 +36,34 @@ export function DateCell({
|
|||
targetWeek = "",
|
||||
onSaveWeek,
|
||||
defaultMode,
|
||||
scheduleEnd = "",
|
||||
schedMode,
|
||||
onSaveRange,
|
||||
onClear,
|
||||
srStatusSuffix,
|
||||
}: DateCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [cellMode, setCellMode] = useState<"week" | "date">(
|
||||
allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date",
|
||||
const open = Boolean(anchorEl);
|
||||
const allowRange = Boolean(allowWeekMode && onSaveRange);
|
||||
const { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick } = useWizardDateFieldState(
|
||||
{
|
||||
open,
|
||||
value,
|
||||
scheduleEnd,
|
||||
allowRange,
|
||||
allowWeekMode,
|
||||
targetWeek,
|
||||
schedMode,
|
||||
defaultMode,
|
||||
},
|
||||
);
|
||||
const showWeek = allowWeekMode && !value && !!targetWeek;
|
||||
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
|
||||
const showRange = Boolean(allowRange && schedMode === "range" && value && scheduleEnd);
|
||||
const modes: WizardSchedMode[] = allowRange
|
||||
? ["week", "date", "range"]
|
||||
: allowWeekMode
|
||||
? ["week", "date"]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -60,29 +86,15 @@ export function DateCell({
|
|||
}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
{showWeek ? (
|
||||
<>
|
||||
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||
{fmtWeekOf(targetWeek)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CalendarIcon
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
{value ? (
|
||||
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DateCellValue
|
||||
showRange={showRange}
|
||||
showWeek={showWeek}
|
||||
value={value}
|
||||
scheduleEnd={scheduleEnd}
|
||||
targetWeek={targetWeek}
|
||||
q={q}
|
||||
highlight={highlight}
|
||||
/>
|
||||
</span>
|
||||
{Boolean(srStatusSuffix) && <span className="sr-only">{srStatusSuffix}</span>}
|
||||
<ChevronDown
|
||||
|
|
@ -92,36 +104,35 @@ export function DateCell({
|
|||
/>
|
||||
</button>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
|
||||
>
|
||||
{Boolean(allowWeekMode) && (
|
||||
<div className="mb-2.5 flex items-center gap-1.5">
|
||||
{(["week", "date"] as const).map((m) => {
|
||||
const active = cellMode === m;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setCellMode(m)}
|
||||
className="rounded-full border px-3 text-[11.5px] font-medium"
|
||||
style={{
|
||||
height: 26,
|
||||
background: active ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
|
||||
borderColor: active ? "var(--color-primary)" : "var(--color-border)",
|
||||
color: active ? "var(--color-primary)" : "var(--color-text-secondary)",
|
||||
}}
|
||||
>
|
||||
{m === "week" ? "Week" : "Specific date"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{Boolean(onClear) && (
|
||||
<div className="mb-1.5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="inline-flex items-center gap-0.5 text-xs font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<X size={12} />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<WizardDateModeTabs
|
||||
modes={modes}
|
||||
cellMode={cellMode}
|
||||
allowRange={allowRange}
|
||||
onSelect={setCellMode}
|
||||
/>
|
||||
{allowWeekMode && cellMode === "week" ? (
|
||||
<WeekPicker
|
||||
selected={!value ? targetWeek : ""}
|
||||
|
|
@ -130,6 +141,16 @@ export function DateCell({
|
|||
setAnchorEl(null);
|
||||
}}
|
||||
/>
|
||||
) : allowRange && cellMode === "range" ? (
|
||||
<WizardDateRangePanel
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
onRangeClick={handleRangeClick}
|
||||
onDone={(start, end) => {
|
||||
onSaveRange?.(start, end);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MiniCalendar
|
||||
value={value}
|
||||
|
|
@ -139,21 +160,6 @@ export function DateCell({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{Boolean(onClear) && (
|
||||
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="text-[11.5px] font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
× Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ export function SeverityField({ value, onSelect }: SeverityFieldProps) {
|
|||
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }}
|
||||
>
|
||||
Severity (from APM)
|
||||
<Text as="span" variant="label" tone="error">
|
||||
{" "}
|
||||
*
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
|
|
|
|||
|
|
@ -2,16 +2,19 @@ import { useState } from "react";
|
|||
import { AlertTriangle, Check } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
type WizardWOStatus,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
tableRowToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
BOARD_INLINE_STATUSES,
|
||||
PAST_DUE_BADGE_TOOLTIP,
|
||||
PAST_DUE_POPOVER_BODY,
|
||||
PAST_DUE_RESCHEDULE_CTA,
|
||||
getStatusOptionState,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||
|
||||
|
|
@ -24,6 +27,7 @@ type StatusCellProps = {
|
|||
onPastDueAttempt?: () => void;
|
||||
onRequestComplete?: () => void;
|
||||
readOnly?: boolean;
|
||||
statusOptions?: readonly WizardWOStatus[];
|
||||
};
|
||||
|
||||
function ReadOnlyStatusLabel({ status }: { status: string }) {
|
||||
|
|
@ -64,7 +68,7 @@ function PastDueStatusCell({
|
|||
onPastDueAttempt?.();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
title="This WO missed its deadline. Update Schedule On to unlock status updates."
|
||||
title={PAST_DUE_BADGE_TOOLTIP}
|
||||
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||
>
|
||||
|
|
@ -97,7 +101,7 @@ function PastDueStatusCell({
|
|||
color: "var(--status-pending-text)",
|
||||
}}
|
||||
>
|
||||
This work order is past due. Update Schedule On before the status can be updated.
|
||||
{PAST_DUE_POPOVER_BODY}
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -108,7 +112,7 @@ function PastDueStatusCell({
|
|||
}}
|
||||
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||
>
|
||||
Update Schedule On →
|
||||
{PAST_DUE_RESCHEDULE_CTA}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -124,6 +128,7 @@ export function StatusCell({
|
|||
onPastDueAttempt,
|
||||
onRequestComplete,
|
||||
readOnly = false,
|
||||
statusOptions = BOARD_INLINE_STATUSES,
|
||||
}: StatusCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const closabilityGaps = rowClosabilityGaps(wo);
|
||||
|
|
@ -162,22 +167,19 @@ export function StatusCell({
|
|||
onClose={() => setAnchorEl(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||
slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }}
|
||||
>
|
||||
{ALL_WIZARD_STATUSES.map((status) => {
|
||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||
const missingTitle = isCompletedBlocked
|
||||
? formatClosabilityMissingTitle(closabilityGaps)
|
||||
: undefined;
|
||||
{statusOptions.map((status) => {
|
||||
const option = getStatusOptionState(wo, status, closabilityGaps);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
disabled={isCompletedBlocked}
|
||||
title={missingTitle}
|
||||
disabled={option.disabled}
|
||||
title={option.title}
|
||||
onClick={() => {
|
||||
if (isCompletedBlocked) {
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
if (status === "Completed" && wo.status !== "Completed") {
|
||||
|
|
@ -189,9 +191,9 @@ export function StatusCell({
|
|||
setAnchorEl(null);
|
||||
}}
|
||||
className={
|
||||
isCompletedBlocked
|
||||
? "flex w-full cursor-not-allowed items-center gap-2 rounded px-2 py-1.5 opacity-50"
|
||||
: "flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
option.disabled
|
||||
? "flex w-full cursor-not-allowed items-center justify-between gap-2 rounded px-2 py-1.5 opacity-50"
|
||||
: "flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||
}
|
||||
>
|
||||
<WoTableStatusLabel status={status} />
|
||||
|
|
|
|||
|
|
@ -18,14 +18,32 @@ type TypeCellProps = {
|
|||
value: WOType | "";
|
||||
severity?: WorkOrderSeverity | null;
|
||||
q?: string;
|
||||
viewOnly?: boolean;
|
||||
onSave: (type: WOType, severity: WorkOrderSeverity | null) => void;
|
||||
};
|
||||
|
||||
export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
|
||||
function TypeCellValue({ value, q }: { value: WOType | ""; q?: string }) {
|
||||
if (value) {
|
||||
return <WoTableTypeLabel type={value} q={q} />;
|
||||
}
|
||||
return <span className="text-[13px] text-[var(--color-text-muted)]">Select type of WO</span>;
|
||||
}
|
||||
|
||||
export function TypeCell({ value, severity = null, q, viewOnly = false, onSave }: TypeCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalSeed, setModalSeed] = useState<WOType | "">(value);
|
||||
const [resetSeverity, setResetSeverity] = useState(false);
|
||||
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
|
||||
|
||||
if (viewOnly) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<TypeCellValue value={value} q={q} />
|
||||
{Boolean(showSeverityBadge && severity != null) && <SeverityBadge severity={severity!} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pick(t: WOType) {
|
||||
setAnchorEl(null);
|
||||
|
|
@ -43,23 +61,19 @@ export function TypeCell({ value, severity = null, q, onSave }: TypeCellProps) {
|
|||
onSave(t, needsSeverity ? severity : null);
|
||||
}
|
||||
|
||||
const showSeverityBadge = Boolean(value !== "" && typeNeedsSeverity(value) && severity != null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={value ? `Type of WO, ${value}` : "Select type of WO"}
|
||||
title={value ? undefined : "Select type of WO"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
}}
|
||||
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{value ? (
|
||||
<WoTableTypeLabel type={value} q={q} />
|
||||
) : (
|
||||
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
|
||||
)}
|
||||
<TypeCellValue value={value} q={q} />
|
||||
{Boolean(showSeverityBadge && severity != null) && (
|
||||
<SeverityBadge
|
||||
severity={severity!}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
|
|
@ -76,6 +84,10 @@ export function TypeSeverityModal({
|
|||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingRight: 1,
|
||||
fontFamily: "var(--font-display)",
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
|
|
@ -83,6 +95,9 @@ export function TypeSeverityModal({
|
|||
}}
|
||||
>
|
||||
Type & severity
|
||||
<IconButton aria-label="Close" onClick={() => onOpenChange(false)} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -93,6 +108,10 @@ export function TypeSeverityModal({
|
|||
sx={{ fontSize: 12, fontWeight: 600, marginBottom: "6px" }}
|
||||
>
|
||||
Type
|
||||
<Text as="span" variant="label" tone="error">
|
||||
{" "}
|
||||
*
|
||||
</Text>
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{WO_TYPES.map((t) => (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
setSaveError: (error: string | null) => void;
|
||||
canSubmit: boolean;
|
||||
};
|
||||
|
||||
export function useVendorDialogSave({
|
||||
|
|
@ -33,6 +34,7 @@ export function useVendorDialogSave({
|
|||
onSave,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
canSubmit,
|
||||
}: UseVendorDialogSaveArgs) {
|
||||
const resolveSaveVendor = () =>
|
||||
allVendors.find((vendor) => String(vendor.id) === selectedKey) ??
|
||||
|
|
@ -44,6 +46,7 @@ export function useVendorDialogSave({
|
|||
selectedVendor;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSaveError(null);
|
||||
let saveVendor = resolveSaveVendor();
|
||||
const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined;
|
||||
|
|
@ -65,8 +68,8 @@ export function useVendorDialogSave({
|
|||
onSave({
|
||||
vendorId: saveVendor ? String(saveVendor.id) : "",
|
||||
company: resolvedCompany || anchorCompanyName,
|
||||
tech: saveVendor?.contactName ?? tech,
|
||||
techPhone: phone,
|
||||
tech: selectedKey ? (saveVendor?.contactName ?? tech) : "",
|
||||
techPhone: selectedKey ? phone : "",
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Wrench } from "lucide-react";
|
||||
import { Building2, Wrench } from "lucide-react";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
|
|
@ -10,9 +10,14 @@ import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/
|
|||
import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician";
|
||||
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
||||
import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema";
|
||||
import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
import type {
|
||||
CompanyOption,
|
||||
TechnicianOption,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||
|
||||
type VendorDialogBodyProps = {
|
||||
selectedCompany: string;
|
||||
companyOptions: CompanyOption[];
|
||||
selectedKey: string;
|
||||
technicianOptions: TechnicianOption[];
|
||||
anchorCompanyName: string;
|
||||
|
|
@ -23,6 +28,7 @@ type VendorDialogBodyProps = {
|
|||
resolvedCompany: string;
|
||||
selectedVendor: VendorDropdownItem | undefined;
|
||||
saveError: string | null;
|
||||
onCompanyPick: (company: string) => void;
|
||||
onPick: (key: string) => void;
|
||||
onPhoneChange: (phone: string) => void;
|
||||
onNotesChange: (notes: string) => void;
|
||||
|
|
@ -34,6 +40,8 @@ type VendorDialogBodyProps = {
|
|||
};
|
||||
|
||||
export function VendorDialogBody({
|
||||
selectedCompany,
|
||||
companyOptions,
|
||||
selectedKey,
|
||||
technicianOptions,
|
||||
anchorCompanyName,
|
||||
|
|
@ -44,6 +52,7 @@ export function VendorDialogBody({
|
|||
resolvedCompany,
|
||||
selectedVendor,
|
||||
saveError,
|
||||
onCompanyPick,
|
||||
onPick,
|
||||
onPhoneChange,
|
||||
onNotesChange,
|
||||
|
|
@ -53,17 +62,46 @@ export function VendorDialogBody({
|
|||
<>
|
||||
<div className="mt-3 space-y-3">
|
||||
<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
|
||||
value={selectedKey}
|
||||
placeholder="Search technician …"
|
||||
placeholder={selectedCompany ? "Select technician …" : "Pick a company first"}
|
||||
disabled={!selectedCompany}
|
||||
icon={
|
||||
<Wrench size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||
}
|
||||
searchPlaceholder="Search technician…"
|
||||
options={technicianOptions.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
label: option.name,
|
||||
}))}
|
||||
onPick={onPick}
|
||||
/>
|
||||
|
|
@ -80,7 +118,7 @@ export function VendorDialogBody({
|
|||
|
||||
{Boolean(tech) && (
|
||||
<div>
|
||||
<FieldLabel>Technician Phone</FieldLabel>
|
||||
<FieldLabel>Technician Phone (optional)</FieldLabel>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
|
|
@ -91,15 +129,8 @@ export function VendorDialogBody({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{Boolean(resolvedCompany) && (
|
||||
<div>
|
||||
<FieldLabel>Company</FieldLabel>
|
||||
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Notes</FieldLabel>
|
||||
<FieldLabel>Notes (optional)</FieldLabel>
|
||||
<textarea
|
||||
value={notes}
|
||||
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 type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
|
|
@ -30,10 +30,7 @@ export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompany
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{Boolean(vendor?.contactName) && <Detail icon={User} text={vendor?.contactName ?? ""} />}
|
||||
{Boolean(vendor?.tradeSpecialties) && (
|
||||
<Detail icon={Wrench} text={vendor?.tradeSpecialties ?? ""} />
|
||||
)}
|
||||
{Boolean(vendor?.address) && <Detail icon={MapPin} text={vendor?.address ?? ""} />}
|
||||
{Boolean(email) && <Detail icon={Mail} text={email} />}
|
||||
{vendor != null && Boolean(vendor.address) && (
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ type VendorDialogFooterProps = {
|
|||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
viewOnly?: boolean;
|
||||
canSubmit?: boolean;
|
||||
};
|
||||
|
||||
const lookDisabledSx = {
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
"&:hover": { backgroundColor: "var(--color-primary)", opacity: 0.5 },
|
||||
};
|
||||
|
||||
export function VendorDialogFooter({
|
||||
|
|
@ -12,6 +19,7 @@ export function VendorDialogFooter({
|
|||
onCancel,
|
||||
onSave,
|
||||
viewOnly = false,
|
||||
canSubmit = false,
|
||||
}: VendorDialogFooterProps) {
|
||||
if (viewOnly) {
|
||||
return (
|
||||
|
|
@ -36,7 +44,13 @@ export function VendorDialogFooter({
|
|||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
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"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,44 @@ export type TechnicianOption = {
|
|||
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(
|
||||
vendors: VendorDropdownItem[],
|
||||
selectedCompany: string,
|
||||
tech: string,
|
||||
company: string,
|
||||
currentCompany: string,
|
||||
): TechnicianOption[] {
|
||||
const options = new Map<string, TechnicianOption>();
|
||||
|
||||
|
|
@ -25,6 +59,9 @@ export function buildTechnicianOptions(
|
|||
};
|
||||
|
||||
for (const vendor of vendors) {
|
||||
if (vendor.companyName !== selectedCompany) {
|
||||
continue;
|
||||
}
|
||||
const contact = vendor.contactName.trim() || vendor.companyName.trim();
|
||||
add({
|
||||
key: String(vendor.id),
|
||||
|
|
@ -36,17 +73,16 @@ export function buildTechnicianOptions(
|
|||
});
|
||||
}
|
||||
|
||||
if (tech) {
|
||||
if (tech && selectedCompany === currentCompany) {
|
||||
const vendor =
|
||||
vendors.find((v) => v.contactName === tech && v.companyName === company) ??
|
||||
vendors.find((v) => v.contactName === tech) ??
|
||||
vendors.find((v) => v.companyName === company);
|
||||
vendors.find((v) => v.contactName === tech && v.companyName === currentCompany) ??
|
||||
vendors.find((v) => v.contactName === tech);
|
||||
const key = vendor ? String(vendor.id) : tech;
|
||||
add({
|
||||
key,
|
||||
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
||||
label: tech,
|
||||
name: tech,
|
||||
company: company || tech,
|
||||
company: currentCompany,
|
||||
phone: "",
|
||||
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 { Text } from "@/components/ui/text";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
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 { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
|
||||
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 { useVendorDialogForm } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form";
|
||||
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";
|
||||
|
|
@ -44,85 +33,15 @@ export function VendorDialog({
|
|||
viewOnly = false,
|
||||
onSave,
|
||||
}: VendorDialogProps) {
|
||||
const createVendor = useCreateVendor();
|
||||
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 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,
|
||||
const form = useVendorDialogForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
setSaveError,
|
||||
vendorId,
|
||||
company,
|
||||
tech,
|
||||
techPhone,
|
||||
vendors,
|
||||
onSave,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -134,43 +53,47 @@ export function VendorDialog({
|
|||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 480 },
|
||||
style: { borderRadius: 12, maxWidth: 560 },
|
||||
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||
Technician
|
||||
Vendor
|
||||
</DialogTitle>
|
||||
<Text as="p" className="sr-only">
|
||||
Search and select the technician
|
||||
Search and select the vendor company
|
||||
</Text>
|
||||
|
||||
<fieldset disabled={viewOnly} className="min-w-0 border-0 p-0">
|
||||
<VendorDialogBody
|
||||
selectedKey={selectedKey}
|
||||
technicianOptions={technicianOptions}
|
||||
anchorCompanyName={anchorCompanyName}
|
||||
companyVendorForAdd={viewOnly ? undefined : (companyVendor ?? undefined)}
|
||||
tech={selected?.name ?? t}
|
||||
phone={phone}
|
||||
notes={notes}
|
||||
resolvedCompany={resolvedCompany}
|
||||
selectedVendor={selectedVendor}
|
||||
saveError={saveError}
|
||||
onPick={handlePick}
|
||||
onPhoneChange={setPhone}
|
||||
onNotesChange={setNotes}
|
||||
onTechnicianCreated={handleTechnicianCreated}
|
||||
selectedCompany={form.selectedCompany}
|
||||
companyOptions={form.companyOptions}
|
||||
selectedKey={form.selectedKey}
|
||||
technicianOptions={form.technicianOptions}
|
||||
anchorCompanyName={form.anchorCompanyName}
|
||||
companyVendorForAdd={viewOnly ? undefined : (form.companyVendor ?? undefined)}
|
||||
tech={form.selected?.name ?? form.t}
|
||||
phone={form.phone}
|
||||
notes={form.notes}
|
||||
resolvedCompany={form.resolvedCompany}
|
||||
selectedVendor={form.selectedVendor}
|
||||
saveError={form.saveError}
|
||||
onCompanyPick={form.handleCompanyPick}
|
||||
onPick={form.handlePick}
|
||||
onPhoneChange={form.setPhone}
|
||||
onNotesChange={form.setNotes}
|
||||
onTechnicianCreated={form.handleTechnicianCreated}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<VendorDialogFooter
|
||||
viewOnly={viewOnly}
|
||||
isPending={createVendor.isPending}
|
||||
isPending={form.isPending}
|
||||
canSubmit={form.canSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSave={handleSave}
|
||||
onSave={form.handleSave}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -18,11 +18,15 @@ type NotifyNavigate = {
|
|||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
function showRescheduleToast(message: string, onNavigate: () => void) {
|
||||
function showRescheduleToast(
|
||||
message: string,
|
||||
onNavigate: () => void,
|
||||
linkLabel = "Click to view it.",
|
||||
) {
|
||||
const toastId = toast(
|
||||
createElement(RescheduleToast, {
|
||||
message,
|
||||
linkLabel: "Click to view it.",
|
||||
linkLabel,
|
||||
onNavigate: () => {
|
||||
toast.dismiss(toastId);
|
||||
onNavigate();
|
||||
|
|
@ -35,6 +39,13 @@ function showRescheduleToast(message: string, onNavigate: () => void) {
|
|||
}
|
||||
|
||||
export function weekMoveNotify(weekStart: string, { onNavigate }: NotifyNavigate) {
|
||||
if (!weekStart) {
|
||||
return showRescheduleToast(
|
||||
"Work order moved to Unscheduled.",
|
||||
onNavigate,
|
||||
"View in Advanced Filters.",
|
||||
);
|
||||
}
|
||||
return showRescheduleToast(
|
||||
`Work order moved to Unscheduled · ${fmtWeekOf(weekStart)}.`,
|
||||
onNavigate,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { toast } from "react-toastify";
|
||||
import type { WorkOrderSeverity } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import {
|
||||
formatSeverityEscalationToast,
|
||||
shouldAnnounceSeverityEscalation,
|
||||
} from "@/domain/work-orders/utils/severity-escalation";
|
||||
|
||||
type PersistPatchFn = (
|
||||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
options?: { onSuccess?: () => void },
|
||||
) => void;
|
||||
|
||||
export function saveExistingRowType(
|
||||
onPatch: PersistPatchFn,
|
||||
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity">,
|
||||
type: WOType,
|
||||
severity: WorkOrderSeverity | null,
|
||||
) {
|
||||
const patch = { type, severity };
|
||||
if (!shouldAnnounceSeverityEscalation(row.severity, severity)) {
|
||||
onPatch(row.id, patch);
|
||||
return;
|
||||
}
|
||||
onPatch(row.id, patch, {
|
||||
onSuccess: () => toast.warn(formatSeverityEscalationToast(row.woNumber, severity)),
|
||||
});
|
||||
}
|
||||
|
|
@ -17,6 +17,10 @@ export function toInlineDraftRow(draft: WorkOrderTableDraft) {
|
|||
dispatcherId: draft.dispatcherId,
|
||||
dispatcherName: "",
|
||||
scheduledOn: draft.scheduledOn,
|
||||
scheduleWeekOnly: draft.scheduleWeekOnly,
|
||||
scheduleEnd: draft.scheduleEnd,
|
||||
schedMode: draft.schedMode,
|
||||
vendorId: draft.vendorId,
|
||||
completedDate: "",
|
||||
dueDate: draft.dueDate,
|
||||
pm: draft.pm,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useMemo } from "react";
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
applySchedulePatch,
|
||||
applyScheduleRangePatch,
|
||||
type WorkOrderTablePatchFn,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
|
||||
|
|
@ -9,6 +10,27 @@ import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
|||
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
|
||||
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||
|
||||
function revealOrNotifyScheduledDay(args: {
|
||||
id: string | number;
|
||||
date: string;
|
||||
weekMonday: string;
|
||||
onNavigateToDay: (dateIso: string) => void;
|
||||
revealScheduledRow: (id: string | number) => void;
|
||||
}) {
|
||||
const { id, date, weekMonday, onNavigateToDay, revealScheduledRow } = args;
|
||||
if (shouldNotifyCrossWeek(date, weekMonday)) {
|
||||
rescheduleNotify(date, {
|
||||
onNavigate: () => {
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
}
|
||||
|
||||
type UseWoTableRowHandlersOptions = {
|
||||
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||
weekMonday: string;
|
||||
|
|
@ -81,29 +103,45 @@ export function useWoTableRowHandlers({
|
|||
},
|
||||
onScheduleDate: (id: string | number, date: string) => {
|
||||
onPatch(id, applySchedulePatch(date, false));
|
||||
if (shouldNotifyCrossWeek(date, weekMonday)) {
|
||||
rescheduleNotify(date, {
|
||||
onNavigate: () => {
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
onNavigateToDay(date);
|
||||
revealScheduledRow(id);
|
||||
revealOrNotifyScheduledDay({
|
||||
id,
|
||||
date,
|
||||
weekMonday,
|
||||
onNavigateToDay,
|
||||
revealScheduledRow,
|
||||
});
|
||||
},
|
||||
onScheduleRange: (id: string | number, start: string, end: string) => {
|
||||
onPatch(id, applyScheduleRangePatch(start, end));
|
||||
revealOrNotifyScheduledDay({
|
||||
id,
|
||||
date: start,
|
||||
weekMonday,
|
||||
onNavigateToDay,
|
||||
revealScheduledRow,
|
||||
});
|
||||
},
|
||||
onScheduleWeek: (id: string | number, week: string) => {
|
||||
onPatch(id, {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
});
|
||||
const isClear = week === "";
|
||||
onPatch(
|
||||
id,
|
||||
isClear
|
||||
? applySchedulePatch("", false)
|
||||
: {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
},
|
||||
);
|
||||
weekMoveNotify(week, {
|
||||
onNavigate: () => {
|
||||
onNavigateToDay(week);
|
||||
if (!isClear) {
|
||||
onNavigateToDay(week);
|
||||
}
|
||||
onOpenAdvancedFilters?.();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import { ApptTimeCell } from "./cells/appt-time-cell";
|
|||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||
import {
|
||||
CREATION_INLINE_STATUSES,
|
||||
withAutoScheduleStatus,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { StatusCell } from "./cells/status-cell";
|
||||
import { TypeCell } from "./cells/type-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
|
|
@ -74,7 +78,8 @@ function InlineScheduleOnCell({
|
|||
weekDefault: boolean;
|
||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
}) {
|
||||
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
|
||||
const hasSchedule =
|
||||
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
|
||||
return (
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<DateCell
|
||||
|
|
@ -83,31 +88,57 @@ function InlineScheduleOnCell({
|
|||
targetWeek={draft.targetWeek}
|
||||
defaultMode={weekDefault ? "week" : "date"}
|
||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onSave={(date) =>
|
||||
onPatch({
|
||||
scheduledOn: date,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: date,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onSaveWeek={(week) =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onSaveRange={(start, end) =>
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: start,
|
||||
scheduleEnd: end,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () =>
|
||||
onPatch({
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
scheduleWeekOnly: false,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: "",
|
||||
targetWeek: "",
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
|
@ -215,6 +246,7 @@ export function WoTableInlineRowCells({
|
|||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<StatusCell
|
||||
wo={draftRow as never}
|
||||
statusOptions={CREATION_INLINE_STATUSES}
|
||||
onChangeStatus={(status) => onPatch({ status })}
|
||||
onOpenSlideOver={() => {}}
|
||||
onRequestComplete={onRequestComplete}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import type { CSSProperties } from "react";
|
|||
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { applyAssignedToTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
import { saveExistingRowType } from "./save-existing-row-type";
|
||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||
import { DateCell } from "./cells/date-cell";
|
||||
import { DueDateCell } from "./cells/due-date-cell";
|
||||
|
|
@ -41,6 +43,9 @@ export function WoTableRowCells({
|
|||
handlers,
|
||||
onPatchRow,
|
||||
}: WoTableRowCellsProps) {
|
||||
const hasSchedule =
|
||||
Boolean(row.scheduledOn) || Boolean(row.targetWeek) || Boolean(row.scheduleEnd);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
|
|
@ -103,7 +108,8 @@ export function WoTableRowCells({
|
|||
value={row.type}
|
||||
severity={row.severity}
|
||||
q={search}
|
||||
onSave={(t, sev) => onPatchRow({ type: t, severity: sev })}
|
||||
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
|
||||
onSave={(t, sev) => saveExistingRowType(handlers.onPatch, row, t, sev)}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
@ -129,8 +135,12 @@ export function WoTableRowCells({
|
|||
highlight={forceScheduleHighlight}
|
||||
allowWeekMode
|
||||
targetWeek={row.targetWeek}
|
||||
scheduleEnd={row.scheduleEnd}
|
||||
schedMode={row.schedMode}
|
||||
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
||||
onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)}
|
||||
onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)}
|
||||
onClear={hasSchedule && !viewOnly ? () => handlers.onScheduleWeek(row.id, "") : undefined}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export type WoTableRowHandlers = {
|
|||
) => void;
|
||||
onScheduleDate: (id: string | number, date: string) => void;
|
||||
onScheduleWeek: (id: string | number, week: string) => void;
|
||||
onScheduleRange: (id: string | number, start: string, end: string) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
onOpenSlideOver: (row: WorkOrderTableRow) => void;
|
||||
onRequestComplete?: (row: WorkOrderTableRow) => void;
|
||||
|
|
@ -66,6 +67,31 @@ type WoTableRowProps = {
|
|||
handlers: WoTableRowHandlers;
|
||||
};
|
||||
|
||||
function guardScheduleHandlers(
|
||||
row: WorkOrderTableRow,
|
||||
handlers: WoTableRowHandlers,
|
||||
): WoTableRowHandlers {
|
||||
return {
|
||||
...handlers,
|
||||
onPatch: (id, patch, options) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||
handlers.onPatch(id, patch, options);
|
||||
},
|
||||
onScheduleDate: (id, date) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
|
||||
handlers.onScheduleDate(id, date);
|
||||
},
|
||||
onScheduleWeek: (id, week) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
|
||||
handlers.onScheduleWeek(id, week);
|
||||
},
|
||||
onScheduleRange: (id, start, end) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: start })) return;
|
||||
handlers.onScheduleRange(id, start, end);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function WoTableRow({
|
||||
row,
|
||||
allRows,
|
||||
|
|
@ -88,21 +114,7 @@ export function WoTableRow({
|
|||
const isCoreLocked = isWorkOrderCoreLocked(row.status);
|
||||
const closedForEdit = isCanceled || isCoreLocked;
|
||||
|
||||
const guardedHandlers: WoTableRowHandlers = {
|
||||
...handlers,
|
||||
onPatch: (id, patch, options) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||
handlers.onPatch(id, patch, options);
|
||||
},
|
||||
onScheduleDate: (id, date) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { scheduledOn: date })) return;
|
||||
handlers.onScheduleDate(id, date);
|
||||
},
|
||||
onScheduleWeek: (id, week) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, { targetWeek: week })) return;
|
||||
handlers.onScheduleWeek(id, week);
|
||||
},
|
||||
};
|
||||
const guardedHandlers = guardScheduleHandlers(row, handlers);
|
||||
|
||||
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
|
||||
if (!canApplyWorkOrderTablePatch(row.status, patch)) return;
|
||||
|
|
@ -189,7 +201,7 @@ export function WoTableRow({
|
|||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={forceDueDateHighlight}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
viewOnly={closedForEdit}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
@ -200,7 +212,7 @@ export function WoTableRow({
|
|||
pmTypes={pmTypes}
|
||||
search={search}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
viewOnly={closedForEdit}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
|||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useDeleteWorkOrderMedia,
|
||||
useUpdateWorkOrderMediaCategory,
|
||||
useUploadWorkOrderMedia,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
|
@ -35,14 +34,12 @@ function resolveWorkOrderVersion(
|
|||
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
|
||||
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
|
||||
const uploadMutation = useUploadWorkOrderMedia(row.id);
|
||||
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
||||
|
||||
const pending =
|
||||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
||||
const pending = uploadMutation.isPending || deleteMutation.isPending;
|
||||
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
|
||||
|
||||
const requireVersion = (): string | null => {
|
||||
|
|
@ -69,14 +66,18 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
>
|
||||
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon size={16} className="shrink-0" style={{ color: "var(--foreground)" }} />
|
||||
<DialogTitle className="p-0" style={{ fontSize: 15, color: "var(--color-text-primary)" }}>
|
||||
<ImageIcon size={18} className="shrink-0" style={{ color: "var(--foreground)" }} />
|
||||
<DialogTitle
|
||||
sx={{ p: 0, fontSize: 16, lineHeight: 1.25, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
Photos & Videos
|
||||
</DialogTitle>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-muted)" }}>
|
||||
#{row.woNumber}
|
||||
</span>
|
||||
</div>
|
||||
<Text className="sr-only" variant="description">
|
||||
Upload photos and videos for this work order, then assign Before, After, or Extra.
|
||||
Upload photos and videos for this work order.
|
||||
</Text>
|
||||
|
||||
<div className="mt-4">
|
||||
|
|
@ -101,11 +102,6 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
|||
if (!workOrderVersion) return;
|
||||
deleteMutation.mutate({ mediaId: id, workOrderVersion });
|
||||
}}
|
||||
onCategorize={(id, category) => {
|
||||
const workOrderVersion = requireVersion();
|
||||
if (!workOrderVersion) return;
|
||||
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
|
||||
}}
|
||||
onRetry={retryUpload}
|
||||
onCancel={cancelUpload}
|
||||
readOnly={isReadOnly}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import {
|
|||
type MediaUploaderDropzoneProps = {
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
pending: boolean;
|
||||
categorizeAfterUpload?: boolean;
|
||||
onFilesSelected: (files: FileList | null) => void;
|
||||
};
|
||||
|
||||
export function MediaUploaderDropzone({
|
||||
inputRef,
|
||||
pending,
|
||||
categorizeAfterUpload = false,
|
||||
onFilesSelected,
|
||||
}: MediaUploaderDropzoneProps) {
|
||||
return (
|
||||
|
|
@ -27,7 +29,7 @@ export function MediaUploaderDropzone({
|
|||
e.preventDefault();
|
||||
onFilesSelected(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 120,
|
||||
border: "1px dashed var(--color-border)",
|
||||
|
|
@ -39,8 +41,8 @@ export function MediaUploaderDropzone({
|
|||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
|
||||
upload
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
|
||||
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -17,12 +17,13 @@ export function MediaUploaderGridItemMeta({
|
|||
onCategorize,
|
||||
}: MediaUploaderGridItemMetaProps) {
|
||||
const failed = file.uploadStatus === "failed";
|
||||
const managesPurpose = Boolean(onCategorize);
|
||||
const needsCategory =
|
||||
!readOnly &&
|
||||
!file.isLegacy &&
|
||||
file.uploadStatus !== "uploading" &&
|
||||
file.uploadStatus !== "failed" &&
|
||||
Boolean(onCategorize);
|
||||
managesPurpose;
|
||||
|
||||
return (
|
||||
<div className="space-y-1 px-1 py-1">
|
||||
|
|
@ -34,10 +35,10 @@ export function MediaUploaderGridItemMeta({
|
|||
{file.errorMessage || "Upload failed"}
|
||||
</div>
|
||||
)}
|
||||
{needsCategory ? (
|
||||
{needsCategory && (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
value={file.purpose ?? ""}
|
||||
value={file.purpose === "Extra" ? "" : (file.purpose ?? "")}
|
||||
disabled={pending}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as UploadableMediaCategory;
|
||||
|
|
@ -57,7 +58,8 @@ export function MediaUploaderGridItemMeta({
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
)}
|
||||
{Boolean(!needsCategory && managesPurpose) && (
|
||||
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||
{file.purpose || (file.isLegacy ? "Legacy" : "")}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -91,7 +91,12 @@ export function MediaUploader({
|
|||
return (
|
||||
<div>
|
||||
{!readOnly && (
|
||||
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
|
||||
<MediaUploaderDropzone
|
||||
inputRef={inputRef}
|
||||
pending={pending}
|
||||
categorizeAfterUpload={Boolean(onCategorize)}
|
||||
onFilesSelected={addFiles}
|
||||
/>
|
||||
)}
|
||||
<MediaUploaderGrid
|
||||
uploads={uploads}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ type UseWizardDateFieldStateArgs = {
|
|||
allowWeekMode?: boolean;
|
||||
targetWeek: string;
|
||||
schedMode?: WizardSchedMode;
|
||||
defaultMode?: "week" | "date";
|
||||
};
|
||||
|
||||
function resolveInitialMode({
|
||||
|
|
@ -17,9 +18,10 @@ function resolveInitialMode({
|
|||
allowWeekMode,
|
||||
value,
|
||||
targetWeek,
|
||||
defaultMode,
|
||||
}: Pick<
|
||||
UseWizardDateFieldStateArgs,
|
||||
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek"
|
||||
"allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek" | "defaultMode"
|
||||
>): WizardSchedMode {
|
||||
if (allowRange && schedMode === "range") {
|
||||
return "range";
|
||||
|
|
@ -27,6 +29,9 @@ function resolveInitialMode({
|
|||
if (allowWeekMode && !value && targetWeek) {
|
||||
return "week";
|
||||
}
|
||||
if (allowWeekMode && defaultMode) {
|
||||
return defaultMode;
|
||||
}
|
||||
return "date";
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +44,7 @@ export function useWizardDateFieldState({
|
|||
allowWeekMode,
|
||||
targetWeek,
|
||||
schedMode,
|
||||
defaultMode,
|
||||
}: UseWizardDateFieldStateArgs) {
|
||||
const initialMode = resolveInitialMode({
|
||||
allowRange,
|
||||
|
|
@ -46,6 +52,7 @@ export function useWizardDateFieldState({
|
|||
allowWeekMode,
|
||||
value,
|
||||
targetWeek,
|
||||
defaultMode,
|
||||
});
|
||||
|
||||
const [cellMode, setCellMode] = useState<WizardSchedMode>(initialMode);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { withAutoScheduleStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
|
||||
import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
|
|
@ -146,7 +147,11 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
<div>
|
||||
<WizLabelRow
|
||||
optional
|
||||
onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
|
||||
onClear={
|
||||
hasWizardSchedule(draft)
|
||||
? () => onPatch(withAutoScheduleStatus(draft, clearSchedulePatch()))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Schedule On
|
||||
</WizLabelRow>
|
||||
|
|
@ -158,34 +163,40 @@ function WizardTypeScheduleDates({ draft, onUpdate, onPatch }: WizardTypeSchedul
|
|||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onChange={(iso) =>
|
||||
onPatch({
|
||||
scheduledOn: iso,
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: iso,
|
||||
scheduleWeekOnly: false,
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onChangeWeek={(w) =>
|
||||
onPatch({
|
||||
scheduledOn: w,
|
||||
scheduleWeekOnly: Boolean(w),
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
targetWeek: w,
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: w,
|
||||
scheduleWeekOnly: Boolean(w),
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
targetWeek: w,
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onChangeRange={(s, e) =>
|
||||
onPatch({
|
||||
scheduledOn: s,
|
||||
scheduleEnd: e,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
})
|
||||
onPatch(
|
||||
withAutoScheduleStatus(draft, {
|
||||
scheduledOn: s,
|
||||
scheduleEnd: e,
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
TIME_SLOTS,
|
||||
WIZARD_STATUS_DOTS,
|
||||
type WizardWOStatus,
|
||||
|
|
@ -15,12 +13,15 @@ import {
|
|||
formatVendorTechnicianLabel,
|
||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||
import {
|
||||
formatClosabilityMissingTitle,
|
||||
getClosabilityGaps,
|
||||
wizardDraftToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
CREATION_INLINE_STATUSES,
|
||||
getStatusOptionState,
|
||||
withAutoScheduleStatus,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
|
||||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
|
|
@ -173,15 +174,15 @@ function WizardApptTimeFields({ draft, onUpdate, onPatch }: WizardApptTimeFields
|
|||
);
|
||||
}
|
||||
|
||||
function buildWizardStatusOptions(closabilityGaps: string[]) {
|
||||
return ALL_WIZARD_STATUSES.map((status) => {
|
||||
const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0;
|
||||
function buildWizardStatusOptions(draft: WorkOrderWizardDraft, closabilityGaps: string[]) {
|
||||
return CREATION_INLINE_STATUSES.map((status) => {
|
||||
const option = getStatusOptionState(draft, status, closabilityGaps);
|
||||
return {
|
||||
key: status,
|
||||
label: status,
|
||||
left: <ColorDot color={WIZARD_STATUS_DOTS[status]} />,
|
||||
disabled: isCompletedBlocked,
|
||||
title: isCompletedBlocked ? formatClosabilityMissingTitle(closabilityGaps) : undefined,
|
||||
disabled: option.disabled,
|
||||
title: option.title,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -192,7 +193,6 @@ export function WizardStepVendorTime({
|
|||
onUpdate,
|
||||
onPatch,
|
||||
}: WizardStepVendorTimeProps) {
|
||||
const [confirmComplete, setConfirmComplete] = useState(false);
|
||||
const selectedVendor = vendors.find((v) => String(v.id) === draft.vendorId);
|
||||
const statusValue = (draft.status || "Incomplete") as WizardWOStatus;
|
||||
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
|
||||
|
|
@ -272,27 +272,16 @@ export function WizardStepVendorTime({
|
|||
placeholder="Select status…"
|
||||
search={false}
|
||||
icon={<ColorDot color={statusDotColor} />}
|
||||
options={buildWizardStatusOptions(closabilityGaps)}
|
||||
options={buildWizardStatusOptions(draft, closabilityGaps)}
|
||||
onPick={(status) => {
|
||||
if (status === "Completed" && closabilityGaps.length > 0) return;
|
||||
if (status === "Completed" && draft.status !== "Completed") {
|
||||
setConfirmComplete(true);
|
||||
const option = getStatusOptionState(draft, status as WizardWOStatus, closabilityGaps);
|
||||
if (option.disabled) {
|
||||
return;
|
||||
}
|
||||
onUpdate("status", status as WizardWOStatus);
|
||||
onPatch(withAutoScheduleStatus(draft, { status: status as WizardWOStatus }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<WorkOrderCompleteDialog
|
||||
open={confirmComplete}
|
||||
isPending={false}
|
||||
onDialogClose={() => setConfirmComplete(false)}
|
||||
onKeepOpen={() => setConfirmComplete(false)}
|
||||
onConfirm={() => {
|
||||
onUpdate("status", "Completed");
|
||||
setConfirmComplete(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { API_PATHS } from "@/api/api-paths";
|
|||
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||
import { apiGet, apiPost, apiRequestRaw } from "@/api/api";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { uploadFormWithProgress } from "@/api/upload-form-with-progress";
|
||||
import { traceHttpOperation } from "@/observability/http-transaction";
|
||||
import {
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
|
|
@ -33,10 +35,10 @@ async function readResponseJson(response: Response): Promise<unknown> {
|
|||
}
|
||||
}
|
||||
|
||||
function throwDocumentHttpError(response: Response, data: unknown): never {
|
||||
function throwDocumentHttpError(status: number, data: unknown): never {
|
||||
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||
|
||||
if (response.status === 409) {
|
||||
if (status === 409) {
|
||||
throw new BoardPatchConflictError(
|
||||
typeof record.message === "string"
|
||||
? record.message
|
||||
|
|
@ -45,14 +47,14 @@ function throwDocumentHttpError(response: Response, data: unknown): never {
|
|||
);
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
if (status === 422) {
|
||||
throw new BoardPatchValidationError(
|
||||
typeof record.code === "string" ? record.code : "ValidationError",
|
||||
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||
throw new ApiError(mapHttpStatusToMessage(status, data), status);
|
||||
}
|
||||
|
||||
export const workOrderBoardDocumentsApi = {
|
||||
|
|
@ -96,7 +98,7 @@ export const workOrderBoardDocumentsApi = {
|
|||
return mapBoardDetailComment(handleApiResponse(data) ?? data);
|
||||
}
|
||||
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
/** @deprecated Prefer createComment — modern board endpoint. */
|
||||
|
|
@ -121,26 +123,26 @@ export const workOrderBoardDocumentsApi = {
|
|||
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
|
||||
): Promise<WorkOrderBoardMediaItem> => {
|
||||
const formData = buildMediaFormData(file, category);
|
||||
const response = await apiRequestRaw(
|
||||
"post",
|
||||
API_PATHS.workOrder.media(workOrderId),
|
||||
const { ok, status, data } = await traceHttpOperation(
|
||||
{
|
||||
body: formData,
|
||||
throwHttpErrors: false,
|
||||
signal: options?.signal,
|
||||
onUploadProgress: options?.onProgress
|
||||
? (progress) => {
|
||||
options.onProgress?.(Math.round(progress.percent * 100));
|
||||
}
|
||||
: undefined,
|
||||
method: "POST",
|
||||
url: API_PATHS.workOrder.media(workOrderId),
|
||||
entryPoint: "workOrderBoardDocumentsApi.uploadMedia",
|
||||
},
|
||||
async ({ reportStatus }) => {
|
||||
const result = await uploadFormWithProgress(
|
||||
API_PATHS.workOrder.media(workOrderId),
|
||||
formData,
|
||||
{ signal: options?.signal, onProgress: options?.onProgress },
|
||||
);
|
||||
reportStatus(result.status);
|
||||
return result;
|
||||
},
|
||||
"workOrderBoardDocumentsApi.uploadMedia",
|
||||
);
|
||||
const data = await readResponseJson(response);
|
||||
if (response.ok) {
|
||||
if (ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(status, data);
|
||||
},
|
||||
|
||||
updateMediaCategory: async (
|
||||
|
|
@ -169,7 +171,7 @@ export const workOrderBoardDocumentsApi = {
|
|||
if (response.ok) {
|
||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
deleteMedia: async (
|
||||
|
|
@ -207,7 +209,30 @@ export const workOrderBoardDocumentsApi = {
|
|||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
getMediaContent: async (
|
||||
workOrderId: string | number,
|
||||
mediaId: string | number,
|
||||
): Promise<Blob> => {
|
||||
const response = await apiRequestRaw(
|
||||
"get",
|
||||
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
|
||||
{ credentials: "include", throwHttpErrors: false },
|
||||
"workOrderBoardDocumentsApi.getMediaContent",
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new ApiError(mapHttpStatusToMessage(404), 404);
|
||||
}
|
||||
|
||||
const data = await readResponseJson(response);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
uploadCompletionDoc: async (
|
||||
|
|
@ -237,6 +262,6 @@ export const workOrderBoardDocumentsApi = {
|
|||
if (response.ok) {
|
||||
return mapCompletion(handleApiResponse(data) ?? data);
|
||||
}
|
||||
throwDocumentHttpError(response, data);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,3 +21,11 @@ export class BoardPatchValidationError extends Error {
|
|||
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,
|
||||
} from "@/domain/work-orders/types/work-order-detail";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
|
||||
import { toDisplayFileName } from "@/domain/work-orders/utils/display-file-name";
|
||||
import type {
|
||||
FileCategory,
|
||||
MediaCategory,
|
||||
|
|
@ -220,7 +221,7 @@ export function mapBoardDetailMediaItem(raw: unknown): WorkOrderBoardMediaItem {
|
|||
id: String(id ?? (readString(item, "id") || cryptoRandomId())),
|
||||
category: mapMediaCategory(item.category ?? item.Category),
|
||||
url: readString(item, "url", "Url"),
|
||||
fileName: readString(item, "fileName", "FileName") || "file",
|
||||
fileName: toDisplayFileName(readString(item, "fileName", "FileName") || "file"),
|
||||
uploadedAt: readString(item, "uploadedAt", "UploadedAt"),
|
||||
isLegacy: isLegacyFlag || (id != null && id <= 0),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
|
|||
/** Optimistic overlay only — backend owns isAddOn after schedule ops (SH-184). */
|
||||
"isAddOn",
|
||||
"primaryDispatchStatus",
|
||||
/** Client-only Schedule On range overlay — API persists range start as scheduledDate. */
|
||||
"scheduleEnd",
|
||||
"schedMode",
|
||||
]);
|
||||
|
||||
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { WorkOrderAdditionalContact } from "./work-order-additional-contact";
|
||||
import type { WizardWOStatus, WOType } from "./work-order-wizard";
|
||||
import type { WizardSchedMode, WizardWOStatus, WOType } from "./work-order-wizard";
|
||||
import type { WorkOrderSeverity } from "./work-order-severity";
|
||||
import type { UpliftSummary } from "./work-order-uplift";
|
||||
|
||||
|
|
@ -65,6 +65,10 @@ export interface WorkOrderTableRow {
|
|||
originalDate: string;
|
||||
isPastDue: boolean;
|
||||
scheduleWeekOnly: boolean;
|
||||
/** Optimistic range end; API persists only `scheduledOn` (range start). */
|
||||
scheduleEnd?: string;
|
||||
/** Last Schedule On mode used in this session. */
|
||||
schedMode?: WizardSchedMode;
|
||||
mediaCount: number;
|
||||
title: string;
|
||||
priority: string;
|
||||
|
|
@ -105,6 +109,8 @@ export interface WorkOrderTableDraft {
|
|||
additionalContacts: WorkOrderAdditionalContact[];
|
||||
scheduleWeekOnly: boolean;
|
||||
targetWeek: string;
|
||||
scheduleEnd?: string;
|
||||
schedMode?: WizardSchedMode;
|
||||
/** Manual Add-On override when schedule is empty at inline create. */
|
||||
isAddOnOverride: boolean;
|
||||
}
|
||||
|
|
@ -137,6 +143,8 @@ export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
|
|||
additionalContacts: [],
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isAddOnOverride: false,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import { toast } from "react-toastify";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import {
|
||||
boardPatchValidationMessage,
|
||||
BoardPatchConflictError,
|
||||
BoardPatchValidationError,
|
||||
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||
|
|
@ -19,9 +20,15 @@ import type {
|
|||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||
import {
|
||||
maybeAutoSchedulePatch,
|
||||
mergeAutoSchedulePatch,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
export { maybeAutoSchedulePatch };
|
||||
|
||||
export type { WorkOrderTablePatch };
|
||||
|
||||
export type WorkOrderTablePatchOptions = {
|
||||
|
|
@ -124,10 +131,16 @@ export function useWorkOrderTableMutations(
|
|||
|
||||
const patchMutation = useMutation({
|
||||
meta: { suppressErrorToast: true },
|
||||
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
|
||||
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
|
||||
onMutate: async ({ id, patch }) => {
|
||||
onPatch(id, patch);
|
||||
mutationFn: ({
|
||||
id,
|
||||
persistPatch,
|
||||
}: {
|
||||
id: string | number;
|
||||
patch: WorkOrderTablePatch;
|
||||
persistPatch: WorkOrderTablePatch;
|
||||
}) => enqueuePatch(id, () => runWorkOrderTablePatch(id, persistPatch, getRow)),
|
||||
onMutate: async ({ id, persistPatch }) => {
|
||||
onPatch(id, persistPatch);
|
||||
},
|
||||
onSuccess: (result, { id }) => {
|
||||
if (result.kind === "board") {
|
||||
|
|
@ -149,7 +162,7 @@ export function useWorkOrderTableMutations(
|
|||
toast.error(error.message || "Conflict — refreshed with latest data");
|
||||
} else if (error instanceof BoardPatchValidationError) {
|
||||
clearPatch(id);
|
||||
toast.error(error.message);
|
||||
toast.error(boardPatchValidationMessage(error));
|
||||
} else {
|
||||
clearPatch(id);
|
||||
toast.error(error.message || "Failed to update work order");
|
||||
|
|
@ -158,15 +171,21 @@ export function useWorkOrderTableMutations(
|
|||
},
|
||||
});
|
||||
|
||||
const resolvePatch = (id: string | number, patch: WorkOrderTablePatch) =>
|
||||
mergeAutoSchedulePatch(getRow?.(id), patch);
|
||||
|
||||
const patchField: WorkOrderTablePatchFn = (id, patch, options) => {
|
||||
void patchMutation.mutateAsync({ id, patch }).then(
|
||||
const persistPatch = resolvePatch(id, patch);
|
||||
void patchMutation.mutateAsync({ id, patch, persistPatch }).then(
|
||||
() => options?.onSuccess?.(),
|
||||
(error: Error) => options?.onError?.(error),
|
||||
);
|
||||
};
|
||||
|
||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
|
||||
patchMutation.mutateAsync({ id, patch });
|
||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) => {
|
||||
const persistPatch = resolvePatch(id, patch);
|
||||
return patchMutation.mutateAsync({ id, patch, persistPatch });
|
||||
};
|
||||
|
||||
return {
|
||||
patchField,
|
||||
|
|
@ -189,19 +208,9 @@ export function tableRowFromDetail(detail: Awaited<ReturnType<typeof workOrdersA
|
|||
);
|
||||
}
|
||||
|
||||
/** Optimistic UI only — persistence auto-schedule is handled by the board PATCH API. */
|
||||
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
|
||||
const hasDate = !!row.scheduledOn;
|
||||
const hasDispatcher = !!row.dispatcherId;
|
||||
if (hasDate && hasDispatcher && row.status === "Incomplete") {
|
||||
return { status: "Scheduled" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a schedule table patch. Clearing the schedule optimistically clears
|
||||
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response (SH-184).
|
||||
* `isAddOn`; day/week moves leave `isAddOn` for the board PATCH response.
|
||||
*/
|
||||
export function applySchedulePatch(
|
||||
scheduledOn: string,
|
||||
|
|
@ -212,6 +221,8 @@ export function applySchedulePatch(
|
|||
scheduledOn: "",
|
||||
targetWeek: scheduledOn,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
...deriveDayFields(""),
|
||||
};
|
||||
}
|
||||
|
|
@ -221,6 +232,8 @@ export function applySchedulePatch(
|
|||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isAddOn: false,
|
||||
...deriveDayFields(""),
|
||||
};
|
||||
|
|
@ -229,6 +242,17 @@ export function applySchedulePatch(
|
|||
scheduledOn: trimmed,
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
...deriveDayFields(trimmed),
|
||||
};
|
||||
}
|
||||
|
||||
/** Range lands on the first day; end/mode stay optimistic (no board API field). */
|
||||
export function applyScheduleRangePatch(start: string, end: string): WorkOrderTablePatch {
|
||||
return {
|
||||
...applySchedulePatch(start, false),
|
||||
scheduleEnd: end,
|
||||
schedMode: "range",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { SEVERITY_LEVELS } from "@/domain/work-orders/types/work-order-severity";
|
||||
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order-detail";
|
||||
|
||||
export type AuditActionSource = Pick<
|
||||
|
|
@ -26,11 +27,51 @@ export type AuditActionRuleContext = {
|
|||
|
||||
export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null;
|
||||
|
||||
function tryStatusRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
function formatSeverityAuditLabel(raw: string): string {
|
||||
const n = Number(raw.trim());
|
||||
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) {
|
||||
return SEVERITY_LEVELS[n].label;
|
||||
}
|
||||
const sevMatch = raw.trim().match(/^sev\s*([1-5])$/i);
|
||||
if (sevMatch) {
|
||||
return `SEV ${sevMatch[1]}`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function tryTypeRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
const matches =
|
||||
fieldKey === "type" ||
|
||||
fieldKey === "workordertype" ||
|
||||
fieldKey === "wotype" ||
|
||||
actionKey === "typechanged" ||
|
||||
actionKey.includes("workordertype");
|
||||
if (!matches || !newValue.text) return null;
|
||||
|
||||
return `Changed Type to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
|
||||
function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
const matches = fieldKey.includes("severity") || actionKey.includes("severity");
|
||||
if (!matches || !newValue.text) return null;
|
||||
|
||||
const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false };
|
||||
return `Changed Severity to ${wrapAuditValue(labeled)}`;
|
||||
}
|
||||
|
||||
function tryStatusRule({
|
||||
fieldKey,
|
||||
actionKey,
|
||||
oldValue,
|
||||
newValue,
|
||||
}: AuditActionRuleContext): string | null {
|
||||
const matches =
|
||||
fieldKey.includes("status") || fieldKey === "lifecyclestatus" || actionKey.includes("status");
|
||||
if (!matches || !newValue.text) return null;
|
||||
|
||||
if (oldValue.text) {
|
||||
return `Changed status from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
return `Changed status to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +160,8 @@ function tryAssignedToRule({
|
|||
/** Applied in order; the first non-null result wins. */
|
||||
export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
|
||||
tryStatusRule,
|
||||
tryTypeRule,
|
||||
trySeverityRule,
|
||||
tryApptRule,
|
||||
tryScheduleRule,
|
||||
tryCarriedOverRule,
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
20
src/domain/work-orders/utils/severity-escalation.ts
Normal file
20
src/domain/work-orders/utils/severity-escalation.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import {
|
||||
SEVERITY_LEVELS,
|
||||
type WorkOrderSeverity,
|
||||
} from "@/domain/work-orders/types/work-order-severity";
|
||||
|
||||
function isCriticalSeverity(severity: WorkOrderSeverity | null | undefined): severity is 1 | 2 {
|
||||
return severity === 1 || severity === 2;
|
||||
}
|
||||
|
||||
/** AAP: toast only when severity newly enters SEV 1/2 — not on re-save of the same value. */
|
||||
export function shouldAnnounceSeverityEscalation(
|
||||
previous: WorkOrderSeverity | null | undefined,
|
||||
next: WorkOrderSeverity | null | undefined,
|
||||
): next is 1 | 2 {
|
||||
return isCriticalSeverity(next) && !isCriticalSeverity(previous);
|
||||
}
|
||||
|
||||
export function formatSeverityEscalationToast(woNumber: string, severity: 1 | 2): string {
|
||||
return `WO #${woNumber} escalated to ${SEVERITY_LEVELS[severity].label}.`;
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
|||
"scheduledOn",
|
||||
"targetWeek",
|
||||
"scheduleWeekOnly",
|
||||
"scheduleEnd",
|
||||
"schedMode",
|
||||
"dueDate",
|
||||
"completedDate",
|
||||
"apptTime",
|
||||
|
|
|
|||
195
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
195
src/domain/work-orders/utils/wo-lifecycle-transitions.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import {
|
||||
ALL_WIZARD_STATUSES,
|
||||
type WizardWOStatus,
|
||||
} from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
PENDING_UPLIFT_GAP,
|
||||
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
/** AAP `ALL_STATUSES` — Canceled is kebab-only. */
|
||||
export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES;
|
||||
|
||||
/** AAP `CREATION_STATUSES` — wizard Step 3 and inline create. */
|
||||
export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [
|
||||
"Incomplete",
|
||||
"Pending",
|
||||
"Scheduled",
|
||||
];
|
||||
|
||||
export const SCHEDULED_NEEDS_DATE_MSG =
|
||||
"Set a Schedule On date before marking this work order as Scheduled.";
|
||||
|
||||
export const PAST_DUE_BADGE_TOOLTIP =
|
||||
"This WO missed its service window. Reschedule to unlock status updates.";
|
||||
|
||||
export const PAST_DUE_POPOVER_BODY =
|
||||
"This work order is past due. A new date must be scheduled before the status can be updated.";
|
||||
|
||||
export const PAST_DUE_RESCHEDULE_CTA = "Reschedule →";
|
||||
|
||||
export const COMPLETED_DOC_BLOCK_MSG =
|
||||
"Generate or mark the completion document as not required before completing this work order";
|
||||
|
||||
export const COMPLETED_MEDIA_BLOCK_MSG =
|
||||
"Upload at least one completion photo before completing this work order.";
|
||||
|
||||
const COMPLETE_ACTION = "completing this work order";
|
||||
|
||||
const EMPTY_FIELD_TOKENS = new Set(["", "-", "—", "N/A", "n/a"]);
|
||||
|
||||
const PRE_SCHEDULE_STATUSES = new Set<string>(["Incomplete", "Pending", ""]);
|
||||
|
||||
const FIELD_GAP_TO_AAP_LABEL: [string, string][] = [
|
||||
["Dispatcher", "Assigned To"],
|
||||
["Company", "Vendor"],
|
||||
["Completed Date", "Completed Date"],
|
||||
["Service", "Service"],
|
||||
];
|
||||
|
||||
export type ScheduleDateInput = {
|
||||
scheduledOn?: string;
|
||||
scheduleWeekOnly?: boolean;
|
||||
schedMode?: string;
|
||||
};
|
||||
|
||||
export type StatusOptionState = {
|
||||
disabled: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function isLifecycleFieldEmpty(value?: string): boolean {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return EMPTY_FIELD_TOKENS.has(trimmed);
|
||||
}
|
||||
|
||||
/** AAP `hasScheduleDate`: concrete day/range only — week-only does not count. */
|
||||
export function hasConcreteScheduleDate(row: ScheduleDateInput): boolean {
|
||||
if (row.scheduleWeekOnly === true || row.schedMode === "week") {
|
||||
return false;
|
||||
}
|
||||
return !isLifecycleFieldEmpty(row.scheduledOn);
|
||||
}
|
||||
|
||||
export function isPreScheduleStatus(status: string): boolean {
|
||||
return PRE_SCHEDULE_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export function formatAapCompletedBlockTitle(gaps: string[]): string | undefined {
|
||||
if (gaps.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fields = FIELD_GAP_TO_AAP_LABEL.filter(([gap]) => gaps.includes(gap)).map(
|
||||
([, label]) => label,
|
||||
);
|
||||
|
||||
if (fields.length === 1 && fields[0] === "Service") {
|
||||
return `Select a service before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (fields.length > 0) {
|
||||
return `Fill in ${fields.join(", ")} before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (gaps.includes("WO number")) {
|
||||
return `Assign a real APM number (not SH) before ${COMPLETE_ACTION}.`;
|
||||
}
|
||||
if (gaps.includes(PENDING_UPLIFT_GAP)) {
|
||||
return UPLIFT_PENDING_COMPLETED_TITLE;
|
||||
}
|
||||
if (gaps.includes("Completion document")) {
|
||||
return COMPLETED_DOC_BLOCK_MSG;
|
||||
}
|
||||
if (gaps.includes("At least 1 photo")) {
|
||||
return COMPLETED_MEDIA_BLOCK_MSG;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getStatusOptionState(
|
||||
row: ScheduleDateInput,
|
||||
status: WizardWOStatus,
|
||||
closabilityGaps: string[],
|
||||
): StatusOptionState {
|
||||
if (status === "Scheduled" && !hasConcreteScheduleDate(row)) {
|
||||
return { disabled: true, title: SCHEDULED_NEEDS_DATE_MSG };
|
||||
}
|
||||
if (status === "Completed") {
|
||||
const title = formatAapCompletedBlockTitle(closabilityGaps);
|
||||
if (title) {
|
||||
return { disabled: true, title };
|
||||
}
|
||||
}
|
||||
return { disabled: false };
|
||||
}
|
||||
|
||||
/** AAP `maybeAutoSchedule` — no dispatcher requirement. */
|
||||
export function maybeAutoSchedulePatch(
|
||||
row: ScheduleDateInput & { status?: string },
|
||||
): WorkOrderTablePatch {
|
||||
const hasDate = hasConcreteScheduleDate(row);
|
||||
if (hasDate && isPreScheduleStatus(row.status ?? "")) {
|
||||
return { status: "Scheduled" };
|
||||
}
|
||||
if (!hasDate && row.status === "Scheduled") {
|
||||
return { status: "Pending" };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function patchTouchesScheduleOrStatus(patch: WorkOrderTablePatch): boolean {
|
||||
return (
|
||||
patch.status !== undefined ||
|
||||
patch.scheduledOn !== undefined ||
|
||||
patch.scheduleWeekOnly !== undefined ||
|
||||
patch.targetWeek !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeAutoSchedulePatch(
|
||||
row: WorkOrderTableRow | undefined,
|
||||
patch: WorkOrderTablePatch,
|
||||
): WorkOrderTablePatch {
|
||||
if (!row || !patchTouchesScheduleOrStatus(patch)) {
|
||||
return patch;
|
||||
}
|
||||
const merged = {
|
||||
status: patch.status ?? row.status,
|
||||
scheduledOn: patch.scheduledOn !== undefined ? patch.scheduledOn : row.scheduledOn,
|
||||
scheduleWeekOnly:
|
||||
patch.scheduleWeekOnly !== undefined ? patch.scheduleWeekOnly : row.scheduleWeekOnly,
|
||||
};
|
||||
return { ...patch, ...maybeAutoSchedulePatch(merged) };
|
||||
}
|
||||
|
||||
/** Apply schedule fields and the AAP auto-status for that next schedule. */
|
||||
export function withAutoScheduleStatus<T extends ScheduleDateInput & { status?: string }>(
|
||||
current: T,
|
||||
schedulePatch: Partial<T> & ScheduleDateInput,
|
||||
): Partial<T> {
|
||||
const next = {
|
||||
status: schedulePatch.status ?? current.status ?? "",
|
||||
scheduledOn:
|
||||
schedulePatch.scheduledOn !== undefined ? schedulePatch.scheduledOn : current.scheduledOn,
|
||||
scheduleWeekOnly:
|
||||
schedulePatch.scheduleWeekOnly !== undefined
|
||||
? schedulePatch.scheduleWeekOnly
|
||||
: current.scheduleWeekOnly,
|
||||
};
|
||||
return { ...schedulePatch, ...maybeAutoSchedulePatch(next) };
|
||||
}
|
||||
|
||||
/** AAP create: apply maybeAutoSchedule to the draft before POST. */
|
||||
export function resolveCreationStatus(row: ScheduleDateInput & { status: string }): WizardWOStatus {
|
||||
const autoStatus = maybeAutoSchedulePatch(row).status;
|
||||
if (autoStatus === "Scheduled" || autoStatus === "Pending") {
|
||||
return autoStatus;
|
||||
}
|
||||
return (row.status || "Incomplete") as WizardWOStatus;
|
||||
}
|
||||
|
|
@ -1,9 +1,38 @@
|
|||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
/** 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 = 50_000_000;
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]);
|
||||
const MEDIA_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 MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||
|
||||
const EXTRA_DOC_MIME_TYPES = new Set([
|
||||
...MEDIA_MIME_TYPES,
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
]);
|
||||
|
||||
const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]);
|
||||
|
||||
function fileExtension(file: File): string {
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.slice(dot) : "";
|
||||
}
|
||||
|
||||
function isTypeAllowed(file: File, mimes: Set<string>, extensions: Set<string>): boolean {
|
||||
const mimeOk = Boolean(file.type) && mimes.has(file.type.toLowerCase());
|
||||
const extOk = extensions.has(fileExtension(file));
|
||||
return mimeOk || extOk;
|
||||
}
|
||||
|
||||
export function isWorkOrderFileTooLarge(
|
||||
file: File,
|
||||
|
|
@ -13,24 +42,25 @@ export function isWorkOrderFileTooLarge(
|
|||
}
|
||||
|
||||
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
|
||||
const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase());
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? name.slice(dot) : "";
|
||||
const extOk = ALLOWED_EXTENSIONS.has(ext);
|
||||
return mimeOk || extOk;
|
||||
return isTypeAllowed(file, MEDIA_MIME_TYPES, MEDIA_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isWorkOrderExtraDocTypeAllowed(file: File): boolean {
|
||||
return isTypeAllowed(file, EXTRA_DOC_MIME_TYPES, EXTRA_DOC_EXTENSIONS);
|
||||
}
|
||||
|
||||
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 workOrderExtraDocsAcceptAttribute(): string {
|
||||
return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`;
|
||||
}
|
||||
|
||||
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
|
||||
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
|
||||
return `${Math.round(maxBytes / 1_000_000)} MB`;
|
||||
}
|
||||
|
||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
|
||||
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
||||
return isWorkOrderCompletionSurfaceReadOnly(status);
|
||||
|
|
|
|||
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)", () => {
|
||||
renderWithProviders(
|
||||
<>
|
||||
<VendorDialogFooter isPending={false} onCancel={() => undefined} onSave={() => undefined} />
|
||||
<VendorDialogFooter
|
||||
isPending={false}
|
||||
canSubmit={false}
|
||||
onCancel={() => undefined}
|
||||
onSave={() => undefined}
|
||||
/>
|
||||
<DialogCancelButton />
|
||||
</>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -92,4 +92,64 @@ describe("buildInlineDraftCreateInput", () => {
|
|||
expect(input.assigneeId).toBe("");
|
||||
expect(payload).not.toHaveProperty("assignTo");
|
||||
});
|
||||
|
||||
it("maps range mode so create persists the start as scheduledDate", () => {
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: "9",
|
||||
woNumber: "25007",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
scheduledOn: "2026-06-08",
|
||||
scheduleEnd: "2026-06-12",
|
||||
schedMode: "range" as const,
|
||||
scheduleWeekOnly: false,
|
||||
};
|
||||
|
||||
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||
const payload = mapWizardDraftToBoardCreatePayload(input);
|
||||
|
||||
expect(input.schedMode).toBe("range");
|
||||
expect(input.scheduleEnd).toBe("2026-06-12");
|
||||
expect(payload.scheduledDate).toBe("2026-06-08");
|
||||
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||
expect(payload.targetWeek).toBeUndefined();
|
||||
expect(payload).not.toHaveProperty("scheduleEnd");
|
||||
});
|
||||
|
||||
it("promotes Pending to Scheduled when create has a concrete date", () => {
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: "9",
|
||||
woNumber: "25006",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
status: "Pending" as const,
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
};
|
||||
|
||||
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||
expect(input.status).toBe("Scheduled");
|
||||
});
|
||||
|
||||
it("demotes Scheduled to Pending when create has no concrete date", () => {
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: "9",
|
||||
woNumber: "25005",
|
||||
pocName: "Jane",
|
||||
pocPhone: "555-0100",
|
||||
status: "Scheduled" as const,
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: true,
|
||||
targetWeek: "",
|
||||
};
|
||||
|
||||
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||
expect(input.status).toBe("Pending");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
|
||||
import { DueDateCell } from "@/app/(protected)/workorders/_components/list/table/cells/due-date-cell";
|
||||
import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
function currentMonthIso(day: number): string {
|
||||
const [y, m] = todayIso().split("-").map(Number);
|
||||
return `${y}-${pad2(m)}-${pad2(day)}`;
|
||||
}
|
||||
|
||||
async function openRangeTab(triggerName: RegExp | string) {
|
||||
fireEvent.click(screen.getByRole("button", { name: triggerName }));
|
||||
const rangeTab = await screen.findByRole("button", { name: "Range" });
|
||||
fireEvent.click(rangeTab);
|
||||
}
|
||||
|
||||
describe("DateCell — Schedule On range", () => {
|
||||
it("shows Week, Date, and Range when onSaveRange is provided", async () => {
|
||||
renderWithProviders(
|
||||
<DateCell
|
||||
value=""
|
||||
allowWeekMode
|
||||
onSave={vi.fn()}
|
||||
onSaveWeek={vi.fn()}
|
||||
onSaveRange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "—" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Week" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Date" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Range" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Specific date" })).not.toBeInTheDocument();
|
||||
}, 10_000);
|
||||
|
||||
it("keeps Week / Specific date and hides Range without onSaveRange", async () => {
|
||||
renderWithProviders(<DateCell value="" allowWeekMode onSave={vi.fn()} onSaveWeek={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "—" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Week" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Specific date" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Range" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits start and end via Done, including inverted clicks", async () => {
|
||||
const onSaveRange = vi.fn();
|
||||
renderWithProviders(
|
||||
<DateCell
|
||||
value=""
|
||||
allowWeekMode
|
||||
onSave={vi.fn()}
|
||||
onSaveWeek={vi.fn()}
|
||||
onSaveRange={onSaveRange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openRangeTab("—");
|
||||
fireEvent.click(screen.getByRole("button", { name: "20" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "5" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^done$/i }));
|
||||
|
||||
expect(onSaveRange).toHaveBeenCalledWith(currentMonthIso(5), currentMonthIso(20));
|
||||
});
|
||||
|
||||
it("does not submit until both ends are picked", async () => {
|
||||
const onSaveRange = vi.fn();
|
||||
renderWithProviders(
|
||||
<DateCell
|
||||
value=""
|
||||
allowWeekMode
|
||||
onSave={vi.fn()}
|
||||
onSaveWeek={vi.fn()}
|
||||
onSaveRange={onSaveRange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openRangeTab("—");
|
||||
fireEvent.click(screen.getByRole("button", { name: "5" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: /^done$/i })).toBeDisabled();
|
||||
expect(onSaveRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not persist when switching tabs without confirming", async () => {
|
||||
const onSave = vi.fn();
|
||||
const onSaveRange = vi.fn();
|
||||
renderWithProviders(
|
||||
<DateCell
|
||||
value=""
|
||||
allowWeekMode
|
||||
onSave={onSave}
|
||||
onSaveWeek={vi.fn()}
|
||||
onSaveRange={onSaveRange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openRangeTab("—");
|
||||
fireEvent.click(screen.getByRole("button", { name: "5" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Date" }));
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(onSaveRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Clear above the mode tabs and closes after clearing", async () => {
|
||||
const onClear = vi.fn();
|
||||
renderWithProviders(
|
||||
<DateCell
|
||||
value="2026-09-09"
|
||||
allowWeekMode
|
||||
onSave={vi.fn()}
|
||||
onSaveWeek={vi.fn()}
|
||||
onSaveRange={vi.fn()}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /09-09/ }));
|
||||
|
||||
const clear = await screen.findByRole("button", { name: "Clear" });
|
||||
const week = screen.getByRole("button", { name: "Week" });
|
||||
expect(clear.compareDocumentPosition(week) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
fireEvent.click(clear);
|
||||
expect(onClear).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByRole("button", { name: "Clear" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DueDateCell — no Schedule On range tabs", () => {
|
||||
it("does not offer Week / Date / Range on a PM due date", () => {
|
||||
renderWithProviders(<DueDateCell type="PM" value="2026-07-15" onSave={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /07-15/ }));
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Week" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Range" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Specific date" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -116,24 +116,46 @@ describe("MediaUploadModal API wiring", () => {
|
|||
expect(screen.getByText("before.jpg")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
|
||||
expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("categorizes with workOrderVersion from detail.info.rowVersion", async () => {
|
||||
it("offers no purpose picker — the table modal only uploads and removes", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("before.jpg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/category for before\.jpg/i), {
|
||||
target: { value: "After" },
|
||||
expect(screen.queryByLabelText(/category for before\.jpg/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
||||
expect(categorizeMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows only the file name under each tile — no purpose label", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("before.jpg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(categorizeMutate).toHaveBeenCalledWith({
|
||||
expect(screen.queryByText("Before")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Extra")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes media with workOrderVersion from detail.info.rowVersion", async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("before.jpg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^remove$/i }));
|
||||
|
||||
expect(deleteMutate).toHaveBeenCalledWith({
|
||||
mediaId: "11",
|
||||
category: "After",
|
||||
workOrderVersion: "DETAIL-VER==",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { createRef } from "react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone";
|
||||
import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
describe("MediaUploaderDropzone", () => {
|
||||
it("matches the AAP Photos accept copy and keeps it media-only", () => {
|
||||
renderWithProviders(
|
||||
<MediaUploaderDropzone
|
||||
inputRef={createRef<HTMLInputElement>()}
|
||||
pending={false}
|
||||
onFilesSelected={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/PDF/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/DOC/)).not.toBeInTheDocument();
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute());
|
||||
expect(input?.getAttribute("accept")).not.toContain(".pdf");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { weekMoveNotify } from "@/app/(protected)/workorders/_components/list/table/reschedule-notify";
|
||||
|
||||
const { toast } = vi.hoisted(() => {
|
||||
const notify = Object.assign(
|
||||
vi.fn((_content: unknown, _options?: unknown) => 1),
|
||||
{
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
);
|
||||
return { toast: notify };
|
||||
});
|
||||
|
||||
vi.mock("react-toastify", () => ({ toast }));
|
||||
|
||||
describe("weekMoveNotify", () => {
|
||||
beforeEach(() => {
|
||||
toast.mockClear();
|
||||
toast.dismiss.mockClear();
|
||||
});
|
||||
|
||||
it("uses the AAP Unscheduled message and Advanced Filters action when clearing", () => {
|
||||
weekMoveNotify("", { onNavigate: vi.fn() });
|
||||
|
||||
const content = toast.mock.calls[0][0] as {
|
||||
props: { message: string; linkLabel: string };
|
||||
};
|
||||
expect(content.props).toMatchObject({
|
||||
message: "Work order moved to Unscheduled.",
|
||||
linkLabel: "View in Advanced Filters.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const toastWarn = vi.fn();
|
||||
|
||||
vi.mock("react-toastify", () => ({
|
||||
toast: { warn: (...args: unknown[]) => toastWarn(...args) },
|
||||
}));
|
||||
|
||||
import { saveExistingRowType } from "@/app/(protected)/workorders/_components/list/table/save-existing-row-type";
|
||||
|
||||
const row = { id: 7, woNumber: "00000024965", severity: 3 as const };
|
||||
|
||||
describe("saveExistingRowType", () => {
|
||||
beforeEach(() => {
|
||||
toastWarn.mockReset();
|
||||
});
|
||||
|
||||
it("warns when severity newly enters SEV 1 only after persist onSuccess", () => {
|
||||
const onPatch = vi.fn();
|
||||
|
||||
saveExistingRowType(onPatch, row, "Emergency", 1);
|
||||
|
||||
expect(toastWarn).not.toHaveBeenCalled();
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
7,
|
||||
{ type: "Emergency", severity: 1 },
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
);
|
||||
|
||||
const { onSuccess } = onPatch.mock.calls[0]![2] as { onSuccess: () => void };
|
||||
onSuccess();
|
||||
expect(toastWarn).toHaveBeenCalledWith("WO #00000024965 escalated to SEV 1.");
|
||||
});
|
||||
|
||||
it("does not warn when staying on a critical severity", () => {
|
||||
const onPatch = vi.fn();
|
||||
|
||||
saveExistingRowType(onPatch, { ...row, severity: 1 }, "Emergency", 1);
|
||||
|
||||
expect(toastWarn).not.toHaveBeenCalled();
|
||||
expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: 1 });
|
||||
expect(onPatch.mock.calls[0]![2]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn when clearing severity on a PM correction", () => {
|
||||
const onPatch = vi.fn();
|
||||
|
||||
saveExistingRowType(onPatch, { ...row, severity: 2 }, "PM", null);
|
||||
|
||||
expect(toastWarn).not.toHaveBeenCalled();
|
||||
expect(onPatch).toHaveBeenCalledWith(7, { type: "PM", severity: null });
|
||||
expect(onPatch.mock.calls[0]![2]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "react-toastify";
|
||||
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
|
|
@ -21,6 +23,10 @@ const avetaDoc: MediaFile = {
|
|||
};
|
||||
|
||||
describe("SlideOverExtrasTab Aveta", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
it("does not label the first extra document as Aveta when none is explicitly Aveta", () => {
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab
|
||||
|
|
@ -71,4 +77,88 @@ describe("SlideOverExtrasTab Aveta", () => {
|
|||
expect(onCategorize).toHaveBeenCalledWith("m1", "Aveta");
|
||||
expect(screen.queryByText("safety-form.pdf")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens Extra Docs through authenticated content instead of a static href", async () => {
|
||||
const blob = new Blob(["pdf"], { type: "application/pdf" });
|
||||
const getMediaContent = vi.spyOn(workOrdersApi, "getMediaContent").mockResolvedValue(blob);
|
||||
const createObjectURL = vi.fn(() => "blob:authorized");
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL: vi.fn() });
|
||||
const tab = {
|
||||
opener: window,
|
||||
location: { href: "about:blank" },
|
||||
close: vi.fn(),
|
||||
};
|
||||
const open = vi.fn(() => tab);
|
||||
vi.stubGlobal("open", open);
|
||||
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab
|
||||
uploads={[{ ...photo, url: "https://cdn.example.com/Assets/Documents/secret.pdf" }]}
|
||||
workOrderId={10}
|
||||
avetaRequired
|
||||
isReadOnly
|
||||
onUpload={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "safety-form.pdf" }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getMediaContent).toHaveBeenCalledWith(10, "m1");
|
||||
});
|
||||
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
||||
expect(open).toHaveBeenCalledWith("about:blank", "_blank");
|
||||
expect(tab.opener).toBeNull();
|
||||
expect(tab.location.href).toBe("blob:authorized");
|
||||
});
|
||||
|
||||
it("shows an error when the browser blocks the document tab", async () => {
|
||||
const getMediaContent = vi.spyOn(workOrdersApi, "getMediaContent");
|
||||
const toastError = vi.spyOn(toast, "error").mockImplementation(vi.fn());
|
||||
vi.stubGlobal(
|
||||
"open",
|
||||
vi.fn(() => null),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab
|
||||
uploads={[photo]}
|
||||
workOrderId={10}
|
||||
isReadOnly
|
||||
onUpload={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "safety-form.pdf" }));
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith(
|
||||
"Unable to open this document. Please allow pop-ups and try again.",
|
||||
);
|
||||
expect(getMediaContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Extra Docs accept copy with PDF DOC and 50MB", () => {
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={vi.fn()} onRemove={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 50MB per file"),
|
||||
).toBeInTheDocument();
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
expect(input?.getAttribute("accept")).toContain(".pdf");
|
||||
expect(input?.getAttribute("accept")).toContain(".doc");
|
||||
expect(input?.getAttribute("accept")).toContain(".docx");
|
||||
});
|
||||
|
||||
it("disables document opening until the work order id is available", () => {
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab uploads={[photo]} isReadOnly onUpload={vi.fn()} onRemove={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "safety-form.pdf" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SlideOverInfoTabEditView } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
|
||||
SiteCell: () => null,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
|
||||
() => ({
|
||||
EditableWONumberCell: () => null,
|
||||
}),
|
||||
);
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
|
||||
TypeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
|
||||
AssignedToCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
|
||||
ApptTimeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({
|
||||
PMTypeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
|
||||
VendorCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({
|
||||
StatusCell: () => null,
|
||||
}));
|
||||
|
||||
function currentMonthIso(day: number): string {
|
||||
const [y, m] = todayIso().split("-").map(Number);
|
||||
return `${y}-${pad2(m)}-${pad2(day)}`;
|
||||
}
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
id: 42,
|
||||
woNumber: "WO-42",
|
||||
site: "SEA1",
|
||||
type: "PM",
|
||||
severity: null,
|
||||
dispatcherId: "u1",
|
||||
dispatcherName: "Dispatcher",
|
||||
scheduledOn: "",
|
||||
completedDate: "",
|
||||
targetWeek: "",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
location: "Seattle",
|
||||
locationId: "loc-1",
|
||||
pm: "HVAC",
|
||||
serviceNotes: "",
|
||||
extraServices: [],
|
||||
company: "Vendor Co",
|
||||
tech: "Tech",
|
||||
techPhone: "",
|
||||
vendorId: "v1",
|
||||
apptStart: "",
|
||||
apptEnd: "",
|
||||
apptTime: "",
|
||||
status: "Incomplete",
|
||||
docStatus: "No",
|
||||
pocName: "",
|
||||
pocPhone: "",
|
||||
pocNotes: "",
|
||||
additionalContacts: [],
|
||||
dueDate: "",
|
||||
rescheduleCount: 0,
|
||||
carriedOver: 0,
|
||||
isAddOn: false,
|
||||
originalWeek: "",
|
||||
originalDate: "",
|
||||
isPastDue: false,
|
||||
scheduleWeekOnly: false,
|
||||
mediaCount: 0,
|
||||
title: "",
|
||||
priority: "",
|
||||
primaryDispatchId: null,
|
||||
rowVersion: "v1",
|
||||
dispatchRowVersion: "",
|
||||
flagColor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SlideOverInfoTabEditView — Schedule On range", () => {
|
||||
it("patches range start/end and schedMode from the DateCell", async () => {
|
||||
const onDraftChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<SlideOverInfoTabEditView
|
||||
draft={baseRow()}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
allOrders={[]}
|
||||
onDraftChange={onDraftChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scheduleRow = screen.getByText("Schedule On").closest("div");
|
||||
fireEvent.click(scheduleRow!.querySelector("button")!);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Range" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "5" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "20" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^done$/i }));
|
||||
|
||||
expect(onDraftChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduledOn: currentMonthIso(5),
|
||||
scheduleEnd: currentMonthIso(20),
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
}),
|
||||
);
|
||||
}, 10_000);
|
||||
|
||||
it("clears every schedule field in the draft", async () => {
|
||||
const onDraftChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<SlideOverInfoTabEditView
|
||||
draft={baseRow({
|
||||
scheduledOn: currentMonthIso(5),
|
||||
scheduleEnd: currentMonthIso(20),
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: currentMonthIso(1),
|
||||
dayGroup: "09/05",
|
||||
dayLabel: "Sat",
|
||||
isPastDue: true,
|
||||
status: "Scheduled",
|
||||
})}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
allOrders={[]}
|
||||
onDraftChange={onDraftChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scheduleRow = screen.getByText("Schedule On").closest("div");
|
||||
fireEvent.click(scheduleRow!.querySelector("button")!);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /clear/i }));
|
||||
|
||||
expect(onDraftChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
isPastDue: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -31,8 +31,47 @@ describe("StatusCell closability gate", () => {
|
|||
const completed = screen.getByRole("button", { name: /^completed$/i });
|
||||
|
||||
expect(completed.hasAttribute("disabled")).toBe(true);
|
||||
expect(completed.getAttribute("title") ?? "").toContain("Missing:");
|
||||
expect(completed.getAttribute("title") ?? "").toContain("Fill in");
|
||||
fireEvent.click(completed);
|
||||
expect(onChangeStatus).not.toHaveBeenCalledWith("Completed");
|
||||
});
|
||||
|
||||
it("does not offer Canceled", () => {
|
||||
render(<StatusCell wo={incompleteRow} onChangeStatus={vi.fn()} onOpenSlideOver={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
|
||||
expect(screen.queryByRole("button", { name: /^canceled$/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("disables Scheduled when the row has no concrete date", () => {
|
||||
const onChangeStatus = vi.fn();
|
||||
render(
|
||||
<StatusCell
|
||||
wo={{ ...incompleteRow, status: "Pending", scheduledOn: "" }}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /pending/i }));
|
||||
const scheduled = screen.getByRole("button", { name: /^scheduled$/i });
|
||||
expect(scheduled).toBeDisabled();
|
||||
fireEvent.click(scheduled);
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("limits creation options to Incomplete, Pending, and Scheduled", () => {
|
||||
render(
|
||||
<StatusCell
|
||||
wo={{ ...incompleteRow, status: "Incomplete" }}
|
||||
statusOptions={["Incomplete", "Pending", "Scheduled"]}
|
||||
onChangeStatus={vi.fn()}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
|
||||
expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /^en route$/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,18 +22,20 @@ const pastDueRow = {
|
|||
dueDate: "2026-08-01",
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
describe("StatusCell Past Due copy (SH-185)", () => {
|
||||
describe("StatusCell Past Due copy", () => {
|
||||
it("directs the dispatcher to update Schedule On, not Due Date", () => {
|
||||
render(<StatusCell wo={pastDueRow} onChangeStatus={vi.fn()} onOpenSlideOver={vi.fn()} />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: /past due/i });
|
||||
expect(trigger.getAttribute("title") ?? "").toContain("Schedule On");
|
||||
expect(trigger.getAttribute("title") ?? "").toContain("Reschedule to unlock status updates");
|
||||
expect(trigger.getAttribute("title") ?? "").not.toMatch(/due date/i);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
||||
expect(screen.getByText(/update schedule on before the status can be updated/i)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /update schedule on/i })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/a new date must be scheduled before the status can be updated/i),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /reschedule/i })).toBeTruthy();
|
||||
expect(screen.queryByText(/due date/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ describe("StatusCell pending uplift closability", () => {
|
|||
|
||||
const completed = screen.getByRole("button", { name: /completed/i });
|
||||
expect(completed).toBeDisabled();
|
||||
expect(completed).toHaveAttribute("title", "Missing: Company");
|
||||
expect(completed).toHaveAttribute("title", "Fill in Vendor before completing this work order.");
|
||||
fireEvent.click(completed);
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -227,7 +227,10 @@ describe("StatusCell pending uplift closability", () => {
|
|||
|
||||
const completed = screen.getByRole("button", { name: /completed/i });
|
||||
expect(completed).toBeDisabled();
|
||||
expect(completed).toHaveAttribute("title", "Missing: Company");
|
||||
expect(completed).toHaveAttribute(
|
||||
"title",
|
||||
"Fill in Vendor before completing this work order.",
|
||||
);
|
||||
fireEvent.click(completed);
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
},
|
||||
|
|
@ -254,7 +257,7 @@ describe("StatusCell pending uplift closability", () => {
|
|||
|
||||
const completed = screen.getByRole("button", { name: /completed/i });
|
||||
expect(completed).toBeDisabled();
|
||||
expect(completed).toHaveAttribute("title", "Missing: Company");
|
||||
expect(completed).toHaveAttribute("title", "Fill in Vendor before completing this work order.");
|
||||
});
|
||||
|
||||
it("allows Completed after a vendor patch clears leftover refused status", () => {
|
||||
|
|
|
|||
113
src/test/app/(protected)/workorders/type-cell.test.tsx
Normal file
113
src/test/app/(protected)/workorders/type-cell.test.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
|
||||
|
||||
describe("TypeCell", () => {
|
||||
it("shows the AAP empty placeholder", () => {
|
||||
render(<TypeCell value="" onSave={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Select type of WO" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists PM, Reactive, Emergency, and Overdue in the popover", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TypeCell value="PM" onSave={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Type of WO, PM" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Reactive$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^Emergency$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^Overdue$/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves PM from the popover without opening the modal", async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<TypeCell value="Emergency" severity={2} onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Type of WO, Emergency" }));
|
||||
await user.click(screen.getByRole("button", { name: /^PM$/ }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith("PM", null);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens Type & severity when changing PM to Emergency and saves after SEV", async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<TypeCell value="PM" onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Type of WO, PM" }));
|
||||
await user.click(screen.getByRole("button", { name: /^Emergency$/ }));
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("dialog", { name: /Type & severity/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /SEV 1/ }));
|
||||
await user.click(screen.getByRole("button", { name: /^Save$/ }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith("Emergency", 1);
|
||||
});
|
||||
|
||||
it("does not save when Cancel is clicked on the modal", async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<TypeCell value="PM" onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Type of WO, PM" }));
|
||||
await user.click(screen.getByRole("button", { name: /^Emergency$/ }));
|
||||
await user.click(screen.getByRole("button", { name: /^Cancel$/ }));
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears previous severity when switching Emergency to Reactive", async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<TypeCell value="Emergency" severity={3} onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Type of WO, Emergency" }));
|
||||
await user.click(screen.getByRole("button", { name: /^Reactive$/ }));
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
const save = screen.getByRole("button", { name: /^Save$/ });
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /SEV 4/ }));
|
||||
await user.click(save);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith("Reactive", 4);
|
||||
});
|
||||
|
||||
it("does not open the picker or severity modal when viewOnly", async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<TypeCell value="Emergency" severity={2} viewOnly onSave={onSave} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Type of WO, Emergency" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Emergency")).toBeInTheDocument();
|
||||
expect(screen.getByText("SEV 2")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("Emergency"));
|
||||
await user.click(screen.getByText("SEV 2"));
|
||||
|
||||
expect(screen.queryByRole("button", { name: /^Reactive$/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the modal from the severity badge", async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<TypeCell value="Emergency" severity={2} onSave={onSave} />);
|
||||
|
||||
await user.click(screen.getByText("SEV 2"));
|
||||
|
||||
expect(screen.getByRole("dialog", { name: /Type & severity/i })).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
|
||||
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
|
@ -85,6 +85,10 @@ function idleMutation() {
|
|||
}
|
||||
|
||||
describe("useSlideOverMediaState completion filter", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
|
||||
const detail = detailWithMedia([
|
||||
{
|
||||
|
|
@ -119,6 +123,14 @@ describe("useSlideOverMediaState completion filter", () => {
|
|||
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||
isLegacy: false,
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
category: "Extra",
|
||||
url: "https://cdn.example.com/site.jpg",
|
||||
fileName: "site.jpg",
|
||||
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||
isLegacy: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
|
|
@ -134,7 +146,9 @@ describe("useSlideOverMediaState completion filter", () => {
|
|||
"Before",
|
||||
"After",
|
||||
"Completion",
|
||||
"Extra",
|
||||
]);
|
||||
expect(result.current.completionMedia.map((item) => item.id)).toContain("10");
|
||||
expect(
|
||||
result.current.completionMedia.every((item) =>
|
||||
["Photo", "Video", "Report", "Other"].includes(item.category),
|
||||
|
|
@ -142,6 +156,32 @@ describe("useSlideOverMediaState completion filter", () => {
|
|||
).toBe(true);
|
||||
expect(result.current.extrasUploads).toHaveLength(1);
|
||||
expect(result.current.extrasUploads[0]?.purpose).toBe("Extra");
|
||||
expect(result.current.extrasUploads[0]?.id).toBe("4");
|
||||
});
|
||||
|
||||
it("keeps Extra photos on Completion Doc until Extra is assigned", () => {
|
||||
const detail = detailWithMedia([
|
||||
{
|
||||
id: "10",
|
||||
category: "Extra",
|
||||
url: "https://cdn.example.com/site.jpg",
|
||||
fileName: "site.jpg",
|
||||
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||
isLegacy: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detail,
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.completionMedia.map((item) => item.id)).toEqual(["10"]);
|
||||
expect(result.current.extrasUploads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes Aveta-categorized extra documents in extrasUploads", () => {
|
||||
|
|
@ -177,4 +217,65 @@ describe("useSlideOverMediaState completion filter", () => {
|
|||
expect(result.current.extrasUploads.map((item) => item.purpose)).toEqual(["Extra", "Aveta"]);
|
||||
expect(result.current.extrasUploads[1]?.category).toBe("Aveta");
|
||||
});
|
||||
|
||||
it("keeps an Extra photo in Extra Docs after assigning Extra and remounting", () => {
|
||||
const detail = detailWithMedia([
|
||||
{
|
||||
id: "10",
|
||||
category: "Extra",
|
||||
url: "https://cdn.example.com/site.jpg",
|
||||
fileName: "site.jpg",
|
||||
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||
isLegacy: false,
|
||||
},
|
||||
]);
|
||||
const categorizeMutate = vi.fn((_input: unknown, options?: { onSuccess?: () => void }) =>
|
||||
options?.onSuccess?.(),
|
||||
);
|
||||
const categorize = {
|
||||
mutate: categorizeMutate,
|
||||
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
} as never;
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detail,
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: categorize,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.completionMedia).toHaveLength(1);
|
||||
expect(result.current.extrasUploads).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
result.current.categorizeMedia("10", "Extra");
|
||||
});
|
||||
|
||||
expect(result.current.completionMedia).toHaveLength(0);
|
||||
expect(result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]);
|
||||
expect(categorizeMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
mediaId: "10",
|
||||
category: "Extra",
|
||||
workOrderVersion: "1",
|
||||
},
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
);
|
||||
|
||||
unmount();
|
||||
const remounted = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detail,
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(remounted.result.current.completionMedia).toHaveLength(0);
|
||||
expect(remounted.result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useWoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/use-wo-table-row-handlers";
|
||||
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import {
|
||||
applySchedulePatch,
|
||||
applyScheduleRangePatch,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
|
||||
const { rescheduleNotify, weekMoveNotify } = vi.hoisted(() => ({
|
||||
rescheduleNotify: vi.fn(),
|
||||
|
|
@ -24,7 +27,12 @@ function buildTableData(revealScheduledRow = vi.fn()) {
|
|||
} as never;
|
||||
}
|
||||
|
||||
describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
||||
describe("useWoTableRowHandlers schedule navigation", () => {
|
||||
beforeEach(() => {
|
||||
rescheduleNotify.mockClear();
|
||||
weekMoveNotify.mockClear();
|
||||
});
|
||||
|
||||
it("selects the scheduled weekday in the same week so the row stays visible", () => {
|
||||
const onNavigateToDay = vi.fn();
|
||||
const onPatch = vi.fn();
|
||||
|
|
@ -80,6 +88,34 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
|||
expect(revealScheduledRow).toHaveBeenCalledWith("wo-1");
|
||||
});
|
||||
|
||||
it("lands a range on the start day and reveals the row", () => {
|
||||
const onNavigateToDay = vi.fn();
|
||||
const onPatch = vi.fn();
|
||||
const revealScheduledRow = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWoTableRowHandlers({
|
||||
tableData: buildTableData(revealScheduledRow),
|
||||
weekMonday: "2026-05-18",
|
||||
onNavigateToDay,
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onOpenMedia: vi.fn(),
|
||||
onPatch,
|
||||
onScheduleHighlight: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onScheduleRange("wo-1", "2026-05-21", "2026-05-23");
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
"wo-1",
|
||||
applyScheduleRangePatch("2026-05-21", "2026-05-23"),
|
||||
);
|
||||
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-21");
|
||||
expect(revealScheduledRow).toHaveBeenCalledWith("wo-1");
|
||||
expect(rescheduleNotify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens Advanced Filters for week-only moves instead of the removed Unscheduled section", () => {
|
||||
const onNavigateToDay = vi.fn();
|
||||
const onOpenAdvancedFilters = vi.fn();
|
||||
|
|
@ -115,4 +151,36 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
|||
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-25");
|
||||
expect(onOpenAdvancedFilters).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the concrete schedule and opens Advanced Filters from the Unscheduled toast", () => {
|
||||
const onNavigateToDay = vi.fn();
|
||||
const onOpenAdvancedFilters = vi.fn();
|
||||
const onPatch = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWoTableRowHandlers({
|
||||
tableData: buildTableData(),
|
||||
weekMonday: "2026-05-18",
|
||||
onNavigateToDay,
|
||||
onOpenAdvancedFilters,
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onOpenMedia: vi.fn(),
|
||||
onPatch,
|
||||
onScheduleHighlight: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onScheduleWeek("wo-1", "");
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("", false));
|
||||
expect(weekMoveNotify).toHaveBeenCalledWith(
|
||||
"",
|
||||
expect.objectContaining({ onNavigate: expect.any(Function) }),
|
||||
);
|
||||
|
||||
const { onNavigate } = weekMoveNotify.mock.calls[0][1] as { onNavigate: () => void };
|
||||
onNavigate();
|
||||
expect(onNavigateToDay).not.toHaveBeenCalled();
|
||||
expect(onOpenAdvancedFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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"), {
|
||||
target: { value: "(314) 555-9999" },
|
||||
|
|
@ -151,7 +151,7 @@ describe("VendorDialog stable technician identity", () => {
|
|||
});
|
||||
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 }));
|
||||
|
||||
|
|
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
|
|||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
status: "Scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
|
|||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
isAddOnOverride: false,
|
||||
status: "Scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -109,4 +111,33 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
|
|||
expect(screen.getByRole("button", { name: /^done$/i })).toBeDisabled();
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not promote Incomplete to Scheduled for week-only Schedule On", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const onPatch = vi.fn();
|
||||
|
||||
render(
|
||||
<WizardStepTypeSchedule
|
||||
draft={baseDraft({ status: "Incomplete" })}
|
||||
users={[]}
|
||||
onUpdate={onUpdate}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /pick a date/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Week" }));
|
||||
const weekButtons = screen
|
||||
.getAllByRole("button")
|
||||
.filter((button) => /–/.test(button.textContent ?? ""));
|
||||
fireEvent.click(weekButtons[0]);
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduleWeekOnly: true,
|
||||
schedMode: "week",
|
||||
}),
|
||||
);
|
||||
expect(onPatch.mock.calls.some(([patch]) => patch.status === "Scheduled")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,28 +19,25 @@ describe("WizardStepVendorTime Appt Time", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("WizardStepVendorTime closability gate", () => {
|
||||
it("disables Completed when closability requirements are missing", () => {
|
||||
const onUpdate = vi.fn();
|
||||
describe("WizardStepVendorTime creation statuses (AAP)", () => {
|
||||
it("offers only Incomplete, Pending, and Scheduled", () => {
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Scheduled" }}
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
|
||||
vendors={[]}
|
||||
onUpdate={onUpdate}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
|
||||
const completed = screen.getByRole("button", { name: /^completed$/i });
|
||||
expect(completed).toBeDisabled();
|
||||
expect(completed).toHaveAttribute("title", expect.stringContaining("Missing:"));
|
||||
|
||||
fireEvent.click(completed);
|
||||
expect(onUpdate).not.toHaveBeenCalledWith("status", "Completed");
|
||||
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
|
||||
expect(screen.getByRole("button", { name: /^pending$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^scheduled$/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^completed$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^en route$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows selecting a non-Completed status", () => {
|
||||
it("disables Scheduled without a concrete date", () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
|
|
@ -51,10 +48,54 @@ describe("WizardStepVendorTime closability gate", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
|
||||
const scheduled = screen.getByRole("button", { name: /^scheduled$/i });
|
||||
expect(scheduled).toBeDisabled();
|
||||
expect(scheduled).toHaveAttribute(
|
||||
"title",
|
||||
"Set a Schedule On date before marking this work order as Scheduled.",
|
||||
);
|
||||
fireEvent.click(scheduled);
|
||||
expect(onUpdate).not.toHaveBeenCalledWith("status", "Scheduled");
|
||||
});
|
||||
|
||||
it("allows selecting a non-Completed status", () => {
|
||||
const onPatch = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={{ ...EMPTY_WIZARD_DRAFT, type: "PM", status: "Incomplete" }}
|
||||
vendors={[]}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
|
||||
const menu = screen.getByRole("button", { name: /^pending$/i }).closest("div");
|
||||
expect(menu).not.toBeNull();
|
||||
fireEvent.click(within(menu!.parentElement!).getByRole("button", { name: /^pending$/i }));
|
||||
expect(onUpdate).toHaveBeenCalledWith("status", "Pending");
|
||||
expect(onPatch).toHaveBeenCalledWith({ status: "Pending" });
|
||||
});
|
||||
|
||||
it("promotes Pending to Scheduled when picked on a dated draft", () => {
|
||||
const onPatch = vi.fn();
|
||||
render(
|
||||
<WizardStepVendorTime
|
||||
draft={{
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
status: "Scheduled",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
}}
|
||||
vendors={[]}
|
||||
onUpdate={vi.fn()}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /scheduled/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
|
||||
expect(onPatch).toHaveBeenCalledWith({ status: "Scheduled" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import { createRef } from "react";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTableInlineRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row-cells";
|
||||
import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
function currentMonthIso(day: number): string {
|
||||
const [y, m] = todayIso().split("-").map(Number);
|
||||
return `${y}-${pad2(m)}-${pad2(day)}`;
|
||||
}
|
||||
|
||||
describe("WoTableInlineRowCells — Schedule On range", () => {
|
||||
it("patches schedMode range with start and end", async () => {
|
||||
const onPatch = vi.fn();
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<WoTableInlineRowCells
|
||||
draft={EMPTY_TABLE_DRAFT}
|
||||
weekDefault={false}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
allRows={[]}
|
||||
siteCellRef={createRef()}
|
||||
woCellRef={createRef()}
|
||||
siteShowError={false}
|
||||
siteError=""
|
||||
woShowError={false}
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
);
|
||||
|
||||
const scheduleButtons = screen.getAllByRole("button", { name: "—" });
|
||||
fireEvent.click(scheduleButtons[0]);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Range" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "5" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "20" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^done$/i }));
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduledOn: currentMonthIso(5),
|
||||
scheduleEnd: currentMonthIso(20),
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
}),
|
||||
);
|
||||
}, 10_000);
|
||||
});
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row";
|
||||
import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
|
||||
SiteCell: () => null,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
|
||||
() => ({
|
||||
EditableWONumberCell: () => null,
|
||||
}),
|
||||
);
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
|
||||
TypeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
|
||||
AssignedToCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
|
||||
DateCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({
|
||||
DueDateCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({
|
||||
PMTypeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
|
||||
VendorCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
|
||||
ApptTimeCell: () => null,
|
||||
}));
|
||||
|
||||
describe("WoTableInlineRow StatusCell with a concrete schedule", () => {
|
||||
it("keeps Scheduled when Incomplete is picked on a dated draft", () => {
|
||||
const onChange = vi.fn();
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
status: "Incomplete" as const,
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<WoTableInlineRow
|
||||
draft={draft}
|
||||
draftDayKey="2026-06-01"
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
allRows={[]}
|
||||
onChange={onChange}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ status: "Scheduled" }));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
|
||||
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
|
||||
SiteCell: () => null,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
|
||||
() => ({
|
||||
EditableWONumberCell: () => null,
|
||||
}),
|
||||
);
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
|
||||
TypeCell: ({ onSave }: { onSave: (type: string, severity: number) => void }) => (
|
||||
<button type="button" onClick={() => onSave("Emergency", 1)}>
|
||||
persist-type
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
|
||||
DateCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({
|
||||
DueDateCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
|
||||
AssignedToCell: () => null,
|
||||
}));
|
||||
|
||||
const row = { id: 7, woNumber: "00000024965", severity: 3 } as WorkOrderTableRow;
|
||||
|
||||
describe("WoTableRowCells type persist", () => {
|
||||
it("forwards type save through patch onSuccess instead of onPatchRow", () => {
|
||||
const onPatch = vi.fn();
|
||||
const onPatchRow = vi.fn();
|
||||
const handlers = { onPatch } as unknown as WoTableRowHandlers;
|
||||
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<WoTableRowCells
|
||||
row={row}
|
||||
allRows={[]}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
handlers={handlers}
|
||||
onPatchRow={onPatchRow}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "persist-type" }));
|
||||
|
||||
expect(onPatchRow).not.toHaveBeenCalled();
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
7,
|
||||
{ type: "Emergency", severity: 1 },
|
||||
expect.objectContaining({ onSuccess: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -65,6 +65,7 @@ const handlers: WoTableRowHandlers = {
|
|||
onPatch: vi.fn(),
|
||||
onScheduleDate: vi.fn(),
|
||||
onScheduleWeek: vi.fn(),
|
||||
onScheduleRange: vi.fn(),
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onOpenCompDoc: vi.fn(),
|
||||
onOpenUplifts: vi.fn(),
|
||||
|
|
@ -133,4 +134,56 @@ describe("WoTableRow completed selective lock", () => {
|
|||
expect(screen.queryByRole("button", { name: /in progress/i })).not.toBeInTheDocument();
|
||||
expect(handlers.onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not expose the type picker on a Completed row", () => {
|
||||
const row = makeRow();
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<WoTableRow
|
||||
row={row}
|
||||
allRows={[row]}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={["HVAC"]}
|
||||
isSelected={false}
|
||||
isFlashing={false}
|
||||
dragViz={{ dragId: null, overId: null }}
|
||||
handlers={handlers}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Type of WO, PM" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("PM")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not expose the type picker on a Canceled row", () => {
|
||||
const row = makeRow({ status: "Canceled" });
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<WoTableRow
|
||||
row={row}
|
||||
allRows={[row]}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={["HVAC"]}
|
||||
isSelected={false}
|
||||
isFlashing={false}
|
||||
dragViz={{ dragId: null, overId: null }}
|
||||
handlers={handlers}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Type of WO, PM" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("PM")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ function handlers(overrides: Partial<WoTableRowHandlers> = {}): WoTableRowHandle
|
|||
onPatch: vi.fn(),
|
||||
onScheduleDate: vi.fn(),
|
||||
onScheduleWeek: vi.fn(),
|
||||
onScheduleRange: vi.fn(),
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onReorder: vi.fn(),
|
||||
onDragStart: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
|
||||
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { mergeAutoSchedulePatch } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
|
||||
SiteCell: () => null,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
|
||||
() => ({
|
||||
EditableWONumberCell: () => null,
|
||||
}),
|
||||
);
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
|
||||
TypeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
|
||||
AssignedToCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
|
||||
DateCell: ({ onClear }: { onClear?: () => void }) => {
|
||||
if (!onClear) return null;
|
||||
return (
|
||||
<button type="button" onClick={onClear}>
|
||||
clear schedule
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({
|
||||
DueDateCell: () => null,
|
||||
}));
|
||||
|
||||
function renderScheduledRow(
|
||||
row: WorkOrderTableRow,
|
||||
onPersist: (id: string | number, patch: WorkOrderTablePatch) => void,
|
||||
viewOnly = false,
|
||||
) {
|
||||
const handlers = {
|
||||
onScheduleWeek: (id: string | number, week: string) => {
|
||||
onPersist(id, mergeAutoSchedulePatch(row, applySchedulePatch(week, false)));
|
||||
},
|
||||
} as unknown as WoTableRowHandlers;
|
||||
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<WoTableRowCells
|
||||
row={row}
|
||||
allRows={[row]}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
viewOnly={viewOnly}
|
||||
handlers={handlers}
|
||||
onPatchRow={vi.fn()}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
}
|
||||
|
||||
describe("WoTableRowCells Schedule On clear", () => {
|
||||
it.each([
|
||||
{ scheduledOn: "2026-09-09", targetWeek: "", scheduleEnd: "" },
|
||||
{ scheduledOn: "", targetWeek: "2026-09-07", scheduleEnd: "" },
|
||||
{ scheduledOn: "2026-09-09", targetWeek: "", scheduleEnd: "2026-09-11" },
|
||||
])("clears a persisted schedule and derives Pending from Scheduled", (schedule) => {
|
||||
const onPersist = vi.fn();
|
||||
const row = {
|
||||
id: 42,
|
||||
status: "Scheduled",
|
||||
scheduleWeekOnly: Boolean(schedule.targetWeek),
|
||||
schedMode: schedule.targetWeek ? "week" : schedule.scheduleEnd ? "range" : "date",
|
||||
...schedule,
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
renderScheduledRow(row, onPersist);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clear schedule" }));
|
||||
|
||||
expect(onPersist).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
status: "Pending",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["Completed", "Canceled"])("does not expose Clear for a %s row", (status) => {
|
||||
const row = {
|
||||
id: 42,
|
||||
status,
|
||||
scheduledOn: "2026-09-09",
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
scheduleWeekOnly: false,
|
||||
schedMode: "date",
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
renderScheduledRow(row, vi.fn(), true);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "clear schedule" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -64,6 +64,7 @@ const handlers: WoTableRowHandlers = {
|
|||
onPatch: vi.fn(),
|
||||
onScheduleDate: vi.fn(),
|
||||
onScheduleWeek: vi.fn(),
|
||||
onScheduleRange: vi.fn(),
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onOpenCompDoc: vi.fn(),
|
||||
onOpenUplifts: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -271,4 +271,28 @@ describe("CompDocCell slice affordances", () => {
|
|||
|
||||
expect(screen.getByRole("button", { name: /view doc/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the uploaded file count for media work orders", () => {
|
||||
renderWithProviders(
|
||||
<CompDocCell status="No" woType="Emergency" mediaCount={2} onOpenMedia={vi.fn()} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /2 files/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a singular label with one file and the Media prompt with none", () => {
|
||||
const { unmount } = renderWithProviders(
|
||||
<CompDocCell status="No" woType="Emergency" mediaCount={1} onOpenMedia={vi.fn()} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /^1 file$/i })).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderWithProviders(
|
||||
<CompDocCell status="No" woType="Emergency" mediaCount={0} onOpenMedia={vi.fn()} />,
|
||||
{ withAuth: false },
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /^media$/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ const apiPatch = vi.fn();
|
|||
const apiPatchFn = vi.fn();
|
||||
const apiPostFn = vi.fn();
|
||||
const apiDeleteFn = vi.fn();
|
||||
const apiGetFn = vi.fn();
|
||||
const uploadFormFn = vi.fn();
|
||||
|
||||
vi.mock("@/api/upload-form-with-progress", () => ({
|
||||
uploadFormWithProgress: (...args: unknown[]) => uploadFormFn(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/api", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
|
|
@ -22,8 +28,15 @@ vi.mock("@/api/api", () => ({
|
|||
if (method === "patch") return apiPatchFn(url, options);
|
||||
if (method === "post") return apiPostFn(url, options);
|
||||
if (method === "delete") return apiDeleteFn(url, options);
|
||||
if (method === "get") return apiGetFn(url, options);
|
||||
throw new Error(`Unexpected apiRequestRaw method: ${method}`);
|
||||
},
|
||||
api: {
|
||||
patch: (...args: unknown[]) => apiPatchFn(...args),
|
||||
post: (...args: unknown[]) => apiPostFn(...args),
|
||||
delete: (...args: unknown[]) => apiDeleteFn(...args),
|
||||
get: (...args: unknown[]) => apiGetFn(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
|
|
@ -655,44 +668,55 @@ describe("workOrdersApi.updateComment", () => {
|
|||
|
||||
describe("workOrdersApi.uploadMedia", () => {
|
||||
beforeEach(() => {
|
||||
apiPostFn.mockReset();
|
||||
uploadFormFn.mockReset();
|
||||
});
|
||||
|
||||
it("POSTs multipart FormData with file and category", async () => {
|
||||
apiPostFn.mockResolvedValue({
|
||||
it("POSTs multipart FormData with file and category, forwarding progress and signal", async () => {
|
||||
uploadFormFn.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
status: 201,
|
||||
data: {
|
||||
id: 12,
|
||||
category: 3,
|
||||
url: "https://cdn.example.com/extra.jpg",
|
||||
fileName: "extra.jpg",
|
||||
uploadedAt: "2026-07-16T12:00:00Z",
|
||||
isLegacy: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const file = new File(["bytes"], "extra.jpg", { type: "image/jpeg" });
|
||||
const item = await workOrdersApi.uploadMedia(10, file, "Extra");
|
||||
const onProgress = vi.fn();
|
||||
const signal = new AbortController().signal;
|
||||
const item = await workOrdersApi.uploadMedia(10, file, "Extra", { signal, onProgress });
|
||||
|
||||
expect(apiPostFn).toHaveBeenCalledWith(
|
||||
API_PATHS.workOrder.media(10),
|
||||
expect.objectContaining({ throwHttpErrors: false }),
|
||||
);
|
||||
const body = apiPostFn.mock.calls[0]?.[1]?.body as FormData;
|
||||
expect(uploadFormFn).toHaveBeenCalledWith(API_PATHS.workOrder.media(10), expect.any(FormData), {
|
||||
signal,
|
||||
onProgress,
|
||||
});
|
||||
const body = uploadFormFn.mock.calls[0]?.[1] as FormData;
|
||||
expect(body.get("category")).toBe("Extra");
|
||||
expect(body.get("file")).toBeInstanceOf(File);
|
||||
expect(item.id).toBe("12");
|
||||
expect(item.category).toBe("Extra");
|
||||
});
|
||||
|
||||
it("does not upload through Ky, whose streamed body breaks HTTP/1.1 transports", async () => {
|
||||
uploadFormFn.mockResolvedValue({ ok: true, status: 200, data: { id: 12, category: 3 } });
|
||||
|
||||
await workOrdersApi.uploadMedia(10, new File(["x"], "a.jpg", { type: "image/jpeg" }), "Before");
|
||||
|
||||
expect(apiPostFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws BoardPatchValidationError on 422", async () => {
|
||||
apiPostFn.mockResolvedValue({
|
||||
uploadFormFn.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: async () => ({
|
||||
data: {
|
||||
code: "ReadOnly",
|
||||
message: "Work order is read-only.",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { BoardPatchValidationError } =
|
||||
|
|
@ -896,3 +920,46 @@ describe("workOrdersApi.fetchVendorsDropdown", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("API_PATHS.workOrder.mediaContent", () => {
|
||||
it("builds the authorized media content path", () => {
|
||||
expect(API_PATHS.workOrder.mediaContent(10, 12)).toBe("workorders/10/media/12/content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.getMediaContent", () => {
|
||||
beforeEach(() => {
|
||||
apiGetFn.mockReset();
|
||||
});
|
||||
|
||||
it("returns the response blob on 200", async () => {
|
||||
const blob = new Blob(["pdf-bytes"], { type: "application/pdf" });
|
||||
apiGetFn.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: async () => blob,
|
||||
});
|
||||
|
||||
const result = await workOrdersApi.getMediaContent(10, 12);
|
||||
|
||||
expect(result).toBe(blob);
|
||||
expect(apiGetFn).toHaveBeenCalledWith(
|
||||
API_PATHS.workOrder.mediaContent(10, 12),
|
||||
expect.objectContaining({ credentials: "include", throwHttpErrors: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws ApiError on 404 without inventing a filename", async () => {
|
||||
apiGetFn.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ fileName: "secret.pdf" }),
|
||||
});
|
||||
|
||||
await expect(workOrdersApi.getMediaContent(10, 12)).rejects.toMatchObject({
|
||||
name: "ApiError",
|
||||
status: 404,
|
||||
message: "The requested resource was not found.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -236,6 +236,20 @@ describe("mapWorkOrderBoardDetail", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows the uploaded file name instead of the stored guid name", () => {
|
||||
const stored = mapBoardDetailMediaItem({
|
||||
id: 30,
|
||||
category: 3,
|
||||
url: "https://cdn.example.com/7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_6370_1_Before.jpg",
|
||||
fileName: "7a0cf3b5-3eeb-4c4f-a86e-8e2da1b0c9f4_6370_1_Before.jpg",
|
||||
});
|
||||
expect(stored.fileName).toBe("6370_1_Before.jpg");
|
||||
expect(boardMediaItemsToMediaFiles([stored])[0]).toMatchObject({
|
||||
name: "6370_1_Before.jpg",
|
||||
fileType: "image",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps numeric category and marks negative ids as legacy", () => {
|
||||
const legacy = mapBoardDetailMediaItem({
|
||||
id: -1,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import {
|
|||
resolveSiteCode,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
|
||||
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
||||
import {
|
||||
LIFECYCLE_STATUS_API,
|
||||
WORK_ORDER_TYPE_API,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
|
||||
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
|
|
@ -262,6 +265,22 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
|||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps Incomplete on week-only create and omits scheduledDate", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
siteCode: "BK5",
|
||||
status: "Incomplete",
|
||||
scheduleWeekOnly: true,
|
||||
targetWeek: "2026-06-22",
|
||||
});
|
||||
|
||||
expect(payload.lifecycleStatus).toBe(LIFECYCLE_STATUS_API.Incomplete);
|
||||
expect(payload.scheduleWeekOnly).toBe(true);
|
||||
expect(payload.targetWeek).toBe("2026-06-22");
|
||||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers explicit targetWeek when scheduleWeekOnly and scheduledOn is empty", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ describe("isLocalOnlyTablePatch", () => {
|
|||
it("treats primaryDispatchStatus overlay as local-only", () => {
|
||||
expect(isLocalOnlyTablePatch({ primaryDispatchStatus: "" })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats scheduleEnd and schedMode as local-only overlays", () => {
|
||||
expect(isLocalOnlyTablePatch({ scheduleEnd: "2026-06-12", schedMode: "range" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandTablePatchToBoardFieldOps", () => {
|
||||
|
|
@ -123,6 +127,21 @@ describe("expandTablePatchToBoardFieldOps", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("does not emit scheduleEnd or schedMode as board fields", () => {
|
||||
const ops = expandTablePatchToBoardFieldOps({
|
||||
scheduleWeekOnly: false,
|
||||
scheduledOn: "2026-06-08",
|
||||
targetWeek: "",
|
||||
scheduleEnd: "2026-06-12",
|
||||
schedMode: "range",
|
||||
});
|
||||
expect(ops).toEqual([
|
||||
{ field: "scheduleWeekOnly", value: "false" },
|
||||
{ field: "scheduledDate", value: "2026-06-08" },
|
||||
{ field: "targetWeek", value: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits additionalContacts as serialized JSON for board PATCH", () => {
|
||||
expect(
|
||||
expandTablePatchToBoardFieldOps({
|
||||
|
|
|
|||
|
|
@ -90,6 +90,25 @@ describe("mapWorkOrderTableRow avetaRequired", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("mapWorkOrderTableRow mediaCount", () => {
|
||||
it("maps the board mediaCount that feeds the CompDoc file count", () => {
|
||||
const row = mapWorkOrderTableRow({
|
||||
id: 11,
|
||||
workOrderType: WORK_ORDER_TYPE_API.Emergency,
|
||||
mediaCount: 2,
|
||||
});
|
||||
expect(row.mediaCount).toBe(2);
|
||||
});
|
||||
|
||||
it("defaults mediaCount to zero when the board omits it", () => {
|
||||
const row = mapWorkOrderTableRow({
|
||||
id: 12,
|
||||
workOrderType: WORK_ORDER_TYPE_API.Emergency,
|
||||
});
|
||||
expect(row.mediaCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapWorkOrderTableRow primaryDispatchStatus", () => {
|
||||
it.each([
|
||||
["primaryDispatchStatus", "Refused"],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
applyAssignedToTableSave,
|
||||
applyVendorTableSave,
|
||||
workOrderSavedMessage,
|
||||
} from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
||||
|
||||
|
|
@ -94,4 +95,38 @@ describe("useWorkOrderTableMutations assigned-to toast", () => {
|
|||
await waitFor(() => expect(toast.error).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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,11 +23,14 @@ vi.mock("react-toastify", () => ({
|
|||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
import { BoardPatchConflictError } from "@/domain/work-orders/errors/board-patch-errors";
|
||||
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper";
|
||||
import {
|
||||
applySchedulePatch,
|
||||
applyScheduleRangePatch,
|
||||
useWorkOrderTableMutations,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||
import {
|
||||
getClosabilityGaps,
|
||||
|
|
@ -223,6 +226,43 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("maybeAutoSchedule on board patch", () => {
|
||||
it("optimistically promotes Incomplete to Scheduled when a date is set", async () => {
|
||||
const rowState = { ...BASE_ROW, status: "Incomplete" as const, scheduledOn: "" };
|
||||
patchBoardField.mockImplementation(async (_id: unknown, payload: { field: string }) => {
|
||||
if (payload.field === "lifecycleStatus") {
|
||||
return { ...rowState, status: "Scheduled", scheduledOn: "2026-07-15" };
|
||||
}
|
||||
return { ...rowState, scheduledOn: "2026-07-15" };
|
||||
});
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, applySchedulePatch("2026-07-15", false));
|
||||
});
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ scheduledOn: "2026-07-15", status: "Scheduled" }),
|
||||
);
|
||||
await waitFor(() => expect(patchBoardField).toHaveBeenCalled());
|
||||
expect(patchBoardField).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ field: "lifecycleStatus", value: "Scheduled" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySchedulePatch isAddOn optimistic clear", () => {
|
||||
it("clears isAddOn when schedule is cleared", () => {
|
||||
expect(applySchedulePatch("", false)).toMatchObject({
|
||||
|
|
@ -237,6 +277,16 @@ describe("applySchedulePatch isAddOn optimistic clear", () => {
|
|||
expect(applySchedulePatch("2026-07-15", false).isAddOn).toBeUndefined();
|
||||
expect(applySchedulePatch("2026-07-13", true).isAddOn).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps range end/mode optimistic while landing on the start day", () => {
|
||||
expect(applyScheduleRangePatch("2026-06-08", "2026-06-12")).toMatchObject({
|
||||
scheduledOn: "2026-06-08",
|
||||
scheduleEnd: "2026-06-12",
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkOrderTableMutations schedule isAddOn from BE", () => {
|
||||
|
|
@ -642,3 +692,146 @@ describe("useWorkOrderTableMutations apptTime clear", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkOrderTableMutations type and severity", () => {
|
||||
let rowState: WorkOrderTableRow;
|
||||
|
||||
beforeEach(() => {
|
||||
getById.mockReset();
|
||||
update.mockReset();
|
||||
patchBoardField.mockReset();
|
||||
rowState = { ...BASE_ROW, type: "PM", severity: null };
|
||||
});
|
||||
|
||||
it("patches severity before workOrderType and merges the API row", async () => {
|
||||
const fields: string[] = [];
|
||||
patchBoardField.mockImplementation(
|
||||
async (
|
||||
_id: unknown,
|
||||
payload: { field: string; value?: string | null; workOrderVersion: string },
|
||||
) => {
|
||||
fields.push(payload.field);
|
||||
if (payload.field === "severity") {
|
||||
rowState = {
|
||||
...rowState,
|
||||
severity: payload.value ? (Number(payload.value) as 1) : null,
|
||||
rowVersion: `${payload.workOrderVersion}-sev`,
|
||||
};
|
||||
}
|
||||
if (payload.field === "workOrderType") {
|
||||
rowState = {
|
||||
...rowState,
|
||||
type: (payload.value as WorkOrderTableRow["type"]) || rowState.type,
|
||||
rowVersion: `${payload.workOrderVersion}-type`,
|
||||
};
|
||||
}
|
||||
return { ...rowState };
|
||||
},
|
||||
);
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, { type: "Emergency", severity: 1 });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(fields).toEqual(["severity", "workOrderType"]));
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ type: "Emergency", severity: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies currentState on 409 conflict instead of clearing", async () => {
|
||||
const currentState = { ...BASE_ROW, type: "PM" as const, severity: null };
|
||||
patchBoardField.mockRejectedValue(new BoardPatchConflictError("stale", currentState));
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const clearPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, { type: "Emergency", severity: 1 });
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onPatch).toHaveBeenCalledWith(42, expect.objectContaining({ type: "PM" })),
|
||||
);
|
||||
expect(clearPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back with clearPatch on a generic type patch error", async () => {
|
||||
patchBoardField.mockRejectedValue(new Error("network"));
|
||||
|
||||
const onPatch = vi.fn();
|
||||
const clearPatch = vi.fn();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch,
|
||||
clearPatch,
|
||||
getRow: () => rowState,
|
||||
}),
|
||||
{ wrapper: makeWrapper() },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, { type: "Emergency", severity: 1 });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(clearPatch).toHaveBeenCalledWith(42));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkOrderTableMutations audit cache refresh", () => {
|
||||
it("invalidates work-order queries after a successful status transition", async () => {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
|
||||
const nextRow = { ...BASE_ROW, status: "En Route" as const, rowVersion: "wo-v2" };
|
||||
patchBoardField.mockReset();
|
||||
patchBoardField.mockResolvedValue(nextRow);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useWorkOrderTableMutations({
|
||||
onPatch: vi.fn(),
|
||||
clearPatch: () => undefined,
|
||||
getRow: () => BASE_ROW,
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.patchField(42, { status: "En Route" });
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(invalidateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ queryKey: queryKeys.workOrders.all }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -21,7 +21,28 @@ describe("formatBoardAuditAction", () => {
|
|||
oldValue: "Scheduled",
|
||||
newValue: "Incomplete",
|
||||
}),
|
||||
).toBe('Changed status to "Incomplete"');
|
||||
).toBe('Changed status from "Scheduled" to "Incomplete"');
|
||||
});
|
||||
|
||||
it("includes the previous status in the audit sentence when present", () => {
|
||||
const sentence = formatBoardAuditAction({
|
||||
action: "StatusChanged",
|
||||
fieldName: "LifecycleStatus",
|
||||
oldValue: "Pending",
|
||||
newValue: "En Route",
|
||||
});
|
||||
expect(sentence).toBe('Changed status from "Pending" to "En Route"');
|
||||
});
|
||||
|
||||
it("omits from when the previous status is empty", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "StatusChanged",
|
||||
fieldName: "LifecycleStatus",
|
||||
oldValue: "",
|
||||
newValue: "Scheduled",
|
||||
}),
|
||||
).toBe('Changed status to "Scheduled"');
|
||||
});
|
||||
|
||||
it("formats WeekRolled / CarriedOver with increment", () => {
|
||||
|
|
@ -68,6 +89,28 @@ describe("formatBoardAuditAction", () => {
|
|||
).toBe("WO marked as Past Due. Schedule On has passed without completion.");
|
||||
});
|
||||
|
||||
it("formats type correction as Changed Type to (AAP, new value only)", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldPatched",
|
||||
fieldName: "WorkOrderType",
|
||||
oldValue: "PM",
|
||||
newValue: "Emergency",
|
||||
}),
|
||||
).toBe('Changed Type to "Emergency"');
|
||||
});
|
||||
|
||||
it("formats severity as Changed Severity to SEV N", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldPatched",
|
||||
fieldName: "Severity",
|
||||
oldValue: "3",
|
||||
newValue: "1",
|
||||
}),
|
||||
).toBe('Changed Severity to "SEV 1"');
|
||||
});
|
||||
|
||||
it("falls back to from → to for unknown fields", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatSeverityEscalationToast,
|
||||
shouldAnnounceSeverityEscalation,
|
||||
} from "@/domain/work-orders/utils/severity-escalation";
|
||||
|
||||
describe("shouldAnnounceSeverityEscalation", () => {
|
||||
it("announces when severity newly enters SEV 1", () => {
|
||||
expect(shouldAnnounceSeverityEscalation(3, 1)).toBe(true);
|
||||
expect(shouldAnnounceSeverityEscalation(null, 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not announce a re-save of the same critical severity", () => {
|
||||
expect(shouldAnnounceSeverityEscalation(1, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not announce a de-escalation out of SEV 1/2", () => {
|
||||
expect(shouldAnnounceSeverityEscalation(2, 3)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not announce SEV 2 to SEV 1 (already critical)", () => {
|
||||
expect(shouldAnnounceSeverityEscalation(2, 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSeverityEscalationToast", () => {
|
||||
it("matches the AAP warning copy", () => {
|
||||
expect(formatSeverityEscalationToast("00000024965", 1)).toBe(
|
||||
"WO #00000024965 escalated to SEV 1.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { mergeAutoSchedulePatch } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
|
|
@ -97,6 +98,24 @@ describe("slide-over-draft", () => {
|
|||
expect(isSlideOverDraftDirty(draft, row)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears client schedule range overlays in the save patch", () => {
|
||||
const row = baseRow({
|
||||
scheduleEnd: "2026-07-18",
|
||||
schedMode: "range",
|
||||
});
|
||||
const draft = baseRow({
|
||||
scheduledOn: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
});
|
||||
|
||||
expect(buildSlideOverPatch(draft, row)).toEqual({
|
||||
scheduledOn: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("is not dirty when values match", () => {
|
||||
const row = baseRow();
|
||||
expect(isSlideOverDraftDirty(row, row)).toBe(false);
|
||||
|
|
@ -116,6 +135,38 @@ describe("slide-over-draft", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("derives Pending on persist when a Scheduled draft clears Schedule On", () => {
|
||||
const baseline = baseRow({
|
||||
status: "Scheduled",
|
||||
scheduledOn: "2026-07-16",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
});
|
||||
const draft = baseRow({
|
||||
status: "Scheduled",
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
});
|
||||
|
||||
const patch = buildSlideOverPatch(draft, baseline);
|
||||
expect(patch).toEqual({
|
||||
scheduledOn: "",
|
||||
schedMode: undefined,
|
||||
});
|
||||
expect(patch.status).toBeUndefined();
|
||||
expect(mergeAutoSchedulePatch(baseline, patch)).toEqual(
|
||||
expect.objectContaining({
|
||||
scheduledOn: "",
|
||||
status: "Pending",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks ordered additionalContacts for dirty state and patch (SH-218)", () => {
|
||||
const row = baseRow();
|
||||
const contacts = [{ name: "Backup", phone: "555-0101", notes: "Gate" }];
|
||||
|
|
|
|||
|
|
@ -194,6 +194,22 @@ describe("filterByBarAndSearch", () => {
|
|||
expect(ids).toEqual(["pd"]);
|
||||
});
|
||||
|
||||
it("hides a row after type correction when the bar filter no longer matches", () => {
|
||||
const afterCorrection = makeRow({
|
||||
id: "1",
|
||||
type: "Emergency",
|
||||
scheduledOn: "2026-06-03",
|
||||
});
|
||||
|
||||
expect(
|
||||
filterByBarAndSearch(
|
||||
[afterCorrection],
|
||||
bar(weekMonday, { types: new Set<WOType>(["PM"]) }),
|
||||
"",
|
||||
).map((row) => row.id),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not filter by type when the set is empty", () => {
|
||||
const pm = makeRow({ id: "pm", type: "PM", scheduledOn: "2026-06-03" });
|
||||
const emergency = makeRow({ id: "em", type: "Emergency", scheduledOn: "2026-06-03" });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { PENDING_UPLIFT_GAP } from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
BOARD_INLINE_STATUSES,
|
||||
COMPLETED_DOC_BLOCK_MSG,
|
||||
COMPLETED_MEDIA_BLOCK_MSG,
|
||||
CREATION_INLINE_STATUSES,
|
||||
SCHEDULED_NEEDS_DATE_MSG,
|
||||
formatAapCompletedBlockTitle,
|
||||
getStatusOptionState,
|
||||
hasConcreteScheduleDate,
|
||||
maybeAutoSchedulePatch,
|
||||
mergeAutoSchedulePatch,
|
||||
resolveCreationStatus,
|
||||
withAutoScheduleStatus,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
describe("wo-lifecycle-transitions", () => {
|
||||
it("lists AAP board statuses without Canceled", () => {
|
||||
expect(BOARD_INLINE_STATUSES).toEqual([
|
||||
"Incomplete",
|
||||
"Pending",
|
||||
"Scheduled",
|
||||
"En Route",
|
||||
"On Site",
|
||||
"In Progress",
|
||||
"Completed",
|
||||
"Rescheduled",
|
||||
"Pending Quote",
|
||||
]);
|
||||
expect(BOARD_INLINE_STATUSES).not.toContain("Canceled");
|
||||
});
|
||||
|
||||
it("lists AAP creation statuses only", () => {
|
||||
expect(CREATION_INLINE_STATUSES).toEqual(["Incomplete", "Pending", "Scheduled"]);
|
||||
});
|
||||
|
||||
it("treats week-only as no concrete schedule date", () => {
|
||||
expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01", scheduleWeekOnly: true })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01", schedMode: "week" })).toBe(false);
|
||||
expect(hasConcreteScheduleDate({ scheduledOn: "—" })).toBe(false);
|
||||
expect(hasConcreteScheduleDate({ scheduledOn: "2026-06-01" })).toBe(true);
|
||||
});
|
||||
|
||||
it("disables Scheduled without a concrete date", () => {
|
||||
expect(getStatusOptionState({ scheduledOn: "" }, "Scheduled", [])).toEqual({
|
||||
disabled: true,
|
||||
title: SCHEDULED_NEEDS_DATE_MSG,
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes Incomplete/Pending when a concrete date exists", () => {
|
||||
expect(
|
||||
maybeAutoSchedulePatch({
|
||||
status: "Incomplete",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toEqual({ status: "Scheduled" });
|
||||
expect(
|
||||
maybeAutoSchedulePatch({
|
||||
status: "Pending",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toEqual({ status: "Scheduled" });
|
||||
});
|
||||
|
||||
it("reverts Scheduled without a concrete date to Pending", () => {
|
||||
expect(
|
||||
maybeAutoSchedulePatch({
|
||||
status: "Scheduled",
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toEqual({ status: "Pending" });
|
||||
});
|
||||
|
||||
it("does not auto-change In Progress when a date exists", () => {
|
||||
expect(
|
||||
maybeAutoSchedulePatch({
|
||||
status: "In Progress",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("merges auto-schedule onto an existing-row patch", () => {
|
||||
const row = {
|
||||
status: "Incomplete",
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
} as WorkOrderTableRow;
|
||||
expect(
|
||||
mergeAutoSchedulePatch(row, { scheduledOn: "2026-06-02", scheduleWeekOnly: false }),
|
||||
).toEqual({
|
||||
scheduledOn: "2026-06-02",
|
||||
scheduleWeekOnly: false,
|
||||
status: "Scheduled",
|
||||
});
|
||||
expect(mergeAutoSchedulePatch(row, { vendorId: "9" })).toEqual({ vendorId: "9" });
|
||||
expect(
|
||||
mergeAutoSchedulePatch({ ...row, scheduledOn: "2026-06-01" }, { vendorId: "9" }),
|
||||
).toEqual({ vendorId: "9" });
|
||||
});
|
||||
|
||||
it("promotes a pre-schedule wizard pick to Scheduled on a dated draft", () => {
|
||||
const draft = {
|
||||
status: "Scheduled",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
};
|
||||
expect(withAutoScheduleStatus(draft, { status: "Pending" })).toEqual({ status: "Scheduled" });
|
||||
expect(withAutoScheduleStatus(draft, { status: "Incomplete" })).toEqual({
|
||||
status: "Scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
it("still derives Scheduled when a concrete date is set on a draft", () => {
|
||||
const draft = {
|
||||
status: "Pending",
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
};
|
||||
expect(
|
||||
withAutoScheduleStatus(draft, { scheduledOn: "2026-06-02", scheduleWeekOnly: false }),
|
||||
).toEqual({
|
||||
scheduledOn: "2026-06-02",
|
||||
scheduleWeekOnly: false,
|
||||
status: "Scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes pre-schedule picks on dated rows", () => {
|
||||
const row = {
|
||||
status: "Scheduled",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
} as WorkOrderTableRow;
|
||||
expect(mergeAutoSchedulePatch(row, { status: "Pending" })).toEqual({ status: "Scheduled" });
|
||||
expect(mergeAutoSchedulePatch(row, { status: "Incomplete" })).toEqual({
|
||||
status: "Scheduled",
|
||||
});
|
||||
});
|
||||
|
||||
it("still demotes Scheduled when the schedule is cleared", () => {
|
||||
const row = {
|
||||
status: "Scheduled",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
} as WorkOrderTableRow;
|
||||
expect(mergeAutoSchedulePatch(row, { scheduledOn: "", scheduleWeekOnly: false })).toEqual({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
status: "Pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes a Pending create pick to Scheduled when the draft is dated", () => {
|
||||
expect(
|
||||
resolveCreationStatus({
|
||||
status: "Pending",
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toBe("Scheduled");
|
||||
});
|
||||
|
||||
it("demotes Scheduled without a concrete date at create", () => {
|
||||
expect(
|
||||
resolveCreationStatus({
|
||||
status: "Scheduled",
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
}),
|
||||
).toBe("Pending");
|
||||
});
|
||||
|
||||
it("uses AAP completed tooltip hierarchy", () => {
|
||||
expect(formatAapCompletedBlockTitle(["Dispatcher", "Company"])).toBe(
|
||||
"Fill in Assigned To, Vendor before completing this work order.",
|
||||
);
|
||||
expect(formatAapCompletedBlockTitle(["Service"])).toBe(
|
||||
"Select a service before completing this work order.",
|
||||
);
|
||||
expect(formatAapCompletedBlockTitle(["WO number"])).toBe(
|
||||
"Assign a real APM number (not SH) before completing this work order.",
|
||||
);
|
||||
expect(formatAapCompletedBlockTitle([PENDING_UPLIFT_GAP])).toBe(
|
||||
"This work order has an uplift pending approval and cannot be completed.",
|
||||
);
|
||||
expect(formatAapCompletedBlockTitle(["Completion document"])).toBe(COMPLETED_DOC_BLOCK_MSG);
|
||||
expect(formatAapCompletedBlockTitle(["At least 1 photo"])).toBe(COMPLETED_MEDIA_BLOCK_MSG);
|
||||
expect(formatAapCompletedBlockTitle(["Company", PENDING_UPLIFT_GAP])).toBe(
|
||||
"Fill in Vendor before completing this work order.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -45,5 +45,10 @@ describe("work-order-lock", () => {
|
|||
expect(canApplyWorkOrderTablePatch("Completed", { site: "BK1" })).toBe(false);
|
||||
expect(canApplyWorkOrderTablePatch("Scheduled", { dispatcherId: "u2" })).toBe(true);
|
||||
expect(canApplyWorkOrderTablePatch("Canceled", { flagColor: "red" })).toBe(false);
|
||||
expect(canApplyWorkOrderTablePatch("Completed", { type: "Emergency", severity: 1 })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(canApplyWorkOrderTablePatch("Canceled", { type: "PM" })).toBe(false);
|
||||
expect(canApplyWorkOrderTablePatch("Scheduled", { type: "Reactive", severity: 3 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderDocumentsReadOnly,
|
||||
isWorkOrderExtraDocTypeAllowed,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
MAX_WORK_ORDER_MEDIA_BYTES,
|
||||
workOrderExtraDocsAcceptAttribute,
|
||||
workOrderMediaAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
describe("work-order-media-limits", () => {
|
||||
|
|
@ -20,13 +24,16 @@ describe("work-order-media-limits", () => {
|
|||
expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false);
|
||||
});
|
||||
|
||||
it("flags files over 30 MB", () => {
|
||||
it("caps uploads at 50_000_000 bytes and formats decimal MB", () => {
|
||||
expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(50_000_000);
|
||||
expect(formatMediaSizeLimit()).toBe("50 MB");
|
||||
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
|
||||
expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows only JPG PNG MP4 MOV by MIME or extension", () => {
|
||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe(true);
|
||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.jpg", type: "image/jpg" } as File)).toBe(true);
|
||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.png", type: "image/png" } as File)).toBe(true);
|
||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.mp4", type: "video/mp4" } as File)).toBe(true);
|
||||
expect(isWorkOrderMediaTypeAllowed({ name: "a.mov", type: "video/quicktime" } as File)).toBe(
|
||||
|
|
@ -36,5 +43,31 @@ describe("work-order-media-limits", () => {
|
|||
expect(isWorkOrderMediaTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(workOrderMediaAcceptAttribute()).not.toContain(".pdf");
|
||||
});
|
||||
|
||||
it("allows Extra Docs PDF DOC DOCX plus media types", () => {
|
||||
expect(isWorkOrderExtraDocTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isWorkOrderExtraDocTypeAllowed({ name: "a.doc", type: "application/msword" } as File),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkOrderExtraDocTypeAllowed({
|
||||
name: "a.docx",
|
||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
} as File),
|
||||
).toBe(true);
|
||||
expect(isWorkOrderExtraDocTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isWorkOrderExtraDocTypeAllowed({ name: "a.gif", type: "image/gif" } as File)).toBe(
|
||||
false,
|
||||
);
|
||||
const accept = workOrderExtraDocsAcceptAttribute();
|
||||
expect(accept).toContain(".pdf");
|
||||
expect(accept).toContain(".doc");
|
||||
expect(accept).toContain(".docx");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue