@@ -57,6 +98,7 @@ export function MediaUploadModal({
variant="outline"
size="default"
className="text-[12px]"
+ disabled={pending}
onClick={() => onOpenChange(false)}
>
Close
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx
new file mode 100644
index 00000000..9233d212
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-dropzone.tsx
@@ -0,0 +1,94 @@
+import type { RefObject } from "react";
+import { Upload } from "lucide-react";
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import { formatMediaSizeLimit } from "@/domain/work-orders/utils/work-order-media-limits";
+
+const PHOTO_CATEGORIES: UploadableMediaCategory[] = ["Before", "After"];
+
+type MediaUploaderDropzoneProps = {
+ inputRef: RefObject
;
+ category: UploadableMediaCategory;
+ onCategoryChange: (category: UploadableMediaCategory) => void;
+ showCategoryPicker: boolean;
+ pending: boolean;
+ onFilesSelected: (files: FileList | null) => void;
+};
+
+export function MediaUploaderDropzone({
+ inputRef,
+ category,
+ onCategoryChange,
+ showCategoryPicker,
+ pending,
+ onFilesSelected,
+}: MediaUploaderDropzoneProps) {
+ return (
+ <>
+ {showCategoryPicker && (
+
+
+
+
+ )}
+
+ {
+ onFilesSelected(e.target.files);
+ e.target.value = "";
+ }}
+ />
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx
new file mode 100644
index 00000000..55a30ea2
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid-item.tsx
@@ -0,0 +1,96 @@
+import {
+ File as FileIcon,
+ FileText,
+ Film,
+ Image as ImageIcon,
+ Paperclip,
+ Play,
+ X,
+} from "lucide-react";
+import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
+import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
+
+const FILETYPE_ICON: Record = {
+ image: ImageIcon,
+ video: Film,
+ pdf: FileText,
+ doc: FileIcon,
+ other: Paperclip,
+};
+
+type MediaUploaderGridItemProps = {
+ file: MediaFile;
+ readOnly: boolean;
+ pending: boolean;
+ onRemove: (id: string) => void;
+};
+
+export function MediaUploaderGridItem({
+ file,
+ readOnly,
+ pending,
+ onRemove,
+}: MediaUploaderGridItemProps) {
+ const TypeIcon = FILETYPE_ICON[file.fileType];
+ const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
+
+ return (
+
+
+ {file.fileType === "image" ? (
+

+ ) : file.fileType === "video" ? (
+ <>
+
+
+
+
+ >
+ ) : (
+
+
+
+ )}
+ {showDelete && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx b/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx
new file mode 100644
index 00000000..5bba5384
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/media/media-uploader-grid.tsx
@@ -0,0 +1,32 @@
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
+import { MediaUploaderGridItem } from "./media-uploader-grid-item";
+
+type MediaUploaderGridProps = {
+ uploads: MediaFile[];
+ readOnly: boolean;
+ pending: boolean;
+ onRemove: (id: string) => void;
+};
+
+export function MediaUploaderGrid({
+ uploads,
+ readOnly,
+ pending,
+ onRemove,
+}: MediaUploaderGridProps) {
+ if (uploads.length === 0) return null;
+
+ return (
+
+ {uploads.map((file) => (
+
+ ))}
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/media/media-uploader.tsx b/src/app/(protected)/workorders/_components/media/media-uploader.tsx
index bd7fcc9a..106aa32b 100644
--- a/src/app/(protected)/workorders/_components/media/media-uploader.tsx
+++ b/src/app/(protected)/workorders/_components/media/media-uploader.tsx
@@ -1,143 +1,90 @@
-import { useRef } from "react";
+import { useRef, useState } from "react";
+import { toast } from "react-toastify";
+import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
- File as FileIcon,
- FileText,
- Film,
- Image as ImageIcon,
- Paperclip,
- Play,
- Upload,
- X,
-} from "lucide-react";
-import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
+ canDeleteWorkOrderMedia,
+ formatMediaSizeLimit,
+ isWorkOrderFileTooLarge,
+} from "@/domain/work-orders/utils/work-order-media-limits";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
-
-const FILETYPE_ICON: Record = {
- image: ImageIcon,
- video: Film,
- pdf: FileText,
- doc: FileIcon,
- other: Paperclip,
-};
+import { MediaUploaderDropzone } from "./media-uploader-dropzone";
+import { MediaUploaderGrid } from "./media-uploader-grid";
type MediaUploaderProps = {
uploads: MediaFile[];
- onChange: (next: MediaFile[]) => void;
+ /** When provided, uploads go to the API with the selected Before/After category. */
+ onUpload?: (files: File[], category: UploadableMediaCategory) => void;
+ onRemove?: (id: string) => void;
+ /** Legacy local-only mode (table modal fallback). */
+ onChange?: (next: MediaFile[]) => void;
+ readOnly?: boolean;
+ pending?: boolean;
};
-export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
+export function MediaUploader({
+ uploads,
+ onUpload,
+ onRemove,
+ onChange,
+ readOnly = false,
+ pending = false,
+}: MediaUploaderProps) {
const inputRef = useRef(null);
+ const [category, setCategory] = useState("Before");
const addFiles = (files: FileList | null) => {
- if (!files || files.length === 0) return;
- onChange([...uploads, ...Array.from(files).map(makeMediaFile)]);
+ if (readOnly || pending || !files || files.length === 0) return;
+
+ const accepted: File[] = [];
+ for (const file of Array.from(files)) {
+ if (isWorkOrderFileTooLarge(file)) {
+ toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
+ continue;
+ }
+ accepted.push(file);
+ }
+ if (accepted.length === 0) return;
+
+ if (onUpload) {
+ onUpload(accepted, category);
+ return;
+ }
+
+ if (onChange) {
+ onChange([...uploads, ...accepted.map(makeMediaFile)]);
+ }
};
- const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id));
+ const remove = (id: string) => {
+ if (readOnly || pending) return;
+ const target = uploads.find((u) => u.id === id);
+ if (target && !canDeleteWorkOrderMedia(target)) return;
+ if (onRemove) {
+ onRemove(id);
+ return;
+ }
+ onChange?.(uploads.filter((u) => u.id !== id));
+ };
return (
-
-
addFiles(e.target.files)}
- />
- {uploads.length > 0 && (
-
- {uploads.map((u) => {
- const TypeIcon = FILETYPE_ICON[u.fileType];
- return (
-
-
- {u.fileType === "image" ? (
-

- ) : u.fileType === "video" ? (
- <>
-
-
-
-
- >
- ) : (
-
-
-
- )}
-
-
-
-
- );
- })}
-
+ {!readOnly && (
+
)}
+
);
}
diff --git a/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts b/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
index fb3013e5..128da982 100644
--- a/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
+++ b/src/app/(protected)/workorders/_hooks/use-comp-doc-dialog.ts
@@ -1,5 +1,9 @@
import { useEffect, useMemo, useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { toast } from "react-toastify";
import { showCompDocDraftSavedToast } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast";
+import { locationsApi } from "@/domain/locations/api/locations-api";
+import type { Location } from "@/domain/locations/types/location";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import type {
CompDocData,
@@ -16,6 +20,7 @@ import {
docStateOf,
fmtDocNow,
} from "@/domain/work-orders/utils/completion-doc-state";
+import { queryKeys } from "@/infra/query-key/query-key";
type UseCompDocDialogParams = {
row: WorkOrderTableRow;
@@ -25,6 +30,18 @@ type UseCompDocDialogParams = {
onDateCompletedChange?: (date: string) => void;
};
+function buildAddressLine(site: { street: string; fullAddress: string }): string {
+ if (site.street.trim()) return site.street.trim();
+ return site.fullAddress !== "—" ? site.fullAddress : "";
+}
+
+function locationUpdatePayload(location: Location, address: string): Location {
+ return {
+ ...location,
+ address,
+ };
+}
+
export function useCompDocDialog({
row,
open,
@@ -32,6 +49,7 @@ export function useCompDocDialog({
onSave,
onDateCompletedChange,
}: UseCompDocDialogParams) {
+ const queryClient = useQueryClient();
const locId = String(row.locationId || "");
const isMockLocation = locId.startsWith("mock-");
const { data: apiLocationDetail } = useLocationDetail(
@@ -44,6 +62,7 @@ export function useCompDocDialog({
const [docState, setDocState] = useState("draft");
const [generatedAt, setGeneratedAt] = useState("");
const [editDateCompleted, setEditDateCompleted] = useState("");
+ const [editAddress, setEditAddress] = useState("");
useEffect(() => {
if (!open) return;
@@ -51,7 +70,8 @@ export function useCompDocDialog({
setDocState(docStateOf(row.docStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(row.completedDate || "");
- }, [open, row.id, row.docStatus, row.completedDate, data?.notes, data?.generatedAt]);
+ setEditAddress(buildAddressLine(resolveCompDocSite(row, locationDetail)));
+ }, [open, row, data?.notes, data?.generatedAt, locationDetail]);
const amazonRep = row.pocName || "";
const seaRep = row.tech || "";
@@ -59,8 +79,9 @@ export function useCompDocDialog({
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated";
+ const effectiveStreet = editAddress.trim() || site.street;
const docFields: DocFields = {
- street: site.street,
+ street: effectiveStreet,
city: site.city,
state: site.state,
zip: site.zip,
@@ -69,6 +90,18 @@ export function useCompDocDialog({
seaRep,
};
+ const previewSite = useMemo(
+ () => ({
+ ...site,
+ street: effectiveStreet,
+ fullAddress:
+ [effectiveStreet, site.label, site.zip].filter(Boolean).join(", ") ||
+ effectiveStreet ||
+ "—",
+ }),
+ [site, effectiveStreet],
+ );
+
function persist(extra: Partial, status: CompDocSaveStatus) {
onSave({ notes, generatedAt, ...extra }, status);
}
@@ -79,6 +112,26 @@ export function useCompDocDialog({
}
}
+ async function syncAddressIfChanged(): Promise {
+ const next = editAddress.trim();
+ const current = buildAddressLine(site);
+ if (next === current) return;
+ if (isMockLocation || !locId) return;
+ if (!locationDetail) {
+ toast.error("Location details are still loading. Try saving again.");
+ return;
+ }
+
+ try {
+ await locationsApi.update(locId, locationUpdatePayload(locationDetail, next));
+ void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(locId) });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to update address");
+ }
+ }
+
function handleMarkNotRequired() {
setDocState("not-required");
persist({}, "NN");
@@ -92,6 +145,7 @@ export function useCompDocDialog({
function handleSaveDraft() {
persist({}, "No");
syncCompletedDateIfChanged();
+ void syncAddressIfChanged();
showCompDocDraftSavedToast();
}
@@ -101,6 +155,7 @@ export function useCompDocDialog({
setDocState("generated");
persist({ generatedAt: ts }, "Yes");
syncCompletedDateIfChanged();
+ void syncAddressIfChanged();
}
function handleDownloadPdf() {
@@ -108,11 +163,13 @@ export function useCompDocDialog({
}
return {
- site,
+ site: previewSite,
notes,
setNotes,
docState,
generatedAt,
+ editAddress,
+ setEditAddress,
editDateCompleted,
setEditDateCompleted,
amazonRep,
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
index c0c99ba9..375883e0 100644
--- a/src/app/(protected)/workorders/index.tsx
+++ b/src/app/(protected)/workorders/index.tsx
@@ -21,9 +21,9 @@ import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-order
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
-import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
+import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
@@ -33,7 +33,6 @@ export default function WorkOrdersListPage() {
const [docRow, setDocRow] = useState(null);
const [mediaRow, setMediaRow] = useState(null);
const [completionDocs, setCompletionDocs] = useState>({});
- const [mediaFiles, setMediaFiles] = useState>({});
const filters = useWorkOrdersListFilters(user?.id);
const tableData = useWorkOrdersTableData(filters);
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
@@ -121,7 +120,10 @@ export default function WorkOrdersListPage() {
tableData.setSelectedId(row.id);
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
}}
- onOpenCompDoc={setDocRow}
+ onOpenCompDoc={(row) => {
+ if (!hasCompletionDocService(row.pm)) return;
+ setDocRow(row);
+ }}
onOpenMedia={setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
@@ -167,11 +169,6 @@ export default function WorkOrdersListPage() {
onOpenChange={(open) => {
if (!open) setMediaRow(null);
}}
- uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
- onChange={(files) => {
- setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
- patchField(activeMediaRow.id, { mediaCount: files.length });
- }}
/>
)}
diff --git a/src/domain/work-orders/api/work-order-board-documents-api.ts b/src/domain/work-orders/api/work-order-board-documents-api.ts
new file mode 100644
index 00000000..5532459f
--- /dev/null
+++ b/src/domain/work-orders/api/work-order-board-documents-api.ts
@@ -0,0 +1,169 @@
+import { API_PATHS } from "@/api/api-paths";
+import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
+import { api, apiGet, apiPost } from "@/api/api";
+import { handleApiResponse } from "@/api/handle-api-response";
+import {
+ BoardPatchConflictError,
+ BoardPatchValidationError,
+} from "@/domain/work-orders/errors/board-patch-errors";
+import {
+ buildCompletionDocFormData,
+ buildMediaFormData,
+ mapBoardDetailComment,
+ mapBoardDetailMediaItem,
+ mapCompletion,
+ type UploadableMediaCategory,
+} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+import type {
+ WorkOrderBoardCompletion,
+ WorkOrderBoardMediaItem,
+} from "@/domain/work-orders/types/work-order-board-detail";
+import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
+
+async function readResponseJson(response: Response): Promise {
+ try {
+ return await response.json();
+ } catch {
+ return undefined;
+ }
+}
+
+function throwDocumentHttpError(response: Response, data: unknown): never {
+ const record = typeof data === "object" && data !== null ? (data as Record) : {};
+
+ if (response.status === 409) {
+ throw new BoardPatchConflictError(
+ typeof record.message === "string"
+ ? record.message
+ : "The record was modified by another user. Refresh and retry.",
+ null,
+ );
+ }
+
+ if (response.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);
+}
+
+export const workOrderBoardDocumentsApi = {
+ listComments: async (workOrderId: string | number): Promise => {
+ const data = await apiGet(API_PATHS.workOrder.comments(workOrderId));
+ const items = handleApiResponse(data);
+ return Array.isArray(items) ? items.map(mapBoardDetailComment) : [];
+ },
+
+ createComment: async (workOrderId: string | number, text: string): Promise => {
+ const data = await apiPost(API_PATHS.workOrder.comments(workOrderId), { text });
+ return mapBoardDetailComment(handleApiResponse(data));
+ },
+
+ updateComment: async (
+ workOrderId: string | number,
+ commentId: string | number,
+ text: string,
+ ): Promise => {
+ const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), {
+ json: { text },
+ throwHttpErrors: false,
+ });
+ const data = await readResponseJson(response);
+
+ if (response.ok) {
+ if (response.status === 204 || data == null) {
+ return mapBoardDetailComment({ id: commentId, text });
+ }
+ return mapBoardDetailComment(handleApiResponse(data) ?? data);
+ }
+
+ throwDocumentHttpError(response, data);
+ },
+
+ /** @deprecated Prefer createComment — modern board endpoint. */
+ addComment: async (
+ workOrderId: string | number,
+ text: string,
+ _commentType?: string,
+ ): Promise => {
+ return workOrderBoardDocumentsApi.createComment(workOrderId, text);
+ },
+
+ listMedia: async (workOrderId: string | number): Promise => {
+ const data = await apiGet(API_PATHS.workOrder.media(workOrderId));
+ const items = handleApiResponse(data);
+ return Array.isArray(items) ? items.map(mapBoardDetailMediaItem) : [];
+ },
+
+ uploadMedia: async (
+ workOrderId: string | number,
+ file: File,
+ category: UploadableMediaCategory,
+ ): Promise => {
+ const formData = buildMediaFormData(file, category);
+ const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
+ body: formData,
+ throwHttpErrors: false,
+ });
+ const data = await readResponseJson(response);
+ if (response.ok) {
+ return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
+ }
+ throwDocumentHttpError(response, data);
+ },
+
+ deleteMedia: async (workOrderId: string | number, mediaId: string | number): Promise => {
+ const numericId = typeof mediaId === "number" ? mediaId : Number(mediaId);
+ if (!Number.isFinite(numericId) || numericId <= 0) {
+ throw new BoardPatchValidationError(
+ "InvalidMedia",
+ "Legacy media cannot be deleted via this endpoint.",
+ );
+ }
+
+ const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
+ throwHttpErrors: false,
+ });
+
+ if (response.status === 204) {
+ return;
+ }
+
+ const data = await readResponseJson(response);
+ if (response.ok) {
+ return;
+ }
+ throwDocumentHttpError(response, data);
+ },
+
+ uploadCompletionDoc: async (
+ workOrderId: string | number,
+ params: {
+ file: File;
+ workOrderVersion: string;
+ signOffName?: string;
+ signOffSignature?: string;
+ },
+ ): Promise => {
+ if (!params.workOrderVersion) {
+ throw new BoardPatchValidationError(
+ "WorkOrderVersionRequired",
+ "workOrderVersion is required to upload a completion document.",
+ );
+ }
+
+ const formData = buildCompletionDocFormData(params);
+ const response = await api.post(API_PATHS.workOrder.completionDoc(workOrderId), {
+ body: formData,
+ throwHttpErrors: false,
+ });
+ const data = await readResponseJson(response);
+ if (response.ok) {
+ return mapCompletion(handleApiResponse(data) ?? data);
+ }
+ throwDocumentHttpError(response, data);
+ },
+};
diff --git a/src/domain/work-orders/api/work-orders-api.ts b/src/domain/work-orders/api/work-orders-api.ts
index ae08f8ad..244de823 100644
--- a/src/domain/work-orders/api/work-orders-api.ts
+++ b/src/domain/work-orders/api/work-orders-api.ts
@@ -1,6 +1,7 @@
import { API_PATHS } from "@/api/api-paths";
import { api, apiDelete, apiGet, apiPost, apiPut, mapHttpStatusToMessage } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
+import { workOrderBoardDocumentsApi } from "@/domain/work-orders/api/work-order-board-documents-api";
import {
isWoTableMockEnabled,
shouldUseMockWorkOrderDetail,
@@ -13,6 +14,7 @@ import {
getMockWorkOrdersList,
MOCK_WO_TABLE_USERS,
} from "@/domain/work-orders/mocks/wo-table-mock-data";
+import { mapWorkOrderBoardDetail } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import {
mapDispatchComment,
mapDispatchDetail,
@@ -44,6 +46,7 @@ import type {
WorkOrderFormValues,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
+import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import {
DISPATCH_LOCATION_WO_PAGE_SIZE,
type WorkOrdersListParams,
@@ -123,6 +126,8 @@ async function mockNetworkDelay(): Promise {
}
export const workOrdersApi = {
+ ...workOrderBoardDocumentsApi,
+
getList: async (params: WorkOrdersListParams = {}): Promise => {
if (isWoTableMockEnabled()) {
await mockNetworkDelay();
@@ -182,6 +187,11 @@ export const workOrdersApi = {
return mapWorkOrderDetail(handleApiResponse(data));
},
+ getBoardDetail: async (id: string | number): Promise => {
+ const data = await apiGet(API_PATHS.workOrder.boardDetail(id));
+ return mapWorkOrderBoardDetail(handleApiResponse(data));
+ },
+
create: async (
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
): Promise => {
diff --git a/src/domain/work-orders/errors/board-patch-errors.ts b/src/domain/work-orders/errors/board-patch-errors.ts
new file mode 100644
index 00000000..1cce7140
--- /dev/null
+++ b/src/domain/work-orders/errors/board-patch-errors.ts
@@ -0,0 +1,23 @@
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+export class BoardPatchConflictError extends Error {
+ readonly status = 409;
+ readonly currentState: WorkOrderTableRow | null;
+
+ constructor(message: string, currentState: WorkOrderTableRow | null) {
+ super(message);
+ this.name = "BoardPatchConflictError";
+ this.currentState = currentState;
+ }
+}
+
+export class BoardPatchValidationError extends Error {
+ readonly status = 422;
+ readonly code: string;
+
+ constructor(code: string, message: string) {
+ super(message);
+ this.name = "BoardPatchValidationError";
+ this.code = code;
+ }
+}
diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
new file mode 100644
index 00000000..18da0e34
--- /dev/null
+++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
@@ -0,0 +1,263 @@
+import { DOC_STATUS_API } from "@/domain/work-orders/types/work-order-board";
+import type {
+ BoardMediaCategory,
+ WorkOrderBoardCompletion,
+ WorkOrderBoardDetail,
+ WorkOrderBoardMediaItem,
+} from "@/domain/work-orders/types/work-order-board-detail";
+import { BOARD_MEDIA_CATEGORY_API } from "@/domain/work-orders/types/work-order-board-detail";
+import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
+import type {
+ WorkOrderAuditEntry,
+ WorkOrderComment,
+} from "@/domain/work-orders/types/work-order-detail";
+import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
+import type {
+ FileCategory,
+ MediaCategory,
+ MediaFile,
+} from "@/domain/work-orders/types/work-order-media";
+
+function asRecord(raw: unknown): Record {
+ return typeof raw === "object" && raw !== null ? (raw as Record) : {};
+}
+
+function readString(record: Record, ...keys: string[]): string {
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "string") {
+ return value;
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+ }
+ return "";
+}
+
+function readNumber(record: Record, ...keys: string[]): number | null {
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === "string" && value.trim() !== "") {
+ const parsed = Number(value);
+ if (Number.isFinite(parsed)) {
+ return parsed;
+ }
+ }
+ }
+ return null;
+}
+
+function readBoolean(record: Record, ...keys: string[]): boolean {
+ for (const key of keys) {
+ const value = record[key];
+ if (typeof value === "boolean") {
+ return value;
+ }
+ }
+ return false;
+}
+
+function mapDocStatus(raw: unknown): CompletionDocStatus {
+ const numeric = typeof raw === "number" ? raw : Number(raw);
+ if (numeric === DOC_STATUS_API.Yes) return "Yes";
+ if (numeric === DOC_STATUS_API.No) return "No";
+ if (numeric === DOC_STATUS_API.NotNeeded) return "NN";
+ if (raw === "Yes" || raw === "No" || raw === "NN") return raw;
+ if (raw === "NotNeeded") return "NN";
+ return "";
+}
+
+function mapMediaCategory(raw: unknown): BoardMediaCategory {
+ const numeric = typeof raw === "number" ? raw : Number(raw);
+ if (numeric === BOARD_MEDIA_CATEGORY_API.Before) return "Before";
+ if (numeric === BOARD_MEDIA_CATEGORY_API.After) return "After";
+ if (numeric === BOARD_MEDIA_CATEGORY_API.Extra) return "Extra";
+ if (numeric === BOARD_MEDIA_CATEGORY_API.Completion) return "Completion";
+ return "Extra";
+}
+
+export function mapBoardDetailComment(raw: unknown): WorkOrderComment {
+ const item = asRecord(raw);
+ const id = readNumber(item, "id");
+ return {
+ id: id ?? (readString(item, "id") || `comment-${readString(item, "time")}`),
+ createdDate: readString(item, "time", "createdDate", "CreatedDate"),
+ commenttext: readString(item, "text", "commenttext", "Commenttext"),
+ commentType: readString(item, "commentType", "CommentType") || "internal",
+ userName: readString(item, "userName", "UserName", "authorName", "AuthorName"),
+ commenter: readString(item, "commenter", "Commenter") || null,
+ recordType: readString(item, "recordType", "RecordType") || null,
+ };
+}
+
+export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrderAuditEntry {
+ const item = asRecord(raw);
+ const time = readString(item, "time", "createdAt", "CreatedAt");
+ const typeRaw = readString(item, "type", "Type").toLowerCase();
+ const userName = readString(item, "userName", "UserName");
+
+ return {
+ id: `${time || "audit"}-${index}`,
+ fieldName: readString(item, "fieldName", "FieldName"),
+ oldValue: readString(item, "oldValue", "OldValue"),
+ newValue: readString(item, "newValue", "NewValue"),
+ action: readString(item, "action", "Action"),
+ createdAt: time,
+ userName: typeRaw === "system" ? "System" : userName,
+ };
+}
+
+export type UploadableMediaCategory = "Before" | "After" | "Extra";
+
+export function buildMediaFormData(file: File, category: UploadableMediaCategory): FormData {
+ const form = new FormData();
+ form.append("category", category);
+ form.append("file", file);
+ return form;
+}
+
+export function buildCompletionDocFormData(params: {
+ file: File;
+ workOrderVersion: string;
+ signOffName?: string;
+ signOffSignature?: string;
+}): FormData {
+ const form = new FormData();
+ form.append("file", params.file);
+ form.append("workOrderVersion", params.workOrderVersion);
+ if (params.signOffName) {
+ form.append("signOffName", params.signOffName);
+ }
+ if (params.signOffSignature) {
+ form.append("signOffSignature", params.signOffSignature);
+ }
+ return form;
+}
+
+export function mapCompletion(raw: unknown): WorkOrderBoardCompletion {
+ const item = asRecord(raw);
+ const templateRaw = item.template ?? item.Template;
+ const templateRecord = templateRaw != null ? asRecord(templateRaw) : null;
+ const signoffsRaw = item.dispatchSignoffs ?? item.DispatchSignoffs;
+ const signoffs = Array.isArray(signoffsRaw)
+ ? signoffsRaw.map((entry) => {
+ const s = asRecord(entry);
+ return {
+ dispatchId: readNumber(s, "dispatchId", "DispatchId"),
+ signoffType: readString(s, "signoffType", "SignoffType"),
+ name: readString(s, "name", "Name"),
+ signedAt: readString(s, "signedAt", "SignedAt"),
+ };
+ })
+ : [];
+
+ return {
+ docStatus: mapDocStatus(item.docStatus ?? item.DocStatus),
+ template: templateRecord
+ ? {
+ id: readNumber(templateRecord, "id", "Id") ?? 0,
+ name: readString(templateRecord, "name", "Name"),
+ serviceKey: readString(templateRecord, "serviceKey", "ServiceKey"),
+ workOrderType: readNumber(templateRecord, "workOrderType", "WorkOrderType"),
+ templateUrl: readString(templateRecord, "templateUrl", "TemplateUrl"),
+ isActive: readBoolean(templateRecord, "isActive", "IsActive"),
+ }
+ : null,
+ signOffName: readString(item, "signOffName", "SignOffName"),
+ signOffAttachment: readString(item, "signOffAttachment", "SignOffAttachment"),
+ signOffSignature: readString(item, "signOffSignature", "SignOffSignature"),
+ dispatchSignoffs: signoffs,
+ };
+}
+
+export function mapBoardDetailMediaItem(raw: unknown): WorkOrderBoardMediaItem {
+ const item = asRecord(raw);
+ const id = readNumber(item, "id");
+ const isLegacyFlag = readBoolean(item, "isLegacy", "IsLegacy");
+ return {
+ id: String(id ?? (readString(item, "id") || cryptoRandomId())),
+ category: mapMediaCategory(item.category ?? item.Category),
+ url: readString(item, "url", "Url"),
+ fileName: readString(item, "fileName", "FileName") || "file",
+ uploadedAt: readString(item, "uploadedAt", "UploadedAt"),
+ isLegacy: isLegacyFlag || (id != null && id <= 0),
+ };
+}
+
+function cryptoRandomId(): string {
+ return `media-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+}
+
+function classifyFileName(fileName: string, url: string): FileCategory {
+ const name = `${fileName} ${url}`.toLowerCase();
+ if (/\.(jpe?g|png|gif|webp|bmp)(\?|$)/i.test(name) || name.includes("image/")) {
+ return "image";
+ }
+ if (/\.(mp4|mov|webm|m4v)(\?|$)/i.test(name) || name.includes("video/")) {
+ return "video";
+ }
+ if (/\.pdf(\?|$)/i.test(name)) {
+ return "pdf";
+ }
+ if (/\.(docx?|xlsx?|pptx?)(\?|$)/i.test(name)) {
+ return "doc";
+ }
+ return "other";
+}
+
+function boardCategoryToMediaCategory(category: BoardMediaCategory): MediaCategory {
+ if (category === "Extra") return "Other";
+ if (category === "Completion") return "Report";
+ return "Photo";
+}
+
+/** Adapt board detail media items for existing MediaUploader / Extras UI. */
+export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): MediaFile[] {
+ return items.map((item) => ({
+ id: item.id,
+ name: item.fileName,
+ fileType: classifyFileName(item.fileName, item.url),
+ url: item.url,
+ category: boardCategoryToMediaCategory(item.category),
+ isLegacy: item.isLegacy || Number(item.id) <= 0,
+ }));
+}
+
+export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail {
+ const root = asRecord(raw);
+ const infoRaw = asRecord(root.info ?? root.Info ?? root);
+ const commentsRaw = root.comments ?? root.Comments;
+ const auditRaw = root.audit ?? root.Audit;
+ const mediaRaw = root.media ?? root.Media;
+ const completionRaw = root.completion ?? root.Completion;
+
+ // Normalize board-detail field aliases before the shared table-row mapper.
+ const normalizedInfo = {
+ ...infoRaw,
+ site: infoRaw.site ?? infoRaw.Site ?? infoRaw.siteCode ?? infoRaw.SiteCode,
+ tech: infoRaw.tech ?? infoRaw.Tech ?? infoRaw.techName ?? infoRaw.TechName,
+ status:
+ infoRaw.status ??
+ infoRaw.Status ??
+ infoRaw.lifecycleStatusLabel ??
+ infoRaw.LifecycleStatusLabel,
+ };
+
+ const comments = Array.isArray(commentsRaw) ? commentsRaw.map(mapBoardDetailComment) : [];
+ const audit = Array.isArray(auditRaw)
+ ? auditRaw.map((entry, index) => mapBoardDetailAuditEntry(entry, index))
+ : [];
+ const media = Array.isArray(mediaRaw) ? mediaRaw.map(mapBoardDetailMediaItem) : [];
+
+ return {
+ info: mapWorkOrderTableRow(normalizedInfo),
+ completion: mapCompletion(completionRaw ?? {}),
+ comments,
+ audit,
+ media,
+ };
+}
diff --git a/src/domain/work-orders/types/work-order-board-detail.ts b/src/domain/work-orders/types/work-order-board-detail.ts
new file mode 100644
index 00000000..590aee84
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-board-detail.ts
@@ -0,0 +1,167 @@
+import type { WorkOrderAuditEntry, WorkOrderComment } from "./work-order-detail";
+import type { CompletionDocStatus, WorkOrderTableRow } from "./work-order-table-row";
+
+/** API media.category: 1=Before, 2=After, 3=Extra, 4=Completion */
+export const BOARD_MEDIA_CATEGORY_API = {
+ Before: 1,
+ After: 2,
+ Extra: 3,
+ Completion: 4,
+} as const;
+
+export type BoardMediaCategoryApi =
+ (typeof BOARD_MEDIA_CATEGORY_API)[keyof typeof BOARD_MEDIA_CATEGORY_API];
+
+export type BoardMediaCategory = "Before" | "After" | "Extra" | "Completion";
+
+export interface WorkOrderBoardDetailInfoApi {
+ id: number;
+ woNumber?: string | null;
+ rescheduleCount?: number;
+ carriedOver?: number;
+ workOrderType?: number | null;
+ isPastDue?: boolean;
+ siteCode?: string | null;
+ locationName?: string | null;
+ pocName?: string | null;
+ pocPhone?: string | null;
+ pocNotes?: string | null;
+ lifecycleStatus?: number | null;
+ lifecycleStatusLabel?: string | null;
+ legacyStatus?: string | null;
+ dispatcherId?: string | null;
+ dispatcherName?: string | null;
+ initials?: string | null;
+ color?: string | null;
+ dueDate?: string | null;
+ scheduledDate?: string | null;
+ targetWeek?: string | null;
+ scheduleWeekOnly?: boolean | null;
+ dayGroup?: string | null;
+ vendorId?: number | null;
+ vendorName?: string | null;
+ techName?: string | null;
+ techPhone?: string | null;
+ apptDate?: string | null;
+ apptTime?: string | null;
+ pm?: string | null;
+ serviceNotes?: string | null;
+ extraServices?: string[] | null;
+ docStatus?: number | null;
+ flagColor?: string | null;
+ primaryDispatchId?: number | null;
+ rowVersion?: string | null;
+ dispatchRowVersion?: string | null;
+ description?: string | null;
+ trade?: string | null;
+ problem?: string | null;
+ subTrade?: string | null;
+ originalWeek?: string | null;
+ originalDate?: string | null;
+ completedDate?: string | null;
+ vendorNotes?: string | null;
+}
+
+export interface WorkOrderBoardCompletionTemplateApi {
+ id: number;
+ name?: string | null;
+ serviceKey?: string | null;
+ workOrderType?: number | null;
+ templateUrl?: string | null;
+ isActive?: boolean | null;
+}
+
+export interface WorkOrderBoardDispatchSignoffApi {
+ dispatchId?: number | null;
+ signoffType?: string | null;
+ name?: string | null;
+ signedAt?: string | null;
+}
+
+export interface WorkOrderBoardCompletionApi {
+ docStatus?: number | null;
+ template?: WorkOrderBoardCompletionTemplateApi | null;
+ signOffName?: string | null;
+ signOffAttachment?: string | null;
+ signOffSignature?: string | null;
+ dispatchSignoffs?: WorkOrderBoardDispatchSignoffApi[] | null;
+}
+
+export interface WorkOrderBoardDetailCommentApi {
+ id?: number | null;
+ authorId?: string | null;
+ authorName?: string | null;
+ text?: string | null;
+ time?: string | null;
+ documents?: unknown;
+}
+
+export interface WorkOrderBoardDetailAuditApi {
+ type?: string | null;
+ dispatcherId?: string | null;
+ action?: string | null;
+ fieldName?: string | null;
+ oldValue?: string | null;
+ newValue?: string | null;
+ time?: string | null;
+ dispatchId?: number | null;
+}
+
+export interface WorkOrderBoardDetailMediaApi {
+ id?: number | null;
+ category?: number | null;
+ url?: string | null;
+ fileName?: string | null;
+ uploadedAt?: string | null;
+ isLegacy?: boolean | null;
+}
+
+export interface WorkOrderBoardDetailApi {
+ info: WorkOrderBoardDetailInfoApi;
+ completion?: WorkOrderBoardCompletionApi | null;
+ comments?: WorkOrderBoardDetailCommentApi[] | null;
+ audit?: WorkOrderBoardDetailAuditApi[] | null;
+ media?: WorkOrderBoardDetailMediaApi[] | null;
+}
+
+export interface WorkOrderBoardCompletionTemplate {
+ id: number;
+ name: string;
+ serviceKey: string;
+ workOrderType: number | null;
+ templateUrl: string;
+ isActive: boolean;
+}
+
+export interface WorkOrderBoardDispatchSignoff {
+ dispatchId: number | null;
+ signoffType: string;
+ name: string;
+ signedAt: string;
+}
+
+export interface WorkOrderBoardCompletion {
+ docStatus: CompletionDocStatus;
+ template: WorkOrderBoardCompletionTemplate | null;
+ signOffName: string;
+ signOffAttachment: string;
+ signOffSignature: string;
+ dispatchSignoffs: WorkOrderBoardDispatchSignoff[];
+}
+
+export interface WorkOrderBoardMediaItem {
+ id: string;
+ category: BoardMediaCategory;
+ url: string;
+ fileName: string;
+ uploadedAt: string;
+ isLegacy: boolean;
+}
+
+export interface WorkOrderBoardDetail {
+ info: WorkOrderTableRow;
+ completion: WorkOrderBoardCompletion;
+ comments: WorkOrderComment[];
+ audit: WorkOrderAuditEntry[];
+ media: WorkOrderBoardMediaItem[];
+}
diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts
new file mode 100644
index 00000000..cf37765c
--- /dev/null
+++ b/src/domain/work-orders/types/work-order-board.ts
@@ -0,0 +1,8 @@
+/** Numeric board DocStatus enum aligned with SHOC board API. */
+export const DOC_STATUS_API = {
+ Yes: 1,
+ No: 2,
+ NotNeeded: 3,
+} as const;
+
+export type DocStatusApi = (typeof DOC_STATUS_API)[keyof typeof DOC_STATUS_API];
diff --git a/src/domain/work-orders/types/work-order-media.ts b/src/domain/work-orders/types/work-order-media.ts
index eb346814..68fd8faf 100644
--- a/src/domain/work-orders/types/work-order-media.ts
+++ b/src/domain/work-orders/types/work-order-media.ts
@@ -9,6 +9,8 @@ export interface MediaFile {
fileType: FileCategory;
url: string;
category: MediaCategory;
+ /** True when media is a legacy Before/After/Completion column (not deletable). */
+ isLegacy?: boolean;
}
export const MEDIA_CATEGORIES: MediaCategory[] = [
diff --git a/src/domain/work-orders/use-cases/use-work-order-detail.ts b/src/domain/work-orders/use-cases/use-work-order-detail.ts
index 3b7c1456..d4554ffe 100644
--- a/src/domain/work-orders/use-cases/use-work-order-detail.ts
+++ b/src/domain/work-orders/use-cases/use-work-order-detail.ts
@@ -9,6 +9,7 @@ import type {
VendorDropdownItem,
WorkOrderDetail,
} from "@/domain/work-orders/types/work-order";
+import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import { queryKeys } from "@/infra/query-key/query-key";
import { requireQueryParam } from "@/lib/query/require-query-param";
@@ -22,6 +23,17 @@ export function useWorkOrderDetail(
});
}
+export function useWorkOrderBoardDetail(
+ id: string | number | undefined,
+ enabled = true,
+): UseQueryResult {
+ return useQuery({
+ queryKey: queryKeys.workOrders.boardDetail(id ?? ""),
+ queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")),
+ enabled: enabled && id !== undefined && id !== "",
+ });
+}
+
export function useWorkOrderLocations(): UseQueryResult {
return useQuery({
queryKey: queryKeys.locations.dropdown(),
diff --git a/src/domain/work-orders/use-cases/use-work-order-mutations.ts b/src/domain/work-orders/use-cases/use-work-order-mutations.ts
index 942a048b..cad90501 100644
--- a/src/domain/work-orders/use-cases/use-work-order-mutations.ts
+++ b/src/domain/work-orders/use-cases/use-work-order-mutations.ts
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/r
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
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 { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
import type {
DispatchComment,
@@ -13,12 +14,26 @@ import type {
WorkOrderFormValues,
WorkOrderVendorUpdatePayload,
} from "@/domain/work-orders/types/work-order";
+import type { WorkOrderBoardMediaItem } from "@/domain/work-orders/types/work-order-board-detail";
import { queryKeys } from "@/infra/query-key/query-key";
function invalidateWorkOrders(queryClient: ReturnType) {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
}
+function invalidateBoardDetail(
+ queryClient: ReturnType,
+ workOrderId: string | number,
+) {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.workOrders.boardDetail(workOrderId),
+ });
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.workOrders.boardMedia(workOrderId),
+ });
+ void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
+}
+
interface AddWorkOrderCommentInput {
text: string;
commentType: "customer" | "internal";
@@ -52,6 +67,48 @@ interface UpdateWorkOrderVendorInput {
payload: WorkOrderVendorUpdatePayload;
}
+export interface UploadWorkOrderMediaInput {
+ file: File;
+ category: UploadableMediaCategory;
+}
+
+export function useUploadWorkOrderMedia(
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ file, category }: UploadWorkOrderMediaInput) =>
+ workOrdersApi.uploadMedia(workOrderId, file, category),
+ onSuccess: () => {
+ invalidateBoardDetail(queryClient, workOrderId);
+ invalidateWorkOrders(queryClient);
+ toast.success("File uploaded");
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to upload file");
+ },
+ });
+}
+
+export function useDeleteWorkOrderMedia(
+ workOrderId: string | number,
+): UseMutationResult {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (mediaId: string | number) => workOrdersApi.deleteMedia(workOrderId, mediaId),
+ onSuccess: () => {
+ invalidateBoardDetail(queryClient, workOrderId);
+ invalidateWorkOrders(queryClient);
+ toast.success("File removed");
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to remove file");
+ },
+ });
+}
+
export function useCreateWorkOrder(): UseMutationResult<
WorkOrderDetail,
Error,
diff --git a/src/domain/work-orders/utils/completion-doc-service-gate.ts b/src/domain/work-orders/utils/completion-doc-service-gate.ts
new file mode 100644
index 00000000..a92c0baf
--- /dev/null
+++ b/src/domain/work-orders/utils/completion-doc-service-gate.ts
@@ -0,0 +1,4 @@
+/** CompDoc is gated on a selected primary service (`pm`). */
+export function hasCompletionDocService(pm: string | null | undefined): boolean {
+ return Boolean(pm?.trim());
+}
diff --git a/src/domain/work-orders/utils/completion-doc-service-title.ts b/src/domain/work-orders/utils/completion-doc-service-title.ts
index 6df69e70..decf73f9 100644
--- a/src/domain/work-orders/utils/completion-doc-service-title.ts
+++ b/src/domain/work-orders/utils/completion-doc-service-title.ts
@@ -17,15 +17,12 @@ const SERVICE_PHRASE: Record = {
"Assess Window Leaks": "Window Leak Assessment",
};
-const FREQ_CYCLE = ["13 Week", "26 Week", "52 Week"];
-
-function hashNum(s: string, mod: number): number {
- let h = 0;
- for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
- return h % mod;
-}
-
-export function serviceTitle(row: Pick): string {
- const phrase = SERVICE_PHRASE[row.pm] ?? row.pm;
- return `Amer-Building ${phrase} — ${FREQ_CYCLE[hashNum(row.woNumber, FREQ_CYCLE.length)]}`;
+/**
+ * Title derived only from the selected service (`pm`).
+ * Never fabricates maintenance-cycle metadata from the work-order number.
+ */
+export function serviceTitle(row: Pick): string {
+ const service = row.pm.trim();
+ const phrase = SERVICE_PHRASE[service] ?? service;
+ return `Amer-Building ${phrase}`;
}
diff --git a/src/domain/work-orders/utils/work-order-media-limits.ts b/src/domain/work-orders/utils/work-order-media-limits.ts
new file mode 100644
index 00000000..4c513763
--- /dev/null
+++ b/src/domain/work-orders/utils/work-order-media-limits.ts
@@ -0,0 +1,27 @@
+/** Backend RequestSizeLimit for media and completion-doc uploads. */
+export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
+
+export function isWorkOrderFileTooLarge(
+ file: File,
+ maxBytes = MAX_WORK_ORDER_MEDIA_BYTES,
+): boolean {
+ return file.size > maxBytes;
+}
+
+export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
+ return `${Math.round(maxBytes / (1024 * 1024))} MB`;
+}
+
+/** Backend IsReadOnly: Canceled or Completed. */
+export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
+ return status === "Canceled" || status === "Completed";
+}
+
+export function canDeleteWorkOrderMedia(media: {
+ id: string | number;
+ isLegacy?: boolean;
+}): boolean {
+ if (media.isLegacy) return false;
+ const numericId = typeof media.id === "number" ? media.id : Number(media.id);
+ return Number.isFinite(numericId) && numericId > 0;
+}
diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts
index 4548791b..02d4a74a 100644
--- a/src/infra/query-key/query-key.ts
+++ b/src/infra/query-key/query-key.ts
@@ -12,6 +12,8 @@ export const queryKeys = {
byLocation: (locationId: string | number, search = "") =>
[...queryKeys.workOrders.all, "byLocation", locationId, search] as const,
detail: (id: string | number) => [...queryKeys.workOrders.all, "detail", id] as const,
+ boardDetail: (id: string | number) => [...queryKeys.workOrders.all, "boardDetail", id] as const,
+ boardMedia: (id: string | number) => [...queryKeys.workOrders.all, "boardMedia", id] as const,
dispatch: (id: string | number) => [...queryKeys.workOrders.all, "dispatch", id] as const,
vendors: (trade?: string, siteZip?: string) =>
[...queryKeys.workOrders.all, "vendors", trade ?? "", siteZip ?? ""] as const,
diff --git a/src/test/app/(protected)/workorders/comp-doc-cell-service-gate.test.tsx b/src/test/app/(protected)/workorders/comp-doc-cell-service-gate.test.tsx
new file mode 100644
index 00000000..f5bf9847
--- /dev/null
+++ b/src/test/app/(protected)/workorders/comp-doc-cell-service-gate.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { CompDocCell } from "@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell";
+
+describe("CompDocCell service gate", () => {
+ it("shows a disabled placeholder when no service is selected", () => {
+ const onOpen = vi.fn();
+ render();
+
+ expect(screen.getByText("Select service")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /draft|view doc|n\/n/i })).not.toBeInTheDocument();
+ });
+
+ it("opens CompDoc when a service is selected", () => {
+ const onOpen = vi.fn();
+ render(
+ ,
+ );
+
+ screen.getByRole("button", { name: /draft/i }).click();
+ expect(onOpen).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
index 4b18debc..875491a1 100644
--- a/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
+++ b/src/test/app/(protected)/workorders/comp-doc-dialog-completion-contract.test.tsx
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
@@ -66,21 +67,26 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
function renderDialog(row: WorkOrderTableRow) {
// Mirrors board wiring: only completedDate / docStatus — never scheduledOn or lifecycle.
const patches: Array> = [];
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
render(
- {
- onSave(data, status);
- patches.push({ docStatus: status });
- }}
- onDateCompletedChange={(date) => {
- onDateCompletedChange(date);
- patches.push({ completedDate: date });
- }}
- />,
+
+ {
+ onSave(data, status);
+ patches.push({ docStatus: status });
+ }}
+ onDateCompletedChange={(date) => {
+ onDateCompletedChange(date);
+ patches.push({ completedDate: date });
+ }}
+ />
+ ,
);
return { patches };
diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
new file mode 100644
index 00000000..fe6318ed
--- /dev/null
+++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx
@@ -0,0 +1,99 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+
+const uploadMutate = vi.fn();
+const deleteMutate = vi.fn();
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
+ useWorkOrderBoardDetail: () => ({
+ data: {
+ info: { status: "Scheduled" },
+ media: [
+ {
+ id: "11",
+ fileName: "before.jpg",
+ url: "https://example.com/before.jpg",
+ category: "Before",
+ isLegacy: false,
+ },
+ ],
+ comments: [],
+ audit: [],
+ completion: {},
+ },
+ isLoading: false,
+ }),
+}));
+
+vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
+ useUploadWorkOrderMedia: () => ({ mutate: uploadMutate, isPending: false }),
+ useDeleteWorkOrderMedia: () => ({ mutate: deleteMutate, isPending: false }),
+}));
+
+function baseRow(): WorkOrderTableRow {
+ return {
+ id: 7,
+ woNumber: "WO-7",
+ site: "SEA1",
+ type: "Emergency",
+ dispatcherId: "u1",
+ dispatcherName: "Dispatcher",
+ scheduledOn: "2026-06-01",
+ completedDate: "",
+ targetWeek: "",
+ dayGroup: "",
+ dayLabel: "",
+ location: "Seattle",
+ locationId: "loc-1",
+ pm: "",
+ company: "",
+ tech: "",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Scheduled",
+ docStatus: "",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ dueDate: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ isPastDue: false,
+ scheduleWeekOnly: false,
+ mediaCount: 1,
+ title: "Emergency",
+ priority: "Sev 1",
+ };
+}
+
+describe("MediaUploadModal API wiring", () => {
+ beforeEach(() => {
+ uploadMutate.mockReset();
+ deleteMutate.mockReset();
+ });
+
+ it("renders persisted media from board detail (refresh-safe)", async () => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("before.jpg")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
+ expect(screen.getByLabelText(/category/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
index 94ccb6d9..35782571 100644
--- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
+++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx
@@ -227,7 +227,13 @@ describe("CompDocCell slice affordances", () => {
it("shows View Doc when onOpen is provided", () => {
renderWithProviders(
- ,
+ ,
{ withAuth: false },
);
diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
new file mode 100644
index 00000000..441dee03
--- /dev/null
+++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it } from "vitest";
+import {
+ boardMediaItemsToMediaFiles,
+ buildCompletionDocFormData,
+ buildMediaFormData,
+ mapBoardDetailMediaItem,
+ mapWorkOrderBoardDetail,
+} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
+
+const SAMPLE_DETAIL = {
+ info: {
+ id: 123,
+ woNumber: "00000000123",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ workOrderType: 2,
+ isPastDue: false,
+ siteCode: "SITE-01",
+ locationName: "Main Warehouse",
+ pocName: "John Doe",
+ pocPhone: "555-0100",
+ pocNotes: null,
+ lifecycleStatus: 3,
+ lifecycleStatusLabel: "Scheduled",
+ legacyStatus: null,
+ dispatcherId: "user-guid",
+ dispatcherName: "Jane Dispatcher",
+ initials: "JD",
+ color: "#3B82F6",
+ dueDate: "2026-07-20T00:00:00Z",
+ scheduledDate: "2026-07-16T00:00:00Z",
+ targetWeek: "2026-07-13",
+ scheduleWeekOnly: false,
+ dayGroup: "Wed",
+ vendorId: 45,
+ vendorName: "ACME HVAC",
+ techName: "Mike Tech",
+ techPhone: "555-0200",
+ apptDate: "2026-07-16T14:00:00Z",
+ apptTime: "10:00 AM",
+ pm: "HVAC PM",
+ serviceNotes: "Check filters",
+ extraServices: ["Filter Change"],
+ docStatus: 2,
+ flagColor: "#EF4444",
+ primaryDispatchId: 789,
+ rowVersion: "AQAAAAAAAQE=",
+ dispatchRowVersion: "AQAAAAAAAQE=",
+ },
+ completion: {
+ docStatus: 2,
+ template: {
+ id: 1,
+ name: "HVAC PM Completion",
+ serviceKey: "HVAC PM",
+ workOrderType: 2,
+ templateUrl: "https://example.com/hvac.pdf",
+ isActive: true,
+ },
+ signOffName: null,
+ signOffAttachment: null,
+ signOffSignature: null,
+ dispatchSignoffs: [
+ {
+ dispatchId: 789,
+ signoffType: "Customer",
+ name: "Site Contact",
+ signedAt: "2026-07-16T15:30:00.0000000Z",
+ },
+ ],
+ },
+ comments: [
+ {
+ id: 1,
+ authorId: "user-1",
+ text: "Called vendor",
+ time: "2026-06-01T12:00:00.0000000Z",
+ documents: null,
+ },
+ ],
+ audit: [
+ {
+ type: "system",
+ dispatcherId: null,
+ action: "WeekRolled",
+ fieldName: "CarriedOver",
+ oldValue: "0",
+ newValue: "1",
+ time: "2026-07-16T10:00:00.0000000Z",
+ dispatchId: null,
+ },
+ ],
+ media: [
+ {
+ id: 10,
+ category: 1,
+ url: "https://cdn.example.com/before.jpg",
+ fileName: "before.jpg",
+ uploadedAt: "2026-07-15T18:00:00.0000000Z",
+ isLegacy: false,
+ },
+ {
+ id: 11,
+ category: 3,
+ url: "https://cdn.example.com/extra.pdf",
+ fileName: "extra.pdf",
+ uploadedAt: "2026-07-15T19:00:00.0000000Z",
+ isLegacy: false,
+ },
+ ],
+};
+
+describe("mapWorkOrderBoardDetail", () => {
+ it("maps nested info/completion/comments/audit/media", () => {
+ const detail = mapWorkOrderBoardDetail(SAMPLE_DETAIL);
+
+ expect(detail.info.id).toBe(123);
+ expect(detail.info.woNumber).toBe("00000000123");
+ expect(detail.info.location).toBe("Main Warehouse");
+ expect(detail.info.status).toBe("Scheduled");
+ expect(detail.info.docStatus).toBe("No");
+ expect(detail.info.company).toBe("ACME HVAC");
+ expect(detail.info.tech).toBe("Mike Tech");
+ expect(detail.info.pm).toBe("HVAC PM");
+
+ expect(detail.completion.docStatus).toBe("No");
+ expect(detail.completion.template?.name).toBe("HVAC PM Completion");
+ expect(detail.completion.dispatchSignoffs).toHaveLength(1);
+ expect(detail.completion.dispatchSignoffs[0]?.signoffType).toBe("Customer");
+
+ expect(detail.comments).toHaveLength(1);
+ expect(detail.comments[0]?.commenttext).toBe("Called vendor");
+ expect(detail.comments[0]?.createdDate).toBe("2026-06-01T12:00:00.0000000Z");
+ expect(detail.comments[0]?.userName).toBe("");
+
+ expect(detail.audit).toHaveLength(1);
+ expect(detail.audit[0]?.action).toBe("WeekRolled");
+ expect(detail.audit[0]?.userName).toBe("System");
+ expect(detail.audit[0]?.createdAt).toBe("2026-07-16T10:00:00.0000000Z");
+
+ expect(detail.media).toHaveLength(2);
+ expect(detail.media[0]?.category).toBe("Before");
+ expect(detail.media[1]?.category).toBe("Extra");
+ });
+
+ it("maps authorName to userName", () => {
+ const detail = mapWorkOrderBoardDetail({
+ ...SAMPLE_DETAIL,
+ comments: [
+ {
+ id: 2,
+ authorId: "guid-abc",
+ authorName: "Jane Dispatcher",
+ text: "On site",
+ time: "2026-06-02T12:00:00.0000000Z",
+ },
+ ],
+ });
+
+ expect(detail.comments[0]?.userName).toBe("Jane Dispatcher");
+ expect(detail.comments[0]?.commenttext).toBe("On site");
+ });
+
+ it("converts board media to MediaFile for UI", () => {
+ const detail = mapWorkOrderBoardDetail(SAMPLE_DETAIL);
+ const files = boardMediaItemsToMediaFiles(detail.media);
+ expect(files[0]).toMatchObject({
+ id: "10",
+ name: "before.jpg",
+ url: "https://cdn.example.com/before.jpg",
+ category: "Photo",
+ fileType: "image",
+ isLegacy: false,
+ });
+ expect(files[1]?.category).toBe("Other");
+ expect(files[1]?.fileType).toBe("pdf");
+ });
+
+ it("maps numeric category and marks negative ids as legacy", () => {
+ const legacy = mapBoardDetailMediaItem({
+ id: -1,
+ category: 1,
+ url: "https://cdn.example.com/before.jpg",
+ fileName: "before.jpg",
+ });
+ expect(legacy.id).toBe("-1");
+ expect(legacy.category).toBe("Before");
+ expect(legacy.isLegacy).toBe(true);
+ });
+
+ it("builds media and completion FormData fields", () => {
+ const mediaFile = new File(["img"], "a.jpg", { type: "image/jpeg" });
+ const mediaForm = buildMediaFormData(mediaFile, "After");
+ expect(mediaForm.get("category")).toBe("After");
+ expect(mediaForm.get("file")).toBeInstanceOf(File);
+
+ const pdf = new File(["%PDF"], "doc.pdf", { type: "application/pdf" });
+ const completionForm = buildCompletionDocFormData({
+ file: pdf,
+ workOrderVersion: "VER==",
+ signOffName: "Jane",
+ });
+ expect(completionForm.get("workOrderVersion")).toBe("VER==");
+ expect(completionForm.get("signOffName")).toBe("Jane");
+ expect(completionForm.get("file")).toBeInstanceOf(File);
+ });
+});
diff --git a/src/test/domain/work-orders/utils/completion-doc-service-title.test.ts b/src/test/domain/work-orders/utils/completion-doc-service-title.test.ts
new file mode 100644
index 00000000..380a28e4
--- /dev/null
+++ b/src/test/domain/work-orders/utils/completion-doc-service-title.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
+import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
+
+describe("completion doc service gate", () => {
+ it("rejects empty or whitespace-only pm", () => {
+ expect(hasCompletionDocService("")).toBe(false);
+ expect(hasCompletionDocService(" ")).toBe(false);
+ expect(hasCompletionDocService(null)).toBe(false);
+ expect(hasCompletionDocService(undefined)).toBe(false);
+ });
+
+ it("accepts a selected service", () => {
+ expect(hasCompletionDocService("HVAC Inspection")).toBe(true);
+ });
+});
+
+describe("completion doc service title", () => {
+ it("uses the selected service without fabricating a frequency cycle", () => {
+ expect(serviceTitle({ pm: "HVAC Inspection" })).toBe("Amer-Building HVAC Inspection");
+ expect(serviceTitle({ pm: "HVAC Inspection" })).not.toMatch(/Week/);
+ });
+
+ it("maps known service phrases without hashing the work-order number", () => {
+ expect(serviceTitle({ pm: "Scrubber Pit" })).toBe("Amer-Building Scrubber Pit Augering");
+ });
+});
diff --git a/src/test/domain/work-orders/utils/work-order-media-limits.test.ts b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts
new file mode 100644
index 00000000..932e74e4
--- /dev/null
+++ b/src/test/domain/work-orders/utils/work-order-media-limits.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import {
+ canDeleteWorkOrderMedia,
+ isWorkOrderDocumentsReadOnly,
+ isWorkOrderFileTooLarge,
+ MAX_WORK_ORDER_MEDIA_BYTES,
+} from "@/domain/work-orders/utils/work-order-media-limits";
+
+describe("work-order-media-limits", () => {
+ it("treats Canceled and Completed as read-only", () => {
+ expect(isWorkOrderDocumentsReadOnly("Canceled")).toBe(true);
+ expect(isWorkOrderDocumentsReadOnly("Completed")).toBe(true);
+ expect(isWorkOrderDocumentsReadOnly("Scheduled")).toBe(false);
+ });
+
+ it("allows delete only for non-legacy positive ids", () => {
+ expect(canDeleteWorkOrderMedia({ id: "12", isLegacy: false })).toBe(true);
+ expect(canDeleteWorkOrderMedia({ id: -1, isLegacy: true })).toBe(false);
+ expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false);
+ });
+
+ it("flags files over 30 MB", () => {
+ expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
+ expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
+ });
+});