diff --git a/src/api/api-error.ts b/src/api/api-error.ts index 6746359a..d9dabc83 100644 --- a/src/api/api-error.ts +++ b/src/api/api-error.ts @@ -1,5 +1,15 @@ import { isHTTPError, isNetworkError, isTimeoutError } from "ky"; +export class ApiError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + export function mapHttpStatusToMessage(status: number, data?: unknown): string { if (status === 401) { return "You are not authorized to access this page."; diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 88b16228..a22ef333 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -28,6 +28,13 @@ export const API_PATHS = { changeStatus: "WorkOrder/ChangeStatus", boardDetail: (id: string | number) => `workorders/${id}/detail`, boardPatch: (id: string | number) => `workorders/${id}/board`, + comments: (id: string | number) => `workorders/${id}/comments`, + commentItem: (workOrderId: string | number, commentId: string | number) => + `workorders/${workOrderId}/comments/${commentId}`, + media: (id: string | number) => `workorders/${id}/media`, + mediaItem: (id: string | number, mediaId: string | number) => + `workorders/${id}/media/${mediaId}`, + completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, }, pmSchedule: { getList: "PMSchedule/GetList", diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx index a1ed784d..3a38f2e3 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-details-section.tsx @@ -7,6 +7,8 @@ import { CompDocEditableRow } from "./comp-doc-editable-row"; type CompDocDetailsSectionProps = { row: WorkOrderTableRow; site: CompDocSiteInfo; + editAddress: string; + onAddressChange: (value: string) => void; editDateCompleted: string; onDateCompletedChange: (value: string) => void; amazonRep: string; @@ -17,6 +19,8 @@ type CompDocDetailsSectionProps = { export function CompDocDetailsSection({ row, site, + editAddress, + onAddressChange, editDateCompleted, onDateCompletedChange, amazonRep, @@ -44,7 +48,13 @@ export function CompDocDetailsSection({ label="Site Code" value={`${row.site} · Amazon.com Services LLC`} /> - + void; editDateCompleted: string; amazonRep: string; seaRep: string; @@ -24,6 +26,8 @@ export function CompDocSidebar({ docState, generatedAt, notes, + editAddress, + onAddressChange, editDateCompleted, amazonRep, seaRep, @@ -41,6 +45,8 @@ export function CompDocSidebar({ void; @@ -18,6 +21,7 @@ type CompDocCellProps = { export function CompDocCell({ status, woType, + pm = "", mediaCount = 0, onOpen, onOpenMedia, @@ -43,6 +47,20 @@ export function CompDocCell({ return null; } + if (!hasCompletionDocService(pm)) { + return ( + + + Select service + + ); + } + const generated = status === "Yes"; const notRequired = status === "NN"; const color = generated ? "var(--color-success)" : "var(--color-text-muted)"; diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx index db611f97..388f583b 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-row.tsx @@ -304,6 +304,7 @@ export function WoTableRow({ handlers.onOpenCompDoc!(row) : undefined} onOpenMedia={() => handlers.onOpenMedia(row)} diff --git a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx index 779bdb28..c0ea3e58 100644 --- a/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx +++ b/src/app/(protected)/workorders/_components/media/media-upload-modal.tsx @@ -1,32 +1,53 @@ -import { type MouseEvent } from "react"; -import { Dialog, DialogContent, DialogTitle } from "@mui/material"; +import { type MouseEvent, useMemo } from "react"; +import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material"; import { Image as ImageIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import { LoadingRegion } from "@/components/ui/loading-region"; import { Text } from "@/components/ui/text"; -import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; +import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail"; +import { + useDeleteWorkOrderMedia, + useUploadWorkOrderMedia, +} from "@/domain/work-orders/use-cases/use-work-order-mutations"; +import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits"; +import { guardDialogClose } from "@/lib/dialog-guard"; import { MediaUploader } from "./media-uploader"; type MediaUploadModalProps = { row: WorkOrderTableRow; open: boolean; onOpenChange: (open: boolean) => void; - uploads: MediaFile[]; - onChange: (next: MediaFile[]) => void; }; -export function MediaUploadModal({ - row, - open, - onOpenChange, - uploads, - onChange, -}: MediaUploadModalProps) { +export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) { + const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open); + const uploadMutation = useUploadWorkOrderMedia(row.id); + const deleteMutation = useDeleteWorkOrderMedia(row.id); + const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status); + + const uploads = useMemo( + () => + boardMediaItemsToMediaFiles( + (detail?.media ?? []).filter( + (item) => + item.category === "Before" || + item.category === "After" || + item.category === "Completion", + ), + ), + [detail?.media], + ); + + const pending = uploadMutation.isPending || deleteMutation.isPending; + const handleClose = guardDialogClose(pending, () => onOpenChange(false)); + return ( onOpenChange(false)} + onClose={handleClose} maxWidth={false} slotProps={{ ...dialogBlurBackdropSlotProps, @@ -49,7 +70,27 @@ export function MediaUploadModal({
- + {isLoading && !detail ? ( + + + + + + + + ) : ( + { + for (const file of files) { + uploadMutation.mutate({ file, category }); + } + }} + onRemove={(id) => deleteMutation.mutate(id)} + readOnly={isReadOnly} + pending={pending} + /> + )}
@@ -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.name} + ) : file.fileType === "video" ? ( + <> +
+
+
+ {file.name} +
+
+
+ ); +} 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.name} - ) : u.fileType === "video" ? ( - <> -
-
-
- {u.name} -
-
-
- ); - })} -
+ {!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); + }); +});