From 437ed3529ceed195e6b5b2a1f0dabefab8c10b1f Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 17 Aug 2026 11:41:55 +0200 Subject: [PATCH] feat(collection-confirm): implement confirmation dialog for stock collection - Introduced a new `ConfirmStockDialog` to handle stock collection confirmations, allowing operators to review per-line quantities and any short lines before finalizing the action. - Updated the `CollectionConfirmPage` to manage the new confirmation state and trigger the dialog upon clicking the "Confirm collection" button. - Enhanced tests to cover the new dialog functionality, ensuring correct behavior for both full and short quantity lines during the confirmation process. - Improved documentation to clarify the flow and user interactions with the new confirmation dialog. --- ...004089-orders-process-stock-toggle-plan.md | 292 +++++++++ .../2026-08-17-add-order-items-in-stock.ts | 85 +++ .../(app)/collection-confirm/page.test.tsx | 112 +++- src/app/(app)/collection-confirm/page.tsx | 60 +- src/app/(app)/orders/[id]/process/page.tsx | 556 +++++++++++++++--- src/app/api/v1/stock/fulfill/route.test.ts | 51 ++ src/app/api/v1/stock/fulfill/route.ts | 23 +- .../confirm-stock-dialog.test.tsx | 182 ++++++ .../confirm-stock-dialog.tsx | 171 ++++++ .../stock/fulfill-from-stock-dialog.tsx | 440 -------------- src/components/ui/button.tsx | 2 + 11 files changed, 1442 insertions(+), 532 deletions(-) create mode 100644 .kilo/plans/1786948004089-orders-process-stock-toggle-plan.md create mode 100644 scripts/migrations/2026-08-17-add-order-items-in-stock.ts create mode 100644 src/components/collection-confirm/confirm-stock-dialog.test.tsx create mode 100644 src/components/collection-confirm/confirm-stock-dialog.tsx delete mode 100644 src/components/stock/fulfill-from-stock-dialog.tsx diff --git a/.kilo/plans/1786948004089-orders-process-stock-toggle-plan.md b/.kilo/plans/1786948004089-orders-process-stock-toggle-plan.md new file mode 100644 index 0000000..b274cf9 --- /dev/null +++ b/.kilo/plans/1786948004089-orders-process-stock-toggle-plan.md @@ -0,0 +1,292 @@ +# Order process page — split Send PO per supplier into in-stock + non-in-stock buttons + +## What the operator wants + +When a supplier group has both non-in-stock and in-stock editable +lines (e.g. "NSS Fasteners" with 2 pending/processed items, one +in-stock + one not), the current single Send PO button shows +`"NSS Fasteners (2)"`. The operator wants: + +- **Send PO** button (default / primary colour) — count of + **non-in-stock** lines only. Click creates the supplier PO + PDF + + email for those. +- **In-stock ready** button (different colour, amber/orange) — + count of **in-stock** lines only. Click flips those to + `ready_invoice`, unlinks from any draft PO, auto-cancels empty + POs. + +The two buttons sit next to each other in the Send POs card, one +per supplier. + +## Goal + +Replace the single per-supplier Send PO button with a pair of +buttons that split the supplier's editable lines by `in_stock`. + +## Decisions + +### D1. Filter shape + +Today, `sendPoForSupplier`'s `matching` is: + +```ts +const matching = items.filter( + (it) => + (it.supplier || "") === supplierId && + ["pending", "processed"].includes(it.item_status) +); +``` + +For the buttons, mirror this split: +- `nonInStock` = pending/processed + supplier match + `!in_stock` +- `inStock` = pending/processed + supplier match + `in_stock` + +`po_generated` items (still on a draft PO) stay outside the Send POs +card — those are handled by the new in-stock toggle that already +unlinks on tick-ON (last shipped plan). The two buttons cover the +"clean editable" path only. + +### D2. Button visibility + +A supplier group renders the pair only when at least one of the +two counts is > 0. A supplier with 0 non-in-stock + 0 in-stock +editable items is hidden entirely (same as today). + +### D3. Button labels + +- Send PO: `{suppliername} ({nonInStockCount})` — e.g. `"NSS Fasteners (1)"`. +- In-stock ready: `{suppliername} in-stock ({inStockCount})` — e.g. + `"NSS Fasteners in-stock (1)"`. Icon: `Package` (lucide) for + visual differentiation from the Send icon. + +Both buttons disabled while one is firing. Send PO uses +`variant="default"` (the existing primary colour). In-stock ready +uses a new amber colour token. Add the token to the existing +semantic theme map (the project uses `--success`, `--warn`, etc., +per `src/app/globals.css`). + +### D4. The existing toggle behaviour stays + +The last shipped plan's `toggleInStock` (immediate unlink + flip on +tick-ON for `po_generated` items) stays as-is. The new button is +the bulk-action counterpart for `pending`/`processed` items where +the toggle alone is a soft flag. They don't conflict — they cover +different `item_status` starting points. + +### D5. Handler split + +Today `sendPoForSupplier` runs both paths (non-in-stock PO + in-stock +bulk flip) in one call. Keep that as-is for the new Send PO button +when there are also in-stock lines in scope. But because the Send +PO button now only counts non-in-stock, the in-stock loop inside +`sendPoForSupplier` will be empty for that button's invocation — +the in-stock subset is handled by the separate button. + +So: +- `sendPoForSupplier(supplierId)` — only iterates `nonInStock` + items; in-stock branch becomes dead code, remove it. The "if + there are also in-stock items in this supplier" toast suffix goes + away too. Cancelled-PO toast stays. +- New `markInStockReady(supplierId)` — only iterates `inStock` + items: bulk update each to `purchase_order: ""`, + `item_status: "ready_invoice"`; per affected PO, trim back-link + or auto-cancel if empty; toast summary includes the cancelled POs + and the count. + +## Tasks + +### A. `sendPoForSupplier` — drop the in-stock branch + +File: `src/app/(app)/orders/[id]/process/page.tsx:1043-1196`. + +- Remove the `inStock = matching.filter(...)` split. +- Remove the `nonInStock` filter (replace with the new + `nonInStockCount` derived locally for the button — see C). +- Drop the in-stock loop + the affected-PO unlink + cancel block. +- Drop the toast suffix that mentions in-stock items. +- Keep the cancelled-PO toast for the cases where Send PO removes + items and the PO becomes empty — actually with the new flow + that never happens because Send PO doesn't unlink, so the + cancelled-PO logic should be dropped from `sendPoForSupplier` + entirely too. +- Keep the existing PO + PDF + email flow as the body. + +### B. New `markInStockReady(supplierId)` handler + +Same file, alongside `sendPoForSupplier`. + +- Filter items for this supplier where + `item_status ∈ [pending, processed] && it.in_stock === true`. +- If empty: toast "No in-stock items for this supplier" and return. +- Set `sendingSupplier(supplierId)` for spinner reuse (rename the + state to `busySupplierId` if cleaner — keep `sendingSupplier` + for the Send PO button, add `markingInStockSupplier` for this + new one. Or one combined `busySupplierId: string | null`). +- For each in-stock item: bulk update + `purchase_order: ""`, `item_status: "ready_invoice"`. Surface + per-item failures the same way as the current in-stock path. +- Group items by their current `purchase_order` (if any) — for + draft-PO linked items the unlink logic still applies. Most + in-stock items won't be on a draft PO (the toggle already + handles that), but defensive coverage. +- Per affected PO: read back-link, remove this batch's item ids, + trim or auto-cancel if empty. +- Summary toast: `N in-stock item(s) for {supplier} marked ready + for invoicing.`, plus the auto-cancelled-PO follow-up toast. + +### C. Send POs card layout + +File: `src/app/(app)/orders/[id]/process/page.tsx:1447-1485`. + +Today: + +```tsx +{Object.entries(bySupplier).map(([supplierId, list]) => { + const sendable = list.filter((it) => + ["pending", "processed"].includes(it.item_status), + ); + return ( + + ); +})} +``` + +Replace with: + +```tsx +{Object.entries(bySupplier).map(([supplierId, list]) => { + const editable = list.filter((it) => + ["pending", "processed"].includes(it.item_status), + ); + if (editable.length === 0) return null; + const nonInStockCount = editable.filter((it) => !it.in_stock).length; + const inStockCount = editable.filter((it) => it.in_stock).length; + const isBusy = sendingSupplier === supplierId || markingInStockSupplier === supplierId; + return ( +
+ {nonInStockCount > 0 && ( + + )} + {inStockCount > 0 && ( + + )} +
+ ); +})} +``` + +### D. Add the amber variant + +The codebase uses `variant="default"`, `"outline"`, `"secondary"`, +`"destructive"`, `"ghost"`, `"link"` on ` )} - {name} ({sendable.length}) - + {inStockCount > 0 && ( + + )} + ); })} @@ -1801,7 +2195,7 @@ function ItemCard({ ? "bg-success/15 border-success/40 text-success-foreground dark:text-success" : "border-input bg-background hover:bg-muted/40 text-muted-foreground", )} - title="Toggle to mark this item as already on hand — Send PO will jump straight to 'collected' instead of 'po_generated'." + title="Mark this item as already in our shop. Send PO will flip it to 'ready for invoicing' (no PO / driver / delivery)." > { ); }); + it("update mode: accepts intent='stock_booked' and lands the order_item in that state", async () => { + // The process page (/orders/[id]/process) redirects an existing + // order_item to fulfil-from-stock mid-order. The natural landing + // state is "stock_booked" — same as CREATE mode on /orders/new. + getOne.mockResolvedValueOnce(okStockRow({ current_qty: 12 })); + getOne.mockResolvedValueOnce(okOrderRow({ item_status: "processed" })); + createLedger.mockResolvedValueOnce({ id: "ledger-sb", notes: "" }); + updateStockItem.mockResolvedValueOnce({}); + updateOrderItem.mockResolvedValueOnce({}); + + const res = await POST( + postJSON({ + mode: "update", + order_id: "order-1", + order_item_id: "oi-1", + stock_item_id: "stock-1", + qty: 4, + intent: "stock_booked", + }), + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.resulting_status).toBe("stock_booked"); + expect(body.new_current_qty).toBe(8); + expect(updateOrderItem).toHaveBeenCalledWith( + "oi-1", + expect.objectContaining({ + item_status: "stock_booked", + stock_booked_from: "stock-1", + }), + ); + }); + + it("update mode: rejects unknown intent values", async () => { + const res = await POST( + postJSON({ + mode: "update", + order_item_id: "oi-1", + stock_item_id: "stock-1", + qty: 1, + // Pretend a stale client sends the old "stocked" alias. + intent: "stocked", + }), + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.reason).toBe("invalid_intent"); + }); + it("update mode: 404s when the order_item doesn't exist", async () => { // The route catches the order_item fetch error and returns a // 422 with the missing_order_item reason. (The exact status diff --git a/src/app/api/v1/stock/fulfill/route.ts b/src/app/api/v1/stock/fulfill/route.ts index 89c9e4f..ba4c713 100644 --- a/src/app/api/v1/stock/fulfill/route.ts +++ b/src/app/api/v1/stock/fulfill/route.ts @@ -37,7 +37,7 @@ import { getClientIdentity } from "@/lib/dev-identity"; * order_item_id: string, // PB id of the existing line * stock_item_id: string, * qty: number, - * intent: "collected" | "delivered", + * intent: "collected" | "delivered" | "stock_booked", * notes?: string, * } * @@ -46,6 +46,12 @@ import { getClientIdentity } from "@/lib/dev-identity"; * intent. `stock_booked_from` is stamped on top as an * audit-trail field (consistent with v1 surplus-book). * + * `"stock_booked"` is the landing state used by the process page + * when an existing order_item is redirected to fulfil-from-stock + * mid-order — it mirrors CREATE mode so the same line stays in + * the same `item_status` it would have if it had been entered + * via `/orders/new` with the same toggle on. + * * ## Why one endpoint for both modes * * The order-item CREATE and the stock deduction have to land @@ -103,7 +109,7 @@ interface StockFulfillBody { selling_price?: number | null; cost_price?: number | null; // UPDATE-mode fields - intent?: "collected" | "delivered"; + intent?: "collected" | "delivered" | "stock_booked"; notes?: string; } @@ -410,6 +416,11 @@ async function handleCreateMode( * in stock, ready to deliver). * - "delivered" → counter pick-up (customer has the * goods now). + * - "stock_booked" → mirrors CREATE mode. Used by the + * `/orders/[id]/process` page when an existing + * order_item is redirected to fulfil-from-stock + * mid-order, so the line lands in the same + * state it would have via `/orders/new`. * ========================================================================== */ async function handleUpdateMode( @@ -442,12 +453,16 @@ async function handleUpdateMode( { status: 422 }, ); } - if (intent !== "collected" && intent !== "delivered") { + if ( + intent !== "collected" && + intent !== "delivered" && + intent !== "stock_booked" + ) { return NextResponse.json( { ok: false, reason: "invalid_intent" as const, - message: 'intent must be "collected" or "delivered"', + message: 'intent must be "collected", "delivered", or "stock_booked"', }, { status: 422 }, ); diff --git a/src/components/collection-confirm/confirm-stock-dialog.test.tsx b/src/components/collection-confirm/confirm-stock-dialog.test.tsx new file mode 100644 index 0000000..bb42a71 --- /dev/null +++ b/src/components/collection-confirm/confirm-stock-dialog.test.tsx @@ -0,0 +1,182 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { ConfirmStockDialog } from "./confirm-stock-dialog"; +import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; + +function makeRow(overrides: Partial & { id: string }): ConfirmRow { + return { + id: overrides.id, + sku: overrides.sku ?? `SKU-${overrides.id}`, + description: overrides.description ?? `Row ${overrides.id}`, + order_qty: overrides.order_qty ?? 10, + qty_collected: overrides.qty_collected ?? 10, + cost_price: overrides.cost_price ?? 1.5, + source: "stock", + ...overrides, + }; +} + +function getDialog() { + return screen.getByRole("dialog"); +} + +function getSubmit(dialog = getDialog()) { + return within(dialog).getByTestId("confirm-stock-submit"); +} + +describe("ConfirmStockDialog", () => { + it("renders a row per line with current / ordered qty and the row count in the submit label", () => { + const rows = [ + makeRow({ id: "a", order_qty: 10, qty_collected: 10 }), + makeRow({ id: "b", order_qty: 5, qty_collected: 5 }), + ]; + render( + , + ); + + expect(within(getDialog()).getByTestId("confirm-stock-row-a")).toHaveTextContent("10 / 10"); + expect(within(getDialog()).getByTestId("confirm-stock-row-b")).toHaveTextContent("5 / 5"); + expect(within(getDialog()).getByTestId("confirm-stock-row-a")).toHaveAttribute("data-short", "false"); + expect(getSubmit()).toHaveTextContent("Confirm 2 lines"); + }); + + it("uses the singular 'line' label when only one row is staged", () => { + render( + , + ); + expect(getSubmit()).toHaveTextContent("Confirm 1 line"); + }); + + it("flags short lines (collected < ordered) and surfaces a warning banner", () => { + const rows = [ + makeRow({ id: "a", order_qty: 10, qty_collected: 8 }), + makeRow({ id: "b", order_qty: 5, qty_collected: 5 }), + ]; + render( + , + ); + const rowA = within(getDialog()).getByTestId("confirm-stock-row-a"); + expect(rowA).toHaveAttribute("data-short", "true"); + expect(rowA).toHaveTextContent("8 / 10"); + + const rowB = within(getDialog()).getByTestId("confirm-stock-row-b"); + expect(rowB).toHaveAttribute("data-short", "false"); + + const warning = within(getDialog()).getByTestId("confirm-stock-short-warning"); + expect(warning).toHaveTextContent(/1 short collected line \(1 of 2\)/i); + }); + + it("singularises the short warning copy when only one of one line is short", () => { + render( + , + ); + expect(within(getDialog()).getByTestId("confirm-stock-short-warning")) + .toHaveTextContent(/1 short collected line \(1 of 1\)/i); + }); + + it("shows the all-clear summary when no line is short", () => { + render( + , + ); + expect( + within(getDialog()).getByText(/All 2 lines are at full collected qty/i), + ).toBeInTheDocument(); + }); + + it("calls onSubmit when the confirm button is clicked", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render( + , + ); + await user.click(getSubmit()); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("calls onOpenChange(false) when Cancel is clicked", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + , + ); + await user.click(within(getDialog()).getByRole("button", { name: /Cancel/i })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("disables the submit button while busy", () => { + render( + , + ); + expect(getSubmit()).toBeDisabled(); + }); + + it("disables the submit button when no rows are staged", () => { + render( + , + ); + expect(getSubmit()).toBeDisabled(); + }); + + it("renders nothing when closed (so the body doesn't leak stale data)", () => { + render( + , + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/collection-confirm/confirm-stock-dialog.tsx b/src/components/collection-confirm/confirm-stock-dialog.tsx new file mode 100644 index 0000000..527a34f --- /dev/null +++ b/src/components/collection-confirm/confirm-stock-dialog.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useMemo } from "react"; +import { AlertTriangle, Loader2 } from "lucide-react"; + +import { ModalShell } from "@/components/ui/modal-shell"; +import { Button } from "@/components/ui/button"; +import type { ConfirmRow } from "@/lib/server/collection-confirm-service"; + +type ConfirmStockDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + /** + * The rows this confirm covers. The dialog reads + * `order_qty`/`qty_collected` from each row to flag any short lines + * before the operator commits. + */ + rows: ConfirmRow[]; + /** Small label above the title (typically the PO number). */ + eyebrow?: React.ReactNode; + /** Disables the submit button + shows a spinner while the parent persists. */ + busy?: boolean; + /** + * Called when the operator clicks Confirm. The parent fires + * `POST /api/v1/collection-confirm/confirm-stock` and reloads the + * feed. No payload needed — the rows are already known. + */ + onSubmit: () => Promise | void; +}; + +/** + * Warehouse-side close-of-day confirm modal. + * + * Mirrors the customer-side `ConfirmInvoiceDialog` pattern: a real + * modal that gates the "Confirm collection" action behind a + * deliberate click. The dialog surfaces: + * + * - The PO number (eyebrow). + * - A one-line description of what the action does (snap + * `quantity_received` to the full `quantity` on every line, drop + * the card off the list, hand the line to the accountant for + * invoicing). + * - The list of lines with current `qty_collected` vs `order_qty` + * so the operator can see any short lines BEFORE they confirm. + * - A warning row if any line is short. + * - A "Confirm N line(s)" / "Cancel" footer. + * + * Why this exists: the original `Confirm collection` button fired + * immediately on click and surfaced the result as a toast. Operators + * who used the qty pencil to leave a line intentionally short + * (`qty_collected < order_qty`) could still confirm by accident. + * The dialog makes the action deliberate + surfaces the short lines + * so the operator can re-check before clicking. + */ +export function ConfirmStockDialog(props: ConfirmStockDialogProps) { + // Remount on open so stale warnings don't leak between cards. + return ; +} + +function ConfirmStockDialogContent({ + open, + onOpenChange, + rows, + eyebrow, + busy = false, + onSubmit, +}: ConfirmStockDialogProps) { + const summary = useMemo(() => { + const total = rows.length; + const short = rows.filter((row) => { + const ordered = row.order_qty ?? row.quantity ?? 0; + const collected = row.qty_collected ?? 0; + return collected < ordered; + }); + return { total, short }; + }, [rows]); + + return ( + + + + + } + > +
+
    + {rows.map((row) => { + const ordered = row.order_qty ?? row.quantity ?? 0; + const collected = row.qty_collected ?? 0; + const isShort = collected < ordered; + return ( +
  • + + {row.sku || row.description || row.id} + + + {collected} / {ordered} + +
  • + ); + })} +
+ + {summary.short.length > 0 ? ( +
+ +

+ {summary.short.length} short collected line + {summary.short.length === 1 ? "" : "s"} ({summary.short.length} of{" "} + {summary.total}). Confirming will snap{" "} + {summary.short.length === 1 ? "it" : "them"}{" "} + to the full ordered quantity — re-check the qty pencil if this + isn't what you want. +

+
+ ) : ( +

+ All {summary.total} line{summary.total === 1 ? "" : "s"} are at + full collected qty — confirming is a no-op for the values but + still moves the card off the list and hands the lines to the + accountant. +

+ )} +
+
+ ); +} diff --git a/src/components/stock/fulfill-from-stock-dialog.tsx b/src/components/stock/fulfill-from-stock-dialog.tsx deleted file mode 100644 index 92c85c5..0000000 --- a/src/components/stock/fulfill-from-stock-dialog.tsx +++ /dev/null @@ -1,440 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { Boxes, Loader2, Package, PackageCheck, Search, Truck } from "lucide-react"; -import { toast } from "sonner"; - -import { Button } from "@/components/ui/button"; -import { ModalShell } from "@/components/ui/modal-shell"; -import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; -import { cn } from "@/lib/utils"; -import { getPocketBase } from "@/lib/pocketbase"; -import type { OrderItem, StockItem } from "@/lib/types"; - -/** - * `` — operator-driven v3 stock-out flow. - * - * Used on `/orders/[id]/process` (and, in the future, on - * `/orders/new`) to fulfill a customer order line from existing - * stock. The dialog: - * 1. Shows a searchable list of active stock items (lazy-loaded - * on first open, cached for re-opens). - * 2. Lets the operator pick the stock_item + the qty to book out - * (defaults to the order line's order_qty). - * 3. Toggles `intent`: "collected" (normal delivery path — - * mirror of the `in_stock: true` flow) or "delivered" (counter - * pick-up — the customer has it now, skip the rest of the - * pipeline). - * 4. POSTs `/api/v1/stock/fulfill` with the chosen values. - * 5. On success, calls `onFulfilled(updatedOrderItem)` so the - * parent can patch the row's local state and the order's - * "Complete" badge re-derives. - * - * Block partial stock in v1 — the helper rejects with - * `insufficient_stock` if the operator asks for more than the row - * has. The toast surfaces the error inline; the modal stays open - * so the operator can fix the qty. - */ -export interface FulfillFromStockDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - /** - * The order_item being fulfilled. The dialog uses - * `order_qty` as the default qty + `description` as the - * "what you're fulfilling" header. - */ - orderItem: OrderItem | null; - /** Called after a successful fulfill with the updated order_item. */ - onFulfilled?: (updated: { - id: string; - item_status: string; - new_current_qty: number; - }) => void; -} - -export function FulfillFromStockDialog({ - open, - onOpenChange, - orderItem, - onFulfilled, -}: FulfillFromStockDialogProps) { - // Lazy-loaded stock items list. Fetched on first modal open, - // cached for re-opens. `null` = "haven't tried yet" so we can - // distinguish "empty list" from "loading". - const [stockItems, setStockItems] = useState(null); - const [loading, setLoading] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - const [query, setQuery] = useState(""); - const [selectedId, setSelectedId] = useState(""); - // Default qty to the order_item's order_qty. The user can adjust. - const [qty, setQty] = useState(""); - // "Customer has it now" toggle → maps to intent: "delivered" / - // "collected". Default OFF (collected) — mirrors the in_stock - // flow. - const [deliverNow, setDeliverNow] = useState(false); - - // Lazy-load the active stock items list when the modal opens - // for the first time. Subsequent opens reuse the cache. - useEffect(() => { - if (!open || stockItems !== null) return; - setLoading(true); - (async () => { - 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 : "Failed to load stock items"; - toast.error(message); - setStockItems([]); - } finally { - setLoading(false); - } - })(); - }, [open, stockItems]); - - // Reset the modal state every time it opens with a fresh row - // so the operator never sees a stale qty / stock selection. - useEffect(() => { - if (open && orderItem) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset modal state when (re)opened - setSelectedId(""); - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset modal state when (re)opened - setQuery(""); - // eslint-disable-next-line react-hooks/set-state-in-effect -- pre-fill qty from the order line - setQty(String(orderItem.order_qty ?? orderItem.quantity ?? 0)); - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset intent toggle on open - setDeliverNow(false); - // eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale error on open - setError(null); - } - }, [open, orderItem]); - - // Filter the list by the search box. Match against name / sku / - // description. Same fields the stock-match helper uses for the - // surplus-book picker. (location was removed — office is the only - // bin and operators don't filter by it.) - const filteredItems = useMemo(() => { - if (!stockItems) return []; - const q = query.trim().toLowerCase(); - if (!q) return stockItems; - return stockItems.filter((item) => { - return ( - (item.name ?? "").toLowerCase().includes(q) || - (item.sku ?? "").toLowerCase().includes(q) || - (item.description ?? "").toLowerCase().includes(q) - ); - }); - }, [stockItems, query]); - - const selectedItem = useMemo( - () => stockItems?.find((i) => i.id === selectedId) ?? null, - [stockItems, selectedId], - ); - - const numericQty = Number(qty); - const validQty = Number.isFinite(numericQty) && numericQty > 0; - const hasEnoughStock = - selectedItem == null || - numericQty <= Number(selectedItem.current_qty ?? 0); - const valid = !!selectedId && validQty && hasEnoughStock && !submitting; - - const handleSubmit = async () => { - if (!orderItem || !valid) return; - setSubmitting(true); - setError(null); - try { - const res = await fetch("/api/v1/stock/fulfill", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - order_item_id: orderItem.id, - stock_item_id: selectedId, - qty: numericQty, - intent: deliverNow ? "delivered" : "collected", - }), - }); - const data = (await res.json().catch(() => ({}))) as { - ok?: boolean; - ledger_id?: string; - new_current_qty?: number; - resulting_status?: string; - reason?: string; - message?: string; - error?: string; - }; - - if (!res.ok || data.ok !== true) { - setError( - data.message || - data.error || - `Fulfill failed (HTTP ${res.status}).`, - ); - return; - } - - const updatedStatus = data.resulting_status ?? "collected"; - toast.success( - deliverNow - ? `Fulfilled ${numericQty} from stock — delivered.` - : `Fulfilled ${numericQty} from stock — ready to deliver.`, - ); - // Patch the local stock_items cache so a re-open of the - // modal shows the new on-hand qty without a refetch. - setStockItems((prev) => - prev - ? prev.map((it) => - it.id === selectedId - ? { ...it, current_qty: data.new_current_qty ?? it.current_qty } - : it, - ) - : prev, - ); - onFulfilled?.({ - id: orderItem.id, - item_status: updatedStatus, - new_current_qty: data.new_current_qty ?? 0, - }); - onOpenChange(false); - } catch (err) { - const message = - err instanceof Error ? err.message : "Fulfill request failed"; - setError(message); - } finally { - setSubmitting(false); - } - }; - - return ( - { - if (!o) onOpenChange(false); - }} - size="lg" - eyebrow={orderItem?.description || "Order line"} - title="Fulfill from stock" - description="Book the qty out of existing stock. Lands the order item at the chosen intent." - footer={ -
- - -
- } - > -
- {error && ( -
- {error} -
- )} - -
-
- - - {orderItem?.description || "(no description)"} - -
-
- Line qty:{" "} - - {orderItem?.order_qty ?? orderItem?.quantity ?? 0} - {" "} - (default) -
-
- -
- -
- - setQuery(e.target.value)} - placeholder="Search by name, SKU..." - className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground" - autoFocus - /> -
-
- -
- {loading && ( -
- - Loading stock items… -
- )} - {!loading && stockItems !== null && stockItems.length === 0 && ( -
- No active stock items. Add one on{" "} - - /stock/new - {" "} - first. -
- )} - {!loading && stockItems !== null && stockItems.length > 0 && filteredItems.length === 0 && ( -
- No stock items match the search. -
- )} - {!loading && filteredItems.length > 0 && ( -
    - {filteredItems.map((item) => { - const selected = item.id === selectedId; - const onHand = Number(item.current_qty ?? 0); - const min = Number(item.min_qty ?? 0); - const low = onHand <= min; - return ( -
  • - -
  • - ); - })} -
- )} -
- -
-
- - setQty(e.target.value)} - disabled={!selectedId} - /> - {selectedItem && !hasEnoughStock && ( -

- Only {Number(selectedItem.current_qty ?? 0)} on hand. PO the - rest or pick a different stock item. -

- )} -
-
- - Landing state - - -

- {deliverNow - ? "Order item lands on `delivered`. Skips the rest of the pipeline." - : "Order item lands on `collected`. Goes through the normal delivery / invoice flow."} -

-
-
-
-
- ); -} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index c66b1e0..ad868f1 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -18,6 +18,8 @@ const buttonVariants = cva( "hover:bg-accent hover:text-accent-foreground active:scale-[0.98] transition-all", destructive: "bg-destructive/12 text-destructive border border-destructive/20 hover:bg-destructive/20 active:scale-[0.98] transition-all", + warn: + "bg-warn/12 text-warn-foreground border border-warn/30 hover:bg-warn/20 active:scale-[0.98] transition-all", link: "text-primary underline-offset-4 hover:underline", }, size: {