feat(collection-confirm): Delete photo; drop the Run agent scan button
- Delete photo next to "Add supplier invoice" removes the photo open in the previewer (wrong upload, or one of several for the same PO) after an inline "Delete …? Yes, delete" confirm; the confirm resets when you switch photos. Not offered for the PO PDF. - POST /api/v1/collection-confirm/delete-photo (browser session) → deleteCollectionPhoto: takes the file off every order line carrying it (or the stock purchase's photo slot) and deletes the AI's reads of it plus the checks built on them (shared forgetReads with Move photo). - Removed the header's blue "Run agent scan" button and its results panel (not in use); the /api/v1/agent/scan route stays for the agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
af4cdc66da
commit
e66ea11845
10 changed files with 351 additions and 278 deletions
|
|
@ -106,6 +106,13 @@ and deletes the AI's read of it so it is read again where it now belongs
|
|||
(`POST /api/v1/collection-confirm/move-photo`,
|
||||
`src/lib/server/collection-photo-move-service.ts`).
|
||||
|
||||
Delete photo (added 2026-10-19): next to "Add supplier invoice", **Delete
|
||||
photo** removes the photo open in the previewer — for a wrong upload or a
|
||||
duplicate — after an inline "Delete …? Yes, delete" confirm. It takes the
|
||||
file off every order line (or the stock purchase) carrying it and deletes
|
||||
the AI's read of it. The PO PDF can't be deleted there. This can't be
|
||||
undone (`POST /api/v1/collection-confirm/delete-photo`).
|
||||
|
||||
## Reading the ticks
|
||||
|
||||
| Colour | Meaning |
|
||||
|
|
|
|||
|
|
@ -1636,72 +1636,15 @@ describe("CollectionConfirmPage", () => {
|
|||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Run agent scan button calls /api/v1/agent/scan and surfaces the triage result", async () => {
|
||||
// Mock the page's initial collection-confirm GET to return
|
||||
// some rows so the page renders. The agent-scan REST proxy is
|
||||
// the second fetch call.
|
||||
vi.mocked(window.fetch).mockImplementation(
|
||||
async (input: RequestInfo | URL) => {
|
||||
const url = fetchUrl(input);
|
||||
if (url.startsWith("/api/v1/collection-confirm")) {
|
||||
return jsonResponse({ items: baseRows });
|
||||
}
|
||||
if (url.startsWith("/api/v1/agent/scan")) {
|
||||
return jsonResponse({
|
||||
total_needing_attention: 3,
|
||||
by_kind: { stock: 2, customer: 1 },
|
||||
by_status: { sent: 3 },
|
||||
pos: [
|
||||
{
|
||||
id: "sp-1",
|
||||
kind: "stock",
|
||||
po_number: "PO26081401STK",
|
||||
status: "sent",
|
||||
attachment_filenames: [],
|
||||
diagnostics: {
|
||||
line_count: 5,
|
||||
lines_missing_sku: 1,
|
||||
lines_missing_cost: 2,
|
||||
lines_missing_supplier_sku: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return jsonResponse({});
|
||||
},
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
it("has no Run agent scan button (removed 2026-10-19)", async () => {
|
||||
vi.mocked(window.fetch).mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = fetchUrl(input);
|
||||
if (url.startsWith("/api/v1/collection-confirm")) return jsonResponse({ items: baseRows });
|
||||
return jsonResponse({});
|
||||
});
|
||||
render(<Page />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("collection-confirm-run-agent-scan");
|
||||
expect(button).toBeInTheDocument();
|
||||
await user.click(button);
|
||||
|
||||
// The result panel renders with the triage summary.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId("collection-confirm-agent-scan-result"),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("collection-confirm-agent-scan-result").textContent,
|
||||
).toMatch(/3 PO\(s\) need attention/);
|
||||
// The sample row surfaces in the panel.
|
||||
const sampleRow = screen.getByTestId(
|
||||
"collection-confirm-agent-scan-sample-row",
|
||||
);
|
||||
expect(sampleRow.textContent).toMatch(/PO26081401STK/);
|
||||
|
||||
// Dismiss button closes the panel.
|
||||
await user.click(
|
||||
screen.getByTestId("collection-confirm-agent-scan-dismiss"),
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId("collection-confirm-agent-scan-result"),
|
||||
).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument());
|
||||
expect(screen.queryByTestId("collection-confirm-run-agent-scan")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/run agent scan/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Bot, Loader2, Search, Truck, UserRound } from "lucide-react";
|
||||
import { Search, Truck, UserRound } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -87,35 +87,6 @@ export default function CollectionConfirmPage() {
|
|||
rowIds: string[];
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
/**
|
||||
* Result of the most recent `purchase_orders.scan` MCP call
|
||||
* triggered by the "Run agent scan" button. Surfaced inline at
|
||||
* the top of the page so the operator (and the agent, if driving
|
||||
* the page via the in-browser MCP transport) can see the triage
|
||||
* state without leaving /collection-confirm.
|
||||
*
|
||||
* `null` means "haven't run the scan yet" or "last scan failed".
|
||||
*/
|
||||
const [agentScanResult, setAgentScanResult] = useState<{
|
||||
total: number;
|
||||
by_kind: { stock: number; customer: number };
|
||||
by_status: Record<string, number>;
|
||||
sample: Array<{
|
||||
id: string;
|
||||
kind: string;
|
||||
po_number: string;
|
||||
status: string;
|
||||
has_invoice_attachment: boolean;
|
||||
diagnostics: {
|
||||
line_count: number;
|
||||
lines_missing_sku: number;
|
||||
lines_missing_cost: number;
|
||||
lines_missing_supplier_sku: number;
|
||||
};
|
||||
}>;
|
||||
ran_at: string;
|
||||
} | null>(null);
|
||||
const [agentScanBusy, setAgentScanBusy] = useState<boolean>(false);
|
||||
/**
|
||||
* Floating-viewer state for "Pop out" (draggable, defaults) and
|
||||
* "Fullscreen" (full-viewport lightbox). The page renders a
|
||||
|
|
@ -158,77 +129,6 @@ export default function CollectionConfirmPage() {
|
|||
return () => window.clearTimeout(handle);
|
||||
}, [load]);
|
||||
|
||||
/**
|
||||
* Trigger the `po-agent`'s triage scan via the server-side
|
||||
* proxy at `/api/v1/agent/scan`. The proxy calls the same
|
||||
* `scanPurchaseOrders` service the MCP `purchase_orders.scan`
|
||||
* tool wraps, so the operator sees the same triage the agent
|
||||
* would see — without needing the AGENT_API_KEY bearer (which is
|
||||
* server-only).
|
||||
*
|
||||
* Result is rendered inline at the top of the page so the
|
||||
* operator can see the same triage the agent would compute —
|
||||
* total + by-kind + by-status + a 5-row sample of the POs most
|
||||
* needing attention.
|
||||
*
|
||||
* We don't go through the agent persona's `purchase_orders.diff`
|
||||
* step here — that's a per-PO action; this button is just the
|
||||
* list-side triage.
|
||||
*/
|
||||
const runAgentScan = useCallback(async () => {
|
||||
setAgentScanBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/v1/agent/scan?kind=both", {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Agent scan failed (HTTP ${response.status})`);
|
||||
}
|
||||
const parsed = (await response.json()) as {
|
||||
total_needing_attention: number;
|
||||
by_kind: { stock: number; customer: number };
|
||||
by_status: Record<string, number>;
|
||||
pos: Array<{
|
||||
id: string;
|
||||
kind: string;
|
||||
po_number: string;
|
||||
status: string;
|
||||
attachment_filenames: string[];
|
||||
diagnostics: {
|
||||
line_count: number;
|
||||
lines_missing_sku: number;
|
||||
lines_missing_cost: number;
|
||||
lines_missing_supplier_sku: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
setAgentScanResult({
|
||||
total: parsed.total_needing_attention,
|
||||
by_kind: parsed.by_kind,
|
||||
by_status: parsed.by_status,
|
||||
sample: parsed.pos.slice(0, 5).map((p) => ({
|
||||
id: p.id,
|
||||
kind: p.kind,
|
||||
po_number: p.po_number,
|
||||
status: p.status,
|
||||
has_invoice_attachment: p.attachment_filenames.length > 0,
|
||||
diagnostics: p.diagnostics,
|
||||
})),
|
||||
ran_at: new Date().toISOString(),
|
||||
});
|
||||
toast.success(
|
||||
parsed.total_needing_attention === 0
|
||||
? "Agent scan: nothing to process"
|
||||
: `Agent scan: ${parsed.total_needing_attention} PO(s) need attention`,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setAgentScanBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const email = getClientIdentity();
|
||||
|
|
@ -514,6 +414,26 @@ export default function CollectionConfirmPage() {
|
|||
[addRowsToPos, load],
|
||||
);
|
||||
|
||||
/** Delete a wrongly uploaded or duplicate photo, then reload. */
|
||||
const handleDeletePhoto = useCallback(
|
||||
async (target: { collection: string; ids: string[]; filename: string }) => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/collection-confirm/delete-photo", {
|
||||
method: "POST",
|
||||
headers: authedJsonHeaders(),
|
||||
body: JSON.stringify(target),
|
||||
});
|
||||
const data = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
if (!response.ok) throw new Error(data.error || `Deleting the photo failed (HTTP ${response.status})`);
|
||||
toast.success(`Deleted ${target.filename}`);
|
||||
await load({ silent: true });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[authedJsonHeaders, load],
|
||||
);
|
||||
|
||||
/** Move a driver photo onto the PO its invoice names, then reload. */
|
||||
const handleMovePhoto = useCallback(
|
||||
async (source: { collection: string; id: string; filename: string }, toPoNumber: string) => {
|
||||
|
|
@ -844,25 +764,6 @@ export default function CollectionConfirmPage() {
|
|||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void runAgentScan()}
|
||||
disabled={agentScanBusy}
|
||||
data-testid="collection-confirm-run-agent-scan"
|
||||
title="Calls the po-agent's purchase_orders.scan MCP tool and shows the triage here. The agent uses this same tool at the start of every session."
|
||||
>
|
||||
{agentScanBusy ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
Scanning…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="mr-1.5 h-3.5 w-3.5" />
|
||||
Run agent scan
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -886,83 +787,6 @@ export default function CollectionConfirmPage() {
|
|||
<EmptyState message="No items are awaiting confirmation." />
|
||||
)}
|
||||
|
||||
{/* Agent scan results — surfaced inline at the top of the page
|
||||
when the operator (or the agent via the in-browser MCP
|
||||
transport) clicks "Run agent scan". Shows the same triage
|
||||
state the `po-agent` would compute — total + by-kind +
|
||||
by-status + a 5-row sample of the POs most needing
|
||||
attention. Closes itself when the operator dismisses it;
|
||||
auto-closes when `load()` finishes (so the panel doesn't
|
||||
stay stale after a refresh). */}
|
||||
{agentScanResult ? (
|
||||
<div
|
||||
className="rounded-lg border bg-violet-50/50 p-3 text-xs dark:bg-violet-500/10"
|
||||
data-testid="collection-confirm-agent-scan-result"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2 font-medium text-violet-900 dark:text-violet-200">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
Agent scan — {new Date(agentScanResult.ran_at).toLocaleTimeString()}
|
||||
</div>
|
||||
<p className="text-violet-900/80 dark:text-violet-300/80">
|
||||
{agentScanResult.total} PO(s) need attention
|
||||
{Object.keys(agentScanResult.by_status).length > 0 ? (
|
||||
<>
|
||||
{" "}— by status:{" "}
|
||||
{Object.entries(agentScanResult.by_status)
|
||||
.map(([s, n]) => `${s}: ${n}`)
|
||||
.join(", ")}
|
||||
; by kind: stock {agentScanResult.by_kind.stock}, customer{" "}
|
||||
{agentScanResult.by_kind.customer}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
{agentScanResult.sample.length > 0 ? (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{agentScanResult.sample.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex items-center gap-2 font-mono text-violet-900/80 dark:text-violet-300/80"
|
||||
data-testid="collection-confirm-agent-scan-sample-row"
|
||||
>
|
||||
<span className="font-semibold">{p.po_number}</span>
|
||||
<span className="rounded bg-violet-200 px-1 text-[10px] uppercase tracking-wide text-violet-900 dark:bg-violet-500/30 dark:text-violet-200">
|
||||
{p.kind}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
p.has_invoice_attachment
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
: "text-rose-700 dark:text-rose-400"
|
||||
}
|
||||
>
|
||||
{p.has_invoice_attachment ? "invoice ✓" : "no invoice"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
·{" "}
|
||||
{p.diagnostics.lines_missing_sku +
|
||||
p.diagnostics.lines_missing_cost +
|
||||
p.diagnostics.lines_missing_supplier_sku}{" "}
|
||||
line(s) need fill
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAgentScanResult(null)}
|
||||
data-testid="collection-confirm-agent-scan-dismiss"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rows !== null && groupedRows.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
62
src/app/api/v1/collection-confirm/delete-photo/route.test.ts
Normal file
62
src/app/api/v1/collection-confirm/delete-photo/route.test.ts
Normal file
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
36
src/app/api/v1/collection-confirm/delete-photo/route.ts
Normal file
36
src/app/api/v1/collection-confirm/delete-photo/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(<PoCard rows={[withPhotos]} {...props} onDeletePhoto={onDeletePhoto} />);
|
||||
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(<PoCard rows={[withPhotos]} {...props} onDeletePhoto={onDeletePhoto} />);
|
||||
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(<PoCard rows={[poOnly]} {...props} onDeletePhoto={vi.fn()} />);
|
||||
expect(screen.queryByTestId("po-card-delete-photo-PO-1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("photo on the wrong PO", () => {
|
||||
const wrongDoc = {
|
||||
read_id: "r",
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
/** 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<Attachment["deleteTarget"]>) => Promise<void>;
|
||||
/** Move a driver photo whose invoice names another PO onto that PO. */
|
||||
onMovePhoto?: (source: { collection: string; id: string; filename: string }, toPoNumber: string) => Promise<void>;
|
||||
};
|
||||
|
|
@ -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<string | null>(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<Attachment | null>(
|
||||
() => 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({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{attachments.length > 0 && onInvoiceUpload ? (
|
||||
<div className="mb-3 flex items-center justify-end gap-2">
|
||||
{attachments.length > 0 && (onInvoiceUpload || onDeletePhoto) ? (
|
||||
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
|
||||
{onDeletePhoto && activeAttachment?.deleteTarget ? (
|
||||
confirmDelete ? (
|
||||
<span
|
||||
className="inline-flex flex-wrap items-center gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-2 py-1 text-xs"
|
||||
data-testid={`po-card-delete-confirm-${poNumber}`}
|
||||
>
|
||||
<span>Delete {activeAttachment.filename}? This can't be undone.</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={deletingPhoto}
|
||||
onClick={async () => {
|
||||
const target = activeAttachment.deleteTarget!;
|
||||
setDeletingPhoto(true);
|
||||
try {
|
||||
await onDeletePhoto(target);
|
||||
} finally {
|
||||
setDeletingPhoto(false);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deletingPhoto ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Yes, delete
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
data-testid={`po-card-delete-photo-${poNumber}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete photo
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
{onInvoiceUpload ? (
|
||||
<>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
|
|
@ -791,6 +851,8 @@ export function PoCard({
|
|||
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileUp className="h-3.5 w-3.5" />}
|
||||
{uploading ? "Uploading" : hasCustomerRows ? "Add supplier invoice" : "Add another invoice"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof getAdminPocketBase>>;
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
const reads = await pb.collection(READS).getFullList<Rec>({
|
||||
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<Rec>({ filter: `purchase = "${q(source.id)}"` }) }
|
||||
: { coll: orderItems, items: await pb.collection(orderItems).getFullList<Rec>({ 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<Rec>({ filter: `purchase = "${q(poId)}"` }) }
|
||||
: {
|
||||
coll: source.collection,
|
||||
items: poId ? await pb.collection(source.collection).getFullList<Rec>({ 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) };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue