Compare commits

..

2 commits

Author SHA1 Message Date
Arthur Bassi
e8c3a8ef4f
Merge 64686a59eb into 6a6d519ed7 2026-09-28 19:26:20 +00:00
Arthur Bassi
64686a59eb 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.
2026-09-28 16:25:45 -03:00
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],
});
});
});