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.
This commit is contained in:
Dave 2026-08-17 11:41:55 +02:00
parent d29b91c7c0
commit 437ed3529c
11 changed files with 1442 additions and 532 deletions

View file

@ -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 (
<Button
key={supplierId}
onClick={() => sendPoForSupplier(supplierId)}
disabled={sendable.length === 0 || sendingSupplier === supplierId || !supplier}
variant={sendable.length === 0 ? "outline" : "default"}
size="sm"
>
...
{name} ({sendable.length})
</Button>
);
})}
```
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 (
<div key={supplierId} className="flex items-center gap-1.5">
{nonInStockCount > 0 && (
<Button
onClick={() => sendPoForSupplier(supplierId)}
disabled={isBusy || !supplier}
variant="default"
size="sm"
>
{sendingSupplier === supplierId ? <Loader2 ... /> : <Send ... />}
{name} ({nonInStockCount})
</Button>
)}
{inStockCount > 0 && (
<Button
onClick={() => markInStockReady(supplierId)}
disabled={isBusy || !supplier}
variant="warn"
size="sm"
>
{markingInStockSupplier === supplierId ? <Loader2 ... /> : <Package ... />}
{name} in-stock ({inStockCount})
</Button>
)}
</div>
);
})}
```
### D. Add the amber variant
The codebase uses `variant="default"`, `"outline"`, `"secondary"`,
`"destructive"`, `"ghost"`, `"link"` on `<Button>`. 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.

View file

@ -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);
});

View file

@ -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(<Page />);
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(<Page />);
await waitFor(() =>
expect(screen.getByTestId("po-card-SP-003")).toBeInTheDocument(),

View file

@ -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<boolean>(false);
/**
* Result of the most recent `purchase_orders.scan` MCP call
@ -478,10 +487,12 @@ export default function CollectionConfirmPage() {
/**
* Confirm-collection handler wired to <PoCard> 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).
*/}
<ConfirmStockDialog
open={!!confirmStockState}
eyebrow={confirmStockState?.poNumber ?? ""}
rows={
confirmStockState
? rows?.filter((r) => 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;

View file

@ -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<Record<string, EditableItem>>({});
const [loading, setLoading] = useState(true);
const [sendingSupplier, setSendingSupplier] = useState<string | null>(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<string | null>(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=<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=<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<string, Set<string>>();
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
<h2 className="text-base sm:text-lg font-semibold">Send POs</h2>
</div>
<p className="text-xs text-muted-foreground mb-3">
In-stock items jump to <Badge variant="secondary" className="mx-0.5">collected</Badge>;
rest go to <Badge variant="secondary" className="mx-0.5">driver pool</Badge>.
Each supplier has two buttons:{" "}
<span className="font-medium">Send PO</span> generates the
supplier PO + email for the non-in-stock lines, and{" "}
<span className="font-medium">{`{supplier} in-stock`}</span>{" "}
flips the in-stock lines straight to{" "}
<Badge variant="secondary" className="mx-0.5">ready for invoicing</Badge>.
</p>
<div className="flex flex-wrap gap-2">
{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 (
<Button
<div
key={supplierId}
onClick={() => 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 ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Send className="mr-1.5 h-4 w-4" />
{nonInStockCount > 0 && (
<Button
onClick={() => sendPoForSupplier(supplierId)}
disabled={isBusy || !supplier}
variant="default"
size="sm"
>
{sendingSupplier === supplierId ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Send className="mr-1.5 h-4 w-4" />
)}
{name} ({nonInStockCount})
</Button>
)}
{name} ({sendable.length})
</Button>
{inStockCount > 0 && (
<Button
onClick={() => markInStockReady(supplierId)}
disabled={isBusy || !supplier}
variant="warn"
size="sm"
>
{markingInStockSupplier === supplierId ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Package className="mr-1.5 h-4 w-4" />
)}
{name} in-stock ({inStockCount})
</Button>
)}
</div>
);
})}
</div>
@ -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)."
>
<Checkbox
disabled={approvalLocked}

View file

@ -394,6 +394,57 @@ describe("POST /api/v1/stock/fulfill", () => {
);
});
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

View file

@ -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 },
);

View file

@ -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<ConfirmRow> & { 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(
<ConfirmStockDialog
open
onOpenChange={vi.fn()}
rows={rows}
onSubmit={vi.fn()}
/>,
);
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(
<ConfirmStockDialog
open
onOpenChange={vi.fn()}
rows={[makeRow({ id: "a" })]}
onSubmit={vi.fn()}
/>,
);
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(
<ConfirmStockDialog
open
onOpenChange={vi.fn()}
rows={rows}
onSubmit={vi.fn()}
/>,
);
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(
<ConfirmStockDialog
open
onOpenChange={vi.fn()}
rows={[makeRow({ id: "a", order_qty: 10, qty_collected: 5 })]}
onSubmit={vi.fn()}
/>,
);
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(
<ConfirmStockDialog
open
onOpenChange={vi.fn()}
rows={[
makeRow({ id: "a", order_qty: 10, qty_collected: 10 }),
makeRow({ id: "b", order_qty: 4, qty_collected: 4 }),
]}
onSubmit={vi.fn()}
/>,
);
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(
<ConfirmStockDialog
open
onOpenChange={vi.fn()}
rows={[makeRow({ id: "a" })]}
onSubmit={onSubmit}
/>,
);
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(
<ConfirmStockDialog
open
onOpenChange={onOpenChange}
rows={[makeRow({ id: "a" })]}
onSubmit={vi.fn()}
/>,
);
await user.click(within(getDialog()).getByRole("button", { name: /Cancel/i }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("disables the submit button while busy", () => {
render(
<ConfirmStockDialog
open
onOpenChange={vi.fn()}
rows={[makeRow({ id: "a" })]}
onSubmit={vi.fn()}
busy
/>,
);
expect(getSubmit()).toBeDisabled();
});
it("disables the submit button when no rows are staged", () => {
render(
<ConfirmStockDialog
open
onOpenChange={vi.fn()}
rows={[]}
onSubmit={vi.fn()}
/>,
);
expect(getSubmit()).toBeDisabled();
});
it("renders nothing when closed (so the body doesn't leak stale data)", () => {
render(
<ConfirmStockDialog
open={false}
onOpenChange={vi.fn()}
rows={[makeRow({ id: "a" })]}
onSubmit={vi.fn()}
/>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});

View file

@ -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> | 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 <ConfirmStockDialogContent key={String(props.open)} {...props} />;
}
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 (
<ModalShell.Dialog
open={open}
onOpenChange={onOpenChange}
size="md"
eyebrow={eyebrow}
title="Confirm collection"
description="Snap quantity_received to the full ordered quantity on every line and remove this PO from the list. The accountant will pick the lines up from the invoicing page."
footer={
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={busy}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void onSubmit()}
disabled={busy || summary.total === 0}
data-testid="confirm-stock-submit"
>
{busy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : null}
{summary.total === 1
? "Confirm 1 line"
: `Confirm ${summary.total} lines`}
</Button>
</div>
}
>
<div className="space-y-3">
<ul
className="divide-y rounded-md border bg-muted/20"
data-testid="confirm-stock-row-list"
>
{rows.map((row) => {
const ordered = row.order_qty ?? row.quantity ?? 0;
const collected = row.qty_collected ?? 0;
const isShort = collected < ordered;
return (
<li
key={row.id}
className="flex items-center justify-between gap-3 px-3 py-2 text-xs"
data-testid={`confirm-stock-row-${row.id}`}
data-short={isShort ? "true" : "false"}
>
<span className="min-w-0 flex-1 truncate font-mono">
{row.sku || row.description || row.id}
</span>
<span
className={
isShort
? "font-mono font-semibold text-amber-600 dark:text-amber-400"
: "font-mono tabular-nums text-muted-foreground"
}
>
{collected} / {ordered}
</span>
</li>
);
})}
</ul>
{summary.short.length > 0 ? (
<div
className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
data-testid="confirm-stock-short-warning"
>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p>
{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&apos;t what you want.
</p>
</div>
) : (
<p className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
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.
</p>
)}
</div>
</ModalShell.Dialog>
);
}

View file

@ -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";
/**
* `<FulfillFromStockDialog>` — 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<StockItem[] | null>(null);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string>("");
// Default qty to the order_item's order_qty. The user can adjust.
const [qty, setQty] = useState<string>("");
// "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<StockItem>({
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 (
<ModalShell.Dialog
open={open}
onOpenChange={(o) => {
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={
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
Cancel
</Button>
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={!valid}
>
{submitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : deliverNow ? (
<Truck className="mr-2 h-4 w-4" />
) : (
<Package className="mr-2 h-4 w-4" />
)}
{deliverNow ? "Fulfill + mark delivered" : "Fulfill from stock"}
</Button>
</div>
}
>
<div className="space-y-3">
{error && (
<div
role="alert"
data-od-id="fulfill-error"
className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</div>
)}
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground space-y-1">
<div className="flex items-center gap-2">
<Package className="h-3.5 w-3.5" />
<span className="font-medium text-foreground">
{orderItem?.description || "(no description)"}
</span>
</div>
<div>
Line qty:{" "}
<span className="font-mono text-foreground">
{orderItem?.order_qty ?? orderItem?.quantity ?? 0}
</span>{" "}
(default)
</div>
</div>
<div className="space-y-1.5">
<label
htmlFor="fulfill-stock-search"
className="text-xs font-medium text-muted-foreground"
>
Stock item
</label>
<div className="flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground">
<Search className="h-3.5 w-3.5" />
<input
id="fulfill-stock-search"
type="search"
value={query}
onChange={(e) => 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
/>
</div>
</div>
<div className="max-h-[40vh] overflow-y-auto rounded-md border">
{loading && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
<Loader2 className="inline h-4 w-4 animate-spin mr-2" />
Loading stock items…
</div>
)}
{!loading && stockItems !== null && stockItems.length === 0 && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No active stock items. Add one on{" "}
<a href="/stock/new" className="underline">
/stock/new
</a>{" "}
first.
</div>
)}
{!loading && stockItems !== null && stockItems.length > 0 && filteredItems.length === 0 && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No stock items match the search.
</div>
)}
{!loading && filteredItems.length > 0 && (
<ul className="divide-y">
{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 (
<li key={item.id}>
<button
type="button"
onClick={() => 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}
>
<span
aria-hidden
className={cn(
"mt-1 h-3 w-3 shrink-0 rounded-full border-2",
selected
? "border-primary bg-primary"
: "border-muted-foreground/40",
)}
/>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate font-medium">
{item.name || item.sku || item.id}
</span>
<span className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
{item.sku && (
<span className="font-mono">SKU {item.sku}</span>
)}
<span
className={cn(
"inline-flex items-center gap-0.5 rounded px-1 font-mono",
low
? "bg-amber-100 text-amber-800"
: "text-foreground",
)}
data-od-id="fulfill-on-hand"
>
<Boxes className="h-2.5 w-2.5" />
{onHand} {item.unit ?? "ea"} on hand
</span>
</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label
htmlFor="fulfill-qty"
className="text-xs font-medium text-muted-foreground"
>
Quantity to fulfill
</label>
<Input
id="fulfill-qty"
type="number"
min={1}
value={qty}
onChange={(e) => setQty(e.target.value)}
disabled={!selectedId}
/>
{selectedItem && !hasEnoughStock && (
<p
data-od-id="fulfill-insufficient"
className="text-[11px] text-destructive"
>
Only {Number(selectedItem.current_qty ?? 0)} on hand. PO the
rest or pick a different stock item.
</p>
)}
</div>
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">
Landing state
</span>
<label
htmlFor="fulfill-deliver-now"
className="flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm cursor-pointer"
>
<Checkbox
id="fulfill-deliver-now"
checked={deliverNow}
onCheckedChange={(v) => setDeliverNow(v === true)}
/>
<span className="inline-flex items-center gap-1.5">
{deliverNow ? (
<Truck className="h-3.5 w-3.5" />
) : (
<PackageCheck className="h-3.5 w-3.5" />
)}
Customer has it now (delivered)
</span>
</label>
<p className="text-[11px] text-muted-foreground">
{deliverNow
? "Order item lands on `delivered`. Skips the rest of the pipeline."
: "Order item lands on `collected`. Goes through the normal delivery / invoice flow."}
</p>
</div>
</div>
</div>
</ModalShell.Dialog>
);
}

View file

@ -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: {