@@ -1009,6 +833,7 @@ export default function CollectionConfirmPage() {
onAddToPos={(rowId) => void handleAddToPos([rowId])}
onCheckWithAi={() => handleCheckWithAi(group.poNumber)}
onMovePhoto={handleMovePhoto}
+ onDeletePhoto={handleDeletePhoto}
onConfirmAndAddToPos={() =>
setConfirmState({
poNumber: group.poNumber,
diff --git a/src/app/api/v1/collection-confirm/delete-photo/route.test.ts b/src/app/api/v1/collection-confirm/delete-photo/route.test.ts
new file mode 100644
index 0000000..328b9ee
--- /dev/null
+++ b/src/app/api/v1/collection-confirm/delete-photo/route.test.ts
@@ -0,0 +1,62 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+const deleteCollectionPhoto = vi.fn();
+vi.mock("@/lib/server/collection-photo-move-service", async () => {
+ class MovePhotoError extends Error {
+ constructor(message: string, public status: number) {
+ super(message);
+ }
+ }
+ return { deleteCollectionPhoto: (...a: unknown[]) => deleteCollectionPhoto(...a), MovePhotoError };
+});
+const getBrowserSessionUser = vi.fn();
+vi.mock("@/lib/server/browser-session", () => ({
+ getBrowserSessionUser: (...a: unknown[]) => getBrowserSessionUser(...a),
+}));
+
+import { POST } from "./route";
+import { MovePhotoError } from "@/lib/server/collection-photo-move-service";
+
+const body = { collection: "order_items", ids: ["oi-1"], filename: "inv.jpg" };
+const req = (b: unknown) =>
+ new NextRequest("http://localhost/api/v1/collection-confirm/delete-photo", {
+ method: "POST",
+ headers: { authorization: "Bearer tok", "content-type": "application/json" },
+ body: JSON.stringify(b),
+ });
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ getBrowserSessionUser.mockResolvedValue({ id: "u1", name: "Sam" });
+});
+
+describe("POST /api/v1/collection-confirm/delete-photo", () => {
+ it("401s without a session", async () => {
+ getBrowserSessionUser.mockRejectedValue(Object.assign(new Error("Not authenticated."), { status: 401 }));
+ expect((await POST(req(body))).status).toBe(401);
+ expect(deleteCollectionPhoto).not.toHaveBeenCalled();
+ });
+
+ it.each([[{ ...body, ids: [] }], [{ ...body, filename: "" }], [{ ...body, ids: "oi-1" }], [{}]])(
+ "400s for a bad body %#",
+ async (b) => {
+ expect((await POST(req(b))).status).toBe(400);
+ expect(deleteCollectionPhoto).not.toHaveBeenCalled();
+ },
+ );
+
+ it("deletes the photo", async () => {
+ deleteCollectionPhoto.mockResolvedValue({ removed_from: ["oi-1"] });
+ const res = await POST(req(body));
+ expect(deleteCollectionPhoto).toHaveBeenCalledWith(body);
+ expect(await res.json()).toEqual({ removed_from: ["oi-1"] });
+ });
+
+ it("passes the service's status through", async () => {
+ deleteCollectionPhoto.mockRejectedValue(new MovePhotoError("not the PO itself", 400));
+ const res = await POST(req(body));
+ expect(res.status).toBe(400);
+ expect(await res.json()).toEqual({ error: "not the PO itself" });
+ });
+});
diff --git a/src/app/api/v1/collection-confirm/delete-photo/route.ts b/src/app/api/v1/collection-confirm/delete-photo/route.ts
new file mode 100644
index 0000000..b11fd93
--- /dev/null
+++ b/src/app/api/v1/collection-confirm/delete-photo/route.ts
@@ -0,0 +1,36 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getBrowserSessionUser } from "@/lib/server/browser-session";
+import { deleteCollectionPhoto, MovePhotoError } from "@/lib/server/collection-photo-move-service";
+
+/**
+ * POST /api/v1/collection-confirm/delete-photo
+ *
+ * "Delete photo" on a Collection Confirm card: removes a wrongly uploaded
+ * or duplicate photo (and the AI's read of it). Body:
+ * `{ collection: "order_items" | "stock_purchases", ids: string[], filename }`.
+ * The PO PDF can't be deleted here.
+ *
+ * Auth: browser session (PB token forwarded in `Authorization`).
+ */
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+const str = (v: unknown) => (typeof v === "string" ? v.trim() : "");
+
+export async function POST(req: NextRequest) {
+ try {
+ await getBrowserSessionUser(req);
+ const body = (await req.json().catch(() => ({}))) as { collection?: unknown; ids?: unknown; filename?: unknown };
+ const collection = str(body.collection);
+ const filename = str(body.filename);
+ const ids = Array.isArray(body.ids) ? body.ids.map(str).filter(Boolean) : [];
+ if (!collection || !filename || ids.length === 0) {
+ return NextResponse.json({ error: "collection, ids and filename are required" }, { status: 400 });
+ }
+ return NextResponse.json(await deleteCollectionPhoto({ collection, ids, filename }));
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Deleting the photo failed";
+ const status = err instanceof MovePhotoError ? err.status : (err as { status?: number }).status || 500;
+ return NextResponse.json({ error: message }, { status });
+ }
+}
diff --git a/src/components/collection-confirm/photo-strip.tsx b/src/components/collection-confirm/photo-strip.tsx
index f72a35e..5c01c39 100644
--- a/src/components/collection-confirm/photo-strip.tsx
+++ b/src/components/collection-confirm/photo-strip.tsx
@@ -14,6 +14,12 @@ export type Attachment = {
* uploaded the same filename to multiple rows.
*/
itemId: string;
+ /**
+ * What "Delete photo" removes: the order lines (or the stock purchase)
+ * carrying this file. Absent for files that can't be deleted from
+ * Collection Confirm (the PO PDF).
+ */
+ deleteTarget?: { collection: "order_items" | "stock_purchases"; ids: string[]; filename: string };
};
type PhotoStripProps = {
diff --git a/src/components/collection-confirm/po-card-ai.test.tsx b/src/components/collection-confirm/po-card-ai.test.tsx
index b0f176a..894ec5a 100644
--- a/src/components/collection-confirm/po-card-ai.test.tsx
+++ b/src/components/collection-confirm/po-card-ai.test.tsx
@@ -119,6 +119,39 @@ describe("PoCard AI layer", () => {
expect(box).toHaveTextContent("Year not printed on the document date");
});
+ describe("Delete photo", () => {
+ const withPhotos = makeAiRow({ order_item_attachements: ["one.jpg", "two.jpg"] });
+
+ it("deletes the photo open in the previewer after you confirm", async () => {
+ const onDeletePhoto = vi.fn(async () => {});
+ render(
);
+ await userEvent.click(screen.getByRole("button", { name: /two\.jpg 2 of 2/ }));
+ await userEvent.click(screen.getByTestId("po-card-delete-photo-PO-1"));
+ expect(onDeletePhoto).not.toHaveBeenCalled();
+ expect(screen.getByTestId("po-card-delete-confirm-PO-1")).toHaveTextContent("Delete two.jpg?");
+ await userEvent.click(screen.getByRole("button", { name: "Yes, delete" }));
+ expect(onDeletePhoto).toHaveBeenCalledWith({ collection: "order_items", ids: [withPhotos.id], filename: "two.jpg" });
+ });
+
+ it("can be cancelled", async () => {
+ const onDeletePhoto = vi.fn(async () => {});
+ render(
);
+ await userEvent.click(screen.getByTestId("po-card-delete-photo-PO-1"));
+ await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(screen.queryByTestId("po-card-delete-confirm-PO-1")).not.toBeInTheDocument();
+ expect(onDeletePhoto).not.toHaveBeenCalled();
+ });
+
+ it("isn't offered for the PO PDF", () => {
+ const poOnly = makeAiRow({
+ order_item_attachements: [],
+ expand: { purchase_order: { id: "po-1", documents: ["po.pdf"] } },
+ } as never);
+ render(
);
+ expect(screen.queryByTestId("po-card-delete-photo-PO-1")).not.toBeInTheDocument();
+ });
+ });
+
describe("photo on the wrong PO", () => {
const wrongDoc = {
read_id: "r",
diff --git a/src/components/collection-confirm/po-card.tsx b/src/components/collection-confirm/po-card.tsx
index 5e8fa4e..16f2d3f 100644
--- a/src/components/collection-confirm/po-card.tsx
+++ b/src/components/collection-confirm/po-card.tsx
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
-import { Bot, Camera, Check, ChevronDown, FileText, FileUp, Loader2, Maximize2, PictureInPicture2 } from "lucide-react";
+import { Bot, Camera, Check, ChevronDown, FileText, FileUp, Loader2, Maximize2, PictureInPicture2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Splitter } from "@/components/ui/splitter";
@@ -141,6 +141,8 @@ type Props = {
onCheckWithAi?: () => Promise
;
/** Confirm for invoicing, then add every not-in-POS customer line to the POS. */
onConfirmAndAddToPos?: () => void;
+ /** Delete a wrongly uploaded or duplicate photo (the one open in the previewer). */
+ onDeletePhoto?: (target: NonNullable) => Promise;
/** Move a driver photo whose invoice names another PO onto that PO. */
onMovePhoto?: (source: { collection: string; id: string; filename: string }, toPoNumber: string) => Promise;
};
@@ -202,6 +204,7 @@ export function PoCard({
onCheckWithAi,
onConfirmAndAddToPos,
onMovePhoto,
+ onDeletePhoto,
}: Props) {
const itemCount = rows.length;
const [checkingAi, setCheckingAi] = useState(false);
@@ -225,6 +228,8 @@ export function PoCard({
return out;
}, [rows]);
const [movingPhoto, setMovingPhoto] = useState(null);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [deletingPhoto, setDeletingPhoto] = useState(false);
// Every line the AI read from the chip's document; lines paired with a row
// on this card are marked "matched". Falls back to the paired lines alone
// when the document's full line list is unavailable.
@@ -381,10 +386,16 @@ export function PoCard({
: purchaseOrderId
? `purchase_orders/${encodeURIComponent(purchaseOrderId)}`
: `order_items/${encodeURIComponent(itemId)}`;
+ const deleteTarget: Attachment["deleteTarget"] = stockPurchaseId
+ ? { collection: "stock_purchases", ids: [stockPurchaseId], filename }
+ : purchaseOrderId
+ ? undefined
+ : { collection: "order_items", ids: itemIds, filename };
return {
filename,
itemId,
url: `/api/v1/files/${collectionPath}/${encodeURIComponent(filename)}`,
+ ...(deleteTarget ? { deleteTarget } : {}),
} satisfies Attachment;
}),
[rows],
@@ -399,6 +410,9 @@ export function PoCard({
const [activeAttachment, setActiveAttachment] = useState(
() => attachments[0] ?? null,
);
+ // A pending "Delete …?" belongs to the photo it was asked about.
+ const activeUrl = activeAttachment?.url;
+ useEffect(() => setConfirmDelete(false), [activeUrl]);
// If the row set changes (refresh, PO switch) and the active
// attachment is no longer present, fall back to the first one.
@@ -760,8 +774,54 @@ export function PoCard({
) : null}
- {attachments.length > 0 && onInvoiceUpload ? (
-
+ {attachments.length > 0 && (onInvoiceUpload || onDeletePhoto) ? (
+
+ {onDeletePhoto && activeAttachment?.deleteTarget ? (
+ confirmDelete ? (
+
+ Delete {activeAttachment.filename}? This can't be undone.
+
+
+
+ ) : (
+
+ )
+ ) : null}
+ {onInvoiceUpload ? (
+ <>
: }
{uploading ? "Uploading" : hasCustomerRows ? "Add supplier invoice" : "Add another invoice"}
+ >
+ ) : null}
) : null}
diff --git a/src/lib/server/collection-photo-move-service.test.ts b/src/lib/server/collection-photo-move-service.test.ts
index d52b90c..6c8f3b6 100644
--- a/src/lib/server/collection-photo-move-service.test.ts
+++ b/src/lib/server/collection-photo-move-service.test.ts
@@ -50,7 +50,7 @@ const pb = {
};
vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: async () => pb }));
-import { movePhotoToPo, MovePhotoError } from "./collection-photo-move-service";
+import { deleteCollectionPhoto, movePhotoToPo, MovePhotoError } from "./collection-photo-move-service";
const fetchFile = vi.fn(async () => ({ bytes: Buffer.from("jpeg-bytes"), contentType: "image/jpeg" }));
const SOURCE = { collection: "order_items", id: "oi-thin", filename: "inv_abc.jpg" };
@@ -111,3 +111,45 @@ describe("movePhotoToPo", () => {
expect(writes).toEqual([]);
});
});
+
+describe("deleteCollectionPhoto", () => {
+ it("removes the photo from every line that carries it and forgets the AI's read", async () => {
+ table("order_items").set("oi-dup", {
+ id: "oi-dup",
+ purchase_order: "po-bly",
+ item_status: "collected",
+ order_item_attachements: ["inv_abc.jpg"],
+ ai_check: { read_id: "r1" },
+ });
+
+ await deleteCollectionPhoto({ collection: "order_items", ids: ["oi-thin", "oi-dup"], filename: "inv_abc.jpg" });
+
+ expect(table("order_items").get("oi-thin")?.order_item_attachements).toEqual(["other.jpg"]);
+ expect(table("order_items").get("oi-dup")?.order_item_attachements).toEqual([]);
+ expect(deletes).toEqual([{ coll: "collection_doc_reads", id: "r1" }]);
+ expect(table("order_items").get("oi-thin")?.ai_check).toBeNull();
+ expect(table("order_items").get("oi-dup")?.ai_check).toBeNull();
+ });
+
+ it("clears a stock purchase's single photo slot", async () => {
+ table("stock_purchases").get("sp-1")!.additional_attachments = "dn.jpg";
+ table("collection_doc_reads").set("r3", { id: "r3", source_collection: "stock_purchases", source_id: "sp-1", filename: "dn.jpg" });
+ table("stock_purchase_items").get("spi-1")!.ai_check = { read_id: "r3" };
+
+ await deleteCollectionPhoto({ collection: "stock_purchases", ids: ["sp-1"], filename: "dn.jpg" });
+
+ expect(table("stock_purchases").get("sp-1")?.additional_attachments).toBeNull();
+ expect(deletes).toEqual([{ coll: "collection_doc_reads", id: "r3" }]);
+ expect(table("stock_purchase_items").get("spi-1")?.ai_check).toBeNull();
+ });
+
+ it("refuses the PO PDF and photos that aren't there", async () => {
+ await expect(
+ deleteCollectionPhoto({ collection: "purchase_orders", ids: ["po-bly"], filename: "po.pdf" }),
+ ).rejects.toMatchObject({ status: 400 });
+ await expect(
+ deleteCollectionPhoto({ collection: "order_items", ids: ["oi-thin"], filename: "nope.jpg" }),
+ ).rejects.toMatchObject({ status: 404 });
+ expect(writes).toEqual([]);
+ });
+});
diff --git a/src/lib/server/collection-photo-move-service.ts b/src/lib/server/collection-photo-move-service.ts
index bb0eecd..1c0be60 100644
--- a/src/lib/server/collection-photo-move-service.ts
+++ b/src/lib/server/collection-photo-move-service.ts
@@ -139,21 +139,78 @@ export async function movePhotoToPo(
}
// 3. Forget the AI's read of it where it was, and the checks built on it.
+ await forgetReads(pb, source, srcPoId);
+
+ return { kind: hit.kind, po_number: hit.po_number, target_id: targetId };
+}
+
+type AdminPb = Awaited
>;
+
+/**
+ * Delete the AI's reads of one photo and clear the checks on the PO's
+ * lines that were built on them, so nothing points at a photo that has
+ * moved or gone. `poId` is the customer PO (order-line source) or the
+ * stock purchase itself.
+ */
+async function forgetReads(pb: AdminPb, source: PhotoSource, poId: string): Promise {
const reads = await pb.collection(READS).getFullList({
filter: `source_collection = "${q(source.collection)}" && source_id = "${q(source.id)}" && filename = "${q(source.filename)}"`,
});
const readIds = new Set(reads.map((r) => r.id));
+ if (readIds.size === 0) return;
for (const id of readIds) await pb.collection(READS).delete(id);
- if (readIds.size > 0) {
- const srcLines =
- source.collection === STOCK_POS
- ? { coll: STOCK_LINES, items: await pb.collection(STOCK_LINES).getFullList({ filter: `purchase = "${q(source.id)}"` }) }
- : { coll: orderItems, items: await pb.collection(orderItems).getFullList({ filter: `purchase_order = "${q(srcPoId)}"` }) };
- for (const l of srcLines.items) {
- const readId = (l.ai_check as { read_id?: string } | null)?.read_id;
- if (readId && readIds.has(readId)) await pb.collection(srcLines.coll).update(l.id, { ai_check: null });
- }
+ const lines =
+ source.collection === STOCK_POS
+ ? { coll: STOCK_LINES, items: await pb.collection(STOCK_LINES).getFullList({ filter: `purchase = "${q(poId)}"` }) }
+ : {
+ coll: source.collection,
+ items: poId ? await pb.collection(source.collection).getFullList({ filter: `purchase_order = "${q(poId)}"` }) : [],
+ };
+ for (const l of lines.items) {
+ const readId = (l.ai_check as { read_id?: string } | null)?.read_id;
+ if (readId && readIds.has(readId)) await pb.collection(lines.coll).update(l.id, { ai_check: null });
}
-
- return { kind: hit.kind, po_number: hit.po_number, target_id: targetId };
+}
+
+/**
+ * Delete a photo a driver or buyer uploaded by mistake (or a duplicate)
+ * from Collection Confirm: off every record that carries it, plus the
+ * AI's reads of it. Only driver/buyer uploads — order-line
+ * `order_item_attachements` and a stock purchase's `additional_attachments`;
+ * the PO PDF on `purchase_orders` can't be deleted here. PB removes the
+ * file itself; this can't be undone.
+ */
+export async function deleteCollectionPhoto(args: {
+ collection: string;
+ ids: string[];
+ filename: string;
+}): Promise<{ removed_from: string[] }> {
+ const orderItems = getOrderItemsCollectionName();
+ const collection = args.collection === "order_items" ? orderItems : args.collection;
+ if (collection !== orderItems && collection !== STOCK_POS) {
+ throw new MovePhotoError("Only driver and invoice photos can be deleted here, not the PO itself", 400);
+ }
+ const filename = args.filename.trim();
+ const ids = [...new Set(args.ids.map((i) => i.trim()).filter(Boolean))];
+ if (!filename || ids.length === 0) throw new MovePhotoError("ids and filename are required", 400);
+
+ const pb = await getAdminPocketBase();
+ const field = collection === STOCK_POS ? "additional_attachments" : "order_item_attachements";
+ const holders: Rec[] = [];
+ for (const id of ids) {
+ const rec = (await pb.collection(collection).getOne(id).catch(() => null)) as Rec | null;
+ if (rec && fileList(rec[field]).includes(filename)) holders.push(rec);
+ }
+ if (holders.length === 0) throw new MovePhotoError("That photo isn't there any more", 404);
+
+ for (const rec of holders) {
+ if (collection === STOCK_POS) {
+ await pb.collection(STOCK_POS).update(rec.id, { additional_attachments: null });
+ } else {
+ await pb.collection(collection).update(rec.id, { "order_item_attachements-": [filename] });
+ }
+ const poId = collection === STOCK_POS ? rec.id : String(rec.purchase_order ?? "");
+ await forgetReads(pb, { collection, id: rec.id, filename }, poId);
+ }
+ return { removed_from: holders.map((r) => r.id) };
}