feat(work-orders): accept more than one uplift evidence file

Stage every selected file, wait for each scan, and download by document id.
Refresh the work-order detail snapshot for the current tab strip.
This commit is contained in:
Arthur Bassi 2026-09-28 16:25:45 -03:00 • committed by Cursor Agent
parent adc5a4b33e
commit 10137bd427
No known key found for this signature in database
13 changed files with 232 additions and 39 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 156 KiB

View file

@ -6,8 +6,8 @@ import { Text } from "@/components/ui/text";
const ACCEPTED_TYPES = new Set(["application/pdf", "image/jpeg", "image/jpg", "image/png"]);
type UpliftAttachmentPickerProps = {
file: File | null;
onChange: (file: File | null) => void;
files: File[];
onChange: (files: File[]) => void;
};
function isAllowedUpliftEvidenceFile(file: File): boolean {
@ -15,9 +15,9 @@ function isAllowedUpliftEvidenceFile(file: File): boolean {
return /\.(pdf|jpe?g|png)$/i.test(file.name);
}
function firstAllowedFile(incoming: FileList | null): File | null {
if (!incoming || incoming.length === 0) return null;
return Array.from(incoming).find(isAllowedUpliftEvidenceFile) ?? null;
function allowedFiles(incoming: FileList | null): File[] {
if (!incoming || incoming.length === 0) return [];
return Array.from(incoming).filter(isAllowedUpliftEvidenceFile);
}
function UpliftStagedFile({ file, onRemove }: { file: File; onRemove: () => void }) {
@ -46,19 +46,20 @@ function UpliftStagedFile({ file, onRemove }: { file: File; onRemove: () => void
);
}
export function UpliftAttachmentPicker({ file, onChange }: UpliftAttachmentPickerProps) {
export function UpliftAttachmentPicker({ files, onChange }: UpliftAttachmentPickerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const stageFile = (incoming: FileList | null) => {
const next = firstAllowedFile(incoming);
if (next) onChange(next);
const stageFiles = (incoming: FileList | null) => {
const next = allowedFiles(incoming);
if (next.length === 0) return;
onChange([...files, ...next]);
};
return (
<div className="mt-4">
<FieldLabel>Attachment</FieldLabel>
<Text as="p" variant="caption" tone="muted" className="mb-2">
Optional — PDF, JPG, or PNG.
Optional — one or more PDF, JPG, or PNG files.
</Text>
<button
type="button"
@ -66,7 +67,7 @@ export function UpliftAttachmentPicker({ file, onChange }: UpliftAttachmentPicke
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
stageFile(event.dataTransfer.files);
stageFiles(event.dataTransfer.files);
}}
className="flex w-full items-center justify-center gap-2 transition-colors hover:bg-(--color-bg-muted)"
style={{
@ -77,22 +78,29 @@ export function UpliftAttachmentPicker({ file, onChange }: UpliftAttachmentPicke
>
<Upload size={14} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<Text as="span" variant="caption" tone="muted">
Drag a file here or click to browse
Drag files here or click to browse
</Text>
</button>
<input
ref={inputRef}
type="file"
multiple
accept="application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png"
hidden
onChange={(event) => {
stageFile(event.target.files);
stageFiles(event.target.files);
event.target.value = "";
}}
/>
{file != null && (
<div className="mt-2">
<UpliftStagedFile file={file} onRemove={() => onChange(null)} />
{files.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{files.map((file, index) => (
<UpliftStagedFile
key={`${file.name}-${file.lastModified}-${index}`}
file={file}
onRemove={() => onChange(files.filter((_, itemIndex) => itemIndex !== index))}
/>
))}
</div>
)}
</div>

View file

@ -13,11 +13,7 @@ import {
type WorkOrderUpliftCreateFormProps = {
pending?: boolean;
remainingAllowance: number;
onSubmit: (input: {
amount: number;
notes: string;
file: File | null;
}) => void | Promise<unknown>;
onSubmit: (input: { amount: number; notes: string; files: File[] }) => void | Promise<unknown>;
};
function canSubmitUplift(amount: number, notes: string): boolean {
@ -64,17 +60,17 @@ export function WorkOrderUpliftCreateForm({
}: WorkOrderUpliftCreateFormProps) {
const [amount, setAmount] = useState("");
const [notes, setNotes] = useState("");
const [file, setFile] = useState<File | null>(null);
const [files, setFiles] = useState<File[]>([]);
const parsedAmount = Number(amount);
const helper = upliftAllowanceHelper(parsedAmount, remainingAllowance);
const canSubmit = canSubmitUplift(parsedAmount, notes);
const handleSubmit = async () => {
try {
await onSubmit({ amount: parsedAmount, notes: notes.trim(), file });
await onSubmit({ amount: parsedAmount, notes: notes.trim(), files });
setAmount("");
setNotes("");
setFile(null);
setFiles([]);
} catch {
// why: the mutation toasts onError, so a failed submit keeps the typed values.
}
@ -113,7 +109,7 @@ export function WorkOrderUpliftCreateForm({
/>
</div>
<UpliftAttachmentPicker file={file} onChange={setFile} />
<UpliftAttachmentPicker files={files} onChange={setFiles} />
<div className="mt-3 flex justify-end">
<Button

View file

@ -75,7 +75,8 @@ function UpliftFiledAttachment({
}) {
const hasUrl = file.url !== "";
const downloadEvidence = () => {
void upliftsApi.downloadEvidence(upliftId, file.name).catch((error: unknown) => {
const documentId = /^\d+$/.test(file.id) ? file.id : undefined;
void upliftsApi.downloadEvidence(upliftId, file.name, documentId).catch((error: unknown) => {
toast.error(error instanceof Error ? error.message : "Unable to download evidence.");
});
};

View file

@ -105,8 +105,9 @@ export const upliftsApi = {
downloadEvidence: async (
id: string | number,
fallbackFileName = "uplift-evidence",
documentId?: string | number,
): Promise<void> => {
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence", documentId);
const blob = await response.blob();
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
},
@ -120,8 +121,9 @@ export const upliftsApi = {
id: string | number,
tab: Window | null,
fallbackFileName = "uplift-evidence",
documentId?: string | number,
): Promise<void> => {
const response = await fetchEvidence(id, "upliftsApi.openEvidence");
const response = await fetchEvidence(id, "upliftsApi.openEvidence", documentId);
const blob = await response.blob();
const type = blob.type.split(";")[0].trim().toLowerCase();
if (tab == null || !INLINE_EVIDENCE_TYPES.has(type)) {
@ -145,9 +147,14 @@ const INLINE_EVIDENCE_TYPES = new Set([
const EVIDENCE_URL_TTL_MS = 60_000;
async function fetchEvidence(id: string | number, operation: string): Promise<Response> {
async function fetchEvidence(
id: string | number,
operation: string,
documentId?: string | number,
): Promise<Response> {
const options = documentId == null ? undefined : { searchParams: { documentId } };
try {
return await apiRequestRaw("get", evidenceUrl(id), undefined, operation);
return await apiRequestRaw("get", evidenceUrl(id), options, operation);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);

View file

@ -46,13 +46,21 @@ export const workOrderUpliftsApi = {
workOrderId: string | number,
input: CreateWorkOrderUpliftInput,
): Promise<WorkOrderUplift> => {
const body: { amount: number; notes: string; evidenceDocumentId?: number } = {
const body: {
amount: number;
notes: string;
evidenceDocumentId?: number;
evidenceDocumentIds?: number[];
} = {
amount: input.amount,
notes: input.notes,
};
if (input.evidenceDocumentId != null) {
body.evidenceDocumentId = input.evidenceDocumentId;
}
if (input.evidenceDocumentIds != null && input.evidenceDocumentIds.length > 0) {
body.evidenceDocumentIds = input.evidenceDocumentIds;
}
const data = await apiPost<unknown>(API_PATHS.workOrder.uplifts(workOrderId), body);
return mapWorkOrderUplift(handleApiResponse(data));
},

View file

@ -39,8 +39,9 @@ export interface WorkOrderUplift {
export interface CreateWorkOrderUpliftInput {
amount: number;
notes: string;
file?: File | null;
files?: File[];
evidenceDocumentId?: number;
evidenceDocumentIds?: number[];
}
export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = {

View file

@ -11,6 +11,7 @@ type CreateUpliftBody = {
amount: number;
notes: string;
evidenceDocumentId?: number;
evidenceDocumentIds?: number[];
};
export type SubmitWorkOrderUpliftDeps = {
@ -62,12 +63,15 @@ export async function submitWorkOrderUplift(
input: CreateWorkOrderUpliftInput,
deps: SubmitWorkOrderUpliftDeps,
): Promise<WorkOrderUplift> {
const evidenceDocumentId = input.file
? await waitForPassedScan(workOrderId, input.file, deps)
: undefined;
const evidenceDocumentIds: number[] = [];
for (const file of input.files ?? []) {
evidenceDocumentIds.push(await waitForPassedScan(workOrderId, file, deps));
}
const [evidenceDocumentId] = evidenceDocumentIds;
return deps.create(workOrderId, {
amount: input.amount,
notes: input.notes,
evidenceDocumentId,
evidenceDocumentIds: evidenceDocumentIds.length > 0 ? evidenceDocumentIds : undefined,
});
}

View file

@ -0,0 +1,55 @@
import { useState } from "react";
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UpliftAttachmentPicker } from "@/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker";
import { renderWithProviders } from "@/test/test-utils";
function PickerHarness({ onChange }: { onChange: (files: File[]) => void }) {
const [files, setFiles] = useState<File[]>([]);
return (
<UpliftAttachmentPicker
files={files}
onChange={(next) => {
setFiles(next);
onChange(next);
}}
/>
);
}
function selectFiles(files: File[]) {
const input = document.querySelector('input[type="file"]');
fireEvent.change(input as HTMLInputElement, { target: { files } });
}
describe("UpliftAttachmentPicker", () => {
it("keeps every allowed file from one multi-select", () => {
const onChange = vi.fn();
renderWithProviders(<PickerHarness onChange={onChange} />);
const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" });
const notes = new File(["text"], "notes.txt", { type: "text/plain" });
const photo = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" });
selectFiles([quote, notes, photo]);
expect(screen.getByText("quote.pdf")).toBeInTheDocument();
expect(screen.getByText("photo.jpg")).toBeInTheDocument();
expect(screen.queryByText("notes.txt")).not.toBeInTheDocument();
expect(onChange).toHaveBeenCalledWith([quote, photo]);
expect(document.querySelector('input[type="file"]')?.hasAttribute("multiple")).toBe(true);
});
it("appends a later selection instead of replacing the staged files", () => {
const onChange = vi.fn();
renderWithProviders(<PickerHarness onChange={onChange} />);
const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" });
const photo = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" });
selectFiles([quote]);
selectFiles([photo]);
expect(screen.getByText("quote.pdf")).toBeInTheDocument();
expect(screen.getByText("photo.jpg")).toBeInTheDocument();
expect(onChange).toHaveBeenLastCalledWith([quote, photo]);
});
});

View file

@ -0,0 +1,46 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift";
import { renderWithProviders } from "@/test/test-utils";
const downloadEvidence = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@/domain/uplifts/api/uplifts-api", () => ({
upliftsApi: { downloadEvidence },
}));
const uplift: WorkOrderUplift = {
id: 9,
status: "auto_approved",
amount: 25,
notes: "Leak",
requestedAt: "2026-07-20T10:00:00Z",
requestedById: "dispatcher-1",
requestedByName: "Dispatcher",
decidedAt: "",
decidedByName: "",
decisionNote: "",
attachments: [
{ id: "22", name: "quote.pdf", url: "" },
{ id: "23", name: "photo.jpg", url: "" },
],
};
describe("WorkOrderUpliftListItem evidence download", () => {
it("downloads each attachment by its document id", () => {
renderWithProviders(
<WorkOrderUpliftListItem
uplift={uplift}
readOnly={false}
currentUserId="dispatcher-1"
currentUserIsAdmin={false}
/>,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: "photo.jpg" }));
expect(downloadEvidence).toHaveBeenCalledWith(9, "photo.jpg", "23");
});
});

View file

@ -43,6 +43,27 @@ describe("upliftsApi", () => {
await expect(upliftsApi.canApprove(2)).resolves.toBe(false);
});
it("requests one linked document when a document id is provided", async () => {
apiRequestRaw.mockResolvedValueOnce(
new Response("%PDF", {
headers: { "Content-Disposition": 'attachment; filename="photo.jpg"' },
}),
);
URL.createObjectURL = vi.fn(() => "blob:evidence");
URL.revokeObjectURL = vi.fn();
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
await upliftsApi.downloadEvidence(7, "photo.jpg", 23);
expect(apiRequestRaw).toHaveBeenCalledWith(
"get",
"uplifts/7/evidence",
{ searchParams: { documentId: 23 } },
"upliftsApi.downloadEvidence",
);
click.mockRestore();
});
describe("openEvidence", () => {
function evidenceResponse(body: string, contentType: string): Response {
return new Response(body, {

View file

@ -39,6 +39,24 @@ describe("workOrderUpliftsApi", () => {
});
});
it("includes every scanned document id when several files passed", async () => {
apiPost.mockResolvedValueOnce({ id: 9, status: "pending", amount: 25, notes: "Leak" });
await workOrderUpliftsApi.create(7, {
amount: 25,
notes: "Leak",
evidenceDocumentId: 22,
evidenceDocumentIds: [22, 23],
});
expect(apiPost).toHaveBeenCalledWith("workorders/7/uplifts", {
amount: 25,
notes: "Leak",
evidenceDocumentId: 22,
evidenceDocumentIds: [22, 23],
});
});
it("uploads one evidence file and reads the pending document id", async () => {
apiPostForm.mockResolvedValueOnce({
data: { id: 22, scanStatus: "Pending", originalFileName: "quote.pdf" },

View file

@ -18,13 +18,14 @@ describe("submitWorkOrderUplift", () => {
it("creates without an evidence id when no file is attached", async () => {
const collaborators = deps();
await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: null }, collaborators);
await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", files: [] }, collaborators);
expect(collaborators.uploadEvidence).not.toHaveBeenCalled();
expect(collaborators.create).toHaveBeenCalledWith(7, {
amount: 25,
notes: "Leak",
evidenceDocumentId: undefined,
evidenceDocumentIds: undefined,
});
});
@ -35,13 +36,14 @@ describe("submitWorkOrderUplift", () => {
getEvidenceStatus: vi.fn().mockResolvedValue({ id: 22, scanStatus: "Passed" }),
});
await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators);
await submitWorkOrderUplift(7, { amount: 25, notes: "Leak", files: [quote] }, collaborators);
expect(collaborators.uploadEvidence).toHaveBeenCalledWith(7, quote);
expect(collaborators.create).toHaveBeenCalledWith(7, {
amount: 25,
notes: "Leak",
evidenceDocumentId: 22,
evidenceDocumentIds: [22],
});
});
@ -52,7 +54,7 @@ describe("submitWorkOrderUplift", () => {
});
await expect(
submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators),
submitWorkOrderUplift(7, { amount: 25, notes: "Leak", files: [quote] }, collaborators),
).rejects.toThrow(/rejected/i);
expect(collaborators.create).not.toHaveBeenCalled();
});
@ -65,9 +67,35 @@ describe("submitWorkOrderUplift", () => {
});
await expect(
submitWorkOrderUplift(7, { amount: 25, notes: "Leak", file: quote }, collaborators),
submitWorkOrderUplift(7, { amount: 25, notes: "Leak", files: [quote] }, collaborators),
).rejects.toThrow(/still being scanned/i);
expect(collaborators.create).not.toHaveBeenCalled();
expect(collaborators.sleep).toHaveBeenCalledTimes(30);
});
it("uploads every file and sends each passed document id", async () => {
const quote = new File(["%PDF"], "quote.pdf", { type: "application/pdf" });
const photo = new File(["jpeg"], "photo.jpg", { type: "image/jpeg" });
const collaborators = deps({
uploadEvidence: vi
.fn()
.mockResolvedValueOnce({ id: 22, scanStatus: "Passed" })
.mockResolvedValueOnce({ id: 23, scanStatus: "Passed" }),
});
await submitWorkOrderUplift(
7,
{ amount: 25, notes: "Leak", files: [quote, photo] },
collaborators,
);
expect(collaborators.uploadEvidence).toHaveBeenNthCalledWith(1, 7, quote);
expect(collaborators.uploadEvidence).toHaveBeenNthCalledWith(2, 7, photo);
expect(collaborators.create).toHaveBeenCalledWith(7, {
amount: 25,
notes: "Leak",
evidenceDocumentId: 22,
evidenceDocumentIds: [22, 23],
});
});
});