feat(stock-orders): attachments card on detail page
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
f89bbb3a35
commit
dd57d29de3
4 changed files with 383 additions and 9 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
144
src/components/stock-orders/attachments-card.test.tsx
Normal file
144
src/components/stock-orders/attachments-card.test.tsx
Normal 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"]);
|
||||
});
|
||||
});
|
||||
198
src/components/stock-orders/attachments-card.tsx
Normal file
198
src/components/stock-orders/attachments-card.tsx
Normal 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 "Send to supplier").
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue