fix(work-orders): address SH-116 CompDoc review blockers

Gate CompDoc on selected service, use real pm titles only,
make Address editable via location update, and wire media
to the board API with categories instead of blob state.
This commit is contained in:
Arthur Bassi 2026-07-30 09:04:03 -03:00
parent 2d64c34898
commit 3004d039aa
34 changed files with 1637 additions and 175 deletions

View file

@ -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.";

View file

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

View file

@ -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`}
/>
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
<CompDocEditableRow
label="Address"
value={editAddress}
onChange={onAddressChange}
placeholder={site.fullAddress !== "—" ? site.fullAddress : "Street address"}
disabled={isGenerated}
/>
<CompDocEditableRow
label="Date Work Completed"
value={editDateCompleted}

View file

@ -33,6 +33,8 @@ export function CompDocDialog({
setNotes,
docState,
generatedAt,
editAddress,
setEditAddress,
editDateCompleted,
setEditDateCompleted,
amazonRep,
@ -99,6 +101,8 @@ export function CompDocDialog({
docState={docState}
generatedAt={generatedAt}
notes={notes}
editAddress={editAddress}
onAddressChange={setEditAddress}
editDateCompleted={editDateCompleted}
amazonRep={amazonRep}
seaRep={seaRep}

View file

@ -10,6 +10,8 @@ type CompDocSidebarProps = {
docState: DocState;
generatedAt: string;
notes: string;
editAddress: string;
onAddressChange: (value: string) => 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({
<CompDocDetailsSection
row={row}
site={site}
editAddress={editAddress}
onAddressChange={onAddressChange}
editDateCompleted={editDateCompleted}
onDateCompletedChange={onDateCompletedChange}
amazonRep={amazonRep}

View file

@ -1,6 +1,7 @@
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
@ -9,6 +10,8 @@ function isMediaWO(type: WOType | ""): boolean {
type CompDocCellProps = {
status: CompletionDocStatus;
woType: WOType | "";
/** Primary service (`pm`). Required before CompDoc can open. */
pm?: string;
mediaCount?: number;
/** Omit until completion-doc slice mounts CompDocDialog. */
onOpen?: () => 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 (
<span
title="Select a service before opening the completion document"
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold"
style={{ color: "var(--color-text-muted)", opacity: 0.65 }}
aria-disabled="true"
>
<FileText size={15} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
Select service
</span>
);
}
const generated = status === "Yes";
const notRequired = status === "NN";
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";

View file

@ -304,6 +304,7 @@ export function WoTableRow({
<CompDocCell
status={row.docStatus}
woType={row.type}
pm={row.pm}
mediaCount={row.mediaCount}
onOpen={handlers.onOpenCompDoc ? () => handlers.onOpenCompDoc!(row) : undefined}
onOpenMedia={() => handlers.onOpenMedia(row)}

View file

@ -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 (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -49,7 +70,27 @@ export function MediaUploadModal({
</Text>
<div className="mt-4">
<MediaUploader uploads={uploads} onChange={onChange} />
{isLoading && !detail ? (
<LoadingRegion label="Loading media">
<Stack spacing={1.5}>
<Skeleton variant="rounded" height={100} animation="wave" />
<Skeleton variant="rounded" height={40} animation="wave" />
<Skeleton variant="rounded" height={40} animation="wave" />
</Stack>
</LoadingRegion>
) : (
<MediaUploader
uploads={uploads}
onUpload={(files, category) => {
for (const file of files) {
uploadMutation.mutate({ file, category });
}
}}
onRemove={(id) => deleteMutation.mutate(id)}
readOnly={isReadOnly}
pending={pending}
/>
)}
</div>
<div className="mt-2 flex justify-end">
@ -57,6 +98,7 @@ export function MediaUploadModal({
variant="outline"
size="default"
className="text-[12px]"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Close

View file

@ -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<HTMLInputElement | null>;
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 && (
<div className="mb-2 flex items-center gap-2">
<label
className="text-[11px]"
style={{ color: "var(--color-text-muted)" }}
htmlFor="media-upload-category"
>
Category
</label>
<select
id="media-upload-category"
value={category}
disabled={pending}
onChange={(event) => onCategoryChange(event.target.value as UploadableMediaCategory)}
className="rounded border px-2 py-1 text-[11px]"
style={{
borderColor: "var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
>
{PHOTO_CATEGORIES.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</div>
)}
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
onFilesSelected(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{pending ? "Uploading…" : "Drag files here or click to browse"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
hidden
disabled={pending}
onChange={(e) => {
onFilesSelected(e.target.files);
e.target.value = "";
}}
/>
</>
);
}

View file

@ -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<FileCategory, typeof ImageIcon> = {
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 (
<div
className="overflow-hidden rounded-lg border"
style={{ borderColor: "var(--color-border)" }}
>
<div
className="relative"
style={{
aspectRatio: "1 / 1",
background: "var(--color-bg-muted)",
}}
>
{file.fileType === "image" ? (
<img
src={file.url}
alt={file.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : file.fileType === "video" ? (
<>
<video src={file.url} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
<span className="absolute inset-0 flex items-center justify-center">
<Play size={18} style={{ color: "#fff" }} />
</span>
</>
) : (
<span className="absolute inset-0 flex items-center justify-center">
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
</span>
)}
{showDelete && (
<button
type="button"
onClick={() => onRemove(file.id)}
title="Remove"
disabled={pending}
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
)}
</div>
<div className="px-1 py-1">
<div className="truncate" style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
{file.name}
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="mt-3 grid grid-cols-3 gap-2">
{uploads.map((file) => (
<MediaUploaderGridItem
key={file.id}
file={file}
readOnly={readOnly}
pending={pending}
onRemove={onRemove}
/>
))}
</div>
);
}

View file

@ -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<FileCategory, typeof ImageIcon> = {
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<HTMLInputElement>(null);
const [category, setCategory] = useState<UploadableMediaCategory>("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 (
<div>
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
Drag files here or click to browse
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max 50MB per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
hidden
onChange={(e) => addFiles(e.target.files)}
/>
{uploads.length > 0 && (
<div className="mt-3 grid grid-cols-3 gap-2">
{uploads.map((u) => {
const TypeIcon = FILETYPE_ICON[u.fileType];
return (
<div
key={u.id}
className="overflow-hidden rounded-lg border"
style={{ borderColor: "var(--color-border)" }}
>
<div
className="relative"
style={{
aspectRatio: "1 / 1",
background: "var(--color-bg-muted)",
}}
>
{u.fileType === "image" ? (
<img
src={u.url}
alt={u.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : u.fileType === "video" ? (
<>
<video
src={u.url}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
<span className="absolute inset-0 flex items-center justify-center">
<Play size={18} style={{ color: "#fff" }} />
</span>
</>
) : (
<span className="absolute inset-0 flex items-center justify-center">
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
</span>
)}
<button
type="button"
onClick={() => remove(u.id)}
title="Remove"
className="absolute flex items-center justify-center transition-opacity hover:opacity-80"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
</div>
<div className="px-1 py-1">
<div
className="truncate"
style={{ fontSize: 10, color: "var(--color-text-muted)" }}
>
{u.name}
</div>
</div>
</div>
);
})}
</div>
{!readOnly && (
<MediaUploaderDropzone
inputRef={inputRef}
category={category}
onCategoryChange={setCategory}
showCategoryPicker={Boolean(onUpload)}
pending={pending}
onFilesSelected={addFiles}
/>
)}
<MediaUploaderGrid
uploads={uploads}
readOnly={readOnly}
pending={pending}
onRemove={remove}
/>
</div>
);
}

View file

@ -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<DocState>("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<CompDocData>, status: CompDocSaveStatus) {
onSave({ notes, generatedAt, ...extra }, status);
}
@ -79,6 +112,26 @@ export function useCompDocDialog({
}
}
async function syncAddressIfChanged(): Promise<void> {
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,

View file

@ -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<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
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 });
}}
/>
)}
</Box>

View file

@ -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<unknown> {
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<string, unknown>) : {};
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<WorkOrderComment[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.comments(workOrderId));
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? items.map(mapBoardDetailComment) : [];
},
createComment: async (workOrderId: string | number, text: string): Promise<WorkOrderComment> => {
const data = await apiPost<unknown>(API_PATHS.workOrder.comments(workOrderId), { text });
return mapBoardDetailComment(handleApiResponse(data));
},
updateComment: async (
workOrderId: string | number,
commentId: string | number,
text: string,
): Promise<WorkOrderComment> => {
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<WorkOrderComment> => {
return workOrderBoardDocumentsApi.createComment(workOrderId, text);
},
listMedia: async (workOrderId: string | number): Promise<WorkOrderBoardMediaItem[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.media(workOrderId));
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? items.map(mapBoardDetailMediaItem) : [];
},
uploadMedia: async (
workOrderId: string | number,
file: File,
category: UploadableMediaCategory,
): Promise<WorkOrderBoardMediaItem> => {
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<void> => {
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<WorkOrderBoardCompletion> => {
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);
},
};

View file

@ -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<void> {
}
export const workOrdersApi = {
...workOrderBoardDocumentsApi,
getList: async (params: WorkOrdersListParams = {}): Promise<WorkOrdersListResult> => {
if (isWoTableMockEnabled()) {
await mockNetworkDelay();
@ -182,6 +187,11 @@ export const workOrdersApi = {
return mapWorkOrderDetail(handleApiResponse(data));
},
getBoardDetail: async (id: string | number): Promise<WorkOrderBoardDetail> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.boardDetail(id));
return mapWorkOrderBoardDetail(handleApiResponse(data));
},
create: async (
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
): Promise<WorkOrderDetail> => {

View file

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

View file

@ -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<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readString(record: Record<string, unknown>, ...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<string, unknown>, ...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<string, unknown>, ...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,
};
}

View file

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

View file

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

View file

@ -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[] = [

View file

@ -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<WorkOrderBoardDetail, Error> {
return useQuery({
queryKey: queryKeys.workOrders.boardDetail(id ?? ""),
queryFn: () => workOrdersApi.getBoardDetail(requireQueryParam(id, "Work order id")),
enabled: enabled && id !== undefined && id !== "",
});
}
export function useWorkOrderLocations(): UseQueryResult<LocationOption[], Error> {
return useQuery({
queryKey: queryKeys.locations.dropdown(),

View file

@ -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<typeof useQueryClient>) {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
}
function invalidateBoardDetail(
queryClient: ReturnType<typeof useQueryClient>,
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<WorkOrderBoardMediaItem, Error, UploadWorkOrderMediaInput> {
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<void, Error, string | number> {
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,

View file

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

View file

@ -17,15 +17,12 @@ const SERVICE_PHRASE: Record<string, string> = {
"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<WorkOrderTableRow, "pm" | "woNumber">): 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<WorkOrderTableRow, "pm">): string {
const service = row.pm.trim();
const phrase = SERVICE_PHRASE[service] ?? service;
return `Amer-Building ${phrase}`;
}

View file

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

View file

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

View file

@ -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(<CompDocCell status="No" woType="PM" pm="" onOpen={onOpen} onOpenMedia={vi.fn()} />);
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(
<CompDocCell
status="No"
woType="PM"
pm="HVAC Inspection"
onOpen={onOpen}
onOpenMedia={vi.fn()}
/>,
);
screen.getByRole("button", { name: /draft/i }).click();
expect(onOpen).toHaveBeenCalledTimes(1);
});
});

View file

@ -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<Partial<WorkOrderTableRow>> = [];
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<CompDocDialog
row={row}
open
onOpenChange={vi.fn()}
onSave={(data, status) => {
onSave(data, status);
patches.push({ docStatus: status });
}}
onDateCompletedChange={(date) => {
onDateCompletedChange(date);
patches.push({ completedDate: date });
}}
/>,
<QueryClientProvider client={client}>
<CompDocDialog
row={row}
open
onOpenChange={vi.fn()}
onSave={(data, status) => {
onSave(data, status);
patches.push({ docStatus: status });
}}
onDateCompletedChange={(date) => {
onDateCompletedChange(date);
patches.push({ completedDate: date });
}}
/>
</QueryClientProvider>,
);
return { patches };

View file

@ -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(
<QueryClientProvider client={client}>
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
</QueryClientProvider>,
);
await waitFor(() => {
expect(screen.getByText("before.jpg")).toBeInTheDocument();
});
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
expect(screen.getByLabelText(/category/i)).toBeInTheDocument();
});
});

View file

@ -227,7 +227,13 @@ describe("CompDocCell slice affordances", () => {
it("shows View Doc when onOpen is provided", () => {
renderWithProviders(
<CompDocCell status="Yes" woType="PM" onOpen={vi.fn()} onOpenMedia={vi.fn()} />,
<CompDocCell
status="Yes"
woType="PM"
pm="HVAC Inspection"
onOpen={vi.fn()}
onOpenMedia={vi.fn()}
/>,
{ withAuth: false },
);

View file

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

View file

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

View file

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