diff --git a/docs/collection-return-to-pool.md b/docs/collection-return-to-pool.md new file mode 100644 index 0000000..e66209a --- /dev/null +++ b/docs/collection-return-to-pool.md @@ -0,0 +1,51 @@ +# Return mistaken collections to the pool + +Collection Confirm has a **Return to pool** button on each line and a +**Return PO to collection pool** button at the bottom of each PO card. +The PO button affects only the collected lines shown on that card, not +other lines on the customer order. Both open a confirmation listing the +affected items before making any changes. + +The reset clears driver assignment, collection dates, collected and +assigned quantities, collection problem flags and the previous AI check. +Order quantities, costs, SKUs, photos and notes are retained. Customer +lines return to `item_status = collection_pool`; stock lines return to +`status = collection_pool` with `quantity_received = 0`. An unbooked stock +header marked `received` reopens to `sent` so it leaves the accountant's +booking queue until collection is confirmed again. + +Items already invoiced, dispatched, delivered or booked into stock are +refused. Refused lines are reported separately if some lines can return. +The browser reloads the queue after the operation. Normal confirmation +stays available; Add to POS controls are hidden but their underlying +handlers and endpoints remain. + +## Deployment + +Deploy **both** the app and `pocketbase/pb_hooks/collection_return.pb.js`. +The hook is listed in `pocketbase/pb_hooks/DEPLOY`; publish the committed +hooks folder with `npm run pb:hooks:publish` using the workflow in +`scripts/pb-hooks/README.md`. No collection fields or select values need +to be added. + +The authenticated capability endpoint +`GET /api/wfdash/collection-return-guard` must return `{ "version": 1 }`. +Until the hook is deployed the app refuses resets with HTTP 503. It must +never fall back to an unguarded update. + +Each return uses a PB batch transaction. The hook rechecks row revisions, +stock parent revisions, invoice metadata and stock ledger entries inside +that transaction before allowing the writes. It also guards the opposite +race: a stale customer confirmation cannot advance a returned/assigned +line, and a stock booking cannot insert a receipt after the PO reopens. +Existing PB audit hooks record customer field changes; no extra app audit +notes are added. + +## Validation + +Run the tests under `src/components/collection-confirm`, +`src/app/(app)/collection-confirm`, `src/app/api/v1/collection-confirm`, +and `src/lib/server/collection-return*.test.ts`. The hook tests evaluate +the real JS file in a mocked PB environment; they do not change live data. +After deployment, verify a mistaken collection returns to the pool with +zero collected quantity and can be assigned to a driver again. diff --git a/pocketbase/pb_hooks/DEPLOY b/pocketbase/pb_hooks/DEPLOY index 2180c78..eccd6f3 100644 --- a/pocketbase/pb_hooks/DEPLOY +++ b/pocketbase/pb_hooks/DEPLOY @@ -8,3 +8,4 @@ quotes.pb.js audit_log_utils.js audit_log.pb.js +collection_return.pb.js diff --git a/pocketbase/pb_hooks/collection_return.pb.js b/pocketbase/pb_hooks/collection_return.pb.js new file mode 100644 index 0000000..763a432 --- /dev/null +++ b/pocketbase/pb_hooks/collection_return.pb.js @@ -0,0 +1,94 @@ +/** + * Revision checks for the Collection Confirm return-to-pool batch. + * PB runs each child record request (and these hooks) inside the batch's + * database transaction. A concurrent confirm/invoice/booking therefore + * either precedes these checks or follows the complete reset; it cannot + * slip between validation and the writes. Ordinary updates are untouched. + * Deploy with the app; the service refuses resets without this capability. + */ +routerAdd("GET", "/api/wfdash/collection-return-guard", (e) => { + return e.json(200, { version: 1 }); +}, $apis.requireSuperuserAuth()); + +onRecordUpdateRequest((e) => { + const info = e.requestInfo(); + const guard = info.body && info.body._collection_return_guard; + if (!guard) { + // A confirmer may have read the row before a return committed. Check + // its current state and save together, so it cannot resurrect a pickup. + if (e.record.collection().name === "order_items" && info.body && info.body.item_status === "ready_invoice") { + const originalApp = e.app; + try { + return originalApp.runInTransaction((txApp) => { + e.app = txApp; + const latest = JSON.parse(JSON.stringify(txApp.findRecordById("order_items", e.record.id || e.record.original().id))); + const original = JSON.parse(JSON.stringify(e.record.original())); + if (latest.updated !== original.updated || ["collection_pool", "assigned"].indexOf(latest.item_status) !== -1) { + throw new BadRequestError("Collection changed. Refresh before confirming for invoicing."); + } + return e.next(); + }); + } finally { e.app = originalApp; } + } + return e.next(); + } + if (!e.auth || !e.auth.isSuperuser()) throw new ForbiddenError("Collection resets require the server."); + if (info.context !== "batch") throw new BadRequestError("Collection resets must run in a batch transaction."); + + const current = JSON.parse(JSON.stringify(e.record.original())); + if (!guard.updated || current.updated !== guard.updated) { + throw new BadRequestError("This item changed. Refresh Collection Confirm and try again."); + } + const collection = e.record.collection().name; + let refType = "order_item"; + let refId = current.id; + if (collection === "order_items") { + let details = current.order_item_details; + if (typeof details === "string") { + try { details = JSON.parse(details); } catch (_) { details = null; } + } + const invoice = details && details.invoice; + const billed = invoice && (invoice.qbInvoiceId || invoice.qbInvoiceLink || invoice.billingCompany); + const downstream = ["qty_invoiced", "qty_delivered", "qty_stocked", "qty_picked", "qty_loaded", "surplus_qty"] + .some((field) => Number(current[field] || 0) > 0); + if (current.item_status !== "collected" || downstream || billed || current.customer_invoice_number || + current.delivery_note_number || current.invoiced_date || current.delivered_at || current.stock_booked_from) { + throw new BadRequestError("This item has moved on and cannot be returned to the collection pool."); + } + } else { + let parent = current; + if (collection === "stock_purchase_items") { + if (current.status !== "collected" || !current.purchase) throw new BadRequestError("Stock item is no longer collected."); + parent = JSON.parse(JSON.stringify(e.app.findRecordById("stock_purchases", current.purchase))); + if (!guard.parent_updated || parent.updated !== guard.parent_updated) throw new BadRequestError("Stock PO changed. Refresh before returning it."); + } + if (parent.booked_at || ["sent", "received"].indexOf(parent.status) === -1) { + throw new BadRequestError("Stock PO is booked or no longer open for collection."); + } + refType = "stock_purchase"; + refId = parent.id; + } + const ledger = e.app.findRecordsByFilter("stock_ledger", + "reference_type = {:type} && reference_id = {:id}", "", 1, 0, + { type: refType, id: refId }); + if (ledger.length) throw new BadRequestError("A stock booking exists; collection cannot be reset."); + return e.next(); +}, "order_items", "stock_purchase_items", "stock_purchases"); + +// Booking writes the ledger before inventory. If it read the old received +// quantities before a return, reject its first write after the PO reopens. +// Conversely, a committed ledger entry makes the return guard refuse. +onRecordCreateRequest((e) => { + if (e.record.getString("reference_type") !== "stock_purchase") return e.next(); + const originalApp = e.app; + try { + return originalApp.runInTransaction((txApp) => { + e.app = txApp; + const purchase = JSON.parse(JSON.stringify(txApp.findRecordById("stock_purchases", e.record.getString("reference_id")))); + if (purchase.status !== "received" || purchase.booked_at) { + throw new BadRequestError("Stock PO is no longer awaiting booking. Refresh before trying again."); + } + return e.next(); + }); + } finally { e.app = originalApp; } +}, "stock_ledger"); diff --git a/src/app/(app)/collection-confirm/page-ai.test.tsx b/src/app/(app)/collection-confirm/page-ai.test.tsx index 856ed5a..97765a1 100644 --- a/src/app/(app)/collection-confirm/page-ai.test.tsx +++ b/src/app/(app)/collection-confirm/page-ai.test.tsx @@ -72,12 +72,12 @@ function setupFetch(opts: { confirmOk: boolean }) { }); } -async function openConfirmAndAdd() { +async function openConfirm() { const user = userEvent.setup(); render(); await waitFor(() => expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument()); await user.click(screen.getByTestId("po-card-toggle-PO-1")); - await user.click(screen.getByTestId("po-card-confirm-add-pos-PO-1")); + await user.click(screen.getByTestId("po-card-confirm-PO-1")); const dialog = await screen.findByRole("dialog"); // Pre-filled from ai_check. expect(within(dialog).getByLabelText(/Supplier invoice number/i)).toHaveValue("5028256"); @@ -94,20 +94,19 @@ describe("CollectionConfirmPage AI handlers", () => { addResults = [{ row_id: "row-a", status: "created", stock_item_id: "s" }]; }); - it("toasts added / failed / need-a-SKU counts after Add to POS", async () => { - addResults = [ - { row_id: "row-a", status: "created", stock_item_id: "s" }, - { row_id: "row-c", status: "failed", stock_item_id: null, error: "boom" }, - { row_id: "row-x", status: "needs_sku", stock_item_id: null }, - ]; + it("hides Add to POS and keeps normal confirmation", async () => { setupFetch({ confirmOk: true }); - await openConfirmAndAdd(); - await waitFor(() => expect(toastMock.warning).toHaveBeenCalledWith("1 added, 1 failed, 1 needs a SKU")); + const user = userEvent.setup(); + render(); + await screen.findByTestId("po-card-PO-1"); + await user.click(screen.getByTestId("po-card-toggle-PO-1")); + expect(screen.queryByRole("button", { name: /add to pos/i })).not.toBeInTheDocument(); + expect(screen.getByTestId("po-card-confirm-PO-1")).toBeInTheDocument(); }); it("does not add to the POS when the confirm fails", async () => { const spy = setupFetch({ confirmOk: false }); - await openConfirmAndAdd(); + await openConfirm(); await waitFor(() => expect(spy.mock.calls.some(([a]) => fetchUrl(a) === "/api/v1/order-items/confirm")).toBe(true), ); @@ -115,17 +114,55 @@ describe("CollectionConfirmPage AI handlers", () => { expect(addCalls(spy)).toHaveLength(0); }); - it("adds only the not-in-POS customer rows, once, after the confirm succeeds", async () => { + it("does not add to POS after normal confirmation succeeds", async () => { const spy = setupFetch({ confirmOk: true }); - await openConfirmAndAdd(); - await waitFor(() => expect(addCalls(spy)).toHaveLength(1)); - const [, init] = addCalls(spy)[0]; - expect(JSON.parse(String((init as RequestInit).body))).toEqual({ row_ids: ["row-a", "row-c"] }); - expect((init as RequestInit).headers).toMatchObject({ Authorization: "Bearer tok-123" }); - const order = spy.mock.calls.map(([a]) => fetchUrl(a)); - expect(order.indexOf("/api/v1/order-items/confirm")).toBeLessThan( - order.indexOf("/api/v1/collection-confirm/add-to-pos"), - ); + await openConfirm(); + await waitFor(() => expect(toastMock.success).toHaveBeenCalled()); + expect(addCalls(spy)).toHaveLength(0); + }); + + it.each([false, true])("returns the selected line or PO and refreshes the queue (whole PO: %s)", async (wholePo) => { + const spy = setupFetch({ confirmOk: true }); + let returned = false; + const base = spy.getMockImplementation()!; + spy.mockImplementation(async (input, init) => { + if (fetchUrl(input) === "/api/v1/collection-confirm/return-to-pool") { + returned = true; + return jsonResponse({ returned: wholePo ? rows : [rows[0]], skipped: [] }); + } + if (fetchUrl(input) === "/api/v1/collection-confirm" && returned) { + return jsonResponse({ items: wholePo ? [] : rows.slice(1) }); + } + return base(input, init); + }); + const user = userEvent.setup(); + render(); + await screen.findByTestId("po-card-PO-1"); + await user.click(screen.getByTestId("po-card-toggle-PO-1")); + await user.click(screen.getByTestId(wholePo ? "po-card-return-pool-PO-1" : "return-to-pool-row-a")); + const dialog = screen.getByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: wholePo ? "Return 3 items" : "Return 1 item" })); + await waitFor(() => expect(screen.queryByTestId("items-row-row-a")).not.toBeInTheDocument()); + const call = spy.mock.calls.find(([input]) => fetchUrl(input) === "/api/v1/collection-confirm/return-to-pool")!; + expect(JSON.parse(String(call[1]?.body))).toEqual({ rows: (wholePo ? rows : [rows[0]]).map(row => ({ id: row.id, source: "customer" })) }); + expect(call[1]?.headers).toMatchObject({ Authorization: "Bearer tok-123" }); + expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining("Ready for driver assignment")); + }); + + it("keeps the reset dialog open when the server refuses the return", async () => { + const spy = setupFetch({ confirmOk: true }); + const base = spy.getMockImplementation()!; + spy.mockImplementation(async (input, init) => fetchUrl(input) === "/api/v1/collection-confirm/return-to-pool" + ? jsonResponse({ returned: [], skipped: [{ id: "row-a", reason: "Already invoiced" }] }, { ok: false, status: 409 }) + : base(input, init)); + const user = userEvent.setup(); + render(); + await screen.findByTestId("po-card-PO-1"); + await user.click(screen.getByTestId("po-card-toggle-PO-1")); + await user.click(screen.getByTestId("return-to-pool-row-a")); + await user.click(screen.getByRole("button", { name: "Return 1 item" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Already invoiced"); + expect(screen.getByTestId("items-row-row-a")).toBeInTheDocument(); }); it("Check with AI posts { po_number } with the Bearer header", async () => { diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index 95d6241..9aa8dd8 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -454,6 +454,34 @@ export default function CollectionConfirmPage() { [authedJsonHeaders, load], ); + const handleReturnToPool = useCallback(async (selected: ConfirmRow[]) => { + const response = await fetch("/api/v1/collection-confirm/return-to-pool", { + method: "POST", + headers: authedJsonHeaders(), + body: JSON.stringify({ rows: selected.map(row => ({ id: row.id, source: row.source === "stock" ? "stock" : "customer" })) }), + }); + const data = await response.json().catch(() => ({})) as { + returned?: Array<{ id: string }>; + skipped?: Array<{ id: string; reason: string }>; + error?: string; + }; + if (!response.ok) { + await load({ silent: true }); + throw new Error(data.error || data.skipped?.map(row => row.reason).join(" ") || "Could not return these items."); + } + const count = data.returned?.length ?? 0; + toast.success(`Returned ${count} item${count === 1 ? "" : "s"} to the collection pool. Ready for driver assignment.`); + if (data.skipped?.length) { + toast.warning(`${data.skipped.length} item(s) could not be returned: ${data.skipped.map(row => row.reason).join(" ")}`); + } + setCostOverrides(previous => { + const next = { ...previous }; + for (const row of data.returned ?? []) delete next[row.id]; + return next; + }); + await load({ silent: true }); + }, [authedJsonHeaders, load]); + const handleCheckWithAi = useCallback( async (poNumber: string) => { try { @@ -839,6 +867,7 @@ export default function CollectionConfirmPage() { onSkuChange={(id, sku) => void handleSkuChange(id, sku)} onQtyChange={(id, qty) => void handleQtyChange(id, qty)} onClearRows={(payload) => handleClearRows(group, payload)} + onReturnToPool={handleReturnToPool} onAddToPos={(rowId) => void handleAddToPos([rowId])} onCheckWithAi={() => handleCheckWithAi(group.poNumber)} onMovePhoto={handleMovePhoto} diff --git a/src/app/api/v1/collection-confirm/return-to-pool/route.test.ts b/src/app/api/v1/collection-confirm/return-to-pool/route.test.ts new file mode 100644 index 0000000..bfc00d8 --- /dev/null +++ b/src/app/api/v1/collection-confirm/return-to-pool/route.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/server/browser-session", () => ({ getBrowserSessionUser: vi.fn() })); +vi.mock("@/lib/server/collection-return-service", () => ({ returnCollectedToPool: vi.fn() })); +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { returnCollectedToPool } from "@/lib/server/collection-return-service"; +import { POST } from "./route"; + +const rows = [{ id: "item1", source: "customer" as const }, { id: "stock1", source: "stock" as const }]; +const req = (body: unknown) => new NextRequest("http://localhost/api/v1/collection-confirm/return-to-pool", { + method: "POST", body: JSON.stringify(body), headers: { Authorization: "Bearer token", "content-type": "application/json" }, +}); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getBrowserSessionUser).mockResolvedValue({ id: "buyer", name: "Buyer", user_type: [] }); + vi.mocked(returnCollectedToPool).mockResolvedValue({ returned: rows, skipped: [] }); +}); + +describe("POST collection-confirm/return-to-pool", () => { + it("requires a real browser session", async () => { + vi.mocked(getBrowserSessionUser).mockRejectedValue(Object.assign(new Error("Not authenticated."), { status: 401 })); + expect((await POST(req({ rows }))).status).toBe(401); + expect(returnCollectedToPool).not.toHaveBeenCalled(); + }); + + it.each([null, {}, { rows: [] }, { rows: [{ id: "x", source: "orders" }] }, { rows: [null] }, { rows: [{ id: " ", source: "stock" }] }, { rows: Array(501).fill(rows[0]) }])("rejects invalid selection %#", async (body) => { + expect((await POST(req(body))).status).toBe(400); + expect(returnCollectedToPool).not.toHaveBeenCalled(); + }); + + it("uses the authenticated actor, never an actor supplied in the body", async () => { + const res = await POST(req({ rows, actor: { id: "forged" } })); + expect(res.status).toBe(200); + expect(returnCollectedToPool).toHaveBeenCalledWith(rows, { id: "buyer", name: "Buyer" }); + expect(await res.json()).toEqual({ returned: rows, skipped: [] }); + }); + + it("reports refused selections as conflicts", async () => { + vi.mocked(returnCollectedToPool).mockResolvedValue({ returned: [], skipped: [{ ...rows[0], reason: "Already invoiced" }] }); + const res = await POST(req({ rows })); + expect(res.status).toBe(409); + expect((await res.json()).skipped[0].reason).toBe("Already invoiced"); + }); + + it("does not leak backend errors", async () => { + vi.mocked(returnCollectedToPool).mockRejectedValue(new Error("internal secret")); + const res = await POST(req({ rows })); + expect(res.status).toBe(500); + expect(JSON.stringify(await res.json())).not.toContain("internal secret"); + }); +}); diff --git a/src/app/api/v1/collection-confirm/return-to-pool/route.ts b/src/app/api/v1/collection-confirm/return-to-pool/route.ts new file mode 100644 index 0000000..82096bd --- /dev/null +++ b/src/app/api/v1/collection-confirm/return-to-pool/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { returnCollectedToPool, type CollectionReturnRow } from "@/lib/server/collection-return-service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + try { + const user = await getBrowserSessionUser(req); + const body = await req.json().catch(() => null) as { rows?: unknown } | null; + const rows = body?.rows; + if (!Array.isArray(rows) || !rows.length || rows.length > 500 || rows.some(r => + !r || typeof r.id !== "string" || !r.id.trim() || !["customer", "stock"].includes(r.source), + )) { + return NextResponse.json({ error: "Choose between 1 and 500 collected items." }, { status: 400 }); + } + const result = await returnCollectedToPool(rows as CollectionReturnRow[], { id: user.id, name: user.name }); + return NextResponse.json(result, { status: result.returned.length ? 200 : 409 }); + } catch (err) { + const status = (err as { status?: number }).status || 500; + if (status >= 500) console.error("[collection-confirm/return-to-pool]", err); + return NextResponse.json({ error: status >= 500 ? "Could not return these items. Refresh and try again." : err instanceof Error ? err.message : "Return failed." }, { status }); + } +} diff --git a/src/components/collection-confirm/items-table.tsx b/src/components/collection-confirm/items-table.tsx index 2876b19..0fb21d3 100644 --- a/src/components/collection-confirm/items-table.tsx +++ b/src/components/collection-confirm/items-table.tsx @@ -7,6 +7,7 @@ import { CheckCircle2, MinusCircle, Pencil, + Undo2, XCircle, } from "lucide-react"; import { useState } from "react"; @@ -35,6 +36,7 @@ type Props = { onQtyChange?: (id: string, qty: number) => void; /** "Add to POS" click for a line that is not in the POS yet. */ onAddToPos?: (rowId: string) => void; + onReturnToPool?: (row: ConfirmRow) => void; /** * Force the AI + POS columns on or off. By default the AI column shows * only when some row carries an `ai_check`, and the POS column when some @@ -213,6 +215,7 @@ export function ItemsTable({ onSkuChange, onQtyChange, onAddToPos, + onReturnToPool, showAi, }: Props) { const aiColumn = showAi ?? rows.some((r) => !!r.ai_check); @@ -413,6 +416,17 @@ export function ItemsTable({ {row.description ?? row.po_description ?? "—"} + {onReturnToPool ? ( + + ) : null} {/* AI ticks and POS status sit under the description, not in extra columns: the table shares its pane with the document preview, and trailing columns ended up hidden behind it. */} diff --git a/src/components/collection-confirm/po-card-ai.test.tsx b/src/components/collection-confirm/po-card-ai.test.tsx index 894ec5a..e5c2565 100644 --- a/src/components/collection-confirm/po-card-ai.test.tsx +++ b/src/components/collection-confirm/po-card-ai.test.tsx @@ -214,13 +214,13 @@ describe("PoCard AI layer", () => { await waitFor(() => expect(btn).not.toBeDisabled()); }); - it("renders Confirm and add to POS only when a customer row is not in POS", async () => { + it("hides the POS actions even when their handlers are supplied", () => { const onGo = vi.fn(); const { rerender } = render( - , + , ); - await userEvent.click(screen.getByRole("button", { name: /confirm and add to pos/i })); - expect(onGo).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button", { name: /add to pos/i })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^confirm for invoicing$/i })).toBeInTheDocument(); rerender( { ); expect(screen.queryByRole("button", { name: /confirm and add to pos/i })).not.toBeInTheDocument(); }); + + it("returns one selected line only after confirming the reset", async () => { + const onReturnToPool = vi.fn().mockResolvedValue(undefined); + const row = makeAiRow({ id: "chosen", source: "stock" }); + render(); + await userEvent.click(screen.getByTestId("return-to-pool-chosen")); + expect(onReturnToPool).not.toHaveBeenCalled(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveTextContent("Collected quantities will be reset to zero"); + await userEvent.click(screen.getByRole("button", { name: "Return 1 item" })); + expect(onReturnToPool).toHaveBeenCalledWith([row]); + }); + + it("offers all the card's collected rows in the PO footer and allows cancelling", async () => { + const onReturnToPool = vi.fn(); + render(); + await userEvent.click(screen.getByTestId("po-card-return-pool-PO-1")); + expect(screen.getByRole("button", { name: "Return 2 items" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onReturnToPool).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/collection-confirm/po-card.tsx b/src/components/collection-confirm/po-card.tsx index 16f2d3f..139c5f2 100644 --- a/src/components/collection-confirm/po-card.tsx +++ b/src/components/collection-confirm/po-card.tsx @@ -26,6 +26,7 @@ import { InvoiceViewer } from "./invoice-viewer"; import { ItemsTable } from "./items-table"; import { ClearPanel, type ClearPayload } from "./clear-panel"; import type { Attachment } from "./photo-strip"; +import { ReturnToPoolDialog } from "./return-to-pool-dialog"; /** * Viewport-width probe — returns `true` when the viewport is ≥ @@ -141,6 +142,8 @@ type Props = { onCheckWithAi?: () => Promise; /** Confirm for invoicing, then add every not-in-POS customer line to the POS. */ onConfirmAndAddToPos?: () => void; + /** Return only these collected rows to the pool for a fresh driver assignment. */ + onReturnToPool?: (rows: ConfirmRow[]) => Promise; /** 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. */ @@ -200,13 +203,13 @@ export function PoCard({ onPopOut, onFullscreen, onInvoiceUpload, - onAddToPos, onCheckWithAi, - onConfirmAndAddToPos, + onReturnToPool, onMovePhoto, onDeletePhoto, }: Props) { const itemCount = rows.length; + const [returnRows, setReturnRows] = useState(null); const [checkingAi, setCheckingAi] = useState(false); const [docPanelOpen, setDocPanelOpen] = useState(false); const aiDoc = useMemo(() => cardDocument(rows), [rows]); @@ -245,7 +248,6 @@ export function PoCard({ : [...paired.values()].sort((a, b) => a.line_no - b.line_no); return { aiDocLines: all, matchedLineNos: new Set(paired.keys()) }; }, [rows, aiDoc]); - const hasUnpostedCustomerRows = rows.some((r) => r.source !== "stock" && !r.in_pos); const aiDocPanel = docPanelOpen && aiDoc ? ( @@ -893,7 +895,7 @@ export function PoCard({ onCostChange={onCostChange} onSkuChange={onSkuChange} onQtyChange={onQtyChange} - onAddToPos={onAddToPos} + onReturnToPool={onReturnToPool ? (row) => setReturnRows([row]) : undefined} /> @@ -960,7 +962,7 @@ export function PoCard({ onCostChange={onCostChange} onSkuChange={onSkuChange} onQtyChange={onQtyChange} - onAddToPos={onAddToPos} + onReturnToPool={onReturnToPool ? (row) => setReturnRows([row]) : undefined} /> @@ -1021,11 +1023,11 @@ export function PoCard({ -