From 7d755961f4401c0a100845d9bb1e269cf2e0d326 Mon Sep 17 00:00:00 2001 From: DEls Date: Mon, 26 Oct 2026 13:28:24 +0200 Subject: [PATCH] docs(dn): Inbox 3 build plan Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-26-inbox-3-matching.md | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-26-inbox-3-matching.md diff --git a/docs/superpowers/plans/2026-10-26-inbox-3-matching.md b/docs/superpowers/plans/2026-10-26-inbox-3-matching.md new file mode 100644 index 0000000..eea6c05 --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-inbox-3-matching.md @@ -0,0 +1,316 @@ +# Inbox 3 Matching Screen Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A slip-first delivery-note matching screen (Inbox 3) where each slip line can be fixed in place, with quantity, unit, price, value and "left on PO" shown. Each line moves to Ready to confirm on its own as soon as it is right. + +**Architecture:** +- Inbox 3 is a new route that only admin and office can open. +- It reuses the existing preview/allocation model (`ai-preview.ts`), per-line validation (`prepareLineBooking`) and per-line posting (`postLineWithinLease`). +- New pure modules under `src/lib/inbox-three/` decide per-line readiness and build the table rows. +- New API routes save line edits and run a line-level assistant. Neither route ever posts. +- The existing matching route gains a `post_ready` action that posts only the ready lines. Inbox 3 and the existing inbox's Confirm delivery both call it. + +**Tech Stack:** Next.js 16 (read `node_modules/next/dist/docs/` before writing route or page code), React, TypeScript, zod, PocketBase, vitest, shadcn UI, MiniMax via the OpenAI SDK (existing `review-model.ts`). + +**Spec:** `docs/superpowers/specs/2026-10-26-inbox-3-matching-design.md` + +## Global Constraints + +- Agents (the assistant, Foreman, MCP tools) never post. Only a person presses Confirm: a site manager (existing inbox), or an admin or office user (Inbox 3). +- Inbox 3 is visible to `admin` and to `office_user` with the `financial` module. It is not visible to `site_user`. +- The PO-line picker offers only lines of the note's own PO, plus "Not on this PO". The server rejects any other id; `prepareLineBooking` already throws `PO item does not belong to this purchase order`. +- Price defaults to the PO rate. Flag a line when `|slipRate − poRate| > 0.50` OR `> 1%` of `poRate`. Both rates are net, per PO unit, after unit conversion. +- Every change away from the PO rate or PO unit carries a free-text reason. The assistant writes its own. +- Only unit factors are remembered, per supplier + product, via `rememberConnection`. Prices are never remembered. +- UI line numbers are 1-based and map to 0-based `lineIndex`. +- The existing inbox gets exactly two behaviour changes: the supplier guard (Task 4) and posting only ready lines (Task 3). No redesign of its screen. +- Tests are vitest, colocated `*.test.ts(x)`. Run with `npx vitest run `. + +## Review Focus + +1. **Stale preview.** Another slip on the same PO posts between a line edit and Confirm. Expected: `post_ready` refreshes, re-applies saved decisions and posts. It must not post against the old outstanding quantity. Test in Task 3. +2. **Combined lines where one source line is already posted.** Expected: the save is refused with a clear message and nothing is half-applied. Test in Task 6. +3. **Factor blank, 0, negative or above 10000.** Expected: the line stays open with "Enter how many PO units one {unit} is". No NaN values. Test in Task 5. +4. **VAT-inclusive slip prices.** The slip shows R1 298 incl. while the PO rate is R1 124.35 ex. Expected: the slip rate is converted to ex VAT before the price flag compares. With the default 15% VAT, R1 298 incl. becomes R1 128.70 ex, a difference of R4.35 (0.39%). That is above R0.50, so the line is flagged. The point is that the comparison must not be inflated by VAT (incl vs ex would show 15%). Test in Task 2. +5. **Two people confirm the same slip at once.** Expected: the batch lease serialises them. The second gets "already posted" for those lines, and no line is booked twice. Test in Task 3. + +--- + +### Task 1: Inbox 3 access rule + +**Files:** +- Modify: `src/lib/auth/access.ts` +- Modify: `src/lib/auth/path-access.ts` +- Test: `src/lib/auth/access.test.ts` + +**Interfaces:** +- Produces: `canSeeInboxThree(user: AppUser): boolean` (in `access.ts`). +- Produces: `canAccessPath` returns false for `/dashboard/financial/delivery-notes/inbox-3/**` and `/api/inbox-three/**` when `canSeeInboxThree` is false. + +- [ ] **Step 1: Write failing tests** `describe('canSeeInboxThree')`: + - admin → true; + - office_user with modules `['financial']` → true; + - office_user without financial → false; + - site_user → false; + - clock_only → false; + - `canAccessPath(siteUser, '/dashboard/financial/delivery-notes/inbox-3')` → false; + - `canAccessPath(siteUser, '/dashboard/financial/delivery-notes/inbox-2')` → true (unchanged). +- [ ] **Step 2: Run** `npx vitest run src/lib/auth/access.test.ts`. Expect FAIL: `canSeeInboxThree` is not exported. +- [ ] **Step 3: Implement.** + - `canSeeInboxThree = role==='admin' || (role==='office_user' && canAccessModule(user,'financial'))`. + - In `canAccessPath`, check the inbox-3 page prefix and `/api/inbox-three` before the module mapping. +- [ ] **Step 4: Run** the same command. Expect PASS. +- [ ] **Step 5: Commit** `feat(dn): inbox 3 is office and admin only while testing` + +### Task 2: Per-line readiness and price check (pure) + +**Files:** +- Create: `src/lib/inbox-three/readiness.ts` +- Test: `src/lib/inbox-three/readiness.test.ts` + +**Interfaces:** +- Consumes: `PreviewAllocation`, `ProcessingPreview`, `PreviewContext` (`ai-preview.ts`); `allocationNeedsHelp` (`delivery-ready.ts`); `resolvePoItem` (`match-po-item.ts`); `prepareLineBooking` (`line-booking.ts`). +- Produces: + - `PRICE_TOLERANCE = { rand: 0.5, percent: 1 }`. + - `priceDiffers(poRateNet: number, slipRateNet: number | null): boolean` — false when the slip rate is null. + - `slipRateNet(line: PreviewLine, factor: number, vatPercent: number, amountTaxBasis: 'net'|'gross'|'unknown'): number | null` — per PO unit, ex VAT. + - `guardAgrees(context: PreviewContext, a: PreviewAllocation): boolean` — false when, for any source line, `resolvePoItem` returns `matched` with a different id, or `none` because of a dimension conflict. + - `lineReady(context: PreviewContext, preview: ProcessingPreview, index: number): boolean` — true when all of these hold: + - the allocation is not posted; + - `!allocationNeedsHelp`; + - `netAmount` is finite; + - either `operatorConfirmation` is set, or (`status==='proposed'` AND `guardAgrees` AND the unit equals the PO unit or the allocation has a remembered `ruleKeys` entry AND not `priceDiffers`); + - `prepareLineBooking` does not throw with the allocation's confirmation, or `{confirmed:true}`. + - `readyIndexes(context, preview): number[]`. + +- [ ] **Step 1: Write failing tests** with fixtures from `src/lib/dn-pipeline/__fixtures__` (or a small inline context): + - `priceDiffers(1124.35, 1128.70)` → true; `priceDiffers(100, 100.4)` → false; `priceDiffers(100, null)` → false. + - `slipRateNet({unit_price:1298,...}, 1, 15, 'gross')` → 1128.70 (2 dp). + - `guardAgrees`: slip "U PIPES-600 X 2.5(100D)" allocated to PO "S&S Pipes-1050 X 2.5(100D)" → false; slip "PLIER FENCING 300MM" allocated to "Fencing Pliers" → true. + - `lineReady`: a proposed line with `quantityExcess` 3 → false; a line with `operatorConfirmation` → true; an AI line whose unit differs from the PO's without `ruleKeys` → false. +- [ ] **Step 2: Run** `npx vitest run src/lib/inbox-three/readiness.test.ts`. Expect FAIL. +- [ ] **Step 3: Implement** the signatures above. In `guardAgrees`, a `resolvePoItem` result of `ambiguous` counts as agreeing (it is the guard declining, not contradicting). +- [ ] **Step 4: Run** again. Expect PASS. +- [ ] **Step 5: Commit** `feat(dn): per-line readiness and price tolerance for inbox 3` + +### Task 3: Post only ready lines + +**Files:** +- Modify: `src/lib/dn-pipeline/matching.ts` (add `prepareReadyLines`) +- Modify: `src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.ts` (add action `post_ready`) +- Modify: `src/app/dashboard/financial/delivery-notes/document/[id]/processing-preview.tsx:96-107` (`confirmDelivery` sends `post_ready`) +- Test: `src/lib/dn-pipeline/matching.test.ts`, `src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.test.ts` + +**Interfaces:** +- Consumes: `readyIndexes` (Task 2). +- Produces: + - `prepareReadyLines(context, preview): Array<{sourceLineIds:string[];confirmation:LineConfirmation}>`. It has the same guards as `prepareDelivery` (open site decision, duplicate, stale), but it iterates only `readyIndexes` and does NOT require every source line to be covered. It throws `No lines are ready to confirm` when the result is empty. + - Route action `post_ready` (same body as `post`). It returns `{preview, posted: number, remaining: number}`. + - After posting, the note stage is `ready_to_post` if `readyIndexes` is non-empty, else `needs_review`, unless the note is complete. + - Auto-match on arrival: every place that sets stage from `deliveryReady(preview)` uses `readyIndexes(context, preview).length > 0` instead. That is the processing preview route, the matching `refresh` and `save` branches, and `inbox-two/review.ts`. Lines the AI is sure of then reach Ready to confirm with no human step. Find them all with `grep -rn "deliveryReady(" src`. + +- [ ] **Step 1: Write failing tests:** + - `prepareReadyLines` on a 3-line preview where only line 0 is ready → returns 1 entry; + - with none ready → throws `No lines are ready to confirm`; + - with an open `site_decision` check → throws `Answer the outstanding site question`. + - Route: `post_ready` posts 1 of 3 lines and returns `remaining: 2`. + - Route: two concurrent `post_ready` calls (mock lease) → the second returns a 409 `locked` or posts 0. `line_posting.bookings` never holds duplicate `sourceKeys`. + - Route: stale `snapshotHash` (another slip posted) → refreshes, then posts against the refreshed preview. +- [ ] **Step 2: Run** `npx vitest run src/lib/dn-pipeline/matching.test.ts src/app/api/dn-batches`. Expect FAIL. +- [ ] **Step 3: Implement** `prepareReadyLines` next to `prepareDelivery`, sharing its guard lines through a small `assertConfirmable(context)` helper. Add the `post_ready` branch in the route by copying the `post` loop with `prepareReadyLines`. Point `confirmDelivery` at `post_ready`. +- [ ] **Step 4: Run** again. Expect PASS. Then `npx vitest run src/lib/dn-pipeline` to check for no regressions. +- [ ] **Step 5: Commit** `feat(dn): confirm posts the ready lines and leaves the rest open` + +### Task 4: Supplier guard check + +**Files:** +- Modify: `src/lib/dn-pipeline/checks.ts` (new check id `supplier_matches_po`) +- Modify: `src/lib/dn-pipeline/readiness.ts:16` (add to `REQUIRED_CHECK_IDS`) +- Modify: `src/app/dashboard/financial/delivery-notes/document/[id]/document-detail-view.tsx` (show the reason, plus an override that saves `manual_overrides.supplier_guard_override = {reason, by, at}`) +- Test: `src/lib/dn-pipeline/checks.test.ts` + +**Interfaces:** +- Produces: `supplierMatchesPo(input:{supplierNameRaw:string|null;supplierRegistrationRaw:string|null;linkedSupplier:{id:string;name:string;registration?:string|null};aliases:string[];poSupplierRef:string|null;override?:{reason:string}|null}): {passed:boolean|null;reason:string}`. + - It passes when `poSupplierRef === linkedSupplier.id` AND (the normalised raw name shares the supplier's first significant word or matches an alias, OR the registration digits match), OR an override reason exists. + - It returns null when there is no PO yet. + +- [ ] **Step 1: Write failing tests:** + - raw "FH CHAMBERLAIN TRADING (PTY) LTD" + linked "AVIDAN TRADING CC" → `passed:false`, reason contains "Chamberlain" and "Avidan". + - raw "FH CHAMBERLAIN TRADING (P) LTD" + linked "FH CHAMBERLAINS" → true. + - raw "FM CHAMBERLAIN" (OCR slip) + linked "FH CHAMBERLAINS" with registration `1955/001780/07` matching → true. + - PO supplier_ref ≠ linked supplier → false. + - override `{reason:'merged supplier'}` → true. + - no PO → null. +- [ ] **Step 2: Run** `npx vitest run src/lib/dn-pipeline/checks.test.ts`. Expect FAIL. +- [ ] **Step 3: Implement** and call it from `runNoteChecks`. Aliases come from `supplier_document_aliases` already loaded for the note. If they are not loaded there, pass `[]` and rely on name and registration. +- [ ] **Step 4: Run** again, plus `npx vitest run src/lib/dn-pipeline`. Expect PASS. +- [ ] **Step 5: Commit** `feat(dn): block slips linked to another supplier's PO` + +### Task 5: Line rows model (pure) + +**Files:** +- Create: `src/lib/inbox-three/line-rows.ts` +- Test: `src/lib/inbox-three/line-rows.test.ts` + +**Interfaces:** +- Consumes: Task 2 exports; `PreviewContext`, `ProcessingPreview`; `normaliseUnit`, `sameReceivingUnit`. +- Produces: +```ts +export interface LineEdit { lines: number[]; poItemId: string | null; slipQty: number; slipUnit: string; factor: number | null; price: number | null; usePoRate: boolean; reason: string } +export interface LineRow { + numbers: number[]; // 1-based, e.g. [3,4] for a combined row, label "3+4" + allocationIndex: number; + slipText: string; slipQty: number | null; slipUnit: string | null; slipPrice: number | null; slipAmount: number | null; + poItemId: string | null; poUnit: string | null; poRate: number | null; ordered: number | null; priorReceived: number; + qty: number | null; unit: string | null; factor: number | null; poQty: number | null; // poQty = qty × factor + price: number | null; value: number | null; leftOnPo: number | null; + flags: Array<'unit_differs'|'factor_missing'|'price_differs'|'over_po'|'guard_conflict'|'not_on_po'>; + ready: boolean; posted: boolean; problem: string | null; // plain-language first problem +} +export function buildLineRows(context: PreviewContext, preview: ProcessingPreview): LineRow[] +export function editToConfirmation(row: LineRow, edit: LineEdit, poUnit: string | null): LineConfirmation +export const UNIT_SUGGESTIONS = ['each','roll','m','m²','m³','kg','5kg bag','ton','length','box','pack'] as const +``` +- `editToConfirmation` sends `quantity = slipQty × factor` and `unit = poUnit` when the unit differs from the PO's. When `factor === 1` and the unit differs (the PO unit label is wrong), it sends `unitOverride:true` with the slip unit. It sends `netAmount = round(poQty × price)` when the price was edited, else `usePoRate:true`. The reason is always non-empty: default `"{slipQty} {slipUnit} × {factor} = {poQty} {poUnit}"` and/or `"Price R{price} instead of PO R{poRate}"`. + +- [ ] **Step 1: Write failing tests** using the fencing slip 0007075669 against MEGCPO9448 (inline fixture): + - line 1 "MESH DIAMOND … ROLL", 2 rolls, PO unit "m", rate 1124.35 → `flags` contains `unit_differs`; + - with factor 1 → `value` 2248.70 and `leftOnPo` 0; + - factor null → flag `factor_missing` and `problem` "Enter how many m one roll is"; + - factor 0, −1 and 20000 → `factor_missing`, value null; + - `editToConfirmation` for factor 30 → `{quantity:60, unit:'m', usePoRate:true}` with the reason "2 roll × 30 = 60 m"; + - price edit 1298 → `netAmount` 2596 and no `usePoRate`; + - a combined row of lines [2,3] → `numbers` [3,4]. +- [ ] **Step 2: Run** `npx vitest run src/lib/inbox-three/line-rows.test.ts`. Expect FAIL. +- [ ] **Step 3: Implement.** `leftOnPo = ordered − priorReceived − (this note's posted bookings for the item) − poQty`. +- [ ] **Step 4: Run** again. Expect PASS. +- [ ] **Step 5: Commit** `feat(dn): inbox 3 line rows with unit factor, price and left on PO` + +### Task 6: Save line edits API + +**Files:** +- Create: `src/lib/inbox-three/save-lines.ts` +- Create: `src/app/api/inbox-three/[batchId]/notes/[noteId]/lines/route.ts` +- Test: `src/lib/inbox-three/save-lines.test.ts` + +**Interfaces:** +- Consumes: `LineEdit`, `editToConfirmation`, `buildLineRows` (Task 5); `readyIndexes` (Task 2); `resolveMatch`, `buildPreview` (existing); `rememberConnection` (existing); `withBatchLease`, `loadPreviewContext`. +- Produces: + - `saveLineEdits(context, preview, edits: LineEdit[], actor: string): { preview: ProcessingPreview; remembered: Record | null; readyNow: number[] }`. + - When `edit.lines.length > 1` (combine) or the edit's lines differ from an existing allocation's `sourceLineIds`, it rebuilds the allocations so those source lines form one allocation, using `buildPreview` with the modified allocations, then applies `resolveMatch`. + - It throws `Line {n} is already posted` if any of the lines is posted. + - It calls `rememberConnection` when the unit differs and the factor ≠ 1. + - `POST /api/inbox-three/{batchId}/notes/{noteId}/lines` with body `{ edits: LineEdit[], snapshotHash, decisionHash }`. It returns `{ rows: LineRow[], readyNow: number[] }`. + - It writes `reconciliation_preview`, sets stage `ready_to_post` when `readyIndexes` is non-empty (else `needs_review`), and logs `dn_review_events` with action `inbox_three_save`. + - Guards: `requireApiUser('financial')` + `canSeeInboxThree` + `requireSiteAccess`. It never posts. + +- [ ] **Step 1: Write failing tests:** + - one edit fixing line 5 to PO item "1.6mm Bailing Wire" → `readyNow` [4]; + - an edit with a `poItemId` from another PO → throws `PO item does not belong to this purchase order` and the preview is unchanged; + - combining lines [2,3] when line 3 is posted → throws `Line 4 is already posted`; + - roll → m factor 30 → the returned `remembered` rule has `factor` 30. +- [ ] **Step 2: Run** `npx vitest run src/lib/inbox-three/save-lines.test.ts`. Expect FAIL. +- [ ] **Step 3: Implement** the service, then the thin route (pattern: `matching/route.ts`). +- [ ] **Step 4: Run** again. Expect PASS. +- [ ] **Step 5: Commit** `feat(dn): save inbox 3 line edits without posting` + +### Task 7: Line assistant + +**Files:** +- Create: `src/lib/inbox-three/assistant-schema.ts` +- Create: `src/lib/inbox-three/assistant.ts` +- Create: `src/app/api/inbox-three/[batchId]/notes/[noteId]/assistant/route.ts` +- Test: `src/lib/inbox-three/assistant.test.ts` + +**Interfaces:** +- Consumes: `saveLineEdits` (Task 6); `buildLineRows` (Task 5); the model runner pattern from `src/lib/inbox-two/review-model.ts` (same provider and env vars); `loadReviewImages`. +- Produces: +```ts +export const LineAssistantProposalSchema = z.object({ + reply: z.string().trim().min(1).max(3000), + question: z.string().trim().min(1).max(500).nullable(), + edits: z.array(z.object({ lines: z.array(z.number().int().min(1).max(200)).min(1), poItemId: z.string().nullable(), + slipQty: z.number().finite().min(0), slipUnit: z.string().trim().min(1).max(40), factor: z.number().finite().positive().max(10000).nullable(), + price: z.number().finite().min(0).nullable(), reason: z.string().trim().min(1).max(500) }).strict()).max(50).default([]), +}).strict(); +export function runLineAssistant(pb: PocketBase, batchId: string, noteId: string, actor: string, message: string, deps?: {...}): Promise<{ reply: string; question: string|null; rows: LineRow[]; readyNow: number[] }> +``` +- The model receives: + - the numbered `LineRow[]` (1-based); + - the PO lines with ids; + - the slip and PO page images; + - the chat history (`dn_review_events` action `inbox_three_assistant`, last 30). +- The system prompt states: it may only use the PO item ids listed; lines are numbered from 1; it never posts. +- The server converts `lines` to 0-based, sets `usePoRate = price === null`, and passes the edits to `saveLineEdits`. When that throws, the reply gets the error appended and nothing is saved. + +- [ ] **Step 1: Write failing tests** (mock the model): + - the proposal "lines 3 and 4 → kerb item, 60 m" → `saveLineEdits` is called with `lines:[2,3]`; + - a proposal using an unknown `poItemId` → reply contains "not on this purchase order" and the stored preview is unchanged; + - the proposal is never able to call a post function (assert that `postLineWithinLease` is not imported by `assistant.ts`, via a source-text test); + - each exchange writes one `dn_review_events` row with `actor` and `after.edits`. +- [ ] **Step 2: Run** `npx vitest run src/lib/inbox-three/assistant.test.ts`. Expect FAIL. +- [ ] **Step 3: Implement** the schema, service and route. Use the same lease, same-origin check and replay-by-requestId pattern as `inbox-two/review.ts`. +- [ ] **Step 4: Run** again. Expect PASS. +- [ ] **Step 5: Commit** `feat(dn): inbox 3 assistant edits lines by number, never posts` + +### Task 8: Inbox 3 list and workspace UI + +**Files:** +- Create: `src/app/dashboard/financial/delivery-notes/inbox-3/page.tsx` (list) +- Create: `src/app/dashboard/financial/delivery-notes/inbox-3/[id]/page.tsx` (workspace) +- Create: `src/app/dashboard/financial/delivery-notes/inbox-3/line-table.tsx`, `po-line-picker.tsx`, `unit-input.tsx`, `documents-panel.tsx`, `line-assistant.tsx`, `ready-banner.tsx` +- Modify: `src/lib/inbox-two/model.ts` (add `readyLines:number` to `InboxRow`, computed with `readyIndexes`; a row with `readyLines>0` and open lines appears in both "ready" and "attention") +- Test: `src/app/dashboard/financial/delivery-notes/inbox-3/line-table.test.tsx`, `src/lib/inbox-two/model.test.ts` + +**Interfaces:** +- Consumes: `buildLineRows`, `UNIT_SUGGESTIONS`, `LineEdit` (Task 5); the lines API (Task 6); the assistant API (Task 7); `post_ready` (Task 3); `canSeeInboxThree` (Task 1); `PageViewer` from the inbox-2 workspace. +- Behaviour, as in the approved mockup v2 (`.superpowers/brainstorm/9061-1792991193/content/inbox3-slip-first-v2.html`): + - **Table columns:** # · On the slip · Goes to PO line · Received · Unit · Price · Value · Left on PO. + - **Rows:** only rows with `!ready && !posted` are shown. + - **Banner:** "{n} lines sent to Ready to confirm · show". Show expands a read-only list with a **Confirm these lines** button for admin and office, which calls `post_ready`. + - **PO line picker:** a searchable combobox of this PO's lines (description · ordered · unit · rate) plus "Not on this PO". + - **Unit input:** a text input with a datalist of `UNIT_SUGGESTIONS`. When the unit differs from the PO unit, show the inline input "1 {unit} = [__] {poUnit} on the PO". + - **Price:** a number input pre-filled with the PO rate, with the slip rate beside it. When `price_differs`, show an amber badge. + - **Save these lines:** one button that sends every edited row to the lines API. Rows that come back ready leave the table. + - **Right column:** tabs Both / Slip / PO. Under Both, the slip page sits above the PO page. Then the assistant chat. + - **Footer:** "Still here: {n} lines · R{value}" and "Whole slip R{x} vs PO R{y}". +- The pages call `requirePageUser('financial')` and then `canSeeInboxThree`, else `notFound()`. + +- [ ] **Step 1: Write failing tests:** + - `line-table.test.tsx`: renders rows numbered "1" and "3+4"; editing unit to "roll" on an "m" PO line shows "1 roll = … m on the PO"; a ready row is not rendered; Save sends one request containing every edited row. + - `model.test.ts`: a note with `readyLines:2` and one open line appears in both `ready` and `attention`. +- [ ] **Step 2: Run** `npx vitest run src/app/dashboard/financial/delivery-notes/inbox-3 src/lib/inbox-two/model.test.ts`. Expect FAIL. +- [ ] **Step 3: Implement** the components with existing shadcn primitives (`Command`/`Popover` for the picker if present in `src/components/ui`, else a filtered list). +- [ ] **Step 4: Run** again. Expect PASS. Then `npx tsc --noEmit` and `npx eslint src/app/dashboard/financial/delivery-notes/inbox-3 src/lib/inbox-three`. Expect clean. +- [ ] **Step 5: Commit** `feat(dn): inbox 3 slip-first matching screen` + +### Task 9: Existing inbox shows part-ready slips + +**Files:** +- Modify: `src/lib/dn-pipeline/board.ts` (stage `ready_to_post` set by Tasks 3 and 6 is already enough; add `readyLines` and `openLines` counts to each document) +- Modify: `src/app/dashboard/financial/delivery-notes/inbox/document-tile.tsx` (show "{readyLines} of {total} lines ready") +- Modify: `src/app/dashboard/financial/delivery-notes/document/[id]/processing-preview.tsx` (when not every line is ready, the Confirm button reads "Confirm {n} ready lines" and lists them) +- Test: `src/lib/dn-pipeline/board.test.ts`, `src/app/dashboard/financial/delivery-notes/inbox/document-tile.test.tsx` + +- [ ] **Step 1: Write failing tests:** the board document for a 7-line note with 5 ready has `readyLines:5, openLines:2`; the tile renders "5 of 7 lines ready". +- [ ] **Step 2: Run** `npx vitest run src/lib/dn-pipeline/board.test.ts src/app/dashboard/financial/delivery-notes/inbox`. Expect FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4: Run** again. Expect PASS. +- [ ] **Step 5: Commit** `feat(dn): existing inbox confirms ready lines of part-done slips` + +### Task 10: Re-run the queue and verify live + +**Files:** +- Create: `scripts/inbox-three-rerun.ts` (for each open note: `loadPreviewContext` → `refreshPreview` or the existing processing preview → save; it prints `doc_number, readyLines, openLines`; it never posts) + +- [ ] **Step 1:** Run `npx tsx scripts/inbox-three-rerun.ts --dry-run` against production PocketBase (env from `.env.local`). Expect a table of the open notes with ready/open counts and no writes. +- [ ] **Step 2:** Show David the table. Run without `--dry-run` only after he says yes. +- [ ] **Step 3:** Start the dev server via the preview tool (`.claude/launch.json`). Open `/dashboard/financial/delivery-notes/inbox-3` as admin and check: + - fencing slip 0007075669 (already posted lines show in the banner, not the table); + - DQ0016214 sand: pick the PO line, save, and it leaves the table; + - the assistant "fix line 1, it is 2 rolls at the PO rate" works on a test note. + + Screenshot each check. +- [ ] **Step 4:** As a site_user, open the inbox-3 URL. Expect a 404. +- [ ] **Step 5: Commit** `chore(dn): inbox 3 queue re-run script`