mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
19 commits
a7d359ffc4
...
b7d531f554
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d531f554 | ||
|
|
1fdecdd45d | ||
|
|
8e0f7617aa | ||
|
|
117f7949f5 | ||
|
|
15de3fca0a | ||
|
|
11743caa61 | ||
|
|
eab83f6562 | ||
|
|
252c0d0856 | ||
|
|
ba69706fcf | ||
|
|
c71d9482ec | ||
|
|
dee1578efe | ||
|
|
e761e0dae5 | ||
|
|
1607bac8ad | ||
|
|
47f78fec89 | ||
|
|
edf4a51d4c | ||
|
|
599e38aae3 | ||
|
|
4006145ef8 | ||
|
|
807d8ac1a8 | ||
|
|
6c765e8b42 |
27 changed files with 805 additions and 62 deletions
|
|
@ -37,6 +37,8 @@ export const API_PATHS = {
|
|||
media: (id: string | number) => `workorders/${id}/media`,
|
||||
mediaItem: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}`,
|
||||
mediaContent: (id: string | number, mediaId: string | number) =>
|
||||
`workorders/${id}/media/${mediaId}/content`,
|
||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
||||
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
|
||||
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import {
|
||||
|
|
@ -20,9 +21,9 @@ import {
|
|||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderExtraDocTypeAllowed,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
workOrderMediaAcceptAttribute,
|
||||
workOrderExtraDocsAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||
|
|
@ -43,21 +44,43 @@ type SlideOverExtrasTabProps = {
|
|||
isReadOnly: boolean;
|
||||
pending?: boolean;
|
||||
avetaRequired?: boolean;
|
||||
workOrderId?: string | number;
|
||||
onUpload: (files: File[]) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
};
|
||||
|
||||
async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise<void> {
|
||||
const tab = window.open("about:blank", "_blank");
|
||||
if (!tab) {
|
||||
toast.error("Unable to open this document. Please allow pop-ups and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
tab.opener = null;
|
||||
tab.location.href = objectUrl;
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
} catch {
|
||||
tab.close();
|
||||
toast.error("Unable to open this document.");
|
||||
}
|
||||
}
|
||||
|
||||
function ExtraDocRow({
|
||||
file,
|
||||
isReadOnly,
|
||||
pending,
|
||||
workOrderId,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
}: {
|
||||
file: MediaFile;
|
||||
isReadOnly: boolean;
|
||||
pending: boolean;
|
||||
workOrderId?: string | number;
|
||||
onRemove: (id: string) => void;
|
||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||
}) {
|
||||
|
|
@ -75,15 +98,20 @@ function ExtraDocRow({
|
|||
className="shrink-0"
|
||||
style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }}
|
||||
/>
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-0 flex-1 truncate hover:underline"
|
||||
<button
|
||||
type="button"
|
||||
disabled={workOrderId == null}
|
||||
onClick={() => {
|
||||
if (workOrderId == null) {
|
||||
return;
|
||||
}
|
||||
void openExtraDocContent(workOrderId, file.id);
|
||||
}}
|
||||
className="min-w-0 flex-1 truncate text-left hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ fontSize: 12, color: "var(--foreground)" }}
|
||||
>
|
||||
{file.name}
|
||||
</a>
|
||||
</button>
|
||||
{showCategory ? (
|
||||
<select
|
||||
aria-label={`Category for ${file.name}`}
|
||||
|
|
@ -134,6 +162,7 @@ export function SlideOverExtrasTab({
|
|||
isReadOnly,
|
||||
pending = false,
|
||||
avetaRequired = false,
|
||||
workOrderId,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onCategorize,
|
||||
|
|
@ -145,8 +174,8 @@ export function SlideOverExtrasTab({
|
|||
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||
const accepted: File[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
||||
if (!isWorkOrderExtraDocTypeAllowed(file)) {
|
||||
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV, PDF, DOC).`);
|
||||
continue;
|
||||
}
|
||||
if (isWorkOrderFileTooLarge(file)) {
|
||||
|
|
@ -193,7 +222,7 @@ export function SlideOverExtrasTab({
|
|||
event.preventDefault();
|
||||
addFiles(event.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 100,
|
||||
border: "1px dashed var(--color-border)",
|
||||
|
|
@ -208,13 +237,14 @@ export function SlideOverExtrasTab({
|
|||
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||
Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max {formatMediaSizeLimit().replace(" ", "")}{" "}
|
||||
per file
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={workOrderMediaAcceptAttribute()}
|
||||
accept={workOrderExtraDocsAcceptAttribute()}
|
||||
multiple
|
||||
hidden
|
||||
disabled={pending}
|
||||
|
|
@ -238,6 +268,7 @@ export function SlideOverExtrasTab({
|
|||
file={file}
|
||||
isReadOnly={isReadOnly}
|
||||
pending={pending}
|
||||
workOrderId={workOrderId}
|
||||
onRemove={onRemove}
|
||||
onCategorize={onCategorize}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ function SlideOverScheduleOnField({
|
|||
draft: WorkOrderTableRow;
|
||||
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||
}) {
|
||||
const hasSchedule =
|
||||
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
|
||||
|
||||
return (
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
|
|
@ -80,6 +83,20 @@ function SlideOverScheduleOnField({
|
|||
...deriveDayFields(start),
|
||||
})
|
||||
}
|
||||
onClear={
|
||||
hasSchedule
|
||||
? () =>
|
||||
onDraftChange({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
isPastDue: false,
|
||||
...deriveDayFields(""),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export function WorkOrderSlideOverTabs({
|
|||
>
|
||||
<SlideOverExtrasTab
|
||||
uploads={state.extrasUploads}
|
||||
workOrderId={infoSource?.id}
|
||||
avetaRequired={resolveSlideOverAvetaRequired(
|
||||
state.editing,
|
||||
infoSource?.avetaRequired,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { Popover } from "@mui/material";
|
||||
import { DateCellValue } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell-value";
|
||||
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||
|
|
@ -111,6 +111,22 @@ export function DateCell({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
|
||||
>
|
||||
{Boolean(onClear) && (
|
||||
<div className="mb-1.5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="inline-flex items-center gap-0.5 text-xs font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<X size={12} />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<WizardDateModeTabs
|
||||
modes={modes}
|
||||
cellMode={cellMode}
|
||||
|
|
@ -144,21 +160,6 @@ export function DateCell({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{Boolean(onClear) && (
|
||||
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClear?.();
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
className="text-[11.5px] font-medium transition-colors"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
× Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,11 +18,15 @@ type NotifyNavigate = {
|
|||
onNavigate: () => void;
|
||||
};
|
||||
|
||||
function showRescheduleToast(message: string, onNavigate: () => void) {
|
||||
function showRescheduleToast(
|
||||
message: string,
|
||||
onNavigate: () => void,
|
||||
linkLabel = "Click to view it.",
|
||||
) {
|
||||
const toastId = toast(
|
||||
createElement(RescheduleToast, {
|
||||
message,
|
||||
linkLabel: "Click to view it.",
|
||||
linkLabel,
|
||||
onNavigate: () => {
|
||||
toast.dismiss(toastId);
|
||||
onNavigate();
|
||||
|
|
@ -35,6 +39,13 @@ function showRescheduleToast(message: string, onNavigate: () => void) {
|
|||
}
|
||||
|
||||
export function weekMoveNotify(weekStart: string, { onNavigate }: NotifyNavigate) {
|
||||
if (!weekStart) {
|
||||
return showRescheduleToast(
|
||||
"Work order moved to Unscheduled.",
|
||||
onNavigate,
|
||||
"View in Advanced Filters.",
|
||||
);
|
||||
}
|
||||
return showRescheduleToast(
|
||||
`Work order moved to Unscheduled · ${fmtWeekOf(weekStart)}.`,
|
||||
onNavigate,
|
||||
|
|
|
|||
|
|
@ -122,18 +122,26 @@ export function useWoTableRowHandlers({
|
|||
});
|
||||
},
|
||||
onScheduleWeek: (id: string | number, week: string) => {
|
||||
onPatch(id, {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
});
|
||||
const isClear = week === "";
|
||||
onPatch(
|
||||
id,
|
||||
isClear
|
||||
? applySchedulePatch("", false)
|
||||
: {
|
||||
scheduledOn: "",
|
||||
targetWeek: week,
|
||||
scheduleWeekOnly: true,
|
||||
scheduleEnd: "",
|
||||
schedMode: "week",
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
},
|
||||
);
|
||||
weekMoveNotify(week, {
|
||||
onNavigate: () => {
|
||||
onNavigateToDay(week);
|
||||
if (!isClear) {
|
||||
onNavigateToDay(week);
|
||||
}
|
||||
onOpenAdvancedFilters?.();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ export function WoTableRowCells({
|
|||
handlers,
|
||||
onPatchRow,
|
||||
}: WoTableRowCellsProps) {
|
||||
const hasSchedule =
|
||||
Boolean(row.scheduledOn) || Boolean(row.targetWeek) || Boolean(row.scheduleEnd);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
|
|
@ -137,6 +140,7 @@ export function WoTableRowCells({
|
|||
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
||||
onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)}
|
||||
onSaveRange={(start, end) => handlers.onScheduleRange(row.id, start, end)}
|
||||
onClear={hasSchedule && !viewOnly ? () => handlers.onScheduleWeek(row.id, "") : undefined}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export function WoTableRow({
|
|||
forceScheduleHighlight={forceScheduleHighlight}
|
||||
forceDueDateHighlight={forceDueDateHighlight}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
viewOnly={closedForEdit}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
@ -212,7 +212,7 @@ export function WoTableRow({
|
|||
pmTypes={pmTypes}
|
||||
search={search}
|
||||
mutationCellStyle={coreLockStyle}
|
||||
viewOnly={isCoreLocked}
|
||||
viewOnly={closedForEdit}
|
||||
canceledLockStyle={canceledLockStyle}
|
||||
handlers={guardedHandlers}
|
||||
onPatchRow={patchRow}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function MediaUploaderDropzone({
|
|||
e.preventDefault();
|
||||
onFilesSelected(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-(--color-bg-muted) disabled:opacity-60"
|
||||
style={{
|
||||
minHeight: 120,
|
||||
border: "1px dashed var(--color-border)",
|
||||
|
|
@ -41,7 +41,7 @@ export function MediaUploaderDropzone({
|
|||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit().replace(" ", "")} per file
|
||||
{categorizeAfterUpload ? " · categorize after upload" : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -194,6 +194,27 @@ export const workOrderBoardDocumentsApi = {
|
|||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
getMediaContent: async (
|
||||
workOrderId: string | number,
|
||||
mediaId: string | number,
|
||||
): Promise<Blob> => {
|
||||
const response = await api.get(API_PATHS.workOrder.mediaContent(workOrderId, mediaId), {
|
||||
credentials: "include",
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new ApiError(mapHttpStatusToMessage(404), 404);
|
||||
}
|
||||
|
||||
const data = await readResponseJson(response);
|
||||
throwDocumentHttpError(response.status, data);
|
||||
},
|
||||
|
||||
uploadCompletionDoc: async (
|
||||
workOrderId: string | number,
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
|||
"scheduledOn",
|
||||
"targetWeek",
|
||||
"scheduleWeekOnly",
|
||||
"scheduleEnd",
|
||||
"schedMode",
|
||||
"dueDate",
|
||||
"completedDate",
|
||||
"apptTime",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/** Backend RequestSizeLimit for media and completion-doc uploads. */
|
||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
|
||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
/** Backend RequestSizeLimit for media and completion-doc uploads. */
|
||||
export const MAX_WORK_ORDER_MEDIA_BYTES = 50_000_000;
|
||||
|
||||
const MEDIA_MIME_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
|
|
@ -9,7 +11,28 @@ const ALLOWED_MIME_TYPES = new Set([
|
|||
"video/quicktime",
|
||||
]);
|
||||
|
||||
const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||
const MEDIA_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
|
||||
|
||||
const EXTRA_DOC_MIME_TYPES = new Set([
|
||||
...MEDIA_MIME_TYPES,
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
]);
|
||||
|
||||
const EXTRA_DOC_EXTENSIONS = new Set([...MEDIA_EXTENSIONS, ".pdf", ".doc", ".docx"]);
|
||||
|
||||
function fileExtension(file: File): string {
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot >= 0 ? name.slice(dot) : "";
|
||||
}
|
||||
|
||||
function isTypeAllowed(file: File, mimes: Set<string>, extensions: Set<string>): boolean {
|
||||
const mimeOk = Boolean(file.type) && mimes.has(file.type.toLowerCase());
|
||||
const extOk = extensions.has(fileExtension(file));
|
||||
return mimeOk || extOk;
|
||||
}
|
||||
|
||||
export function isWorkOrderFileTooLarge(
|
||||
file: File,
|
||||
|
|
@ -19,23 +42,24 @@ export function isWorkOrderFileTooLarge(
|
|||
}
|
||||
|
||||
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
|
||||
const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase());
|
||||
const name = file.name.toLowerCase();
|
||||
const dot = name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? name.slice(dot) : "";
|
||||
const extOk = ALLOWED_EXTENSIONS.has(ext);
|
||||
return mimeOk || extOk;
|
||||
return isTypeAllowed(file, MEDIA_MIME_TYPES, MEDIA_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function isWorkOrderExtraDocTypeAllowed(file: File): boolean {
|
||||
return isTypeAllowed(file, EXTRA_DOC_MIME_TYPES, EXTRA_DOC_EXTENSIONS);
|
||||
}
|
||||
|
||||
export function workOrderMediaAcceptAttribute(): string {
|
||||
return "image/jpeg,image/jpg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
|
||||
}
|
||||
|
||||
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
|
||||
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
|
||||
export function workOrderExtraDocsAcceptAttribute(): string {
|
||||
return `${workOrderMediaAcceptAttribute()},.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document`;
|
||||
}
|
||||
|
||||
import { isWorkOrderCompletionSurfaceReadOnly } from "@/domain/work-orders/utils/work-order-lock";
|
||||
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
|
||||
return `${Math.round(maxBytes / 1_000_000)} MB`;
|
||||
}
|
||||
|
||||
/** Completion-doc / completion-media lock: Canceled or Completed. Extra Docs use canMutateWorkOrderExtraDocs. */
|
||||
export function isWorkOrderDocumentsReadOnly(status: string | null | undefined): boolean {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,30 @@ describe("DateCell — Schedule On range", () => {
|
|||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(onSaveRange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Clear above the mode tabs and closes after clearing", async () => {
|
||||
const onClear = vi.fn();
|
||||
renderWithProviders(
|
||||
<DateCell
|
||||
value="2026-09-09"
|
||||
allowWeekMode
|
||||
onSave={vi.fn()}
|
||||
onSaveWeek={vi.fn()}
|
||||
onSaveRange={vi.fn()}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /09-09/ }));
|
||||
|
||||
const clear = await screen.findByRole("button", { name: "Clear" });
|
||||
const week = screen.getByRole("button", { name: "Week" });
|
||||
expect(clear.compareDocumentPosition(week) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
fireEvent.click(clear);
|
||||
expect(onClear).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByRole("button", { name: "Clear" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DueDateCell — no Schedule On range tabs", () => {
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@ describe("MediaUploadModal API wiring", () => {
|
|||
expect(screen.getByText("before.jpg")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Photos & Videos")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers no purpose picker — the table modal only uploads and removes", async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { createRef } from "react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MediaUploaderDropzone } from "@/app/(protected)/workorders/_components/media/media-uploader-dropzone";
|
||||
import { workOrderMediaAcceptAttribute } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
describe("MediaUploaderDropzone", () => {
|
||||
it("matches the AAP Photos accept copy and keeps it media-only", () => {
|
||||
renderWithProviders(
|
||||
<MediaUploaderDropzone
|
||||
inputRef={createRef<HTMLInputElement>()}
|
||||
pending={false}
|
||||
onFilesSelected={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Accepted: JPG, PNG, MP4, MOV · Max 50MB per file"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/categorize after upload/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/PDF/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/DOC/)).not.toBeInTheDocument();
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
expect(input?.getAttribute("accept")).toBe(workOrderMediaAcceptAttribute());
|
||||
expect(input?.getAttribute("accept")).not.toContain(".pdf");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { weekMoveNotify } from "@/app/(protected)/workorders/_components/list/table/reschedule-notify";
|
||||
|
||||
const { toast } = vi.hoisted(() => {
|
||||
const notify = Object.assign(
|
||||
vi.fn((_content: unknown, _options?: unknown) => 1),
|
||||
{
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
);
|
||||
return { toast: notify };
|
||||
});
|
||||
|
||||
vi.mock("react-toastify", () => ({ toast }));
|
||||
|
||||
describe("weekMoveNotify", () => {
|
||||
beforeEach(() => {
|
||||
toast.mockClear();
|
||||
toast.dismiss.mockClear();
|
||||
});
|
||||
|
||||
it("uses the AAP Unscheduled message and Advanced Filters action when clearing", () => {
|
||||
weekMoveNotify("", { onNavigate: vi.fn() });
|
||||
|
||||
const content = toast.mock.calls[0][0] as {
|
||||
props: { message: string; linkLabel: string };
|
||||
};
|
||||
expect(content.props).toMatchObject({
|
||||
message: "Work order moved to Unscheduled.",
|
||||
linkLabel: "View in Advanced Filters.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { toast } from "react-toastify";
|
||||
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
|
||||
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
|
|
@ -21,6 +23,10 @@ const avetaDoc: MediaFile = {
|
|||
};
|
||||
|
||||
describe("SlideOverExtrasTab Aveta", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
it("does not label the first extra document as Aveta when none is explicitly Aveta", () => {
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab
|
||||
|
|
@ -71,4 +77,88 @@ describe("SlideOverExtrasTab Aveta", () => {
|
|||
expect(onCategorize).toHaveBeenCalledWith("m1", "Aveta");
|
||||
expect(screen.queryByText("safety-form.pdf")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens Extra Docs through authenticated content instead of a static href", async () => {
|
||||
const blob = new Blob(["pdf"], { type: "application/pdf" });
|
||||
const getMediaContent = vi.spyOn(workOrdersApi, "getMediaContent").mockResolvedValue(blob);
|
||||
const createObjectURL = vi.fn(() => "blob:authorized");
|
||||
vi.stubGlobal("URL", { ...URL, createObjectURL, revokeObjectURL: vi.fn() });
|
||||
const tab = {
|
||||
opener: window,
|
||||
location: { href: "about:blank" },
|
||||
close: vi.fn(),
|
||||
};
|
||||
const open = vi.fn(() => tab);
|
||||
vi.stubGlobal("open", open);
|
||||
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab
|
||||
uploads={[{ ...photo, url: "https://cdn.example.com/Assets/Documents/secret.pdf" }]}
|
||||
workOrderId={10}
|
||||
avetaRequired
|
||||
isReadOnly
|
||||
onUpload={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "safety-form.pdf" }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getMediaContent).toHaveBeenCalledWith(10, "m1");
|
||||
});
|
||||
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
||||
expect(open).toHaveBeenCalledWith("about:blank", "_blank");
|
||||
expect(tab.opener).toBeNull();
|
||||
expect(tab.location.href).toBe("blob:authorized");
|
||||
});
|
||||
|
||||
it("shows an error when the browser blocks the document tab", async () => {
|
||||
const getMediaContent = vi.spyOn(workOrdersApi, "getMediaContent");
|
||||
const toastError = vi.spyOn(toast, "error").mockImplementation(vi.fn());
|
||||
vi.stubGlobal(
|
||||
"open",
|
||||
vi.fn(() => null),
|
||||
);
|
||||
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab
|
||||
uploads={[photo]}
|
||||
workOrderId={10}
|
||||
isReadOnly
|
||||
onUpload={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "safety-form.pdf" }));
|
||||
|
||||
expect(toastError).toHaveBeenCalledWith(
|
||||
"Unable to open this document. Please allow pop-ups and try again.",
|
||||
);
|
||||
expect(getMediaContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Extra Docs accept copy with PDF DOC and 50MB", () => {
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab uploads={[]} isReadOnly={false} onUpload={vi.fn()} onRemove={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Accepted: JPG, PNG, MP4, MOV, PDF, DOC · Max 50MB per file"),
|
||||
).toBeInTheDocument();
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
expect(input?.getAttribute("accept")).toContain(".pdf");
|
||||
expect(input?.getAttribute("accept")).toContain(".doc");
|
||||
expect(input?.getAttribute("accept")).toContain(".docx");
|
||||
});
|
||||
|
||||
it("disables document opening until the work order id is available", () => {
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab uploads={[photo]} isReadOnly onUpload={vi.fn()} onRemove={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "safety-form.pdf" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -121,4 +121,46 @@ describe("SlideOverInfoTabEditView — Schedule On range", () => {
|
|||
}),
|
||||
);
|
||||
}, 10_000);
|
||||
|
||||
it("clears every schedule field in the draft", async () => {
|
||||
const onDraftChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<SlideOverInfoTabEditView
|
||||
draft={baseRow({
|
||||
scheduledOn: currentMonthIso(5),
|
||||
scheduleEnd: currentMonthIso(20),
|
||||
schedMode: "range",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: currentMonthIso(1),
|
||||
dayGroup: "09/05",
|
||||
dayLabel: "Sat",
|
||||
isPastDue: true,
|
||||
status: "Scheduled",
|
||||
})}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
allOrders={[]}
|
||||
onDraftChange={onDraftChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scheduleRow = screen.getByText("Schedule On").closest("div");
|
||||
fireEvent.click(scheduleRow!.querySelector("button")!);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /clear/i }));
|
||||
|
||||
expect(onDraftChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
dayGroup: "",
|
||||
dayLabel: "",
|
||||
isPastDue: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function buildTableData(revealScheduledRow = vi.fn()) {
|
|||
} as never;
|
||||
}
|
||||
|
||||
describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
||||
describe("useWoTableRowHandlers schedule navigation", () => {
|
||||
beforeEach(() => {
|
||||
rescheduleNotify.mockClear();
|
||||
weekMoveNotify.mockClear();
|
||||
|
|
@ -151,4 +151,36 @@ describe("useWoTableRowHandlers schedule navigation (SH-240)", () => {
|
|||
expect(onNavigateToDay).toHaveBeenCalledWith("2026-05-25");
|
||||
expect(onOpenAdvancedFilters).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the concrete schedule and opens Advanced Filters from the Unscheduled toast", () => {
|
||||
const onNavigateToDay = vi.fn();
|
||||
const onOpenAdvancedFilters = vi.fn();
|
||||
const onPatch = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWoTableRowHandlers({
|
||||
tableData: buildTableData(),
|
||||
weekMonday: "2026-05-18",
|
||||
onNavigateToDay,
|
||||
onOpenAdvancedFilters,
|
||||
onOpenSlideOver: vi.fn(),
|
||||
onOpenMedia: vi.fn(),
|
||||
onPatch,
|
||||
onScheduleHighlight: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onScheduleWeek("wo-1", "");
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith("wo-1", applySchedulePatch("", false));
|
||||
expect(weekMoveNotify).toHaveBeenCalledWith(
|
||||
"",
|
||||
expect.objectContaining({ onNavigate: expect.any(Function) }),
|
||||
);
|
||||
|
||||
const { onNavigate } = weekMoveNotify.mock.calls[0][1] as { onNavigate: () => void };
|
||||
onNavigate();
|
||||
expect(onNavigateToDay).not.toHaveBeenCalled();
|
||||
expect(onOpenAdvancedFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -111,4 +111,33 @@ describe("WizardStepTypeSchedule — Schedule On range selection", () => {
|
|||
expect(screen.getByRole("button", { name: /^done$/i })).toBeDisabled();
|
||||
expect(onPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not promote Incomplete to Scheduled for week-only Schedule On", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const onPatch = vi.fn();
|
||||
|
||||
render(
|
||||
<WizardStepTypeSchedule
|
||||
draft={baseDraft({ status: "Incomplete" })}
|
||||
users={[]}
|
||||
onUpdate={onUpdate}
|
||||
onPatch={onPatch}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /pick a date/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Week" }));
|
||||
const weekButtons = screen
|
||||
.getAllByRole("button")
|
||||
.filter((button) => /–/.test(button.textContent ?? ""));
|
||||
fireEvent.click(weekButtons[0]);
|
||||
|
||||
expect(onPatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduleWeekOnly: true,
|
||||
schedMode: "week",
|
||||
}),
|
||||
);
|
||||
expect(onPatch.mock.calls.some(([patch]) => patch.status === "Scheduled")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row";
|
||||
import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
|
||||
SiteCell: () => null,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
|
||||
() => ({
|
||||
EditableWONumberCell: () => null,
|
||||
}),
|
||||
);
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
|
||||
TypeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
|
||||
AssignedToCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
|
||||
DateCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({
|
||||
DueDateCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({
|
||||
PMTypeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
|
||||
VendorCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
|
||||
ApptTimeCell: () => null,
|
||||
}));
|
||||
|
||||
describe("WoTableInlineRow StatusCell with a concrete schedule", () => {
|
||||
it("keeps Scheduled when Incomplete is picked on a dated draft", () => {
|
||||
const onChange = vi.fn();
|
||||
const draft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
status: "Incomplete" as const,
|
||||
scheduledOn: "2026-06-01",
|
||||
scheduleWeekOnly: false,
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<WoTableInlineRow
|
||||
draft={draft}
|
||||
draftDayKey="2026-06-01"
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
allRows={[]}
|
||||
onChange={onChange}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /incomplete/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^pending$/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ status: "Scheduled" }));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WoTableRowCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-cells";
|
||||
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
|
||||
import type {
|
||||
WorkOrderTablePatch,
|
||||
WorkOrderTableRow,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||
import { mergeAutoSchedulePatch } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/site-cell", () => ({
|
||||
SiteCell: () => null,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell",
|
||||
() => ({
|
||||
EditableWONumberCell: () => null,
|
||||
}),
|
||||
);
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
|
||||
TypeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
|
||||
AssignedToCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
|
||||
DateCell: ({ onClear }: { onClear?: () => void }) => {
|
||||
if (!onClear) return null;
|
||||
return (
|
||||
<button type="button" onClick={onClear}>
|
||||
clear schedule
|
||||
</button>
|
||||
);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({
|
||||
DueDateCell: () => null,
|
||||
}));
|
||||
|
||||
function renderScheduledRow(
|
||||
row: WorkOrderTableRow,
|
||||
onPersist: (id: string | number, patch: WorkOrderTablePatch) => void,
|
||||
viewOnly = false,
|
||||
) {
|
||||
const handlers = {
|
||||
onScheduleWeek: (id: string | number, week: string) => {
|
||||
onPersist(id, mergeAutoSchedulePatch(row, applySchedulePatch(week, false)));
|
||||
},
|
||||
} as unknown as WoTableRowHandlers;
|
||||
|
||||
renderWithProviders(
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<WoTableRowCells
|
||||
row={row}
|
||||
allRows={[row]}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
viewOnly={viewOnly}
|
||||
handlers={handlers}
|
||||
onPatchRow={vi.fn()}
|
||||
/>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
}
|
||||
|
||||
describe("WoTableRowCells Schedule On clear", () => {
|
||||
it.each([
|
||||
{ scheduledOn: "2026-09-09", targetWeek: "", scheduleEnd: "" },
|
||||
{ scheduledOn: "", targetWeek: "2026-09-07", scheduleEnd: "" },
|
||||
{ scheduledOn: "2026-09-09", targetWeek: "", scheduleEnd: "2026-09-11" },
|
||||
])("clears a persisted schedule and derives Pending from Scheduled", (schedule) => {
|
||||
const onPersist = vi.fn();
|
||||
const row = {
|
||||
id: 42,
|
||||
status: "Scheduled",
|
||||
scheduleWeekOnly: Boolean(schedule.targetWeek),
|
||||
schedMode: schedule.targetWeek ? "week" : schedule.scheduleEnd ? "range" : "date",
|
||||
...schedule,
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
renderScheduledRow(row, onPersist);
|
||||
fireEvent.click(screen.getByRole("button", { name: "clear schedule" }));
|
||||
|
||||
expect(onPersist).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
status: "Pending",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["Completed", "Canceled"])("does not expose Clear for a %s row", (status) => {
|
||||
const row = {
|
||||
id: 42,
|
||||
status,
|
||||
scheduledOn: "2026-09-09",
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
scheduleWeekOnly: false,
|
||||
schedMode: "date",
|
||||
} as WorkOrderTableRow;
|
||||
|
||||
renderScheduledRow(row, vi.fn(), true);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "clear schedule" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ const apiPatch = vi.fn();
|
|||
const apiPatchFn = vi.fn();
|
||||
const apiPostFn = vi.fn();
|
||||
const apiDeleteFn = vi.fn();
|
||||
const apiGetFn = vi.fn();
|
||||
const uploadFormFn = vi.fn();
|
||||
|
||||
vi.mock("@/api/upload-form-with-progress", () => ({
|
||||
|
|
@ -27,6 +28,7 @@ vi.mock("@/api/api", () => ({
|
|||
patch: (...args: unknown[]) => apiPatchFn(...args),
|
||||
post: (...args: unknown[]) => apiPostFn(...args),
|
||||
delete: (...args: unknown[]) => apiDeleteFn(...args),
|
||||
get: (...args: unknown[]) => apiGetFn(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -911,3 +913,46 @@ describe("workOrdersApi.fetchVendorsDropdown", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("API_PATHS.workOrder.mediaContent", () => {
|
||||
it("builds the authorized media content path", () => {
|
||||
expect(API_PATHS.workOrder.mediaContent(10, 12)).toBe("workorders/10/media/12/content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workOrdersApi.getMediaContent", () => {
|
||||
beforeEach(() => {
|
||||
apiGetFn.mockReset();
|
||||
});
|
||||
|
||||
it("returns the response blob on 200", async () => {
|
||||
const blob = new Blob(["pdf-bytes"], { type: "application/pdf" });
|
||||
apiGetFn.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: async () => blob,
|
||||
});
|
||||
|
||||
const result = await workOrdersApi.getMediaContent(10, 12);
|
||||
|
||||
expect(result).toBe(blob);
|
||||
expect(apiGetFn).toHaveBeenCalledWith(
|
||||
API_PATHS.workOrder.mediaContent(10, 12),
|
||||
expect.objectContaining({ credentials: "include", throwHttpErrors: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws ApiError on 404 without inventing a filename", async () => {
|
||||
apiGetFn.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ fileName: "secret.pdf" }),
|
||||
});
|
||||
|
||||
await expect(workOrdersApi.getMediaContent(10, 12)).rejects.toMatchObject({
|
||||
name: "ApiError",
|
||||
status: 404,
|
||||
message: "The requested resource was not found.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import {
|
|||
resolveSiteCode,
|
||||
} from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
|
||||
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
|
||||
import {
|
||||
LIFECYCLE_STATUS_API,
|
||||
WORK_ORDER_TYPE_API,
|
||||
} from "@/domain/work-orders/types/work-order-board";
|
||||
|
||||
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
|
||||
|
||||
|
|
@ -262,6 +265,22 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
|||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps Incomplete on week-only create and omits scheduledDate", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
type: "PM",
|
||||
siteCode: "BK5",
|
||||
status: "Incomplete",
|
||||
scheduleWeekOnly: true,
|
||||
targetWeek: "2026-06-22",
|
||||
});
|
||||
|
||||
expect(payload.lifecycleStatus).toBe(LIFECYCLE_STATUS_API.Incomplete);
|
||||
expect(payload.scheduleWeekOnly).toBe(true);
|
||||
expect(payload.targetWeek).toBe("2026-06-22");
|
||||
expect(payload.scheduledDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers explicit targetWeek when scheduleWeekOnly and scheduledOn is empty", () => {
|
||||
const payload = mapWizardDraftToBoardCreatePayload({
|
||||
...EMPTY_WIZARD_DRAFT,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { mergeAutoSchedulePatch } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
|
||||
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||
return {
|
||||
|
|
@ -97,6 +98,24 @@ describe("slide-over-draft", () => {
|
|||
expect(isSlideOverDraftDirty(draft, row)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears client schedule range overlays in the save patch", () => {
|
||||
const row = baseRow({
|
||||
scheduleEnd: "2026-07-18",
|
||||
schedMode: "range",
|
||||
});
|
||||
const draft = baseRow({
|
||||
scheduledOn: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
});
|
||||
|
||||
expect(buildSlideOverPatch(draft, row)).toEqual({
|
||||
scheduledOn: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("is not dirty when values match", () => {
|
||||
const row = baseRow();
|
||||
expect(isSlideOverDraftDirty(row, row)).toBe(false);
|
||||
|
|
@ -116,6 +135,38 @@ describe("slide-over-draft", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("derives Pending on persist when a Scheduled draft clears Schedule On", () => {
|
||||
const baseline = baseRow({
|
||||
status: "Scheduled",
|
||||
scheduledOn: "2026-07-16",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: "date",
|
||||
});
|
||||
const draft = baseRow({
|
||||
status: "Scheduled",
|
||||
scheduledOn: "",
|
||||
scheduleWeekOnly: false,
|
||||
targetWeek: "",
|
||||
scheduleEnd: "",
|
||||
schedMode: undefined,
|
||||
});
|
||||
|
||||
const patch = buildSlideOverPatch(draft, baseline);
|
||||
expect(patch).toEqual({
|
||||
scheduledOn: "",
|
||||
schedMode: undefined,
|
||||
});
|
||||
expect(patch.status).toBeUndefined();
|
||||
expect(mergeAutoSchedulePatch(baseline, patch)).toEqual(
|
||||
expect.objectContaining({
|
||||
scheduledOn: "",
|
||||
status: "Pending",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks ordered additionalContacts for dirty state and patch (SH-218)", () => {
|
||||
const row = baseRow();
|
||||
const contacts = [{ name: "Backup", phone: "555-0101", notes: "Gate" }];
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canDeleteWorkOrderMedia,
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderDocumentsReadOnly,
|
||||
isWorkOrderExtraDocTypeAllowed,
|
||||
isWorkOrderFileTooLarge,
|
||||
isWorkOrderMediaTypeAllowed,
|
||||
MAX_WORK_ORDER_MEDIA_BYTES,
|
||||
workOrderExtraDocsAcceptAttribute,
|
||||
workOrderMediaAcceptAttribute,
|
||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||
|
||||
describe("work-order-media-limits", () => {
|
||||
|
|
@ -20,7 +24,9 @@ describe("work-order-media-limits", () => {
|
|||
expect(canDeleteWorkOrderMedia({ id: "0" })).toBe(false);
|
||||
});
|
||||
|
||||
it("flags files over 30 MB", () => {
|
||||
it("caps uploads at 50_000_000 bytes and formats decimal MB", () => {
|
||||
expect(MAX_WORK_ORDER_MEDIA_BYTES).toBe(50_000_000);
|
||||
expect(formatMediaSizeLimit()).toBe("50 MB");
|
||||
expect(isWorkOrderFileTooLarge({ size: MAX_WORK_ORDER_MEDIA_BYTES + 1 } as File)).toBe(true);
|
||||
expect(isWorkOrderFileTooLarge({ size: 10 } as File)).toBe(false);
|
||||
});
|
||||
|
|
@ -37,5 +43,31 @@ describe("work-order-media-limits", () => {
|
|||
expect(isWorkOrderMediaTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(workOrderMediaAcceptAttribute()).not.toContain(".pdf");
|
||||
});
|
||||
|
||||
it("allows Extra Docs PDF DOC DOCX plus media types", () => {
|
||||
expect(isWorkOrderExtraDocTypeAllowed({ name: "a.pdf", type: "application/pdf" } as File)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isWorkOrderExtraDocTypeAllowed({ name: "a.doc", type: "application/msword" } as File),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkOrderExtraDocTypeAllowed({
|
||||
name: "a.docx",
|
||||
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
} as File),
|
||||
).toBe(true);
|
||||
expect(isWorkOrderExtraDocTypeAllowed({ name: "a.jpg", type: "image/jpeg" } as File)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isWorkOrderExtraDocTypeAllowed({ name: "a.gif", type: "image/gif" } as File)).toBe(
|
||||
false,
|
||||
);
|
||||
const accept = workOrderExtraDocsAcceptAttribute();
|
||||
expect(accept).toContain(".pdf");
|
||||
expect(accept).toContain(".doc");
|
||||
expect(accept).toContain(".docx");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue