feat(stock-orders): attachments card on detail page

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-06 23:29:00 +02:00
parent f89bbb3a35
commit dd57d29de3
4 changed files with 383 additions and 9 deletions

View file

@ -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({
</CardContent>
</Card>
<AttachmentsCard
purchaseId={purchase.id}
attachments={normaliseAttachmentArray(
purchase.additional_attachments,
)}
onAttachmentsChanged={() => void fetchAll()}
/>
{purchase.notes && (
<Card>
<CardHeader>

View file

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

View file

@ -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("<AttachmentsCard />", () => {
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("renders the empty state and upload button when no attachments exist", () => {
render(
<AttachmentsCard
purchaseId="sp-1"
attachments={[]}
onAttachmentsChanged={vi.fn()}
/>,
);
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(
<AttachmentsCard
purchaseId="sp-1"
attachments={["invoice.pdf", "packing-slip.pdf"]}
onAttachmentsChanged={vi.fn()}
/>,
);
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(
<AttachmentsCard
purchaseId="sp-1"
attachments={[]}
onAttachmentsChanged={onChanged}
/>,
);
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(
<AttachmentsCard
purchaseId="sp-1"
attachments={[]}
onAttachmentsChanged={onChanged}
/>,
);
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(
<AttachmentsCard
purchaseId="sp-1"
attachments={["keep.pdf", "drop.pdf"]}
onAttachmentsChanged={onChanged}
/>,
);
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"]);
});
});

View file

@ -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
* `<input type="file">` + 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/<id>/<token>`
* 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<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Card data-testid="stock-order-attachments-card">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<div className="min-w-0">
<CardTitle className="flex items-center gap-2 text-lg">
<Paperclip className="h-4 w-4 text-muted-foreground" />
Attachments
</CardTitle>
<CardDescription>
Supplier invoices, packing slips, photos. Distinct from the
versioned PO PDF (which lives under &quot;Send to supplier&quot;).
</CardDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onClick={() => fileInputRef.current?.click()}
data-testid={`stock-order-attachments-upload-${purchaseId}`}
>
{busy ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileUp className="mr-2 h-4 w-4" />
)}
Upload
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf,image/*"
className="sr-only"
aria-label={`Upload attachment for stock purchase ${purchaseId}`}
data-testid={`stock-order-attachments-file-input-${purchaseId}`}
onChange={async (e) => {
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);
}}
/>
</CardHeader>
<CardContent>
{error && (
<div
role="alert"
data-testid="stock-order-attachments-error"
className="mb-3 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</div>
)}
{attachments.length === 0 ? (
<div
data-testid="stock-order-attachments-empty"
className="rounded-lg border border-dashed py-6 text-center text-sm text-muted-foreground"
>
No attachments yet.
</div>
) : (
<ul className="flex flex-wrap gap-2">
{attachments.map((token) => (
<li
key={token}
className="inline-flex items-center gap-1 rounded-md border bg-background px-2 py-1 text-sm"
>
<a
href={`/api/v1/files/stock_purchases/${purchaseId}/${encodeURIComponent(token)}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
data-testid={`stock-order-attachment-chip-${token}`}
>
{token}
</a>
<button
type="button"
onClick={() => void handleDelete(token)}
disabled={busy}
aria-label={`Delete ${token}`}
className="ml-1 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-50"
data-testid={`stock-order-attachment-delete-${token}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}