WFDASH-WEBAPP/docs/superpowers/specs/2026-07-30-stock-quick-draft-design.md
SJCoetz 23e8e94d7c docs(specs): stock quick-draft modal design
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>
2026-07-30 21:26:47 +02:00

15 KiB
Raw Blame History

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_items
    • GET /api/v1/stock-quick-draft/supplier-history?stockItemId= — supplier history for a stock item
    • POST /api/v1/stock-quick-draft/add-line — create-or-append a line; accepts an inline newStockItem payload 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.tsx using ModalShell.Dialog
  • "New Stock Item" button added to /stock-orders page 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.ts style)
  • Component tests for the modal (mirroring src/components/order-sprint-process-modal.test.tsx style)

Out of scope:

  • Any schema changes to stock_purchases, stock_purchase_items, stock_items, or suppliers
  • New PocketBase collections or select-field values (no admin-side migration needed)
  • New status values on stock_purchases (the existing draft status 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-orders is 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

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 fields
  • 404 { 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 refetch
  • 500 { 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):

  1. Header — "Stock Order Reference" label + editable text input (default: "Stock order entry — " + formatMonthYear(new Date()) where formatMonthYear returns e.g. "Jul 2026"). Local state only; not persisted on the server; resets each time the modal opens.

  2. 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.

  3. 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 hits DELETE /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 is GET /api/v1/stock-purchases?status=draft filtered client-side to drafts touched in this modal session; refetch uses the same call.

  4. 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):

  • searchStockItemsForQuickDraft returns empty → modal shows "No stock items match" with the "+ New SKU" toggle highlighted
  • getStockItemSupplierHistory returns 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 message
  • appendLine: PB write fails → failure returned; 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 desc
  • findOrCreateOpenDraft (covered transitively by addQuickDraftLine tests): no draft → creates one; existing draft → returns it; status filter excludes received/sent drafts
  • addQuickDraftLine: full happy path (new SKU + new draft); existing SKU + existing draft (appends); existing SKU + no draft (creates); validation errors return failure

Route tests:

  • supplier-history/route.test.ts: mocked admin PB; 400 missing param, 200 with history, 200 empty
  • add-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:

  1. Open modal → type known SKU → history shows last cost
  2. Open modal → type "zzz nothing" → "+ New SKU" highlighted, no history
  3. Add 3 items to supplier A, 2 to supplier B → /stock-orders list shows 2 drafts (one per supplier)
  4. Reload page → reopen modal → cards region shows the 2 drafts with their lines
  5. 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.