Adds spec for the new item-first quick-draft modal on /stock-orders: item-first stock-order entry, accumulates per supplier into draft POs, reuses existing Send/Receive flow. No schema changes. Co-authored-by: Orca <help@stably.ai>
15 KiB
Stock Quick-Draft Modal Design
Date: 2026-07-30
Area: src/app/(app)/stock-orders/ (modal), src/lib/server/stock-quick-draft-service.ts (new)
Status: Drafted for review
Goal
Add a fast item-first stock-order entry path on the /stock-orders page so any buyer can quickly add multiple stock items across multiple suppliers in one session, even if they don't know which supplier each item should come from. Reuses the existing draft-PO model so the result lands as normal draft stock_purchases rows ready for the existing Send / Receive flow.
User Need
Today, /stock-orders/new requires the buyer to pick a supplier first, then load all items under that supplier. This works for a single-supplier PO but is slow when a buyer wants to plan a multi-supplier stock order — and especially slow when the buyer doesn't know where a given item is sourced.
The new flow inverts this: the buyer types an item first, sees which suppliers we've bought it from before (with last cost as a negotiation reference), picks one, sets qty + cost, and adds it. Items accumulate per supplier in supplier-cards inside the modal. Multiple drafts can be built in one session and finalized via the existing Send flow at the end of the month.
Approved Direction
Add a "New Stock Item" button on the /stock-orders page header that opens a ModalShell.Dialog (size lg). Inside the modal:
- a search box that typeahead-finds existing
stock_items - a supplier-history panel that shows suppliers where we've bought the picked item before, with last cost and date as read-only reference (not auto-filled)
- an inline qty + cost row that appears after the supplier is picked
- an "Add & next" button that saves the line and resets the form for the next item
- a "+ New SKU not in stock yet" toggle for items not in the catalogue
- a no-history fallback that shows the full supplier list when there's no prior purchase data
- a supplier-cards region at the bottom showing the buyer's in-progress drafts grouped by supplier, with edit/remove per line
- "Save & close" and "Finalize" buttons in the footer — both just close the modal; drafts persist as normal draft
stock_purchases
Each "Add & next" creates or extends a draft stock_purchase row (one per supplier) so the end state is 1 draft PO per supplier with all accumulated lines, regardless of how many times the modal was opened.
Scope
In scope:
- New service module
src/lib/server/stock-quick-draft-service.ts - New API routes:
GET /api/v1/stock-quick-draft/search?q=&limit=— typeahead for stock_itemsGET /api/v1/stock-quick-draft/supplier-history?stockItemId=— supplier history for a stock itemPOST /api/v1/stock-quick-draft/add-line— create-or-append a line; accepts an inlinenewStockItempayload for the "+ New SKU" path (single endpoint handles both cases — keeps the modal's POST flow uniform)DELETE /api/v1/stock-quick-draft/line/[lineId]— remove a line the modal added (lets the buyer clean up mistakes without leaving the modal)
- New component
src/components/stock-orders/quick-draft-modal.tsxusingModalShell.Dialog - "New Stock Item" button added to
/stock-orderspage header between "Reorder from low stock" and "+ New stock order" - Unit tests for the service module
- Route tests for the new endpoints (mirroring
src/app/api/v1/orders/sprint/counts/route.test.tsstyle) - Component tests for the modal (mirroring
src/components/order-sprint-process-modal.test.tsxstyle)
Out of scope:
- Any schema changes to
stock_purchases,stock_purchase_items,stock_items, orsuppliers - New PocketBase collections or select-field values (no admin-side migration needed)
- New status values on
stock_purchases(the existingdraftstatus is reused) - Changes to
/stock-orders/new,/stock-orders/[id], or/stock-orders/reorder - Changes to the existing Send / Receive / PDF / n8n flows
- New sidebar nav items (the button on
/stock-ordersis the only entry) - Multi-session concurrency control beyond best-effort server-side lookup
- A batch-send-all action; the buyer still uses the existing per-PO Send button on each draft
Data Flow
Flow 1 — Search & history
[user types "115 cutting disc"]
modal → debounce 200ms → GET /api/v1/stock-quick-draft/search?q=115+cutting&limit=10
service.searchStockItemsForQuickDraft:
pb.collection("stock_items").getList(1, 10, {
filter: "is_active=true && (sku ~ {:q} || name ~ {:q})",
sort: "name"
})
returns: [{ id, sku, name, description, unit }, ...]
[user clicks a result]
modal → GET /api/v1/stock-quick-draft/supplier-history?stockItemId=<id>
service.getStockItemSupplierHistory(id):
- query stock_purchase_items filtered by stock_item=id, expand=purchase.supplier
- reduce to per-supplier aggregate: { supplierId, supplierName, lastCost, lastDate, timesBought }
- sort by lastDate desc
returns: [{ supplierId, supplierName, lastCost, lastDate, timesBought }, ...]
Flow 2 — Add & next (happy path)
[user picks supplier, fills qty + cost, clicks "Add & next"]
modal → POST /api/v1/stock-quick-draft/add-line
body: { supplierId, stockItemId, quantity, costPrice }
service.addQuickDraftLine:
1. findOrCreateOpenDraft(supplierId):
- pb.collection("stock_purchases").getList(1, 1, {
filter: "supplier={:id} && status='draft'",
sort: "-created"
})
- if found → return that id
- if not → generateStockPurchaseNumber(), create with status="draft", supplier=id, notes=""
2. appendLine: create stock_purchase_items row with purchase=<id>, stock_item=<id>,
description=<stockItem.name>, quantity, cost_price
3. return { purchaseId, purchaseNumber, lineId }
modal:
- toast.success("Added to <supplier> PO <purchaseNumber>")
- clears the input, resets qty + cost to defaults
- re-fetches the cards region to show the new line
[focus returns to the search box → next item]
Flow 3 — "+ New SKU not in stock yet"
[user clicks the toggle]
modal flips the row to a create-new form (name + SKU inputs)
[user fills name + SKU, picks supplier + qty + cost, clicks "Add & next"]
modal → POST /api/v1/stock-quick-draft/add-line
body: { supplierId, quantity, costPrice, newStockItem: { name, sku } }
service.addQuickDraftLine (extended):
- if input.newStockItem present → first create the stock_items row
(current_qty: 0, is_active: true, supplier: supplierId)
- then run the same findOrCreateOpenDraft + appendLine logic
modal: same toast + reset behaviour
Concurrency
Two buyers adding items to the same supplier at the same time: findOrCreateOpenDraft runs server-side per request. Worst case is two drafts get created in a race window, both with status=draft. Buyer can merge manually via /stock-orders/[id]. Known limitation, not a bug.
API Contracts
GET /api/v1/stock-quick-draft/search
Query: q (required, min 2 chars), limit (optional, default 10, max 25)
Response:
{
"ok": true,
"items": [
{ "id": "...", "sku": "STK-CD-115", "name": "115mm Cutting Disc", "description": "...", "unit": "ea" }
]
}
Errors: 400 { ok: false, reason: "missing_query" } when q missing.
GET /api/v1/stock-quick-draft/supplier-history
Query: stockItemId (required)
Response:
{
"ok": true,
"history": [
{ "supplierId": "...", "supplierName": "Steel & Pipe Co", "lastCost": 18.50, "lastDate": "2026-07-14T...", "timesBought": 2 }
]
}
Returns empty array when no history. Errors: 400 { ok: false, reason: "missing_stock_item_id" }.
POST /api/v1/stock-quick-draft/add-line
Body:
{
"supplierId": "...",
"stockItemId": "...",
"quantity": 50,
"costPrice": 18.50,
"newStockItem": null
}
For the "+ New SKU" path, stockItemId is omitted and newStockItem: { name, sku, description?, unit? } is present.
Response (success):
{
"ok": true,
"purchaseId": "...",
"purchaseNumber": "PO26073001STK",
"lineId": "...",
"createdDraft": true
}
createdDraft is true when this call created the draft PO, false when it appended to an existing one — useful for analytics.
Errors:
400 { ok: false, reason: "validation", message }— missing/invalid fields404 { ok: false, reason: "supplier_not_found" }404 { ok: false, reason: "stock_item_not_found" }409 { ok: false, reason: "draft_race", message }— purchase_number collision; client should refetch500 { ok: false, reason: "create_failed" | "append_failed", message }
UI
Single modal opened from the /stock-orders page header button. Internal structure (three regions in one file):
-
Header — "Stock Order Reference" label + editable text input (default:
"Stock order entry — " + formatMonthYear(new Date())whereformatMonthYearreturns e.g."Jul 2026"). Local state only; not persisted on the server; resets each time the modal opens. -
Search region — text input with debounce; on result click, fetches and renders supplier-history dropdown. Inline qty + cost inputs appear when a supplier is picked. "+ New SKU" toggle flips the row to a name + SKU sub-form. "Add & next" button is disabled until all required fields are valid.
-
Cards region — re-fetches open drafts on modal open and after each successful add. Shows one card per supplier with header (supplier name + estimated total =
Σ(qty × cost_price)) and rows (description, qty, cost, Remove button per line). Remove hitsDELETE /api/v1/stock-quick-draft/line/[lineId]. Edit (qty / cost / supplier) is out of scope for v1 — buyer uses/stock-orders/[id]to edit. Initial fetch isGET /api/v1/stock-purchases?status=draftfiltered client-side to drafts touched in this modal session; refetch uses the same call. -
Footer — "Save & close" and "Finalize" buttons (both close the modal; drafts persist as normal draft
stock_purchases). A small status line shows "X lines · Y suppliers · last saved Nm ago". "Resume later" = re-open the modal; the cards region loads all open drafts via the existing list endpoint, so the buyer picks up where they left off.
Service Module
src/lib/server/stock-quick-draft-service.ts — admin PB only, consistent with other service modules. Exports:
export interface SupplierHistoryEntry {
supplierId: string;
supplierName: string;
lastCost: number;
lastDate: string; // ISO
timesBought: number;
}
export interface StockItemSearchResult {
id: string;
sku: string;
name: string;
description: string;
unit?: string;
}
export interface AddQuickDraftLineInput {
supplierId: string;
stockItemId?: string;
quantity: number;
costPrice: number;
newStockItem?: { name: string; sku: string; description?: string; unit?: string };
}
export type AddQuickDraftLineResult =
| { ok: true; purchaseId: string; purchaseNumber: string; lineId: string; createdDraft: boolean }
| { ok: false; reason: string; message: string };
export async function searchStockItemsForQuickDraft(
pb: PocketBase,
q: string,
limit?: number,
): Promise<StockItemSearchResult[]>;
export async function getStockItemSupplierHistory(
pb: PocketBase,
stockItemId: string,
): Promise<SupplierHistoryEntry[]>;
export async function addQuickDraftLine(
pb: PocketBase,
input: AddQuickDraftLineInput,
): Promise<AddQuickDraftLineResult>;
export async function removeQuickDraftLine(
pb: PocketBase,
lineId: string,
): Promise<{ ok: true } | { ok: false; reason: string; message: string }>;
Reuses generateStockPurchaseNumber from stock-purchases-service.ts for new draft creation.
Error Handling
Modal-level (client):
- Network failure on
add-line→ toast.error with message; row stays filled so user can retry; cards region not refetched - 409 stale purchase-number race → toast.warn("A draft was created by someone else — refresh to see it"), refetch cards
- Validation failures (qty ≤ 0, cost < 0, no supplier picked) → inline form error, button stays disabled
Service-level (server):
searchStockItemsForQuickDraftreturns empty → modal shows "No stock items match" with the "+ New SKU" toggle highlightedgetStockItemSupplierHistoryreturns empty → modal shows fallback: full supplier dropdown with hint "No prior history for this item — pick any supplier"findOrCreateOpenDraft: PB write fails →failure: { code: "create_failed" }; modal surfaces PB error messageappendLine: PB write fails →failurereturned; the draft may still exist without the line (buyer can edit it directly via/stock-orders/[id])
Things we don't handle:
- PB connection loss mid-flow → user sees toast, retries
- Multiple browsers editing the same draft → not solved; existing draft flow doesn't either
- Malicious cost values → out of scope; draft is editable until Send
Testing Strategy
Unit (service module):
getStockItemSupplierHistory: 0 purchases → empty; 1 purchase → single entry; multiple purchases same supplier → aggregated timesBought; multiple suppliers → sorted by lastDate descfindOrCreateOpenDraft(covered transitively byaddQuickDraftLinetests): no draft → creates one; existing draft → returns it; status filter excludes received/sent draftsaddQuickDraftLine: full happy path (new SKU + new draft); existing SKU + existing draft (appends); existing SKU + no draft (creates); validation errors returnfailure
Route tests:
supplier-history/route.test.ts: mocked admin PB; 400 missing param, 200 with history, 200 emptyadd-line/route.test.ts: mocked admin PB; 400 invalid body, 404 unknown supplier, 200 happy path, 409 race
Component tests:
quick-draft-modal.test.tsx: search → debounced fetch → result click → history renders; "+ New SKU" toggle → form flips → add-line with newStockItem body; no-history fallback → full supplier dropdown; Add & next → toast + form reset + cards refetch
Pattern follows existing tests in src/app/api/v1/orders/sprint/counts/route.test.ts and src/components/order-sprint-process-modal.test.tsx.
Manual smoke checklist:
- Open modal → type known SKU → history shows last cost
- Open modal → type "zzz nothing" → "+ New SKU" highlighted, no history
- Add 3 items to supplier A, 2 to supplier B →
/stock-orderslist shows 2 drafts (one per supplier) - Reload page → reopen modal → cards region shows the 2 drafts with their lines
- Click "Send to supplier" on a draft → existing send flow works unchanged
Open Questions
None at design time. One implementation-time decision to flag: whether to add an index on stock_purchases(supplier, status) based on findOrCreateOpenDraft profile. Not adding it upfront — YAGNI until measured.