docs(specs): add collection confirm revamp design
Removes the Adjust Quantity modal, surfaces invoice photos in a per-PO strip, exposes inline-editable cost prices, and migrates the page to a thin service layer (listCollectedItems, updateOrderItemCost, confirmOrderItemsForInvoicing) with three new /api/v1 routes. Cost edits are audited in the existing notes feed as category=cost entries; confirm is one PocketBase batch. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
7136623331
commit
d1c5ea0643
1 changed files with 204 additions and 0 deletions
|
|
@ -0,0 +1,204 @@
|
|||
# Collection Confirm Revamp — Design
|
||||
|
||||
**Date:** 2026-08-02
|
||||
**Status:** Approved
|
||||
**Scope:** `/collection-confirm` page — remove the Adjust Quantity modal, surface invoice photos prominently, expose per-item supplier cost and make it editable, and re-shape the row into a PO-grouped card with a photo strip and a compact items table.
|
||||
|
||||
## Problem
|
||||
|
||||
The collection confirm screen is where an operator verifies that what was collected in the field matches the supplier invoice before items move to invoicing. Three issues block that workflow today:
|
||||
|
||||
1. The "Adjust Quantity" modal (the colloquial "jazz quantity") lets the operator overwrite `qty_collected` after the fact, with a Reason note. The driver has already recorded the collected quantity in the field; an operator override on a *confirm* screen is a footgun and the modal is removed.
|
||||
2. The invoice photo (which the driver takes at collection time and uploads via the existing Attach dialog into `order_item_attachements`) is shown as a small chip among the row's attachments, with no visual emphasis. The operator cannot easily see the photo to verify the line items.
|
||||
3. The supplier cost (`order_items.cost_price`) is never rendered on this page. When the operator confirms for invoicing they have no way to spot a price the driver recorded wrong, and no way to correct it before the row leaves the confirm flow.
|
||||
|
||||
The page is also one of the last client components in the app that calls PocketBase directly via `getPocketBase()`. The new mutations are the right cut line to migrate it onto the service layer per the project's data-access discipline.
|
||||
|
||||
## Solution Overview
|
||||
|
||||
One card per Purchase Order. Each card has:
|
||||
|
||||
- A **photo strip** at the top, deduped across the items in the PO, with one thumbnail chip per distinct invoice file. Clicking a chip opens the file in a new tab via the existing `/api/v1/files/order_items/{id}/{filename}` proxy.
|
||||
- A **compact items table** below the photo: SKU/Description, Ordered, Collected, Cost (inline-editable), Surplus flag.
|
||||
- A single **Confirm for invoicing** button at the bottom of the card that opens a small dialog for supplier invoice date and number, then writes the cost overrides + invoice details + status in one PB batch request.
|
||||
|
||||
Stock-source rows keep their existing "Book to stock" action and are not affected by cost editing.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
┌─ PO 26081401 — Acme Steel ─ supplier · 3 items ──────────────────┐
|
||||
│ [ photo ] [ photo ] [ photo ] ← thumbnails, click to expand │
|
||||
├───────────────────────────────────────────────────────────────────┤
|
||||
│ SKU Description Ord Coll Cost Surplus Edit │
|
||||
│ SK-001 Steel sheet 1m 10 10 R45.00 — [ pencil ] │
|
||||
│ SK-002 Steel rod 2m 20 22 R12.50 +2 [ pencil ] │
|
||||
│ SK-003 Steel bracket 5 5 R8.00 — [ pencil ] │
|
||||
├───────────────────────────────────────────────────────────────────┤
|
||||
│ [ Confirm for invoicing → opens date/number dialog ] │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
collection-confirm/page.tsx (client component, render + local state)
|
||||
│
|
||||
├── GET loadConfirmFeed() via fetch('/api/v1/collection-confirm')
|
||||
│ service: listCollectedItems() in src/lib/server/collection-confirm-service.ts
|
||||
│
|
||||
├── POST save row.cost_price via fetch('/api/v1/order-items/{id}/cost')
|
||||
│ service: updateOrderItemCost(id, { cost_price, actor })
|
||||
│ route: src/app/api/v1/order-items/[id]/cost/route.ts
|
||||
│
|
||||
└── POST confirm PO row(s) via fetch('/api/v1/order-items/confirm')
|
||||
body: { itemIds, invoice: { date, number }, costOverrides: { [itemId]: cost_price } }
|
||||
service: confirmOrderItemsForInvoicing(ids, { invoice, costOverrides, actor })
|
||||
route: src/app/api/v1/order-items/confirm/route.ts
|
||||
```
|
||||
|
||||
### `listCollectedItems()`
|
||||
|
||||
Returns the same `ConfirmRow[]` shape the page uses today, sourced server-side:
|
||||
|
||||
- `order_items` rows with `item_status = "collected"`, expanded with `purchase_order.supplier, order.company, order.customer`. Same fields as the current client-side `load()`.
|
||||
- `stock_purchase_items` rows with `status = "collected"`, normalised to the `ConfirmRow` shape with `source: "stock"`.
|
||||
|
||||
The expansion and projection are identical to the existing `load()` function (`page.tsx:234–378`) so no consumer-visible schema change.
|
||||
|
||||
### `updateOrderItemCost(id, { cost_price, actor })`
|
||||
|
||||
- Writes one number to `order_items.cost_price`.
|
||||
- Appends a `category="cost"` note to the `notes` feed with `{ setBy, setByName, setAt, prevCost, newCost }`. Skips the note if `prevCost === newCost`.
|
||||
- Returns the updated row.
|
||||
|
||||
### `confirmOrderItemsForInvoicing(ids, { invoice, costOverrides, actor })`
|
||||
|
||||
- For each row in `ids`: runs the existing `buildConfirmPatch` logic (flips `item_status` → `ready_invoice`, writes `order_item_details.invoice = { date, number, setBy, setByName, setAt }`, appends a `category="confirm"` note).
|
||||
- For any row whose `id` is in `costOverrides` and whose override differs from the current `cost_price`: writes the new `cost_price` and appends a `category="cost"` note.
|
||||
- All updates for a single `confirm` call are issued as one PocketBase batch request via the SDK `pb.createBatch()` helper, so a server-side failure aborts the batch and none of the rows are left in a half-written state. Note: PocketBase does not offer true ACID transactions across collections, so a partial commit is still possible at the storage layer if a network drop happens mid-batch; in that case the operator retries and any already-written rows are skipped (matched by the same `itemIds` + the new `item_status = "ready_invoice"` filter).
|
||||
- Returns the list of updated record ids.
|
||||
|
||||
## Schema
|
||||
|
||||
- `order_items.cost_price` already exists (`pb-exports/pb_schema-2.json:1820–1831`, `type: "number"`, `min: 0`). No PB schema change needed.
|
||||
- `order_items.order_item_details` JSON blob — no new keys. The existing `invoice` and `deliveryNote` and `print` and `split` sections are unchanged.
|
||||
- `order_items.notes` JSON feed — gain a new `category="cost"` entry shape: `{ id, ts, setBy, setByName, prevCost, newCost }`. Mirrors the existing `category="confirm"` shape.
|
||||
- `order_items.item_status` — `ready_invoice` is already a valid value (used by today's `buildConfirmPatch`). No `select` value to add in the PB admin.
|
||||
- `order_item_attachements` — unchanged. Continues to receive invoice photos via the existing `AttachDialog`.
|
||||
|
||||
## Components
|
||||
|
||||
### New
|
||||
|
||||
- `src/components/collection-confirm/po-card.tsx` — wraps a single PO group. Renders the photo strip + items table + confirm button. Receives `rows`, `supplier`, `poNumber`, `onCostChange`, `onConfirm`.
|
||||
- `src/components/collection-confirm/photo-strip.tsx` — dedupes `order_item_attachements` filenames across the rows in the PO, renders a row of thumbnail chips, opens the file in a new tab on click via `/api/v1/files/order_items/{id}/{filename}`. Empty state: "No invoice photo attached" with a hint and a link to the Attach dialog.
|
||||
- `src/components/collection-confirm/items-table.tsx` — compact 5-column table. Each cost cell is an inline `<Input>` with click-to-edit; pencil swaps to input, Enter/blur saves, Esc cancels.
|
||||
- `src/components/collection-confirm/confirm-invoice-dialog.tsx` — extracted from the current `ConfirmInvoiceDialog` JSX (`page.tsx:1682–1771`). Now also receives a `costOverrides` map and surfaces them in a summary line at the bottom of the dialog body.
|
||||
|
||||
### Changed
|
||||
|
||||
- `src/app/(app)/collection-confirm/page.tsx` — drops from ~2200 lines to a thin orchestrator. Keeps `load()` (now `await listCollectedItems()`) and PO grouping, hands each group to `<PoCard>`. Removes `QuantityDialog`, `handleQuantity`, the "Adjust Quantity" action button, and the `category="quantity"` note writer. Keeps `AttachDialog` and `ReturnToPoolDialog`.
|
||||
- `src/app/(app)/collection-confirm/view-model.ts` — drop the `canConfirmRow` quantity-related branches. Add `appendCostNote(prevCost, newCost, actor)`, `dedupeAttachments(rows)`. Extend `buildConfirmPatch` to take an optional `costOverride` and include it in the patch payload.
|
||||
- `src/app/api/v1/collection-confirm/route.ts` — new. GET → `listCollectedItems()`.
|
||||
- `src/app/api/v1/order-items/[id]/cost/route.ts` — new. POST → `updateOrderItemCost(id, body)`.
|
||||
- `src/app/api/v1/order-items/confirm/route.ts` — new. POST → `confirmOrderItemsForInvoicing(body)`.
|
||||
- `src/lib/server/collection-confirm-service.ts` — new. Holds the three service functions above plus the PB plumbing.
|
||||
|
||||
### Unchanged
|
||||
|
||||
- `ConfirmInvoiceDialog` JSX is moved, not rewritten. The existing `buildConfirmPatch` is reused; it gains a new optional `costOverride` argument.
|
||||
- `order_item_attachements` upload via `AttachDialog` stays as-is. The `pb.collection("order_items").update(row.id, formData)` direct call can keep working for that one mutation.
|
||||
- `returnRowsToPool` and the `ReturnToPoolDialog` are unchanged.
|
||||
|
||||
## Behaviour
|
||||
|
||||
### Cost edit (single row)
|
||||
|
||||
1. Row shows cost as a read-only R-formatted value with a small pencil icon to the right.
|
||||
2. Click pencil → cell becomes a number `<Input>` with the current value selected. Helper text: "Press Enter to save, Esc to cancel."
|
||||
3. **Enter** or **blur** → `POST /api/v1/order-items/{id}/cost` with `{ cost_price: <new> }`. Cell flips back to read-only with the new value and a brief "saved" toast.
|
||||
4. **Esc** → revert; no save, no note.
|
||||
5. Validation: must be a non-negative number; reject empty / negative / NaN with an inline error and keep the input open.
|
||||
6. Optimistic UI: the cell swaps to the new value immediately, then snaps back if the request fails. Error toast names the row.
|
||||
|
||||
### Confirm (whole PO, possibly many rows)
|
||||
|
||||
1. Operator selects one or more rows in the PO card via the existing checkbox column. (Bulk confirm across rows in the same PO; rows in different POs are their own group.)
|
||||
2. The "Confirm for invoicing" button at the bottom of the PO card opens the extracted dialog with two fields: supplier invoice date, supplier invoice number. Dialog footer shows a summary line: `Confirming N row(s) · M cost override(s) applied`.
|
||||
3. **Submit** → `POST /api/v1/order-items/confirm` with `{ itemIds, invoice: { date, number }, costOverrides }`. Service runs `buildConfirmPatch` for each row, plus the `cost_price` write for any row in `costOverrides`. All writes for the batch are issued via the SDK `pb.createBatch()` helper.
|
||||
4. After success, the rows disappear from the confirm feed (`item_status = "ready_invoice"` no longer matches the `item_status = "collected"` filter). Success toast with the count.
|
||||
|
||||
### Photo dedup
|
||||
|
||||
`dedupeAttachments(rows)` unions all `order_item_attachements` filenames across the rows in the PO and groups by filename. The strip shows one chip per distinct filename with a label like "Invoice 1", "Invoice 2". Clicking a chip opens the file in a new tab via the existing file-stream route (`/api/v1/files/order_items/{firstItemId}/{filename}`).
|
||||
|
||||
Empty state: "No invoice photo attached" with a hint that the driver should attach via the existing Attach dialog. The operator can still confirm without a photo (the photo is a verification aid, not a gate).
|
||||
|
||||
### Removal of "Adjust Quantity"
|
||||
|
||||
- `QuantityDialog` component and `handleQuantity` removed from `page.tsx`.
|
||||
- "Adjust Quantity" action button on each row removed.
|
||||
- `category="quantity"` note writer removed.
|
||||
- `canConfirmRow` in `view-model.ts` simplified — its quantity branch is no longer relevant. The `problem_flag`-guarded branch stays.
|
||||
- Tests: any case in `page.test.tsx` that exercises the quantity modal is removed; surviving cases are updated to point at the new items-table cells.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Network failure on cost edit → cell snaps back to the previous value, error toast names the row.
|
||||
- Network failure on confirm → the PB batch aborts; toast reads "Confirm failed — no rows changed." The dialog stays open.
|
||||
- Invalid cost (negative, NaN) → inline error, input stays open, no request sent.
|
||||
- Empty supplier invoice number or date → submit button disabled, helper text under the field reads "Required."
|
||||
- File-stream 404 (broken filename) → toast names the missing file; the rest of the row still confirms.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit (view-model, jest)
|
||||
|
||||
- `appendCostNote(prevCost, newCost, actor)` — three cases:
|
||||
1. Writes the right shape with prev/new values.
|
||||
2. Preserves existing notes.
|
||||
3. No-op when `prevCost === newCost` (returns existing notes unchanged).
|
||||
- `dedupeAttachments(rows)` — three cases:
|
||||
1. Distinct filenames across items → one chip each.
|
||||
2. Duplicate filename across items → one chip with `itemIds: [a, b]`.
|
||||
3. Empty rows → empty array.
|
||||
- `buildConfirmPatch(row, { costOverride, invoice, actor })` — extend the existing two cases. Add three new cases:
|
||||
1. Writes `cost_price` when override is provided and different.
|
||||
2. Omits `cost_price` when override is undefined.
|
||||
3. Omits `cost_price` when override equals existing value.
|
||||
|
||||
### Component (page.test.tsx + new)
|
||||
|
||||
- Update the 9 existing cases in `page.test.tsx` to point at the new items-table cells where they previously asserted on the old detail block.
|
||||
- Add new cases:
|
||||
1. Renders a photo strip with one chip per distinct filename; clicking a chip calls `window.open` with the file-stream URL.
|
||||
2. Empty state ("No invoice photo attached") when no rows in the PO have attachments.
|
||||
3. Click pencil → input appears with the current cost.
|
||||
4. Enter on the input → calls `POST /api/v1/order-items/{id}/cost` and updates the cell.
|
||||
5. Esc on the input → reverts without calling the endpoint.
|
||||
6. Submit confirm dialog with cost overrides → `POST /api/v1/order-items/confirm` is called with the right body shape.
|
||||
|
||||
### Service (collection-confirm-service.test.ts, new)
|
||||
|
||||
- `updateOrderItemCost` — happy path: writes `cost_price`, appends a `category="cost"` note. Failure path: throws and rolls back.
|
||||
- `confirmOrderItemsForInvoicing` — happy path with overrides: writes `cost_price` + invoice + status + note in one batch. Happy path without overrides: behaves identically to today's `buildConfirmPatch`. Failure path: batch is aborted and no rows are written.
|
||||
- `listCollectedItems` — returns customer items with `item_status="collected"` plus stock items with `status="collected"`, with the same expansion as today.
|
||||
|
||||
### Coverage targets
|
||||
|
||||
- New view-model helpers: 100% line coverage.
|
||||
- `collection-confirm-service.ts`: ≥ 80% line coverage.
|
||||
- Component tests: at least one assertion per new UI element (photo chip, cost input, confirm dialog body).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Playwright e2e coverage — manual smoke is sufficient.
|
||||
- The three unused PB quantity fields on `order_items` (`qty_processed`, `qty_assigned`, `qty_delivered`, 0 references per `docs/audit/unused-pb-fields.md:139–141`) — separate cleanup PR.
|
||||
- The unused `collection_photo` single-file field on `order_items` (added by `scripts/migrations/2026-07-07-order-items-collection-photo.ts`, missing from the local schema snapshot, no upload UI) — the existing "View photo" button remains conditional; no change here.
|
||||
- Mobile-first / camera-capture UI on the confirm screen itself. The driver attaches via the existing Attach dialog, which works on mobile browsers.
|
||||
- Migrating the rest of the app off `getPocketBase()` direct calls. Only the confirm page + its three new mutations move.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. All design decisions in this spec were resolved during brainstorming.
|
||||
Loading…
Reference in a new issue