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:
parent
d29b91c7c0
commit
437ed3529c
11 changed files with 1442 additions and 532 deletions
292
.kilo/plans/1786948004089-orders-process-stock-toggle-plan.md
Normal file
292
.kilo/plans/1786948004089-orders-process-stock-toggle-plan.md
Normal 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.
|
||||
85
scripts/migrations/2026-08-17-add-order-items-in-stock.ts
Normal file
85
scripts/migrations/2026-08-17-add-order-items-in-stock.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
182
src/components/collection-confirm/confirm-stock-dialog.test.tsx
Normal file
182
src/components/collection-confirm/confirm-stock-dialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
171
src/components/collection-confirm/confirm-stock-dialog.tsx
Normal file
171
src/components/collection-confirm/confirm-stock-dialog.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue