Merge pull request #172 from Sea-Haven-Industries/feat/SH-171-wo-documents

feat(work-orders): complete Extra Docs authorized attachment lifecycle
This commit is contained in:
Arthur Bassi 2026-09-09 18:17:59 -03:00 • committed by GitHub
commit 8e0f7617aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 308 additions and 30 deletions

View file

@ -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) =>

View file

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

View file

@ -147,6 +147,7 @@ export function WorkOrderSlideOverTabs({
>
<SlideOverExtrasTab
uploads={state.extrasUploads}
workOrderId={infoSource?.id}
avetaRequired={resolveSlideOverAvetaRequired(
state.editing,
infoSource?.avetaRequired,

View file

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

View file

@ -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: {

View file

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

View file

@ -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 () => {

View file

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

View file

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

View file

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

View file

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