From dd57d29de3c0c9f8769b04c7b233a2bcaf2f56c0 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 6 Aug 2026 23:29:00 +0200 Subject: [PATCH] feat(stock-orders): attachments card on detail page Co-authored-by: Orca --- src/app/(app)/stock-orders/[id]/page.tsx | 28 +++ .../[id]/attachments/[filename]/route.test.ts | 22 +- .../stock-orders/attachments-card.test.tsx | 144 +++++++++++++ .../stock-orders/attachments-card.tsx | 198 ++++++++++++++++++ 4 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 src/components/stock-orders/attachments-card.test.tsx create mode 100644 src/components/stock-orders/attachments-card.tsx diff --git a/src/app/(app)/stock-orders/[id]/page.tsx b/src/app/(app)/stock-orders/[id]/page.tsx index fed565b..58cbe51 100644 --- a/src/app/(app)/stock-orders/[id]/page.tsx +++ b/src/app/(app)/stock-orders/[id]/page.tsx @@ -50,6 +50,7 @@ import { import { toast } from "sonner"; import getPocketBase from "@/lib/pocketbase"; import { buildDefaultPoEmailBody } from "@/lib/server/po-email-template"; +import { AttachmentsCard } from "@/components/stock-orders/attachments-card"; import type { StockPurchase, @@ -126,6 +127,25 @@ function formatCurrency(n: number | null | undefined): string { })}`; } +/** + * PB may return `additional_attachments` as either a JSON array + * (`["a.pdf", "b.pdf"]`) or a legacy single string. Coerce both + * into a stable string array so the upload card and the + * `/api/v1/files/...` proxy always agree on shape. + */ +function normaliseAttachmentArray( + raw: string[] | string | null | undefined, +): string[] { + if (raw == null) return []; + if (Array.isArray(raw)) return raw; + if (typeof raw === "string" && raw.length > 0) { + // PB comma-joined filenames are typically split by `, ` but be + // defensive — split on any comma and trim. + return raw.split(",").map((s) => s.trim()).filter(Boolean); + } + return []; +} + export default function StockOrderDetailPage({ params, }: { @@ -904,6 +924,14 @@ export default function StockOrderDetailPage({ + void fetchAll()} + /> + {purchase.notes && ( diff --git a/src/app/api/v1/stock-purchases/[id]/attachments/[filename]/route.test.ts b/src/app/api/v1/stock-purchases/[id]/attachments/[filename]/route.test.ts index 640180d..8acc110 100644 --- a/src/app/api/v1/stock-purchases/[id]/attachments/[filename]/route.test.ts +++ b/src/app/api/v1/stock-purchases/[id]/attachments/[filename]/route.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; vi.mock("@/lib/server/stock-purchases-service", () => ({ removeStockPurchaseAttachment: vi.fn(), @@ -23,9 +24,10 @@ describe("DELETE /api/v1/stock-purchases/[id]/attachments/[filename]", () => { it("returns 200 + remaining attachments on success", async () => { rmMock.mockResolvedValueOnce({ attachments: ["keep.pdf"] }); const res = await DELETE( - new Request("http://localhost/api/v1/stock-purchases/sp-1/attachments/drop.pdf", { - method: "DELETE", - }), + new NextRequest( + "http://localhost/api/v1/stock-purchases/sp-1/attachments/drop.pdf", + { method: "DELETE" }, + ), { params: Promise.resolve({ id: "sp-1", filename: "drop.pdf" }) }, ); expect(res.status).toBe(200); @@ -40,9 +42,10 @@ describe("DELETE /api/v1/stock-purchases/[id]/attachments/[filename]", () => { it("returns 404 when the token is not in the array", async () => { rmMock.mockRejectedValueOnce(new Error("Attachment not found: nope.pdf")); const res = await DELETE( - new Request("http://localhost/api/v1/stock-purchases/sp-1/attachments/nope.pdf", { - method: "DELETE", - }), + new NextRequest( + "http://localhost/api/v1/stock-purchases/sp-1/attachments/nope.pdf", + { method: "DELETE" }, + ), { params: Promise.resolve({ id: "sp-1", filename: "nope.pdf" }) }, ); expect(res.status).toBe(404); @@ -53,9 +56,10 @@ describe("DELETE /api/v1/stock-purchases/[id]/attachments/[filename]", () => { it("returns 500 on other service errors", async () => { rmMock.mockRejectedValueOnce(new Error("PB unreachable")); const res = await DELETE( - new Request("http://localhost/api/v1/stock-purchases/sp-1/attachments/drop.pdf", { - method: "DELETE", - }), + new NextRequest( + "http://localhost/api/v1/stock-purchases/sp-1/attachments/drop.pdf", + { method: "DELETE" }, + ), { params: Promise.resolve({ id: "sp-1", filename: "drop.pdf" }) }, ); expect(res.status).toBe(500); diff --git a/src/components/stock-orders/attachments-card.test.tsx b/src/components/stock-orders/attachments-card.test.tsx new file mode 100644 index 0000000..057148b --- /dev/null +++ b/src/components/stock-orders/attachments-card.test.tsx @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { AttachmentsCard } from "./attachments-card"; + +const fetchMock = vi.fn(); + +describe("", () => { + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the empty state and upload button when no attachments exist", () => { + render( + , + ); + expect( + screen.getByTestId("stock-order-attachments-card"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("stock-order-attachments-empty"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("stock-order-attachments-upload-sp-1"), + ).toBeInTheDocument(); + }); + + it("renders one chip per attachment and a download URL", () => { + render( + , + ); + expect( + screen.getByTestId("stock-order-attachment-chip-invoice.pdf"), + ).toHaveAttribute( + "href", + "/api/v1/files/stock_purchases/sp-1/invoice.pdf", + ); + expect( + screen.getByTestId("stock-order-attachment-chip-packing-slip.pdf"), + ).toBeInTheDocument(); + }); + + it("uploads the selected file via POST /api/v1/stock-purchases/.../attachments/upload", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ + ok: true, + attachments: ["invoice.pdf"], + added: ["invoice.pdf"], + }), + }); + const onChanged = vi.fn(); + render( + , + ); + const input = screen.getByTestId( + "stock-order-attachments-file-input-sp-1", + ); + const file = new File(["data"], "invoice.pdf", { type: "application/pdf" }); + fireEvent.change(input, { + target: { files: [file] }, + }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toMatch( + /\/api\/v1\/stock-purchases\/sp-1\/attachments\/upload$/, + ); + expect(init.method).toBe("POST"); + expect(init.body).toBeInstanceOf(FormData); + expect(onChanged).toHaveBeenCalledWith(["invoice.pdf"]); + }); + + it("surfaces upload errors and does not call onAttachmentsChanged on failure", async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 413, + json: async () => ({ error: "File too large" }), + }); + const onChanged = vi.fn(); + render( + , + ); + const input = screen.getByTestId( + "stock-order-attachments-file-input-sp-1", + ); + const file = new File(["data"], "invoice.pdf", { type: "application/pdf" }); + fireEvent.change(input, { + target: { files: [file] }, + }); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect( + screen.getByTestId("stock-order-attachments-error"), + ).toBeInTheDocument(), + ); + expect(onChanged).not.toHaveBeenCalled(); + }); + + it("deletes an attachment when the chip X is clicked", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: true, attachments: ["keep.pdf"] }), + }); + const onChanged = vi.fn(); + render( + , + ); + fireEvent.click( + screen.getByTestId("stock-order-attachment-delete-drop.pdf"), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe( + "/api/v1/stock-purchases/sp-1/attachments/drop.pdf", + ); + expect(init.method).toBe("DELETE"); + expect(onChanged).toHaveBeenCalledWith(["keep.pdf"]); + }); +}); \ No newline at end of file diff --git a/src/components/stock-orders/attachments-card.tsx b/src/components/stock-orders/attachments-card.tsx new file mode 100644 index 0000000..7c306aa --- /dev/null +++ b/src/components/stock-orders/attachments-card.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useRef, useState } from "react"; +import { FileUp, Loader2, Paperclip, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +/** + * Attachments card for the stock-order detail page. + * + * Mirrors the upload strip in + * `src/components/collection-confirm/po-card.tsx` (hidden + * `` + button + chips) but lives at the page + * level rather than inside a collapsible card. The previews route + * through the existing `/api/v1/files/stock_purchases//` + * proxy, which is already whitelisted. + * + * The card does NOT own the attachments array — the parent page + * owns `purchase.additional_attachments` and passes the slice + * down, plus an `onAttachmentsChanged` callback so the page can + * re-fetch via `fetchAll()` after a successful upload/delete. This + * keeps the canonical state machine on the page (matching the + * rest of /stock-orders/[id]). + */ + +type AttachmentsCardProps = { + purchaseId: string; + attachments: string[]; + onAttachmentsChanged: (next: string[]) => void; +}; + +export function AttachmentsCard({ + purchaseId, + attachments, + onAttachmentsChanged, +}: AttachmentsCardProps) { + const fileInputRef = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const handleFile = async (file: File) => { + setError(null); + setBusy(true); + try { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch( + `/api/v1/stock-purchases/${purchaseId}/attachments/upload`, + { method: "POST", body: fd }, + ); + const body = (await res.json().catch(() => ({}))) as { + attachments?: string[]; + error?: string; + }; + if (!res.ok) { + throw new Error(body.error ?? `HTTP ${res.status}`); + } + onAttachmentsChanged(body.attachments ?? []); + toast.success(`Uploaded ${file.name}`); + } catch (err) { + const msg = err instanceof Error ? err.message : "Upload failed"; + setError(msg); + toast.error(msg); + } finally { + setBusy(false); + } + }; + + const handleDelete = async (token: string) => { + setError(null); + setBusy(true); + try { + const res = await fetch( + `/api/v1/stock-purchases/${purchaseId}/attachments/${encodeURIComponent(token)}`, + { method: "DELETE" }, + ); + const body = (await res.json().catch(() => ({}))) as { + attachments?: string[]; + error?: string; + }; + if (!res.ok) { + throw new Error(body.error ?? `HTTP ${res.status}`); + } + onAttachmentsChanged(body.attachments ?? []); + } catch (err) { + const msg = err instanceof Error ? err.message : "Delete failed"; + setError(msg); + toast.error(msg); + } finally { + setBusy(false); + } + }; + + return ( + + +
+ + + Attachments + + + Supplier invoices, packing slips, photos. Distinct from the + versioned PO PDF (which lives under "Send to supplier"). + +
+ + { + const file = e.target.files?.[0]; + // Capture the file before clearing `value` — clearing + // wipes `files`, which would prevent handleFile from + // appending anything to FormData below. + if (!file) return; + e.target.value = ""; // allow re-upload of same filename + await handleFile(file); + }} + /> +
+ + {error && ( +
+ {error} +
+ )} + {attachments.length === 0 ? ( +
+ No attachments yet. +
+ ) : ( +
    + {attachments.map((token) => ( +
  • + + {token} + + +
  • + ))} +
+ )} +
+
+ ); +} \ No newline at end of file