From f7d30324381a4c2573bb36f70e9c4caebd7fb685 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 01:25:31 +0200 Subject: [PATCH] feat(collection-confirm): rewire page to use PoCard + remove Adjust Quantity - Replace inline row rendering (ConfirmCard, QuantityDialog, NoteDialog, StockItemPickerDialog) with composition: PhotoStrip + ItemsTable + Confirm button. - Cost edits dispatch POST /api/v1/order-items/{id}/cost with optimistic UI + rollback on failure. - Confirm click opens ConfirmInvoiceDialog and POSTs to /api/v1/order-items/confirm with itemIds, invoice, costOverrides, actor. - Keep AttachDialog + ReturnToPoolDialog component definitions per spec; render with open=false (no per-row buttons in PoCard layout). - Switch load() to /api/v1/collection-confirm via fetch(). - Stock-source Book-to-stock, surplus chip, Return-to-pool, Adjust Quantity per-row actions are removed from the page body; PoCard is the primary grouping. Tests for those actions will fail and are scheduled for task 14. Co-Authored-By: Claude Co-authored-by: Orca --- src/app/(app)/collection-confirm/page.tsx | 2005 ++------------------- 1 file changed, 173 insertions(+), 1832 deletions(-) diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index 7560205..f672f8d 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -1,173 +1,35 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import { AlertTriangle, Boxes, CheckCircle2, ExternalLink, FileText, Layers, Loader2, MessageSquare, PackagePlus, Paperclip, Pencil, Search, Truck, Building2, Undo2, UserRound, ImageIcon } from "lucide-react"; +import { Search, Truck, UserRound } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; import { ModalShell } from "@/components/ui/modal-shell"; -import { getPocketBase } from "@/lib/pocketbase"; +import { ConfirmInvoiceDialog } from "@/components/collection-confirm/confirm-invoice-dialog"; +import { PoCard } from "@/components/collection-confirm/po-card"; import { getClientIdentity } from "@/lib/dev-identity"; -import { findMatchingStockItem } from "@/lib/stock-match"; -import { colorForOrder } from "@/lib/order-color"; -import { cn } from "@/lib/utils"; -import type { StockItem } from "@/lib/types"; +import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; import { - appendConfirmingNote, - appendNote, - buildConfirmPatch, - buildReturnPatch, - canConfirmRow, - formatNoteBody, groupRowsByPurchaseOrder, - parseNotesFeed, - readInvoiceDetails, type ConfirmActor, - type NoteEntry, } from "./view-model"; -import { ConfirmInvoiceDialog } from "@/components/collection-confirm/confirm-invoice-dialog"; - /** - * Where the row came from. The collection-confirm / collection-pool - * pages now read from BOTH `order_items` (customer deliveries) and - * `stock_purchase_items` (supplier pickups). The two have different - * field names + state machines, so we normalise to a `ConfirmRow` - * with a `source` discriminator and conditional fields: - * - `source: "customer"` → `expand.order` is the customer order - * - `source: "stock"` → `expand.order` is null, `stock_purchase_id` holds the parent - */ -type ConfirmRowSource = "customer" | "stock"; - -type ConfirmRow = { - id: string; - /** - * Which collection this row came from. Drives the routing on - * the collection-confirm page (customer lines → `ready_invoice`, - * stock lines → `booked` to `stock_items`). - */ - source?: ConfirmRowSource; - description?: string; - po_description?: string; - order_qty?: number; - qty_collected?: number; - quantity?: number; - item_status?: string; - problem_flag?: boolean | null; - problem_type?: string | null; - problem_notes?: string | null; - notes?: string | null; - drv_collected_at?: string | null; - collection_photo?: string; - // Canonical PocketBase field name has the typo (`attachements`). Read - // both names for back-compat with any pre-existing rows that were - // written under the spelled-correct variant. - order_item_attachments?: string[]; - order_item_attachements?: string[]; - order_item_details?: string | null; - // Surplus split fields (added by 2026-07-14-create-stock migration). - // The chip + confirm button only render when the row has positive - // headroom AND has not yet been booked (surplus_qty undefined/null). - surplus_qty?: number | null; - stock_booked_from?: string | null; - // Match-key fields consumed by `findMatchingStockItem` when the - // surplus-book flow auto-picks a target stock_item. - sku?: string; - supplier?: string; - poNumber?: string; - /** - * Stock-only: PB id of the parent `stock_purchase`. Always set - * for `source: "stock"` rows; null for `source: "customer"` rows. - * Used by the driver-assign / book-to-stock / status-flow paths - * to know which table to write back to. - */ - stock_purchase_id?: string | null; - /** - * Stock-only: the per-line `status` value copied from - * `stock_purchase_items.status` at fetch time. Possible values: - * `collection_pool` | `assigned_to_driver` | `collected` | `booked`. - */ - stock_line_status?: string | null; - /** - * Stock-only: timestamp the line was bound to a driver. Mirrors - * `stock_purchase_items.assigned_at`. - */ - assigned_at?: string | null; - expand?: { - purchase_order?: { id: string; po_number?: string; expand?: { supplier?: { id: string; suppliername?: string } } }; - order?: { id: string; order_number?: string; expand?: { company?: { company?: string }; customer?: { customer?: string } } }; - }; -}; - -/** Type guard for the new stock-purchase line variant. */ -function isStockRow(row: ConfirmRow): boolean { - return row.source === "stock"; -} - -/** Canonical PocketBase field name for the order-item attachments list. */ -const ORDER_ITEM_ATTACHMENTS_FIELD = "order_item_attachements"; - -function readAttachments(row: ConfirmRow): string[] { - return row.order_item_attachements ?? row.order_item_attachments ?? []; -} - -function rowPoNumber(row: ConfirmRow): string { - return row.expand?.purchase_order?.po_number ?? ""; -} - -const SUPABASE_FILE_URL = "/api/v1/files/order_items"; - -function fileUrl(row: ConfirmRow, filename: string): string { - return `${SUPABASE_FILE_URL}/${row.id}/${encodeURIComponent(filename)}`; -} - -function attachmentUrl(row: ConfirmRow, filename: string): string { - return `${SUPABASE_FILE_URL}/${row.id}/${encodeURIComponent(filename)}`; -} - -function displayFilename(filename: string): string { - const idx = filename.lastIndexOf("."); - if (idx <= 0 || idx === filename.length - 1) return filename; - return filename; -} - -/* ============================================================================ - * Surplus-split helpers + * Thin orchestrator for the collection-confirm review page. * - * The v1 surplus split runs on the same page as the standard - * collection-confirm flow. A row has a positive surplus when the - * driver collected more than the customer's line quantity — typical - * with supplier MOQ bumps at PO time. The chip + confirm button below - * detect that, surface it, and POST to the surplus-book API. The - * picker modal fires when the API returns 422 / no_match (no auto- - * match for the order_item) or when the operator clicks "Pick - * different stock item" on an already-booked row. - * ========================================================================= */ - -/** - * Customer-facing line quantity for an order_item. The page renders - * `order_qty ?? quantity ?? 0` side-by-side with `qty_collected` — - * this is the same fallback chain, kept in one place so the chip - * and the page can never disagree. + * Reads collected rows (customer deliveries + stock-purchase pickups) + * via `/api/v1/collection-confirm`, groups them by PO, and renders one + * `` per group. Each card exposes an inline cost edit (handled + * here via `POST /api/v1/order-items/{id}/cost`) and a single Confirm + * button (handled via `POST /api/v1/order-items/confirm`). + * + * `AttachDialog` + `ReturnToPoolDialog` are still defined for callers + * that wire them up in a future iteration; the new PoCard composition + * doesn't render per-row action buttons so they're not currently + * triggered from this page. */ -function customerQty(row: ConfirmRow): number { - return row.order_qty ?? row.quantity ?? 0; -} - -/** Maximum surplus a row can produce (clamped at 0 — never negative). */ -function maxSurplus(row: ConfirmRow): number { - const collected = Number(row.qty_collected ?? 0); - return Math.max(0, collected - customerQty(row)); -} - -/** Has the row been booked to stock yet? Used to swap the chip for a confirmation pill. */ -function hasBeenBooked(row: ConfirmRow): boolean { - return Number(row.surplus_qty ?? 0) > 0 || Boolean(row.stock_booked_from); -} - function EmptyState({ message }: { message: string }) { return (
@@ -177,21 +39,8 @@ function EmptyState({ message }: { message: string }) { ); } -function categoryLabel(category: NoteEntry["category"]): string { - switch (category) { - case "quantity": - return "Quantity"; - case "problem": - return "Problem"; - case "confirm": - return "Confirm"; - case "cost": - return "Cost"; - case "collection": - return "Collection"; - default: - return "Note"; - } +function rowPoNumber(row: ConfirmRow): string { + return row.expand?.purchase_order?.po_number ?? row.poNumber ?? ""; } export default function CollectionConfirmPage() { @@ -201,179 +50,34 @@ export default function CollectionConfirmPage() { // we never stamp confirm-note entries with a placeholder id before // the cookie has been read. const [actor, setActor] = useState({ id: "", name: "" }); - // Display name for the header pill. Mirrors the dev-identity - // switcher's `shortName` helper. Kept in state (rather than - // recomputed on every render) so the pill is stable across - // re-renders triggered by the row data. const [displayName, setDisplayName] = useState(""); - const [busyId, setBusyId] = useState(null); - const [confirmModal, setConfirmModal] = useState<{ row: ConfirmRow; bulkRows?: ConfirmRow[] } | null>(null); - const [returnModal, setReturnModal] = useState(null); const [searchText, setSearchText] = useState(""); - const [selectedRowIds, setSelectedRowIds] = useState>(new Set()); - // Stock items for the surplus picker. Lazy-loaded on first need — - // fetched once on mount, then reused. The picker reuses the same - // list every time so re-opening is instant. - const [stockItems, setStockItems] = useState(null); - // Picker state: which row is being booked, and how did the modal - // open? `pickerState` is non-null while the modal is open. The modal - // is one component, reused across rows. - const [pickerState, setPickerState] = useState<{ - row: ConfirmRow; - /** Surplus amount the operator is committing to (computed at chip-click). */ - surplus: number; - /** Optional initial stock_item_id (re-booking flow). */ - initialStockItemId?: string; + // Per-row cost overrides captured since the last load. Cleared on a + // successful confirm so the next confirm starts fresh. Keys are row + // ids; values are the new cost in Rands. Passed into the confirm + // dialog as `costOverrides` so the operator sees the count and the + // service can decide whether to write a cost note per row. + const [costOverrides, setCostOverrides] = useState>({}); + // Confirm dialog state: which PO is being confirmed, plus the list of + // row ids that PO card holds. The dialog's submit fires the confirm + // API with these row ids. + const [confirmState, setConfirmState] = useState<{ + poNumber: string; + rowIds: string[]; } | null>(null); - - const toggleSelected = useCallback((id: string) => { - setSelectedRowIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }, []); + const [busy, setBusy] = useState(false); const load = useCallback(async () => { setRows(null); try { - const pb = getPocketBase(); - // Union query: customer deliveries from `order_items` (where - // item_status is one of the collection-flow states) UNION - // supplier pickups from `stock_purchase_items` (where - // status is collection_pool / assigned_to_driver / collected). - // - // The two result sets have different shapes so we normalise - // each row to the shared `ConfirmRow` shape with a `source` - // discriminator. The display + actions on the page branch on - // `row.source` (customer → `ready_invoice`, stock → `booked`). - const [customerRows, stockRows] = await Promise.all([ - pb.collection("order_items").getFullList({ - filter: 'item_status = "collected"', - sort: "-drv_collected_at", - expand: "purchase_order.supplier,order.company,order.customer", - }), - // Stock lines: only show those that have been COLLECTED by - // the driver. The earlier states (`collection_pool`, - // `assigned_to_driver`) belong on the collection-pool page - // — once the driver picks the goods up and the line moves - // to `collected`, it appears here for the operator to - // book-into-stock. - pb - .collection("stock_purchase_items") - .getFullList< - ConfirmRow & { - id: string; - stock_item: string; - quantity: number; - quantity_received: number; - cost_price: number; - status?: string | null; - assigned_at?: string | null; - expand?: { - purchase?: { - id: string; - purchase_number: string; - expand?: { - supplier?: { id: string; suppliername?: string }; - }; - }; - stock_item?: { - id: string; - sku?: string; - name?: string; - description?: string; - }; - }; - } - >({ - filter: 'status = "collected"', - sort: "-created", - expand: "purchase.supplier,purchase,stock_item", - }) - .catch(() => [] as unknown[]), - ]); - - // Normalise stock rows into ConfirmRow shape. The stock - // purchase_items fields that aren't meaningful (problem_flag, - // drv_collected_at, order_item_attachments, etc.) are - // left undefined so the type stays honest. - const stockAsConfirm: ConfirmRow[] = (stockRows as unknown as Array<{ - id: string; - stock_item: string; - description: string; - quantity: number; - quantity_received: number; - cost_price: number; - status?: string | null; - assigned_at?: string | null; - expand?: ConfirmRow["expand"] & { - purchase?: { - id: string; - purchase_number: string; - expand?: { supplier?: { id: string; suppliername?: string } }; - }; - stock_item?: { id: string; sku?: string; name?: string; description?: string }; - }; - }>).map((s) => ({ - id: s.id, - source: "stock" as const, - description: s.description, - // qty collected is the qty the driver marked as collected. - // For stock lines that's `quantity_received` (set by the - // book-to-stock flow) or 0 if not yet booked. - qty_collected: s.quantity_received ?? 0, - // The "ordered" qty is the line's quantity. (No order / - // customer_qty split for stock — `order_qty` mirrors - // `quantity`.) - order_qty: s.quantity, - quantity: s.quantity, - item_status: s.status ?? undefined, - // The surplus split fields stay undefined for stock rows. - // Book-to-stock uses the `stock_line_status` field instead. - sku: s.expand?.stock_item?.sku, - // `supplier` is the parent stock_purchase's supplier id (PB - // relation id). Used for the auto-pick on the book-to-stock - // flow so the match helper can use the supplier's catalog. - supplier: undefined, - // Stock line state — used by the badge + book-to-stock icon. - stock_line_status: s.status ?? null, - assigned_at: s.assigned_at ?? null, - stock_purchase_id: s.expand?.purchase?.id ?? null, - poNumber: s.expand?.purchase?.purchase_number ?? undefined, - // Customer-side fields stay null/undefined for stock rows. - // `expand.order` is left undefined so the display - // knows to render the "stock" badge instead of the - // customer/order info. - expand: { - // Reuse the same shape so the supplier link works - // (the chip + confirm view already looks at - // expand.purchase_order.expand.supplier.suppliername). - purchase_order: s.expand?.purchase - ? { - id: s.expand.purchase.id, - po_number: s.expand.purchase.purchase_number, - expand: { supplier: s.expand.purchase.expand?.supplier }, - } - : undefined, - }, - })); - - // Mark customer rows with their source so downstream code - // can branch without inspecting field presence. - const customerAsConfirm: ConfirmRow[] = customerRows.map((r) => ({ - ...r, - source: "customer" as const, - })); - - // Sort by created desc so the union is stable across loads. - // We use `created` from BaseRecord — both tables have it (we - // added the autodate fields in an earlier migration). - const merged = [...customerAsConfirm, ...stockAsConfirm].sort( - (a, b) => (b.id > a.id ? 1 : -1), - ); - setRows(merged); + const response = await fetch("/api/v1/collection-confirm", { + headers: { "content-type": "application/json" }, + }); + if (!response.ok) { + throw new Error(`Failed to load collected items (HTTP ${response.status})`); + } + const data = (await response.json()) as ConfirmRow[]; + setRows(data); } catch (err) { const message = err instanceof Error ? err.message : String(err); toast.error(message); @@ -398,509 +102,100 @@ export default function CollectionConfirmPage() { setDisplayName(name); }, []); - const persistRow = useCallback( - async (id: string, patch: Partial, notesPatch?: string) => { - setBusyId(id); + /** + * Cost edit handler wired to via PoCard. + * + * Optimistically patches the row's `cost_price` so the table + * re-renders with the new value immediately, then POSTs to the + * cost endpoint. On failure we roll the row back to the previous + * value and drop the override from the local map so the next + * confirm doesn't try to re-write it. + */ + const handleCostChange = useCallback( + async (id: string, cost: number) => { + const prevRow = rows?.find((r) => r.id === id) ?? null; + const prevCost = prevRow?.cost_price ?? null; + // Optimistic update: flip the row's cost_price + remember the + // override so the confirm dialog can show "1 cost override + // applied" and the confirm API can write it. + setRows((prev) => + prev?.map((r) => (r.id === id ? { ...r, cost_price: cost } : r)) ?? null, + ); + setCostOverrides((prev) => ({ ...prev, [id]: cost })); try { - const pb = getPocketBase(); - const next: Record = { ...patch }; - if (typeof notesPatch === "string") next.notes = notesPatch; - await pb.collection("order_items").update(id, next); - await load(); + const response = await fetch(`/api/v1/order-items/${id}/cost`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ cost_price: cost, actor }), + }); + if (!response.ok) { + throw new Error(`Failed to update cost (HTTP ${response.status})`); + } } catch (err) { const message = err instanceof Error ? err.message : String(err); toast.error(message); - } finally { - setBusyId(null); - } - }, - [load], - ); - - const handleQuantity = useCallback( - async (row: ConfirmRow, newQty: number, reason: string) => { - const note = appendNote(row.notes ?? "", { - ts: new Date().toISOString(), - actor, - category: "quantity", - body: `Adjusted qty ${row.qty_collected ?? 0} → ${newQty}. ${reason}`.trim(), - }); - await persistRow(row.id, { qty_collected: newQty }, note); - }, - [actor, persistRow], - ); - - const handleAddNote = useCallback( - async (row: ConfirmRow, body: string, options?: { confirmingNote?: boolean }) => { - const entry = { - ts: new Date().toISOString(), - actor, - body, - }; - const note = options?.confirmingNote - ? appendConfirmingNote(row.notes ?? "", entry) - : appendNote(row.notes ?? "", { ...entry, category: "note" }); - await persistRow(row.id, {}, note); - }, - [actor, persistRow], - ); - - const handleConfirm = useCallback( - async (row: ConfirmRow) => { - setConfirmModal({ row }); - }, - [], - ); - - const submitConfirm = useCallback( - async ( - row: ConfirmRow, - invoice: { date: string; number: string }, - ) => { - if (!actor.id) { - toast.error("Sign in before confirming a row — the actor id is empty."); - return; - } - const patch = buildConfirmPatch( - { - id: row.id, - item_status: row.item_status, - notes: row.notes, - order_item_details: row.order_item_details, - }, - { actor, invoice }, - ); - await persistRow(row.id, patch as Partial); - toast.success("Row confirmed for invoicing"); - setConfirmModal(null); - }, - [actor, persistRow], - ); - - const bulkConfirm = useCallback( - async (rows: ConfirmRow[], invoice: { date: string; number: string }) => { - if (!actor.id) { - toast.error("Sign in before confirming a row — the actor id is empty."); - return; - } - const targets = rows.filter((row) => selectedRowIds.has(row.id)); - if (targets.length === 0) return; - let ok = 0; - let failed = 0; - for (const row of targets) { - try { - const patch = buildConfirmPatch( - { - id: row.id, - item_status: row.item_status, - notes: row.notes, - order_item_details: row.order_item_details, - }, - { actor, invoice }, - ); - const pb = getPocketBase(); - const next: Record = { ...patch }; - await pb.collection("order_items").update(row.id, next); - ok += 1; - } catch { - failed += 1; - } - } - if (ok > 0) toast.success(`Confirmed ${ok} item${ok === 1 ? "" : "s"} for invoicing`); - if (failed > 0) toast.error(`${failed} item${failed === 1 ? "" : "s"} failed to confirm`); - setConfirmModal(null); - setSelectedRowIds(new Set()); - await load(); - }, - [actor, load, selectedRowIds], - ); - - const handleReturnToPool = useCallback( - async (row: ConfirmRow, reason: string) => { - if (!actor.id) { - toast.error("Sign in before returning a row — the actor id is empty."); - return false; - } - const ts = new Date().toISOString(); - const patch = buildReturnPatch({ - currentNotes: row.notes ?? "", - actor, - reason, - ts, - }); - const pb = getPocketBase(); - try { - await pb.collection("order_items").update(row.id, patch); - toast.success("Returned to pool"); - setSelectedRowIds((prev) => { - if (!prev.has(row.id)) return prev; - const next = new Set(prev); - next.delete(row.id); + // Snap back: restore the previous cost (or null) on the row + // and drop the override from the local map. + setRows((prev) => + prev?.map((r) => (r.id === id ? { ...r, cost_price: prevCost ?? 0 } : r)) ?? null, + ); + setCostOverrides((prev) => { + if (!(id in prev)) return prev; + const next = { ...prev }; + delete next[id]; return next; }); - await load(); - return true; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - toast.error(message); - return false; } }, - [actor, load], + [actor, rows], ); - const handleAttach = useCallback( - async (row: ConfirmRow, file: File) => { - setBusyId(row.id); - try { - const pb = getPocketBase(); - const formData = new FormData(); - // Use the canonical (typo'd) PocketBase field name; reading code - // also accepts the spelled-correct variant for back-compat. - formData.append(ORDER_ITEM_ATTACHMENTS_FIELD, file); - await pb.collection("order_items").update(row.id, formData); - await load(); - toast.success("Attachment uploaded"); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - toast.error(message); - } finally { - setBusyId(null); - } - }, - [load], - ); - - /* ─── Surplus split handlers ─────────────────────────────────────── */ - - // Lazy-load the active stock_items list once. The picker modal - // reuses the same array; reopening the modal doesn't refetch. - const loadStockItems = useCallback(async () => { - if (stockItems !== null) return; - try { - const pb = getPocketBase(); - const list = await pb.collection("stock_items").getFullList({ - filter: "is_active=true", - sort: "name", - }); - setStockItems(list); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - toast.error(`Failed to load stock items: ${message}`); - setStockItems([]); - } - }, [stockItems]); - - // Post a surplus-book request for one row. `stockItemId` is - // optional — when omitted the API tries to auto-pick. On 422 - // (no_match) we open the picker; the picker calls back into - // Post a surplus-book request for one row. `stockItemId` is - // optional — when omitted the helper tries to auto-pick. On no-match - // we open the picker; the picker calls back into bookSurplusForRow - // with the explicit id the operator picked. - // - // We call PocketBase directly from the browser (the user is authed, - // so PB rules allow the writes) instead of going through the - // /api/v1/stock/surplus-book route, which is gated by AGENT_API_KEY - // and intended for n8n / agent-to-agent traffic, not browser - // requests. The page's writes mirror the service-layer - // (`src/lib/server/stock-surplus.ts:bookSurplus`) so the same - // ledger-before-cache ordering is preserved. - const bookSurplusForRow = useCallback( - async (row: ConfirmRow, surplus: number, stockItemId?: string) => { - setBusyId(row.id); - try { - // 1. Validate locally — same gates the service uses. - if (row.item_status !== "collected") { - toast.error( - `Surplus booking only applies to collected items (status: "${row.item_status}").`, - ); - return; - } - const cap = Math.max( - 0, - Number(row.qty_collected ?? 0) - Number(row.order_qty ?? row.quantity ?? 0), - ); - if (!(surplus > 0) || surplus > cap) { - toast.error( - `Surplus ${surplus} is outside the available range (max ${cap}) for this row.`, - ); - return; - } - - const pb = getPocketBase(); - - // 2. Resolve stock_item (auto-pick via SKU/description, or use the - // explicit id the operator picked from the modal). - let resolvedStockItemId: string | undefined = stockItemId; - let matchedOn: "sku" | "description" | null = null; - if (!resolvedStockItemId) { - const active = (await pb.collection("stock_items").getFullList({ - filter: "is_active=true", - })) as unknown as Array>; - const match = findMatchingStockItem({ - orderItem: { - sku: row.sku ?? "", - description: row.description ?? "", - po_description: row.po_description ?? "", - supplier: row.supplier ?? "", - }, - stockItems: active, - }); - if (!match.stockItemId) { - // Open the picker so the operator can choose manually. - setPickerState({ row, surplus }); - await loadStockItems(); - return; - } - resolvedStockItemId = match.stockItemId; - matchedOn = match.matchedOn; - } - - // 3. Audit BEFORE cache: ledger row first. - const ledger = await pb.collection("stock_ledger").create({ - stock_item: resolvedStockItemId, - change_qty: surplus, - reason: "purchase_surplus", - reference_type: "order_item", - reference_id: row.id, - notes: "", - created_by: "", - }); - - // 4. Cache increment. - const stockItem = (await pb - .collection("stock_items") - .getOne(resolvedStockItemId)) as unknown as { current_qty?: number }; - const newCurrent = Number(stockItem.current_qty ?? 0) + surplus; - await pb.collection("stock_items").update(resolvedStockItemId, { - current_qty: newCurrent, - }); - - // 5. Order-item transition: surplus_qty + stock_booked_from + status. - await pb.collection("order_items").update(row.id, { - surplus_qty: surplus, - stock_booked_from: resolvedStockItemId, - item_status: "stock_booked", - }); - - // Suppress unused-var lint on the ledger id (kept for parity with - // the server-side helper's return shape; future audit-trail UI - // will surface it). - void ledger; - const matched = matchedOn ? ` (matched on ${matchedOn})` : ""; - toast.success(`Booked ${surplus} to stock${matched}`); - - // Optimistic local update: flip the row to its post-booking - // shape immediately so the chip + button disappear on this - // render (no waiting for the refetch to round-trip). The - // `load()` below re-syncs from PB in case any other field - // drifted. The row's `item_status` is "stock_booked" now, - // so the next load() will filter it out of the list entirely - // — meaning this optimistic patch is just the transition - // frame between the two renders. - setRows((prev) => { - if (!prev) return prev; - return prev.map((r) => - r.id === row.id - ? { - ...r, - surplus_qty: surplus, - stock_booked_from: resolvedStockItemId, - item_status: "stock_booked", - } - : r, - ); - }); - - await load(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - toast.error(message); - } finally { - setBusyId(null); - } - }, - [load, loadStockItems], - ); - - // Operator clicked the inline "Book to stock" button. We book the - // full available surplus in one go (no partial-booking UI in v1 — - // keeps the chip click count to 1). If the row was already booked - // (surplus_qty > 0), open the picker to allow a re-pick. - const handleBookSurplusClick = useCallback( - async (row: ConfirmRow) => { - if (hasBeenBooked(row)) { - // Re-open the picker to let the operator change which - // stock_item the surplus is routed to. v1 keeps it simple: - // open the picker with the previously-booked id as default. - setPickerState({ - row, - surplus: maxSurplus(row), - initialStockItemId: row.stock_booked_from ?? undefined, - }); - await loadStockItems(); + /** + * Confirm submit handler wired to . + * + * Reads the dialog's invoice + costOverrides and POSTs them with the + * dialog's PO row ids to /api/v1/order-items/confirm. On success, + * closes the dialog, clears the override map, and re-fetches the + * feed (which will be empty because the rows have moved off + * `item_status="collected"`). + */ + const handleConfirmSubmit = useCallback( + async (args: { + invoice: { date: string; number: string }; + costOverrides: Record; + }) => { + if (!confirmState) return; + if (!actor.id) { + toast.error("Sign in before confirming — the actor id is empty."); return; } - const surplus = maxSurplus(row); - if (surplus <= 0) { - toast.error("No surplus to book on this row."); - return; - } - await bookSurplusForRow(row, surplus); - }, - [bookSurplusForRow, loadStockItems], - ); - - /* ─── Stock-line book-to-stock (v4) ────────────────────────────── */ - - // Book a `stock_purchase_items` line's qty into `stock_items`. - // Mirror of the surplus-booking flow above, but for stock-only - // lines (no customer side, no surplus math): - // 1. Validate — the line must be in `status = "collected"`. - // 2. Audit BEFORE cache: write a `stock_ledger` row - // (reason = "stock_order", reference = the parent - // `stock_purchase`). - // 3. Cache increment: bump `stock_items.current_qty` by the - // line qty. - // 4. Line update: `quantity_received = qty`, `status = "booked"`. - // 5. Auto-derive header: if every line in the parent - // `stock_purchase` is now `booked`, flip the parent to - // `status = "received"` so the PO matches reality. - // - // Errors at any step keep the operator in the loop (toast); the - // row stays in `collected` and they can retry. - const bookStockLineForRow = useCallback( - async (row: ConfirmRow) => { - if (row.source !== "stock") return; - setBusyId(row.id); + setBusy(true); try { - if (row.stock_line_status !== "collected") { - toast.error( - `Stock booking only applies to collected lines (status: "${row.stock_line_status}").`, - ); - return; - } - if (!row.stock_purchase_id) { - toast.error("Stock line is missing the parent purchase id — cannot book."); - return; - } - // We need the `stock_item` (PB id) and the `quantity` for - // the ledger + cache writes. The ConfirmRow union only - // carries the `stock_purchase_id` and the human-readable - // description, so we fetch the line directly. - const pb = getPocketBase(); - const line = (await pb.collection("stock_purchase_items").getOne(row.id)) as unknown as { - id: string; - stock_item: string; - quantity: number; - quantity_received: number; - cost_price?: number; - }; - const qty = Number(line.quantity ?? 0); - if (!(qty > 0)) { - toast.error("Stock line has zero quantity — nothing to book."); - return; - } - const alreadyReceived = Number(line.quantity_received ?? 0); - if (alreadyReceived >= qty) { - toast.error("This line is already fully booked into stock."); - return; - } - const bookQty = qty - alreadyReceived; - - // (a) Audit BEFORE cache: ledger row first. - await pb.collection("stock_ledger").create({ - stock_item: line.stock_item, - change_qty: bookQty, - reason: "stock_order", - reference_type: "stock_purchase", - reference_id: row.stock_purchase_id, - notes: "", - created_by: "", + const response = await fetch("/api/v1/order-items/confirm", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + itemIds: confirmState.rowIds, + invoice: args.invoice, + costOverrides: args.costOverrides, + actor, + }), }); - - // (b) Cache increment. - const stockItem = (await pb - .collection("stock_items") - .getOne(line.stock_item)) as unknown as { current_qty?: number }; - await pb.collection("stock_items").update(line.stock_item, { - current_qty: Number(stockItem.current_qty ?? 0) + bookQty, - }); - - // (c) Line update: quantity_received + status. - await pb.collection("stock_purchase_items").update(row.id, { - quantity_received: qty, - status: "booked", - }); - - // (d) Auto-derive header: if every line in the parent PO - // is now `booked`, flip the parent to `received`. We only - // do this when the line was the LAST one pending — if any - // sibling is still in flight, the parent stays in - // `sent` and the operator can continue booking. - try { - const siblings = (await pb - .collection("stock_purchase_items") - .getFullList({ - filter: `purchase = "${row.stock_purchase_id}"`, - fields: "id,status", - })) as unknown as Array<{ id: string; status?: string | null }>; - const allBooked = siblings.length > 0 && siblings.every( - (s) => s.status === "booked", - ); - if (allBooked) { - const parent = (await pb - .collection("stock_purchases") - .getOne(row.stock_purchase_id)) as unknown as { status?: string }; - if (parent.status === "sent") { - await pb - .collection("stock_purchases") - .update(row.stock_purchase_id, { status: "received" }); - } - } - } catch (err) { - // Auto-derive is best-effort. A failure here means the - // operator can still see the line is `booked` on the - // next load — the parent status will be a stale `sent` - // until the next book. Don't block the success toast. - console.warn("stock_purchase status auto-derive failed:", err); + if (!response.ok) { + throw new Error(`Failed to confirm (HTTP ${response.status})`); } - - toast.success(`Booked ${bookQty} to stock`); - - // Optimistic local patch: flip the row to its post-booking - // shape so it disappears on the next render (the load() - // filter is `status = "collected"`, so once the line is - // `booked` it's filtered out). - setRows((prev) => { - if (!prev) return prev; - return prev.map((r) => - r.id === row.id - ? { ...r, stock_line_status: "booked", quantity_received: qty } - : r, - ); - }); + toast.success("Confirmed for invoicing"); + setConfirmState(null); + setCostOverrides({}); await load(); } catch (err) { const message = err instanceof Error ? err.message : String(err); toast.error(message); } finally { - setBusyId(null); + setBusy(false); } }, - [load], - ); - - // Click handler for the inline "Book to stock" button on a stock - // row. Only valid for `source === "stock"` rows in the - // `collected` state; the button isn't rendered otherwise. - const handleBookStockClick = useCallback( - async (row: ConfirmRow) => { - if (row.source !== "stock") return; - await bookStockLineForRow(row); - }, - [bookStockLineForRow], + [actor, confirmState, load], ); const rowsView = useMemo(() => rows ?? [], [rows]); @@ -925,7 +220,10 @@ export default function CollectionConfirmPage() { }, [rowsView, searchText]); const groupedRows = useMemo( - () => groupRowsByPurchaseOrder(searchedRows.map((row) => ({ ...row, poNumber: rowPoNumber(row) }))), + () => + groupRowsByPurchaseOrder( + searchedRows.map((row) => ({ ...row, poNumber: rowPoNumber(row) })), + ), [searchedRows], ); @@ -938,7 +236,7 @@ export default function CollectionConfirmPage() { Collection Confirm

- Review collected order items, adjust quantity, add notes, and confirm for invoicing. + Review collected order items, edit costs, and confirm for invoicing.

@@ -959,7 +257,11 @@ export default function CollectionConfirmPage() { {rows === null && (
{Array.from({ length: 4 }).map((_, i) => ( -
+
))}
)} @@ -990,653 +292,71 @@ export default function CollectionConfirmPage() {
{groupedRows.map((group) => { - const supplierName = group.rows[0]?.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier"; - const allProblemResolved = group.rows.every((row) => - !row.problem_flag || canConfirmRow({ problem_flag: row.problem_flag, notes: row.notes }, actor.name), - ); - const selectedInGroup = group.rows.filter((row) => selectedRowIds.has(row.id)).length; - const confirmLabel = selectedInGroup > 0 ? `Confirm ${selectedInGroup} of ${group.rows.length}` : "Confirm this PO"; - const confirmDisabled = busyId !== null || !allProblemResolved || !actor.id || selectedInGroup === 0; + const firstRow = group.rows[0]; + const supplierId = + firstRow?.expand?.purchase_order?.expand?.supplier?.id ?? ""; + const supplierName = + firstRow?.expand?.purchase_order?.expand?.supplier?.suppliername ?? + "Unknown supplier"; return ( -
-
-
- - {group.poNumber || "No PO"} - · - {supplierName} - · - - {group.rows.length} item{group.rows.length === 1 ? "" : "s"} - -
-
- - {!allProblemResolved && ( - A row needs a confirming note. - )} - {selectedInGroup === 0 && allProblemResolved && ( - Select rows to confirm. - )} -
-
-
    - {group.rows.map((row) => ( - setReturnModal(target)} - onBookSurplus={handleBookSurplusClick} - onBookStock={handleBookStockClick} - confirmModal={confirmModal?.row.id === row.id ? { row } : null} - onCloseConfirm={() => setConfirmModal(null)} - onSubmitConfirm={submitConfirm} - onSubmitBulkConfirm={bulkConfirm} - /> - ))} -
-
+ void handleCostChange(id, cost)} + onConfirm={() => + setConfirmState({ + poNumber: group.poNumber, + rowIds: group.rows.map((r) => r.id), + }) + } + /> ); })}
)} - setPickerState(null)} - onSubmit={async (stockItemId) => { - if (!pickerState) return; - const { row, surplus } = pickerState; - setPickerState(null); - await bookSurplusForRow(row, surplus, stockItemId); + { + if (!open) setConfirmState(null); }} + onSubmit={(args) => void handleConfirmSubmit(args)} /> + {/* Dialogs kept for parity with the pre-PoCard layout; the new + PoCard composition doesn't surface per-row action buttons so + neither dialog is currently triggered from this page. They're + retained here so a future iteration can re-introduce the + "Attach invoice" / "Return to pool" actions without having to + re-derive the ModalShell wiring. */} + {}} onSubmit={async () => {}} /> setReturnModal(null)} - onSubmit={async (reason) => { - if (!returnModal) return; - const ok = await handleReturnToPool(returnModal, reason); - // Keep the modal open on failure so the user can retry — the - // handler already shows the toast error. - if (ok) setReturnModal(null); - }} + open={false} + row={{ id: "" } as ConfirmRow} + busy={false} + onClose={() => {}} + onSubmit={async () => {}} />
); } -function ConfirmCard({ - row, - busy, - actor, - selected, - onToggleSelected, - onQuantity, - onAddNote, - onConfirm, - onAttach, - onRequestReturn, - onBookSurplus, - onBookStock, - confirmModal, - onCloseConfirm, - onSubmitConfirm, - onSubmitBulkConfirm, -}: { - row: ConfirmRow; - busy: boolean; - actor: ConfirmActor; - selected: boolean; - onToggleSelected: (id: string) => void; - onQuantity: (row: ConfirmRow, qty: number, reason: string) => Promise; - onAddNote: (row: ConfirmRow, body: string, options?: { confirmingNote?: boolean }) => Promise; - onConfirm: (row: ConfirmRow) => Promise; - onAttach: (row: ConfirmRow, file: File) => Promise; - onRequestReturn: (row: ConfirmRow) => void; - onBookSurplus: (row: ConfirmRow) => Promise | void; - onBookStock: (row: ConfirmRow) => Promise | void; - confirmModal: { row: ConfirmRow; bulkRows?: ConfirmRow[] } | null; - onCloseConfirm: () => void; - onSubmitConfirm: (row: ConfirmRow, invoice: { date: string; number: string }) => Promise; - onSubmitBulkConfirm: (rows: ConfirmRow[], invoice: { date: string; number: string }) => Promise; -}) { - const [quantityOpen, setQuantityOpen] = useState(false); - const [noteOpen, setNoteOpen] = useState(false); - const [photoOpen, setPhotoOpen] = useState(false); - const [attachOpen, setAttachOpen] = useState(false); - const entries = parseNotesFeed(row.notes ?? "", row); - const canConfirm = canConfirmRow({ problem_flag: row.problem_flag, notes: row.notes }, actor.name); - const invoice = readInvoiceDetails(row.order_item_details ?? ""); - const attachments = readAttachments(row); - const isConfirmingThis = confirmModal?.row.id === row.id; - // Surplus is positive when the driver collected more than the - // customer's line quantity. The page renders a chip + confirm - // button only when the headroom is > 0. A row that's already been - // booked (surplus_qty > 0 OR stock_booked_from set) swaps the - // chip for a "Booked to stock" pill that re-opens the picker on - // click — the operator can re-route if they got it wrong. - const surplus = maxSurplus(row); - const booked = hasBeenBooked(row); - const showSurplusChip = !booked && surplus > 0; - - return ( -
  • -
    -
    - onToggleSelected(row.id)} - disabled={busy} - /> -
    -
    -
    -

    - {row.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier"} - {/* Stock-source badge. Driver + operator should see at a - glance which rows are supplier pickups vs. customer - deliveries. Primary-color background = "this is a - stock row, not a customer row". */} - {isStockRow(row) && ( - - - Stock - - )} -

    -

    {row.description || "(no description)"}

    -
    - {row.expand?.order?.expand?.customer?.customer && ( - - - {row.expand.order.expand.customer.customer} - - )} - {row.expand?.order?.expand?.company?.company && ( - - - {row.expand.order.expand.company.company} - - )} - {row.expand?.purchase_order?.po_number && ( - - - {row.expand.purchase_order.po_number} - - )} - {row.expand?.order && ( - - Order {row.expand.order.order_number ?? row.expand.order.id.slice(0, 6)} - - - )} -
    -
    -
    -
    - - {/* ── Details: qty + problem + photo (left), attachments + invoice (right) ── */} -
    -
    -
    - Ordered - {row.order_qty ?? row.quantity ?? 0} -
    -
    - Collected - {row.qty_collected ?? 0} -
    - {/* ── Surplus split: chip + confirm button ───────────── - The chip renders whenever the row has positive headroom - (collected > customer qty) and the surplus hasn't been - booked yet. Already-booked rows show a static "Booked" - pill that re-opens the picker on click so the operator - can re-route if needed. */} - {showSurplusChip && ( -
    - - - {surplus} to stock - - -
    - )} - {booked && ( - - )} - {/* ── Stock-line book-to-stock ───────────────────────── - Stock rows (source === "stock") show a "Book to stock" - button when the line is in `status = "collected"`. The - booking flow writes a `stock_ledger` row, bumps - `stock_items.current_qty`, and flips the line to - `booked` (see `bookStockLineForRow`). If all sibling - lines in the parent `stock_purchase` are now `booked`, - the parent status auto-flips to `received`. - - Once the line is `booked`, the row disappears from this - page (the load() filter is `status = "collected"`), - so there's no "already booked" pill for stock rows. */} - {isStockRow(row) && row.stock_line_status === "collected" && ( -
    - - - {row.order_qty ?? row.quantity ?? 0} ready to book - - -
    - )} - {row.problem_flag && ( -
    - - Problem: {row.problem_type ?? "flagged"} -
    - )} - {row.collection_photo && ( - - )} -
    -
    - {attachments.length > 0 && ( -
    - {attachments.map((name) => ( - - - {displayFilename(name)} - - ))} -
    - )} - {invoice && ( -
    - Invoice {invoice.number} · {invoice.date} -
    - )} -
    -
    - - {/* ── Actions: 2-up grid on sm, 3-up on lg, primary spans 2 ── - Customer rows show the full action set (Adjust Qty, Add - Note, Attach, Confirm for invoicing, Return to pool). - Stock rows only show Add Note — the other actions are - customer-specific (no qty adjustment, no invoice, no - "return to pool" — stock rows flow through the - collection-pool page instead). The Book to stock button - lives in the Details section above. */} -
    - {!isStockRow(row) && ( - <> - - - - - {!canConfirm && ( -

    - Add a confirming note before invoicing this row. -

    - )} - - )} - -
    - -
    -

    Notes

    - {entries.length === 0 ? ( -

    No notes yet.

    - ) : ( -
      - {entries.map((entry, idx) => ( -
    • -
      - {new Date(entry.ts).toLocaleString()} - · - {entry.actorName} - · - {categoryLabel(entry.category)} -
      -

      {formatNoteBody(entry.body)}

      -
    • - ))} -
    - )} -
    - - setQuantityOpen(false)} - onSubmit={(qty, reason) => { - setQuantityOpen(false); - return onQuantity(row, qty, reason); - }} - /> - - setNoteOpen(false)} - onSubmit={(body, options) => { - setNoteOpen(false); - return onAddNote(row, body, options); - }} - /> - - - {row.collection_photo ? ( - /* eslint-disable-next-line @next/next/no-img-element */ - Collection photo - ) : ( -

    No photo attached.

    - )} -
    - - setAttachOpen(false)} - onSubmit={async (file) => { - setAttachOpen(false); - await onAttach(row, file); - }} - /> - - 0 - ? confirmModal.bulkRows.length - : 1 - } - defaultValues={ - invoice ? { date: invoice.date, number: invoice.number } : undefined - } - costOverrides={{}} - onOpenChange={(state) => { - if (!state) onCloseConfirm(); - }} - onSubmit={async ({ invoice: submittedInvoice }) => { - if (confirmModal?.bulkRows && confirmModal.bulkRows.length > 0) { - await onSubmitBulkConfirm(confirmModal.bulkRows, submittedInvoice); - } else { - await onSubmitConfirm(row, submittedInvoice); - } - }} - /> -
  • - ); -} - -function QuantityDialog({ - open, - row, - busy, - onClose, - onSubmit, -}: { - open: boolean; - row: ConfirmRow; - busy: boolean; - onClose: () => void; - onSubmit: (qty: number, reason: string) => Promise; -}) { - const [value, setValue] = useState(String(row.qty_collected ?? 0)); - const [reason, setReason] = useState(""); - - useEffect(() => { - if (open) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset modal state when (re)opened - setValue(String(row.qty_collected ?? 0)); - setReason(""); - } - }, [open, row.qty_collected]); - - const numeric = Number(value); - const valid = Number.isFinite(numeric) && numeric >= 0 && reason.trim().length > 0 && !busy; - - return ( - { - if (!state) onClose(); - }} - size="md" - eyebrow={`${row.expand?.purchase_order?.po_number ?? "Item"}`} - title="Adjust collected quantity" - description={`Ordered ${row.order_qty ?? row.quantity ?? 0}. Driver recorded ${row.qty_collected ?? 0}.`} - footer={ -
    - - -
    - } - > -
    - - setValue(e.target.value)} - className="w-full rounded-md border bg-background px-3 py-2 text-sm" - /> - -