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 (
+ sendPoForSupplier(supplierId)}
+ disabled={sendable.length === 0 || sendingSupplier === supplierId || !supplier}
+ variant={sendable.length === 0 ? "outline" : "default"}
+ size="sm"
+ >
+ ...
+ {name} ({sendable.length})
+
+ );
+})}
+```
+
+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 && (
+
sendPoForSupplier(supplierId)}
+ disabled={isBusy || !supplier}
+ variant="default"
+ size="sm"
+ >
+ {sendingSupplier === supplierId ? : }
+ {name} ({nonInStockCount})
+
+ )}
+ {inStockCount > 0 && (
+
markInStockReady(supplierId)}
+ disabled={isBusy || !supplier}
+ variant="warn"
+ size="sm"
+ >
+ {markingInStockSupplier === supplierId ? : }
+ {name} in-stock ({inStockCount})
+
+ )}
+
+ );
+})}
+```
+
+### D. Add the amber variant
+
+The codebase uses `variant="default"`, `"outline"`, `"secondary"`,
+`"destructive"`, `"ghost"`, `"link"` on ``. Add a new
+`variant="warn"` that applies an amber bg + matching text colour,
+mirroring the existing `bg-warn/15 border-warn/40 text-warn-foreground`
+chips already used elsewhere on the page (e.g. the
+`"needs supplier"` badge at line 1778-1784).
+
+If `src/components/ui/button.tsx` is the shadcn-style variants
+file, extend its `cva` definition with the new variant. Verify
+the `--warn` token exists in `globals.css` — it should, based on
+the existing badge styling.
+
+### E. Card-level copy update
+
+File: `src/app/(app)/orders/[id]/process/page.tsx:1453-1456`.
+
+Current hint:
+
+> In-stock items jump straight to **ready for invoicing**; rest go
+> through PO + driver + delivery.
+
+Update to:
+
+> Each supplier has two buttons: **Send PO** (creates the
+> supplier PO + email for the non-in-stock lines) and
+> **N supplier-name in-stock** (flips the in-stock lines straight
+> to ready for invoicing).
+
+### F. State plumbing
+
+- Keep `sendingSupplier` for the Send PO button.
+- Add `markingInStockSupplier: string | null` for the new button.
+- `setSendingSupplier(null)` / `setMarkingInStockSupplier(null)`
+ in the respective `finally` blocks.
+
+## Validation steps
+
+Manual on the operator's order:
+
+1. **Supplier with both subsets** — pick a supplier (e.g. add a
+ second pending item for NSS, tick in-stock on one of them).
+ Expect: two buttons render, e.g. `NSS Fasteners (1)` (primary)
+ and `NSS Fasteners in-stock (1)` (amber).
+2. **Send PO** — click the primary button. Expect: PO + PDF + email
+ for the non-in-stock line; non-in-stock subset flips to
+ `po_generated`. In-stock line untouched.
+3. **In-stock ready** — click the amber button. Expect: in-stock
+ line flips to `ready_invoice`, drops out of any draft PO
+ back-link, auto-cancels the empty PO if applicable. Toast
+ *"1 in-stock item for NSS Fasteners marked ready for
+ invoicing."*.
+4. **Supplier with only in-stock** — pick a supplier with only
+ in-stock editable lines. Expect: only the amber button renders,
+ not the primary one.
+5. **Supplier with only non-in-stock** — only the primary button
+ renders.
+6. **No regression** — fresh in-stock toggle on a `po_generated`
+ item still auto-unlinks + flips to ready_invoice (last shipped
+ plan).
+7. **No regression** — supplier-change auto-unlink in `saveItem`
+ still fires.
+
+Automated:
+
+- `npm run lint` — clean for touched files.
+- `npx tsc --noEmit` — no new errors in touched files.
+- `npx vitest run src/app/api/v1/stock/fulfill/route.test.ts` —
+ 18/18 still pass.
+
+## Out of scope
+
+- Renaming `sendingSupplier` to a more generic `busySupplierId`
+ state name (cosmetic; the two-state version is clearer for
+ debugging which button fired).
+- Migrating `process/page.tsx` off `pb-helpers.ts`.
+- The button colour palette itself — `--warn` already exists and
+ matches the existing chip styling. No design token work.
+
+## Open questions
+
+None blocking. Two confirmations worth stating in the PR:
+
+- The amber variant is intentionally low-saturation so it doesn't
+ compete visually with the primary Send PO button.
+- The Send PO button's count is now **non-in-stock only**. Operators
+ used to seeing `(2)` for two pending items will see `(1)` + an
+ amber `(1)` once they tick in-stock on one. Worth a one-line
+ note in the changelog.
diff --git a/scripts/migrations/2026-08-17-add-order-items-in-stock.ts b/scripts/migrations/2026-08-17-add-order-items-in-stock.ts
new file mode 100644
index 0000000..11dca39
--- /dev/null
+++ b/scripts/migrations/2026-08-17-add-order-items-in-stock.ts
@@ -0,0 +1,85 @@
+/**
+ * One-shot migration: add `in_stock` (bool, default false) to the
+ * `order_items` collection.
+ *
+ * Run with:
+ *
+ * npx tsx scripts/migrations/2026-08-17-add-order-items-in-stock.ts
+ *
+ * What this does (idempotent — safe to re-run):
+ *
+ * 1. Reads the `order_items` collection schema.
+ * 2. If an `in_stock` bool field already exists, prints "already
+ * present" and exits.
+ * 3. Otherwise appends a new bool field named `in_stock` and
+ * PATCHes the collection.
+ *
+ * Why this migration exists
+ *
+ * The order process page has a per-line "in stock" checkbox that
+ * flips a boolean on the order_item. The handler writes the field
+ * directly via PocketBase admin PB
+ * (`pb.collection("order_items").update(id, { in_stock: next })`).
+ * The field has never been declared on the schema, which means every
+ * write is silently a no-op — the optimistic UI flips on, the
+ * refresh re-reads PB without the value, and the checkbox reverts.
+ * This blocks the in-stock → ready_invoice Send PO path that
+ * `process/page.tsx:sendPoForSupplier` implements.
+ *
+ * Adding the field here lets every existing `in_stock` write
+ * (process page's `toggleInStock` and `saveItem`, the
+ * `/api/v1/stock/fulfill` route's CREATE mode) start persisting
+ * without any further code change.
+ *
+ * Rollback: delete the `in_stock` field from the order_items
+ * collection in the PB admin UI. No data is lost because the field
+ * was not populated before this migration ran.
+ */
+import PocketBase from "pocketbase";
+
+const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
+const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
+const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
+
+async function main() {
+ if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
+ throw new Error(
+ "Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD env vars (e.g. sourced from .env.local).",
+ );
+ }
+
+ const pb = new PocketBase(PB_URL);
+ await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
+ console.log(`[order-items-in-stock] Authenticated against ${PB_URL}`);
+
+ const orderItems = await pb.collections.getOne("order_items");
+ const hasInStock = orderItems.fields.some(
+ (f: { name: string }) => f.name === "in_stock",
+ );
+
+ if (hasInStock) {
+ out("[order-items-in-stock] order_items.in_stock already exists — skipping.");
+ return;
+ }
+
+ await pb.collections.update(orderItems.id, {
+ fields: [
+ ...orderItems.fields,
+ {
+ name: "in_stock",
+ type: "bool",
+ required: false,
+ presentable: false,
+ },
+ ],
+ });
+
+ console.log(
+ "[order-items-in-stock] Added order_items.in_stock (bool, optional, default false). Migration complete.",
+ );
+}
+
+main().catch((err) => {
+ console.error("[order-items-in-stock] Migration failed:", err);
+ process.exit(1);
+});
\ No newline at end of file
diff --git a/src/app/(app)/collection-confirm/page.test.tsx b/src/app/(app)/collection-confirm/page.test.tsx
index b25785b..0472ece 100644
--- a/src/app/(app)/collection-confirm/page.test.tsx
+++ b/src/app/(app)/collection-confirm/page.test.tsx
@@ -1153,8 +1153,26 @@ describe("CollectionConfirmPage", () => {
// Clear the spy so we can isolate the confirm-stock POST.
fetchSpy.mockClear();
+
+ // The card's "Confirm collection" button now opens a
+ // confirmation dialog (matches the customer-side
+ // `ConfirmInvoiceDialog` pattern) instead of firing the API
+ // directly. The API only fires once the operator clicks the
+ // dialog's submit button.
await user.click(screen.getByTestId("po-card-confirm-stock-SP-001"));
+ // Dialog opens with the staged row + a "no short lines" note
+ // (the fixture has qty_collected === order_qty so no warning).
+ const dialog = await screen.findByRole("dialog");
+ expect(within(dialog).getByTestId("confirm-stock-row-row-stock-1"))
+ .toHaveTextContent("50 / 50");
+ expect(within(dialog).getByTestId("confirm-stock-row-row-stock-1"))
+ .toHaveAttribute("data-short", "false");
+ expect(fetchSpy).not.toHaveBeenCalled();
+
+ // Operator clicks the dialog's confirm button — the API fires.
+ await user.click(within(dialog).getByTestId("confirm-stock-submit"));
+
// The endpoint was POSTed with the right URL + body.
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith(
@@ -1185,6 +1203,99 @@ describe("CollectionConfirmPage", () => {
);
});
+ it("surfaces a short-qty warning in the confirm dialog when a line is below order qty", async () => {
+ // One short + one full line. The short line should be flagged
+ // in the dialog so the operator can re-check the qty pencil
+ // before clicking Confirm. The API still fires after the
+ // warning is acknowledged.
+ const shortRow: ConfirmRow = {
+ id: "row-stock-short",
+ source: "stock",
+ description: "Short stock line",
+ sku: "CT-SHORT",
+ order_qty: 10,
+ qty_collected: 4,
+ quantity: 10,
+ cost_price: 2,
+ stock_purchase_id: "sp-2",
+ stock_line_status: "collected",
+ poNumber: "SP-002",
+ expand: {
+ purchase_order: {
+ id: "sp-2",
+ po_number: "SP-002",
+ expand: { supplier: { id: "s-3", suppliername: "ShortCo" } },
+ },
+ },
+ };
+ const fullRow: ConfirmRow = {
+ id: "row-stock-full",
+ source: "stock",
+ description: "Full stock line",
+ sku: "CT-FULL",
+ order_qty: 5,
+ qty_collected: 5,
+ quantity: 5,
+ cost_price: 2,
+ stock_purchase_id: "sp-2",
+ stock_line_status: "collected",
+ poNumber: "SP-002",
+ expand: {
+ purchase_order: {
+ id: "sp-2",
+ po_number: "SP-002",
+ expand: { supplier: { id: "s-3", suppliername: "ShortCo" } },
+ },
+ },
+ };
+ const fetchSpy = vi
+ .spyOn(window, "fetch")
+ .mockImplementation(async (input) => {
+ const url = fetchUrl(input);
+ if (url === "/api/v1/collection-confirm") {
+ return jsonResponse({ items: [shortRow, fullRow] });
+ }
+ if (url === "/api/v1/collection-confirm/confirm-stock") {
+ return jsonResponse({ updatedIds: ["row-stock-short", "row-stock-full"] });
+ }
+ return jsonResponse({ ok: true });
+ });
+ const user = userEvent.setup();
+ render( );
+
+ await waitFor(() =>
+ expect(screen.getByTestId("po-card-SP-002")).toBeInTheDocument(),
+ );
+ await user.click(screen.getByTestId("po-card-toggle-SP-002"));
+ await user.click(screen.getByTestId("po-card-confirm-stock-SP-002"));
+
+ const dialog = await screen.findByRole("dialog");
+ expect(
+ within(dialog).getByTestId("confirm-stock-row-row-stock-short"),
+ ).toHaveAttribute("data-short", "true");
+ expect(
+ within(dialog).getByTestId("confirm-stock-row-row-stock-full"),
+ ).toHaveAttribute("data-short", "false");
+ expect(
+ within(dialog).getByTestId("confirm-stock-short-warning"),
+ ).toHaveTextContent(/1 short collected line \(1 of 2\)/i);
+
+ // Operator acknowledges the warning and confirms.
+ fetchSpy.mockClear();
+ await user.click(within(dialog).getByTestId("confirm-stock-submit"));
+ await waitFor(() =>
+ expect(fetchSpy).toHaveBeenCalledWith(
+ "/api/v1/collection-confirm/confirm-stock",
+ expect.objectContaining({ method: "POST" }),
+ ),
+ );
+ const confirmCall = fetchSpy.mock.calls.find(
+ ([arg]) => fetchUrl(arg) === "/api/v1/collection-confirm/confirm-stock",
+ );
+ const body = JSON.parse(String((confirmCall![1] as RequestInit).body));
+ expect(body).toEqual({ rowIds: ["row-stock-short", "row-stock-full"] });
+ });
+
it("preserves a null prior cost_price when a cost-edit POST fails (does not coerce to 0)", async () => {
// Drive the page with one row whose cost_price is null. The
@@ -1520,7 +1631,6 @@ describe("CollectionConfirmPage", () => {
vi.mocked(window.fetch).mockImplementation(async () => {
return jsonResponse({ items: [stockRowNoAgentNote] });
});
- const user = userEvent.setup();
render( );
await waitFor(() =>
expect(screen.getByTestId("po-card-SP-003")).toBeInTheDocument(),
diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx
index 7b06d81..6ec4c77 100644
--- a/src/app/(app)/collection-confirm/page.tsx
+++ b/src/app/(app)/collection-confirm/page.tsx
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ConfirmInvoiceDialog } from "@/components/collection-confirm/confirm-invoice-dialog";
+import { ConfirmStockDialog } from "@/components/collection-confirm/confirm-stock-dialog";
import { InvoiceViewer } from "@/components/collection-confirm/invoice-viewer";
import { PoCard } from "@/components/collection-confirm/po-card";
import type { Attachment } from "@/components/collection-confirm/photo-strip";
@@ -70,6 +71,14 @@ export default function CollectionConfirmPage() {
poNumber: string;
rowIds: string[];
} | null>(null);
+ // Stock-side confirm state. Mirrors `confirmState` but for the
+ // warehouse "Confirm collection" flow — opens the
+ // `ConfirmStockDialog` so the operator sees the per-line qty
+ // (and any short lines) before the action commits.
+ const [confirmStockState, setConfirmStockState] = useState<{
+ poNumber: string;
+ rowIds: string[];
+ } | null>(null);
const [busy, setBusy] = useState(false);
/**
* Result of the most recent `purchase_orders.scan` MCP call
@@ -478,10 +487,12 @@ export default function CollectionConfirmPage() {
/**
* Confirm-collection handler wired to when the card holds
- * stock-source rows. POSTs the stock row ids to
- * `/api/v1/collection-confirm/confirm-stock`; on success, re-fetches
- * the feed (the confirmed rows drop off the `status = "collected"`
- * filter on the next load).
+ * stock-source rows. The card's `onConfirmStock` now just opens
+ * `confirmStockState` (the modal). The modal's submit fires
+ * `handleConfirmStock` with the staged row ids, which posts to
+ * `/api/v1/collection-confirm/confirm-stock`. On success the
+ * feed is re-fetched (the confirmed rows drop off the
+ * `quantity_received < quantity` filter on the next load).
*
* This is the warehouse-side close-of-day step. The actual
* posting into `stock_items` happens later in the accountant's
@@ -492,7 +503,8 @@ export default function CollectionConfirmPage() {
* The flash toast reflects the count of rows the server actually
* confirmed so the operator gets a clear confirmation of what
* happened — a successful 200 with an empty `updatedIds` is
- * unusual but tolerated.
+ * tolerated (idempotent no-op for lines that were already fully
+ * received via the qty pencil).
*/
const handleConfirmStock = useCallback(
async (rowIds: string[]) => {
@@ -509,6 +521,7 @@ export default function CollectionConfirmPage() {
}
const data = (await response.json()) as { updatedIds: string[] };
toast.success(`Confirmed ${data.updatedIds.length} row${data.updatedIds.length === 1 ? "" : "s"}`);
+ setConfirmStockState(null);
await load();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -864,7 +877,11 @@ export default function CollectionConfirmPage() {
}
onConfirmStock={
stockRowIds.length > 0
- ? () => void handleConfirmStock(stockRowIds)
+ ? () =>
+ setConfirmStockState({
+ poNumber: group.poNumber,
+ rowIds: stockRowIds,
+ })
: undefined
}
onInvoiceUpload={
@@ -902,6 +919,37 @@ export default function CollectionConfirmPage() {
onSubmit={(args) => void handleConfirmSubmit(args)}
/>
+ {/*
+ Stock-side close-of-day dialog. Mirrors the customer-side
+ `ConfirmInvoiceDialog` pattern: clicking the PoCard's
+ "Confirm collection" button only stages the row ids; the
+ modal forces a deliberate second click and surfaces the
+ per-line qty so the operator can see any short lines before
+ committing. Renders with the staged rows from the most
+ recent card click (defaulting to an empty list so a closed
+ state doesn't show stale data).
+ */}
+ confirmStockState.rowIds.includes(r.id)) ??
+ []
+ : []
+ }
+ busy={busy}
+ onOpenChange={(open) => {
+ if (!open) setConfirmStockState(null);
+ }}
+ onSubmit={() => {
+ if (confirmStockState) {
+ return handleConfirmStock(confirmStockState.rowIds);
+ }
+ return undefined;
+ }}
+ />
+
{/*
Floating viewer — only rendered while a Pop-out / Fullscreen
button has fired. The viewer is keyboard- and mouse-driven;
diff --git a/src/app/(app)/orders/[id]/process/page.tsx b/src/app/(app)/orders/[id]/process/page.tsx
index dd136b1..a5c8650 100644
--- a/src/app/(app)/orders/[id]/process/page.tsx
+++ b/src/app/(app)/orders/[id]/process/page.tsx
@@ -44,6 +44,7 @@ import {
Paperclip,
MessageSquare,
Split,
+ Package,
} from "lucide-react";
import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
@@ -291,6 +292,9 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
const [edits, setEdits] = useState>({});
const [loading, setLoading] = useState(true);
const [sendingSupplier, setSendingSupplier] = useState(null);
+ const [markingInStockSupplier, setMarkingInStockSupplier] = useState<
+ string | null
+ >(null);
// Tracks the item currently being split into its remainder (server
// round-trip in flight) — drives the spinner on the split button.
const [splittingItemId, setSplittingItemId] = useState(null);
@@ -617,6 +621,59 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
? "processed"
: currentItem?.item_status;
+ // Supplier-change unlink. When the operator changes the
+ // supplier on an item that's already linked to a draft PO for
+ // a different supplier, the item would otherwise stay pinned
+ // to the old PO with a mismatched supplier. Unlink it so it
+ // drops back into the editable pool and reappears under Send
+ // POs for the new supplier. Auto-cancels the now-empty PO
+ // (if any) the same way Send PO does for in-stock items.
+ const supplierChanged =
+ !!currentItem &&
+ currentItem.supplier !== committed.supplier &&
+ currentItem.supplier !== "" &&
+ committed.supplier !== "";
+ const unlinkedPoNumbers: string[] = [];
+ let pendingUnlink = false;
+ if (
+ supplierChanged &&
+ typeof currentItem?.purchase_order === "string" &&
+ currentItem.purchase_order.length > 0
+ ) {
+ const prevPoId = currentItem.purchase_order;
+ try {
+ const po = await pb.collection("purchase_orders").getOne(prevPoId);
+ if (po.supplier !== committed.supplier) {
+ const currentLinks: string[] = Array.isArray(po.order_item)
+ ? po.order_item.filter(
+ (x): x is string => typeof x === "string",
+ )
+ : [];
+ const nextLinks = currentLinks.filter((id) => id !== itemId);
+ if (nextLinks.length === 0) {
+ await pb.collection("purchase_orders").update(prevPoId, {
+ order_item: [],
+ status: "cancelled",
+ });
+ unlinkedPoNumbers.push(po.po_number || prevPoId);
+ } else {
+ await pb.collection("purchase_orders").update(prevPoId, {
+ order_item: nextLinks,
+ });
+ }
+ pendingUnlink = true;
+ }
+ } catch (poErr) {
+ console.error(
+ `[save-item] failed to unlink item ${itemId} from old PO ${prevPoId} after supplier change:`,
+ poErr,
+ );
+ // Don't surface a warning toast — the order_item save
+ // still proceeds with the new value. Operator can retry
+ // via the order page if needed.
+ }
+ }
+
// v3 stock-out flow (Stream E). When the operator has
// toggled the line into "fulfill from stock" mode, the
// standard order_item update path is wrong — it would
@@ -634,6 +691,12 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
// to description / selling / notes survive the
// stock-fulfil transition.
if (committed.fulfillFromStock) {
+ // Capture the pre-save status so we can roll back if the
+ // stock half of the save fails. Without this, a row that
+ // was previously `pending` could end up sitting in
+ // `processed` with no stock deduction — silent partial
+ // state that looks saved but isn't.
+ const preSaveStatus = currentItem?.item_status;
// Step 1: standard order_item update (preserves the
// operator's regular edits).
await pb.collection("order_items").update(itemId, {
@@ -677,12 +740,34 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
stock_item_id: committed.stockItemId,
qty: committed.qtyFromStock,
description: committed.description,
+ // Landing state — "stock_booked" mirrors CREATE
+ // mode on /orders/new so the line ends in the
+ // same state regardless of which surface
+ // fulfilled it.
+ intent: "stock_booked",
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const msg =
body.error || `Stock fulfilment failed (${res.status})`;
+ // Roll back item_status so the row doesn't sit
+ // in `processed` while no stock has actually been
+ // deducted. Best-effort — a failure here is
+ // surfaced as a warning but doesn't block the
+ // operator's retry.
+ if (preSaveStatus && preSaveStatus !== nextStatus) {
+ try {
+ await pb.collection("order_items").update(itemId, {
+ item_status: preSaveStatus,
+ });
+ } catch (rollbackErr) {
+ console.warn(
+ `[process] failed to roll back item_status after stock failure for ${itemId}:`,
+ rollbackErr,
+ );
+ }
+ }
setEdits((prev) => ({
...prev,
[itemId]: { ...committed, stockFulfilError: msg, dirty: false, saving: false },
@@ -718,6 +803,19 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
} catch (fetchErr) {
const msg =
fetchErr instanceof Error ? fetchErr.message : "Network error";
+ // Same rollback as the !res.ok branch above.
+ if (preSaveStatus && preSaveStatus !== nextStatus) {
+ try {
+ await pb.collection("order_items").update(itemId, {
+ item_status: preSaveStatus,
+ });
+ } catch (rollbackErr) {
+ console.warn(
+ `[process] failed to roll back item_status after stock network error for ${itemId}:`,
+ rollbackErr,
+ );
+ }
+ }
setEdits((prev) => ({
...prev,
[itemId]: { ...committed, stockFulfilError: msg, dirty: false, saving: false },
@@ -756,8 +854,22 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
pricing_source: committed.pricing_source || null,
matched_price_list_id: committed.matched_price_list_id || null,
pricing_notes: committed.pricing_notes || null,
+ // Supplier-change unlink (Task B): when the operator
+ // switched supplier on a line that was linked to a draft
+ // PO for a different supplier, clear the order_item's
+ // purchase_order so the unlink actually persists. The
+ // parent PO's back-link was already updated above.
+ ...(pendingUnlink ? { purchase_order: "" } : {}),
});
- toast.success(`Item "${committed.description.slice(0, 30)}…" saved`);
+ toast.success(
+ `Item "${committed.description.slice(0, 30)}…" saved${
+ pendingUnlink && unlinkedPoNumbers.length > 0
+ ? ` · unlinked from PO ${unlinkedPoNumbers[0]}`
+ : pendingUnlink
+ ? " · unlinked from previous draft PO"
+ : ""
+ }`,
+ );
}
const fresh = await getOrderItems(id);
@@ -889,14 +1001,134 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
*/
const toggleInStock = async (itemId: string, next: boolean) => {
const before = edits[itemId]?.in_stock;
+ const currentItem = items.find((it) => it.id === itemId);
+
+ // When ticking in-stock ON for a row that's currently linked
+ // to a draft PO, unlink it immediately: remove it from the
+ // PO's order_item back-link, auto-cancel the PO if it becomes
+ // empty, and flip the row to `ready_invoice`. The Send POs
+ // path's matching filter excludes `po_generated` rows, so the
+ // unlink has to happen here — otherwise ticking in-stock on a
+ // po_generated row would silently leave it stuck on the PO.
+ //
+ // Unticking is a no-op on the PO side: `item_status` and
+ // `purchase_order` stay where the previous tick put them. The
+ // operator can re-route manually if they really need to
+ // reverse.
+ let pendingUnlink = false;
+ const cancelledPoNumbers: string[] = [];
+ const prevPoId =
+ currentItem &&
+ typeof currentItem.purchase_order === "string" &&
+ currentItem.purchase_order.length > 0
+ ? currentItem.purchase_order
+ : "";
+ const isOnDraftPo =
+ !!prevPoId &&
+ currentItem?.item_status === "po_generated" &&
+ next === true;
+
// Optimistic UI: flip the local edit state first so the chip
- // turns green immediately.
+ // turns green immediately. When unlinking, also update the
+ // `items` mirror for status + purchase_order so the chip
+ // badge reflects the new state without a full refetch.
updateEdit(itemId, { in_stock: next });
+ if (isOnDraftPo) {
+ pendingUnlink = true;
+ }
+
try {
const pb = getPocketBase();
- await pb.collection("order_items").update(itemId, { in_stock: next });
- const fresh = await getOrderItems(id);
- setItems(fresh);
+
+ if (isOnDraftPo && prevPoId) {
+ try {
+ const po = await pb.collection("purchase_orders").getOne(prevPoId);
+ const currentLinks: string[] = Array.isArray(po.order_item)
+ ? po.order_item.filter(
+ (x: unknown): x is string => typeof x === "string",
+ )
+ : [];
+ const nextLinks = currentLinks.filter((id) => id !== itemId);
+ if (nextLinks.length === 0) {
+ await pb.collection("purchase_orders").update(prevPoId, {
+ order_item: [],
+ status: "cancelled",
+ });
+ cancelledPoNumbers.push(po.po_number || prevPoId);
+ console.log(
+ `[toggle-in-stock] auto-cancelled empty draft PO ${po.po_number || prevPoId} after in-stock unlink`,
+ );
+ } else {
+ await pb.collection("purchase_orders").update(prevPoId, {
+ order_item: nextLinks,
+ });
+ }
+ } catch (poErr) {
+ console.error(
+ `[toggle-in-stock] failed to unlink item ${itemId} from PO ${prevPoId}:`,
+ poErr,
+ );
+ // Don't abort the toggle — the order_item update below
+ // still flips in_stock. Surface the orphan-PO risk as a
+ // toast so the operator can retry.
+ toast.warning(
+ "In-stock flag saved, but couldn't unlink the line from its draft PO. Refresh and retry.",
+ { duration: 12000 },
+ );
+ }
+ }
+
+ await pb.collection("order_items").update(itemId, {
+ in_stock: next,
+ // When unlinking, the order_item must reflect the new
+ // state too — otherwise the line still claims to belong
+ // to the (now-empty) PO and Send POs / invoicing flows
+ // can mis-route it.
+ ...(pendingUnlink
+ ? { purchase_order: "", item_status: "ready_invoice" }
+ : {}),
+ });
+
+ // Local-only sync of the persisted value. Do NOT call
+ // fetchAll — the operator just wants a chip to flip, not a
+ // full order re-fetch (which also re-fires matched-
+ // attachments and bumps the scroll position). Re-seed ONLY
+ // the toggled row so any other dirty edits (e.g. un-saved
+ // pricing the operator just typed) survive.
+ setItems((prev) =>
+ prev.map((it) =>
+ it.id === itemId
+ ? {
+ ...it,
+ in_stock: next,
+ ...(pendingUnlink
+ ? {
+ item_status: "ready_invoice",
+ purchase_order: "",
+ }
+ : {}),
+ }
+ : it,
+ ),
+ );
+ setEdits((prev) => {
+ const cur = prev[itemId];
+ if (!cur) return prev;
+ return {
+ ...prev,
+ [itemId]: { ...cur, in_stock: next },
+ };
+ });
+
+ if (cancelledPoNumbers.length > 0) {
+ toast.message(
+ `Auto-cancelled empty draft PO(s): ${cancelledPoNumbers.join(", ")}.`,
+ {
+ description:
+ "All items were moved to in-stock or another supplier.",
+ },
+ );
+ }
} catch (err) {
// Roll back the optimistic flip.
if (before !== undefined) {
@@ -909,32 +1141,38 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
};
/**
- * Send POs for a single supplier. Kept from v1 — unchanged.
+ * Send POs for a single supplier — non-in-stock lines only.
+ * The companion handler `markInStockReady` covers the in-stock
+ * subset, so this function no longer splits internally. The Send
+ * POs card renders one button per subset per supplier.
*/
const sendPoForSupplier = async (supplierId: string) => {
const supplier = suppliers.find((s) => s.id === supplierId);
if (!supplier) return;
- const matching = items.filter(
+ const nonInStock = items.filter(
(it) =>
(it.supplier || "") === supplierId &&
- ["pending", "processed"].includes(it.item_status)
+ ["pending", "processed"].includes(it.item_status) &&
+ !it.in_stock,
);
- if (matching.length === 0) {
- toast.error("No editable items for this supplier");
+ if (nonInStock.length === 0) {
+ toast.error("No non-in-stock items for this supplier");
return;
}
+
setSendingSupplier(supplierId);
+ const pb = getPocketBase();
try {
- const pb = getPocketBase();
const poNumber = await generatePONumber(id);
- // `it.quantity` is now `number | null` (rows can save with no qty
- // yet). Treat null as 0 for the PO total — a line with no qty
- // contributes nothing. Qty uses the supplier-facing `order_qty`
- // when the operator set one (shorting or MOQ bump), falling back
- // to `quantity` so a never-touched line still counts what the
- // customer ordered. Mirrors the PDF route's subtotal so the PO
- // record's total_amount matches what the rendered document shows.
- const total = matching.reduce(
+ // `it.quantity` is now `number | null` (rows can save with no
+ // qty yet). Treat null as 0 for the PO total — a line with
+ // no qty contributes nothing. Qty uses the supplier-facing
+ // `order_qty` when the operator set one (shorting or MOQ
+ // bump), falling back to `quantity` so a never-touched line
+ // still counts what the customer ordered. Mirrors the PDF
+ // route's subtotal so the PO record's total_amount matches
+ // what the rendered document shows.
+ const total = nonInStock.reduce(
(sum, it) =>
sum +
numOrZero(it.cost_price) * numOrZero(it.order_qty ?? it.quantity),
@@ -945,47 +1183,42 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
order: id,
supplier: supplierId,
// Create as draft — the PDF route flips to "sent" on
- // successful v1 generation. If the PDF fails, the PO stays
- // as draft so the operator can re-trigger from the order
- // detail page's "Regenerate PO" button.
+ // successful v1 generation. If the PDF fails, the PO
+ // stays as draft so the operator can re-trigger from the
+ // order detail page's "Regenerate PO" button.
status: "draft",
total_amount: total,
- // Back-link the order_items that belong to this PO. Mirrors
- // `order_items.purchase_order` on the inverse direction so
- // the PB admin UI on the PO row shows the related lines,
- // and any future PO-side render (history, totals, supplier
- // list) can read them without a separate getList call.
- order_item: matching.map((it) => it.id),
+ // Back-link the order_items that belong to this PO.
+ // Mirrors `order_items.purchase_order` on the inverse
+ // direction so the PB admin UI on the PO row shows the
+ // related lines, and any future PO-side render (history,
+ // totals, supplier list) can read them without a separate
+ // getList call.
+ order_item: nonInStock.map((it) => it.id),
});
- // Link each item to the PO and set its status. Surface per-item
- // failures explicitly — a silent drop here is how operators end
- // up with orphan POs (PO created, but no items linked, so the
- // PDF route 422s with "no items"). We continue past the error
- // and report a count at the end so the operator knows whether
- // to retry.
+ // Link each item to the PO and set its status. Surface
+ // per-item failures explicitly — a silent drop here is how
+ // operators end up with orphan POs (PO created, but no
+ // items linked, so the PDF route 422s with "no items"). We
+ // continue past the error and report a count at the end so
+ // the operator knows whether to retry.
//
// Driver-pool routing is no longer invoked here on purpose:
- // the legacy `queueOrderItemForCollection` helper targeted a
- // separate `collection_pool` table that we no longer use —
- // assignment is now a single write on the order_item row
- // (item_status='assigned', drv_id=, assigned_at=now),
- // driven by the assignment page rather than a parallel queue.
+ // the legacy `queueOrderItemForCollection` helper targeted
+ // a separate `collection_pool` table that we no longer use
+ // — assignment is now a single write on the order_item
+ // row (item_status='assigned', drv_id=,
+ // assigned_at=now), driven by the assignment page rather
+ // than a parallel queue.
let linkFailures = 0;
const linkErrors: string[] = [];
- for (const it of matching) {
+ for (const it of nonInStock) {
try {
- if (it.in_stock) {
- await pb.collection("order_items").update(it.id, {
- purchase_order: po.id,
- item_status: "collected",
- });
- } else {
- await pb.collection("order_items").update(it.id, {
- purchase_order: po.id,
- item_status: "po_generated",
- });
- }
+ await pb.collection("order_items").update(it.id, {
+ purchase_order: po.id,
+ item_status: "po_generated",
+ });
} catch (linkErr) {
linkFailures++;
const msg =
@@ -999,12 +1232,12 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
}
if (linkFailures > 0) {
// Don't try to generate a PDF when items aren't linked —
- // the PDF route 422s on empty POs and the operator is left
- // thinking they sent a PO that never made it to the
+ // the PDF route 422s on empty POs and the operator is
+ // left thinking they sent a PO that never made it to the
// supplier. Surface the failure before any success toast.
const summary = linkErrors[0] ?? "";
toast.warning(
- `PO ${poNumber} created, but ${linkFailures}/${matching.length} item(s) failed to link. The PDF has been skipped — re-link the items and resend from the order page.`,
+ `PO ${poNumber} created, but ${linkFailures}/${nonInStock.length} item(s) failed to link. The PDF has been skipped — re-link the items and resend from the order page.`,
{ description: summary, duration: 12000 },
);
// Fire a refresh so the operator sees the partial state
@@ -1014,11 +1247,13 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
}
// ─── Generate + attach v1 PDF ───────────────────────────
- // Server-side render via /api/v1/purchase-orders/[id]/pdf.
- // On success, the route stamps the PO as "sent" + creates
- // a `purchase_order_pdfs` row (status: current, version: 1)
- // and returns the download URL. On failure, surface a clear
- // error so the operator knows to retry from the order page.
+ // Server-side render via
+ // /api/v1/purchase-orders/[id]/pdf. On success, the route
+ // stamps the PO as "sent" + creates a
+ // `purchase_order_pdfs` row (status: current, version: 1)
+ // and returns the download URL. On failure, surface a
+ // clear error so the operator knows to retry from the
+ // order page.
let pdfInfo: {
pdfId?: string;
downloadUrl?: string;
@@ -1039,9 +1274,9 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
} else {
const errBody = await pdfRes.json().catch(() => ({}));
console.error("[send-po] PDF generation failed:", errBody);
- // Don't block the success toast — the PO is still created
- // and items are linked. Operator can regenerate from the
- // order detail page.
+ // Don't block the success toast — the PO is still
+ // created and items are linked. Operator can
+ // regenerate from the order detail page.
toast.warning(
`PO ${poNumber} created but PDF generation failed (${errBody.code || pdfRes.status}). Regenerate from the order page.`
);
@@ -1058,7 +1293,7 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
action: {
label: "Download",
onClick: () =>
- window.open(pdfInfo.downloadUrl!, "_blank"),
+ window.open(pdfInfo!.downloadUrl!, "_blank"),
},
}
);
@@ -1073,6 +1308,133 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
}
};
+ /**
+ * Bulk-flip the in-stock editable lines for one supplier to
+ * `ready_invoice`. The companion to `sendPoForSupplier` —
+ * together they split the Send POs card into two per-supplier
+ * buttons. The in-stock subset can already have been unlinked
+ * from any draft PO by the per-row `toggleInStock`; this
+ * handler defensively handles the case where some still carry a
+ * `purchase_order` (e.g. operator toggled in-stock then
+ * `Mark in-stock ready` before the auto-unlink finished its
+ * toast cycle).
+ *
+ * Behaviour per line:
+ * - `purchase_order` cleared.
+ * - `item_status` flipped to `ready_invoice`.
+ * Per affected PO:
+ * - back-link trimmed.
+ * - auto-`status: "cancelled"` if the back-link becomes empty.
+ */
+ const markInStockReady = async (supplierId: string) => {
+ const supplier = suppliers.find((s) => s.id === supplierId);
+ if (!supplier) return;
+ const inStock = items.filter(
+ (it) =>
+ (it.supplier || "") === supplierId &&
+ ["pending", "processed"].includes(it.item_status) &&
+ it.in_stock,
+ );
+ if (inStock.length === 0) {
+ toast.error("No in-stock items for this supplier");
+ return;
+ }
+
+ setMarkingInStockSupplier(supplierId);
+ const pb = getPocketBase();
+ try {
+ const affectedPoIds = new Map>();
+ const cancelledPoNumbers: string[] = [];
+ let stockFailures = 0;
+ const stockErrors: string[] = [];
+ for (const it of inStock) {
+ const prevPoId =
+ typeof it.purchase_order === "string" &&
+ it.purchase_order.length > 0
+ ? it.purchase_order
+ : "";
+ if (prevPoId) {
+ if (!affectedPoIds.has(prevPoId)) {
+ affectedPoIds.set(prevPoId, new Set());
+ }
+ affectedPoIds.get(prevPoId)!.add(it.id);
+ }
+ try {
+ await pb.collection("order_items").update(it.id, {
+ purchase_order: "",
+ item_status: "ready_invoice",
+ });
+ } catch (linkErr) {
+ stockFailures++;
+ const msg =
+ linkErr instanceof Error ? linkErr.message : String(linkErr);
+ stockErrors.push(`${it.description.slice(0, 30)}…: ${msg}`);
+ console.error(
+ `[mark-in-stock] failed to flip item ${it.id} to ready_invoice:`,
+ linkErr,
+ );
+ }
+ }
+
+ for (const [poId, unlinkedIds] of affectedPoIds.entries()) {
+ try {
+ const po = await pb.collection("purchase_orders").getOne(poId);
+ const currentLinks: string[] = Array.isArray(po.order_item)
+ ? po.order_item.filter(
+ (x): x is string => typeof x === "string",
+ )
+ : [];
+ const nextLinks = currentLinks.filter(
+ (id) => !unlinkedIds.has(id),
+ );
+ if (nextLinks.length === 0) {
+ await pb.collection("purchase_orders").update(poId, {
+ order_item: [],
+ status: "cancelled",
+ });
+ cancelledPoNumbers.push(po.po_number || poId);
+ console.log(
+ `[mark-in-stock] auto-cancelled empty draft PO ${po.po_number || poId}`,
+ );
+ } else {
+ await pb.collection("purchase_orders").update(poId, {
+ order_item: nextLinks,
+ });
+ }
+ } catch (poErr) {
+ console.error(
+ `[mark-in-stock] failed to update back-link on PO ${poId}:`,
+ poErr,
+ );
+ }
+ }
+
+ if (stockFailures > 0) {
+ const summary = stockErrors[0] ?? "";
+ toast.warning(
+ `${stockFailures}/${inStock.length} in-stock item(s) failed to flip to ready_invoice. Retry from the order page.`,
+ { description: summary, duration: 12000 },
+ );
+ }
+ if (cancelledPoNumbers.length > 0) {
+ toast.message(
+ `Auto-cancelled empty draft PO(s): ${cancelledPoNumbers.join(", ")}.`,
+ { description: "All items were moved to in-stock or another supplier." },
+ );
+ }
+ toast.success(
+ `${inStock.length - stockFailures} in-stock item(s) for ${supplier.suppliername} marked ready for invoicing.`,
+ );
+ await fetchAll();
+ } catch (err: unknown) {
+ toast.error(
+ err instanceof Error ? err.message : "Failed to mark in-stock ready",
+ );
+ } finally {
+ setMarkingInStockSupplier(null);
+ }
+ };
+
/**
* Split a shorted line into its remainder.
*
@@ -1340,33 +1702,65 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
Send POs
- In-stock items jump to collected ;
- rest go to driver pool .
+ Each supplier has two buttons:{" "}
+ Send PO generates the
+ supplier PO + email for the non-in-stock lines, and{" "}
+ {`{supplier} in-stock`} {" "}
+ flips the in-stock lines straight to{" "}
+ ready for invoicing .
{Object.entries(bySupplier).map(([supplierId, list]) => {
const supplier = suppliers.find((s) => s.id === supplierId);
const name = supplier?.suppliername || "Unassigned supplier";
- const sendable = list.filter((it) =>
- ["pending", "processed"].includes(it.item_status)
+ 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 (
-
sendPoForSupplier(supplierId)}
- disabled={
- sendable.length === 0 || sendingSupplier === supplierId || !supplier
- }
- variant={sendable.length === 0 ? "outline" : "default"}
- size="sm"
+ className="flex items-center gap-1.5"
>
- {sendingSupplier === supplierId ? (
-
- ) : (
-
+ {nonInStockCount > 0 && (
+ sendPoForSupplier(supplierId)}
+ disabled={isBusy || !supplier}
+ variant="default"
+ size="sm"
+ >
+ {sendingSupplier === supplierId ? (
+
+ ) : (
+
+ )}
+ {name} ({nonInStockCount})
+
)}
- {name} ({sendable.length})
-
+ {inStockCount > 0 && (
+
markInStockReady(supplierId)}
+ disabled={isBusy || !supplier}
+ variant="warn"
+ size="sm"
+ >
+ {markingInStockSupplier === supplierId ? (
+
+ ) : (
+
+ )}
+ {name} in-stock ({inStockCount})
+
+ )}
+
);
})}
@@ -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 (
+
+ onOpenChange(false)}
+ disabled={busy}
+ >
+ Cancel
+
+ void onSubmit()}
+ disabled={busy || summary.total === 0}
+ data-testid="confirm-stock-submit"
+ >
+ {busy ? (
+
+ ) : null}
+ {summary.total === 1
+ ? "Confirm 1 line"
+ : `Confirm ${summary.total} lines`}
+
+
+ }
+ >
+
+
+ {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={
-
-
onOpenChange(false)}
- disabled={submitting}
- >
- Cancel
-
-
void handleSubmit()}
- disabled={!valid}
- >
- {submitting ? (
-
- ) : deliverNow ? (
-
- ) : (
-
- )}
- {deliverNow ? "Fulfill + mark delivered" : "Fulfill from stock"}
-
-
- }
- >
-
- {error && (
-
- {error}
-
- )}
-
-
-
-
-
- {orderItem?.description || "(no description)"}
-
-
-
- Line qty:{" "}
-
- {orderItem?.order_qty ?? orderItem?.quantity ?? 0}
- {" "}
- (default)
-
-
-
-
-
- Stock item
-
-
-
- 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 (
-
- setSelectedId(item.id)}
- className={cn(
- "flex w-full items-start gap-2 px-3 py-2 text-left text-sm transition-colors",
- selected
- ? "bg-primary/5 ring-1 ring-primary/40"
- : "hover:bg-muted/40",
- )}
- aria-pressed={selected}
- >
-
-
-
- {item.name || item.sku || item.id}
-
-
- {item.sku && (
- SKU {item.sku}
- )}
-
-
- {onHand} {item.unit ?? "ea"} on hand
-
-
-
-
-
- );
- })}
-
- )}
-
-
-
-
-
- Quantity to fulfill
-
-
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
-
-
- setDeliverNow(v === true)}
- />
-
- {deliverNow ? (
-
- ) : (
-
- )}
- Customer has it now (delivered)
-
-
-
- {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: {