mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 06:13:13 +00:00
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:
parent
2d64c34898
commit
3004d039aa
34 changed files with 1637 additions and 175 deletions
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)";
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
169
src/domain/work-orders/api/work-order-board-documents-api.ts
Normal file
169
src/domain/work-orders/api/work-order-board-documents-api.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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> => {
|
||||
|
|
|
|||
23
src/domain/work-orders/errors/board-patch-errors.ts
Normal file
23
src/domain/work-orders/errors/board-patch-errors.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
263
src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
Normal file
263
src/domain/work-orders/mappers/work-order-board-detail-mapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
167
src/domain/work-orders/types/work-order-board-detail.ts
Normal file
167
src/domain/work-orders/types/work-order-board-detail.ts
Normal 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[];
|
||||
}
|
||||
8
src/domain/work-orders/types/work-order-board.ts
Normal file
8
src/domain/work-orders/types/work-order-board.ts
Normal 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];
|
||||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
27
src/domain/work-orders/utils/work-order-media-limits.ts
Normal file
27
src/domain/work-orders/utils/work-order-media-limits.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue