docs(dn): phase 1 plan — the delivery review page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-09 19:59:39 +02:00
parent 4493bbc0db
commit 529028b8d1

View file

@ -0,0 +1,669 @@
# Delivery-note review page (explain-or-ask phase 1) 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:** On a delivery note's page, the clerk sees one card per PO line being booked:
- the delivery-note line(s) in blue on top and the PO line in purple underneath;
- the quantity and value booked;
- green when sure (with the working), amber when a decision is needed (with fix buttons);
- blue/purple totals against the PO;
- a Post button that only works when everything is green.
This sits behind a New/Classic switch, so today's view stays one click away. Inbox cards show the money.
**Architecture:** Two pure modules hold all the logic (`price-explainer.ts`, `review-model.ts`). A new client component (`delivery-review.tsx`) renders their output and reuses today's save and confirm calls, plus today's line editor (`ConnectionEditor`). `ProcessingPreviewPanel` chooses New or Classic. A new `refresh` action on the existing matching route re-applies saved decisions to a stale match instead of forcing an AI re-match. No database or schema changes.
**Tech Stack:** Next.js 16 App Router (client components), React, TypeScript, Tailwind, Vitest (node environment, `renderToStaticMarkup` for components), PocketBase.
**Spec:** `docs/superpowers/specs/2026-10-09-dn-explain-or-ask-design.md` (§4 line colours, §5 the document page, §7 inbox board; this plan is §10 phase 1).
## Global Constraints
- No PocketBase schema changes. No deletes (`.delete(` must not appear in app code).
- Nothing posts automatically. Posting only happens when a person presses Post (the existing `action:'post'` call).
- Colours:
- delivery note = blue: `bg-blue-50 border-l-4 border-blue-500`, dark `dark:bg-blue-950/40`;
- PO = purple: `bg-violet-50 border-l-4 border-violet-500`, dark `dark:bg-violet-950/40`;
- green = `border-green-300`, text `text-green-700 dark:text-green-400`;
- amber = `border-2 border-amber-500`, text `text-amber-700 dark:text-amber-300`.
- Money: `R` + `formatNumber(n,{minimumFractionDigits:2,maximumFractionDigits:2})` from `@/lib/utils` (en-ZA: `R117 569,20`). Quantities: `formatNumber(n,{maximumFractionDigits:4})`.
- Client modules must not import runtime code from `@/lib/dn-pipeline/ai-preview` (it imports `node:crypto`). Use `import type` only.
- It must work on a phone: cards stack, no fixed widths, and buttons wrap.
- Verify every task: `npx vitest run src/lib/dn-pipeline src/app/dashboard/financial/delivery-notes "src/app/api/dn-batches"`, `./node_modules/.bin/tsc --noEmit --project tsconfig.json`, `npx eslint <changed files>`. `split.test.ts` is flaky: re-run it alone if it is the only failure.
- Follow the surrounding style. `src/lib/dn-pipeline/*` uses compact TypeScript; React files use the style of `delivery-matching.tsx`.
---
### Task 1: Price explainer
**Files:**
- Create: `src/lib/dn-pipeline/price-explainer.ts`
- Test: `src/lib/dn-pipeline/price-explainer.test.ts`
**Interfaces:**
- Produces:
- `explainPrice(input:{slipUnitPrice:number|null;poRate:number|null;poDiscount?:number|null;description:string|null}):PriceCheck`
- `explainKit(input:{slipAmounts:Array<number|null>;poRate:number|null;poDiscount?:number|null;quantity:number|null}):PriceCheck`
- `type PriceCheck={ok:boolean;working:string}`
A slip price agrees with the PO rate when `slip ≈ rate × (1 − discount%) × k` for one of these k, tried in order:
| k | when | working text |
|---|---|---|
| 1 | — | `slip price = PO rate` |
| 1.15 | — | `R<slip> incl VAT = R<slip/1.15> excl = PO rate` |
| L | L = length in metres from the description (`/(\d+(?:[.,]\d+)?)\s*m\b/i`, last match, 0 < L ≤ 12) | `R<slip> per <L> m length = R<slip/L>/m = PO rate` |
| L×1.15 | same L | `R<slip> per <L> m length incl VAT = PO rate` |
| 1000 | — | `R<slip> per 1000 = PO rate` |
| 0.001 | — | `R<slip> each = PO rate per 1000` |
Tolerance for a unit price: `|slip − rate×k| ≤ max(0.01, 0.005×slip)`. For a kit: `|Σ slip amounts − rate×quantity| ≤ max(1, 0.01×rate×quantity)`.
- [ ] **Step 1: Write the failing tests**
```ts
// src/lib/dn-pipeline/price-explainer.test.ts
import {expect,it} from 'vitest';
import {explainPrice,explainKit} from './price-explainer';
it('accepts the same price',()=>{
expect(explainPrice({slipUnitPrice:65,poRate:65,description:'110x110x45 UG PVC SEWER JUNCTION'})).toEqual({ok:true,working:'slip price = PO rate'});
});
it('explains a price per length from the description',()=>{
const r=explainPrice({slipUnitPrice:276,poRate:46,description:'160x6M KABELFLEX PIPE & COUPLING'});
expect(r.ok).toBe(true);expect(r.working).toBe('R276,00 per 6 m length = R46,00/m = PO rate');
expect(explainPrice({slipUnitPrice:25,poRate:10,description:'1050 x 2.5m pipe'}).working).toBe('R25,00 per 2,5 m length = R10,00/m = PO rate');
});
it('explains a VAT-inclusive slip and a per-1000 price',()=>{
expect(explainPrice({slipUnitPrice:115,poRate:100,description:'Cement'}).working).toBe('R115,00 incl VAT = R100,00 excl = PO rate');
expect(explainPrice({slipUnitPrice:2604.74,poRate:2.60474,description:'Bricks'}).ok).toBe(true);
});
it('applies the PO discount',()=>{
expect(explainPrice({slipUnitPrice:90,poRate:100,poDiscount:10,description:'Valve'}).ok).toBe(true);
});
it('refuses a price it cannot explain, saying what it tried',()=>{
expect(explainPrice({slipUnitPrice:430,poRate:662,description:'160mm CI END CAP'})).toEqual({ok:false,working:"R430,00 on the slip vs R662,00 on the PO (tried same price, VAT, per 1000)"});
expect(explainPrice({slipUnitPrice:337,poRate:274,description:'80mm GALV PIPE TBE (PER METER) 1 - 80 x 2.0m'}).working).toContain('per 2 m length');
});
it('is fine without a slip price and flags a missing PO rate',()=>{
expect(explainPrice({slipUnitPrice:null,poRate:168.5,description:'G6 Sub Base'})).toEqual({ok:true,working:'no price on the slip'});
expect(explainPrice({slipUnitPrice:10,poRate:null,description:'x'})).toEqual({ok:false,working:'the PO line has no rate'});
});
it('checks a kit by adding up the component amounts',()=>{
expect(explainKit({slipAmounts:[178.5,3492.72,470.4],poRate:593.6,quantity:7})).toEqual({ok:true,working:'kit: slip R4 141,62 ≈ PO R4 155,20'});
expect(explainKit({slipAmounts:[100,100],poRate:593.6,quantity:7}).ok).toBe(false);
expect(explainKit({slipAmounts:[null,null],poRate:593.6,quantity:7})).toEqual({ok:true,working:'no prices on the slip'});
});
```
Note: `formatNumber` uses en-ZA, which prints `R4 141,62` with a non-breaking or narrow space as the thousands separator. Build expected strings with the module's own `money()` helper if the exact space character differs. Check it once with `node -e "console.log(new Intl.NumberFormat('en-ZA',{minimumFractionDigits:2}).format(4141.62))"` and adjust the expectations to the real output. Don't change the formatter.
- [ ] **Step 2: Run them and see them fail**
Run: `npx vitest run src/lib/dn-pipeline/price-explainer.test.ts`
Expected: FAIL, "Cannot find module './price-explainer'".
- [ ] **Step 3: Implement**
```ts
// src/lib/dn-pipeline/price-explainer.ts
import {formatNumber} from '@/lib/utils';
/**
* Does a slip's price agree with the PO rate it is booked at?
*
* Suppliers price the same goods per length, per metre, per 1000 or
* VAT-inclusive, so "different number" is not "wrong". Only a difference no
* known pattern explains is a question for a person.
*/
export type PriceCheck={ok:boolean;working:string};
const money=(n:number)=>`R${formatNumber(n,{minimumFractionDigits:2,maximumFractionDigits:2})}`;
const qty=(n:number)=>formatNumber(n,{maximumFractionDigits:3});
const finite=(v:unknown):v is number=>typeof v==='number'&&Number.isFinite(v);
/** The length in metres a description names ("160x6M", "2.5m", "x 2.0m"), if any. */
export function lengthFromDescription(description:string|null):number|null{
const matches=[...String(description??'').matchAll(/(\d+(?:[.,]\d+)?)\s*m\b/gi)];
const last=matches.at(-1);if(!last)return null;
const length=Number(last[1].replace(',','.'));
return length>0&&length<=12?length:null;
}
export function explainPrice(input:{slipUnitPrice:number|null;poRate:number|null;poDiscount?:number|null;description:string|null}):PriceCheck{
const slip=input.slipUnitPrice;
if(!finite(slip)||slip<=0)return {ok:true,working:'no price on the slip'};
if(!finite(input.poRate))return {ok:false,working:'the PO line has no rate'};
const rate=input.poRate*(1-(finite(input.poDiscount)?input.poDiscount:0)/100);
const close=(k:number)=>Math.abs(slip-rate*k)<=Math.max(0.01,0.005*slip);
const length=lengthFromDescription(input.description);
const tries:Array<[number,string,string]>=[
[1,'same price','slip price = PO rate'],
[1.15,'VAT',`${money(slip)} incl VAT = ${money(slip/1.15)} excl = PO rate`],
...(length?[[length,`per ${qty(length)} m length`,`${money(slip)} per ${qty(length)} m length = ${money(slip/length)}/m = PO rate`] as [number,string,string],
[length*1.15,`per ${qty(length)} m length incl VAT`,`${money(slip)} per ${qty(length)} m length incl VAT = PO rate`] as [number,string,string]]:[]),
[1000,'per 1000',`${money(slip)} per 1000 = PO rate`],
[0.001,'each vs per 1000',`${money(slip)} each = PO rate per 1000`],
];
const hit=tries.find(([k])=>close(k));
if(hit)return {ok:true,working:hit[2]};
const tried=[...new Set(tries.map(([,name])=>name.replace(' incl VAT','').replace('each vs per 1000','per 1000')))].join(', ');
return {ok:false,working:`${money(slip)} on the slip vs ${money(input.poRate)} on the PO (tried ${tried})`};
}
export function explainKit(input:{slipAmounts:Array<number|null>;poRate:number|null;poDiscount?:number|null;quantity:number|null}):PriceCheck{
const amounts=input.slipAmounts.filter(finite);
if(!amounts.length)return {ok:true,working:'no prices on the slip'};
if(!finite(input.poRate)||!finite(input.quantity))return {ok:false,working:'the PO line has no rate'};
const slip=Math.round(amounts.reduce((s,a)=>s+a,0)*100)/100;
const po=Math.round(input.poRate*(1-(finite(input.poDiscount)?input.poDiscount:0)/100)*input.quantity*100)/100;
const ok=Math.abs(slip-po)<=Math.max(1,0.01*po);
return {ok,working:ok?`kit: slip ${money(slip)} ≈ PO ${money(po)}`:`kit: slip ${money(slip)} vs PO ${money(po)}`};
}
```
The "tried" list must be readable: for the end cap (no length in the description) it reads `same price, VAT, per 1000`. With a length it adds `per <L> m length`. Adjust the dedupe if the test text differs, keeping the test as the source of truth.
- [ ] **Step 4: Run the tests until they pass**
Run: `npx vitest run src/lib/dn-pipeline/price-explainer.test.ts`
Expected: PASS (7 tests).
- [ ] **Step 5: Commit**
```bash
git add src/lib/dn-pipeline/price-explainer.ts src/lib/dn-pipeline/price-explainer.test.ts
git commit -m "feat(dn): explain a slip price against the PO rate (length, VAT, per 1000, kits)"
```
---
### Task 2: Review model
**Files:**
- Create: `src/lib/dn-pipeline/review-model.ts`
- Test: `src/lib/dn-pipeline/review-model.test.ts`
**Interfaces:**
- Consumes: `explainPrice`, `explainKit` (Task 1); `allocationNeedsHelp` from `./delivery-ready`; types `ProcessingPreview`, `PreviewAllocation` (type-only import from `./ai-preview`).
- Produces (used by Tasks 3 and 6):
```ts
export type ReviewPoItem={id:string;description:string;unit:string;rate?:number;discount?:number;vatPercent?:number;ordered?:number;received?:number};
export type ReviewSlipLine={sourceLineId:string;description:string;qty:number|null;unit:string;handwritten:boolean;nil:boolean;unitPrice:number|null;amount:number|null};
export type ReviewPoLine={id:string;description:string;unit:string;ordered:number|null;rate:number|null;lineValue:number|null;receivedBefore:number;after:number;complete:boolean};
export type ReviewCard={index:number;key:string;kind:'item'|'kit'|'nil'|'included'|'po_level';colour:'green'|'amber'|'posted';slip:ReviewSlipLine[];po:ReviewPoLine|null;booked:{quantity:number|null;unit:string;net:number|null};working:string|null;decision:string|null;priceProblem:boolean;excess:number|null};
export type ReviewModel={cards:ReviewCard[];green:{count:number;net:number};amber:number;booking:{net:number;vat:number;gross:number};po:{ordered:number|null;before:number|null;after:number|null;afterPct:number|null};readyToPost:boolean;blockers:string[]};
export function buildReviewModel(preview:ProcessingPreview,poItems:ReviewPoItem[]):ReviewModel;
export function previewMoney(preview:Pick<ProcessingPreview,'allocations'|'orderedNet'|'priorNetEstimate'>|null|undefined):{net:number;afterPct:number|null}|null;
```
Rules, in this order, per allocation `a` at index `i`:
1. `a.posted` → colour `posted`, no decision.
2. `a.amountBasis==='not_delivered'` and `a.status==='proposed'` → kind `nil`, green, working `NIL on the slip`.
3. `allocationNeedsHelp(a,preview)` → amber. The decision is:
- `a.reviewQuestion`, else the first `a.reviewReasons`;
- else, if `(a.quantityExcess??0)>0`: `Over the PO by <excess> <unit>`;
- else `Needs a decision`.
4. `a.amountBasis==='included'` → kind `included`, green, working `a.explanation`.
5. No `a.purchaseOrderItemId` → kind `po_level`. Green if `a.netAmount!==null`, working `a.explanation`.
6. Otherwise, the price check:
- one slip line → `explainPrice({slipUnitPrice:line.unit_price,poRate:item.rate,poDiscount:item.discount,description:line.description})`;
- several → kind `kit`, `explainKit({slipAmounts:lines.map(l=>l.line_amount??(l.unit_price!=null&&l.qty!=null?l.unit_price*l.qty:null)),poRate:item.rate,poDiscount:item.discount,quantity:a.quantity})`.
If `!ok` and **no** `a.operatorConfirmation` → amber, `priceProblem:true`, decision `Price doesn't add up: <working>`. If `!ok` and there **is** a confirmation → green, working `Decided: <a.operatorConfirmation.reason ?? 'booked at the PO rate'>`. If `ok` → green, working `<working>`, plus `· PO line complete after this` when `after>=ordered`.
Other fields:
- **Slip lines:** from `preview.lines` by `sourceLineId`. `handwritten = line.receiving_status==='annotated'`. `nil = line.qty===0`.
- **PO line:** from `poItems` by `a.purchaseOrderItemId`:
- `lineValue = ordered×rate×(1−discount%)`, rounded to cents;
- `receivedBefore = item.received ?? 0`;
- `after = receivedBefore + Σ quantity of this note's NOT-posted allocations on that item`;
- `complete = ordered!==null && after>=ordered`.
- **Booking:** totals over not-posted allocations whose `netAmount!==null`:
- `vat = Σ round2(net×(vatPercent??15)/100)`;
- `gross = net + vat`.
- **PO totals:** `ordered=preview.orderedNet`, `before=preview.priorNetEstimate`, `after=before+booking.net`, `afterPct=round1(after/ordered×100)` (null if ordered is missing or 0).
- **green:** count and Σ net of green, not-posted cards.
- **blockers:**
- `possibleDuplicateReferences` → `Possible copy of <refs>`;
- `identityReviewReasons` → each reason;
- no open allocation → `Nothing left to post`.
- `readyToPost = amber===0 && blockers.length===0`.
- `previewMoney`:
- `net` = Σ netAmount of not-posted allocations (null → 0);
- `afterPct` as above;
- `null` when there's no preview.
- [ ] **Step 1: Write the failing tests**
```ts
// src/lib/dn-pipeline/review-model.test.ts
import {expect,it} from 'vitest';
import {buildPreview} from './ai-preview';
import {context} from './preview-fixture';
import {applyPlan} from './matching';
import {buildReviewModel,previewMoney,type ReviewPoItem} from './review-model';
const items:ReviewPoItem[]=[{id:'itemabcdefghijk',description:'1050 pipe',unit:'m',rate:10,discount:0,vatPercent:15,ordered:70,received:5}];
const pipe={confirmed:true as const,poItemId:'itemabcdefghijk',quantity:27.5,unit:'m',usePoRate:true,reason:'11 pipes x 2.5 m'};
const staged=()=>{const c=context();return applyPlan(c,buildPreview(c,{allocations:[]}),{entries:[{sourceLineIds:['pipe'],confirmation:pipe},{sourceLineIds:['ring'],confirmation:{confirmed:true,disposition:'not_delivered',reason:'free rings'}}],closeNil:false}).preview;};
it('turns a fully explained slip green and ready, with the price working and PO position',()=>{
const m=buildReviewModel(staged(),items);
const card=m.cards.find(c=>c.slip[0]?.sourceLineId==='pipe')!;
expect(card).toMatchObject({colour:'green',kind:'item',booked:{quantity:27.5,unit:'m',net:275}});
expect(card.working).toContain('per 2,5 m length');
expect(card.po).toMatchObject({ordered:70,rate:10,lineValue:700,receivedBefore:5,after:32.5,complete:false});
expect(m.amber).toBe(0);expect(m.readyToPost).toBe(true);
expect(m.booking).toMatchObject({net:275,vat:41.25,gross:316.25});
expect(m.green.count).toBe(2);
});
it('makes an unexplained price amber until someone decides',()=>{
const c=context();c.lines[0]={...c.lines[0],unit_price:43};
const p=applyPlan(c,buildPreview(c,{allocations:[]}),{entries:[{sourceLineIds:['pipe'],confirmation:pipe},{sourceLineIds:['ring'],confirmation:{confirmed:true,disposition:'not_delivered',reason:'free'}}],closeNil:false}).preview;
// applyPlan saved a confirmation, so the decision is recorded: green with "Decided".
expect(buildReviewModel(p,items).cards.find(x=>x.slip[0]?.sourceLineId==='pipe')).toMatchObject({colour:'green'});
// Without a saved confirmation the same price is a question.
const raw=buildPreview(c,{allocations:[{sourceLineIds:['pipe'],purchaseOrderItemId:'itemabcdefghijk',quantityBasis:'converted',factor:2.5,amountBasis:'po_rate',sourceAmountBasis:'unknown',ruleKeys:[],explanation:'Pipe',reviewQuestion:null}]});
const card=buildReviewModel(raw,items).cards.find(x=>x.slip[0]?.sourceLineId==='pipe')!;
expect(card).toMatchObject({colour:'amber',priceProblem:true});
expect(card.decision).toMatch(/^Price doesn't add up: R43,00 on the slip vs R10,00 on the PO/);
});
it('makes a line needing help amber with its question, and blocks posting',()=>{
const c=context();const p=buildPreview(c,{allocations:[]});
const m=buildReviewModel(p,items);
expect(m.amber).toBeGreaterThan(0);expect(m.readyToPost).toBe(false);
expect(m.cards.find(x=>x.colour==='amber')!.decision).toBe('How should this delivery line be booked?');
});
it('blocks a possible copy even when every line is green',()=>{
const p=staged();p.possibleDuplicateReferences=['VDN0'];
expect(buildReviewModel(p,items)).toMatchObject({readyToPost:false,blockers:['Possible copy of VDN0']});
});
it('summarises the money for an inbox card',()=>{
const p=staged();p.orderedNet=700;p.priorNetEstimate=50;
expect(previewMoney(p)).toEqual({net:275,afterPct:46.4});
expect(previewMoney(null)).toBeNull();
});
```
- [ ] **Step 2: Run them and see them fail**
Run: `npx vitest run src/lib/dn-pipeline/review-model.test.ts`
Expected: FAIL, module not found.
- [ ] **Step 3: Implement `review-model.ts`**
```ts
// src/lib/dn-pipeline/review-model.ts
import type {ProcessingPreview,PreviewAllocation,PreviewLine} from './ai-preview';
import {allocationNeedsHelp} from './delivery-ready';
import {explainPrice,explainKit} from './price-explainer';
/**
* What the clerk sees for one delivery note: one card per PO line being
* booked, green when the quantity and price are explained, amber when a
* person has to decide. Pure, so the page, the tests and (later) the agents
* all read the same verdict.
*/
export type ReviewPoItem={id:string;description:string;unit:string;rate?:number;discount?:number;vatPercent?:number;ordered?:number;received?:number};
export type ReviewSlipLine={sourceLineId:string;description:string;qty:number|null;unit:string;handwritten:boolean;nil:boolean;unitPrice:number|null;amount:number|null};
export type ReviewPoLine={id:string;description:string;unit:string;ordered:number|null;rate:number|null;lineValue:number|null;receivedBefore:number;after:number;complete:boolean};
export type ReviewCard={index:number;key:string;kind:'item'|'kit'|'nil'|'included'|'po_level';colour:'green'|'amber'|'posted';slip:ReviewSlipLine[];po:ReviewPoLine|null;booked:{quantity:number|null;unit:string;net:number|null};working:string|null;decision:string|null;priceProblem:boolean;excess:number|null};
export type ReviewModel={cards:ReviewCard[];green:{count:number;net:number};amber:number;booking:{net:number;vat:number;gross:number};po:{ordered:number|null;before:number|null;after:number|null;afterPct:number|null};readyToPost:boolean;blockers:string[]};
const DEFAULT_VAT=15;
const round2=(n:number)=>Math.round((n+Number.EPSILON)*100)/100;
const round1=(n:number)=>Math.round((n+Number.EPSILON)*10)/10;
const num=(v:unknown):number|null=>typeof v==='number'&&Number.isFinite(v)?v:null;
function slipLine(line:PreviewLine):ReviewSlipLine{
return {sourceLineId:line.sourceLineId,description:line.description??'',qty:line.qty,unit:line.unit??'',handwritten:line.receiving_status==='annotated',nil:line.qty===0,unitPrice:num(line.unit_price),amount:num(line.line_amount)};
}
export function previewMoney(preview:Pick<ProcessingPreview,'allocations'|'orderedNet'|'priorNetEstimate'>|null|undefined):{net:number;afterPct:number|null}|null{
if(!preview)return null;
const net=round2(preview.allocations.filter(a=>!a.posted).reduce((s,a)=>s+(a.netAmount??0),0));
const ordered=preview.orderedNet,before=preview.priorNetEstimate;
return {net,afterPct:ordered&&before!==null?round1((before+net)/ordered*100):null};
}
export function buildReviewModel(preview:ProcessingPreview,poItems:ReviewPoItem[]):ReviewModel{
const lines=new Map(preview.lines.map(l=>[l.sourceLineId,l]));
const items=new Map(poItems.map(i=>[i.id,i]));
const openQty=(id:string)=>preview.allocations.filter(a=>!a.posted&&a.purchaseOrderItemId===id).reduce((s,a)=>s+(a.quantity??0),0);
const poLine=(id:string|null):ReviewPoLine|null=>{
const item=id?items.get(id):undefined;if(!item)return null;
const ordered=num(item.ordered),rate=num(item.rate),discount=num(item.discount)??0;
const receivedBefore=num(item.received)??0,after=round2(receivedBefore+openQty(item.id));
return {id:item.id,description:item.description,unit:item.unit,ordered,rate,lineValue:ordered!==null&&rate!==null?round2(ordered*rate*(1-discount/100)):null,receivedBefore,after,complete:ordered!==null&&after>=ordered};
};
const card=(a:PreviewAllocation,index:number):ReviewCard=>{
const raw=a.sourceLineIds.map(id=>lines.get(id)).filter((l):l is PreviewLine=>!!l);
const po=poLine(a.purchaseOrderItemId);
const kind:ReviewCard['kind']=a.sourceLineIds.length>1?'kit':'item';
const base={index,key:a.sourceLineIds.join(':'),slip:raw.map(slipLine),po,booked:{quantity:a.quantity,unit:a.unit??po?.unit??'',net:a.netAmount},excess:a.quantityExcess,priceProblem:false,working:null as string|null,decision:null as string|null};
if(a.posted)return {...base,kind,colour:'posted'};
if(a.amountBasis==='not_delivered'&&a.status==='proposed')return {...base,kind:'nil',colour:'green',working:'NIL on the slip'};
if(allocationNeedsHelp(a,preview))return {...base,kind,colour:'amber',decision:a.reviewQuestion||a.reviewReasons[0]||((a.quantityExcess??0)>0?`Over the PO by ${a.quantityExcess} ${a.unit??''}`.trim():'Needs a decision')};
if(a.amountBasis==='included')return {...base,kind:'included',colour:'green',working:a.explanation};
if(!a.purchaseOrderItemId)return a.netAmount!==null?{...base,kind:'po_level',colour:'green',working:a.explanation}:{...base,kind:'po_level',colour:'amber',decision:'Needs a value'};
const item=items.get(a.purchaseOrderItemId);
const check=raw.length>1
?explainKit({slipAmounts:raw.map(l=>num(l.line_amount)??(num(l.unit_price)!==null&&num(l.qty)!==null?num(l.unit_price)!*num(l.qty)!:null)),poRate:num(item?.rate),poDiscount:num(item?.discount),quantity:a.quantity})
:explainPrice({slipUnitPrice:num(raw[0]?.unit_price),poRate:num(item?.rate),poDiscount:num(item?.discount),description:raw[0]?.description??null});
if(!check.ok&&!a.operatorConfirmation)return {...base,kind,colour:'amber',priceProblem:true,decision:`Price doesn't add up: ${check.working}`};
const working=check.ok?check.working:`Decided: ${a.operatorConfirmation?.reason||'booked at the PO rate'}`;
return {...base,kind,colour:'green',working:po?.complete?`${working} · PO line complete after this`:working};
};
const cards=preview.allocations.map(card);
const open=preview.allocations.filter(a=>!a.posted),valued=open.filter(a=>a.netAmount!==null);
const net=round2(valued.reduce((s,a)=>s+a.netAmount!,0));
const vat=round2(valued.reduce((s,a)=>s+round2(a.netAmount!*(a.vatPercent??DEFAULT_VAT)/100),0));
const greens=cards.filter(c=>c.colour==='green'),amber=cards.filter(c=>c.colour==='amber').length;
const ordered=preview.orderedNet,before=preview.priorNetEstimate,after=before!==null?round2(before+net):null;
const blockers=[...(preview.possibleDuplicateReferences.length?[`Possible copy of ${preview.possibleDuplicateReferences.join(', ')}`]:[]),...(preview.identityReviewReasons??[]),...(open.length?[]:['Nothing left to post'])];
return {cards,green:{count:greens.length,net:round2(greens.reduce((s,c)=>s+(c.booked.net??0),0))},amber,booking:{net,vat,gross:round2(net+vat)},
po:{ordered,before,after,afterPct:ordered&&after!==null?round1(after/ordered*100):null},readyToPost:amber===0&&blockers.length===0,blockers};
}
```
- [ ] **Step 4: Run the tests until they pass**
Run: `npx vitest run src/lib/dn-pipeline/review-model.test.ts`
Expected: PASS (5 tests). If `applyPlan` or the fixture produce a slightly different decision text for the unmatched line, match the test to the real `reviewQuestion` the fixture produces. Keep the rule, adjust the literal.
- [ ] **Step 5: Commit**
```bash
git add src/lib/dn-pipeline/review-model.ts src/lib/dn-pipeline/review-model.test.ts
git commit -m "feat(dn): review model — one card per PO line, green/amber with working, totals vs PO"
```
---
### Task 3: The review component
**Files:**
- Create: `src/app/dashboard/financial/delivery-notes/document/[id]/delivery-review.tsx`
- Modify: `src/app/dashboard/financial/delivery-notes/document/[id]/delivery-matching.tsx`: export `ConnectionEditor` (change `function ConnectionEditor` to `export function ConnectionEditor`; nothing else).
- Test: `src/app/dashboard/financial/delivery-notes/document/[id]/delivery-review.test.tsx`
**Interfaces:**
- Consumes: `buildReviewModel`, `ReviewPoItem`, `ReviewCard` (Task 2); `ConnectionEditor`, `MatchingChoice` from `./delivery-matching`; `LineConfirmation` type from `@/lib/dn-pipeline/line-booking`.
- Produces: `export function DeliveryReview(props:{preview:ProcessingPreview;stale:boolean;choices:MatchingChoice[];onSave?:(index:number,c:LineConfirmation,remember?:boolean)=>Promise<void>;onConfirm?:()=>Promise<void>;busy?:boolean})`. Same props as `DeliveryMatchingResult`, so the two can be swapped.
**Layout** (see the approved mockups `slip-review-v3/v4.html`), top to bottom:
1. Any `model.blockers` and the stale message: plain bordered `<p role="status">`.
2. **Green fold:** `<details>` with summary `✓ {green.count} {line|lines} sure · {money(green.net)}`. It's open when `model.amber===0`. Inside, each green card.
3. **Amber cards** (always visible), then posted cards (greyed, labelled "Already received").
4. **Card:**
- a `<div>` with a rounded border: `border-green-300` for green, `border-2 border-amber-500` for amber, `opacity-60` for posted;
- one blue row per slip line: description in bold; `read {qty} {unit}` plus ` ✍` if handwritten, or `NIL`; ` · {money(unitPrice)} each` if a price exists; on the right, `{money(amount)} on slip` if present;
- one purple row for the PO line: `PO · {description}`; `ordered {ordered} {unit} @ {money(rate)} = {money(lineValue)} · received {receivedBefore} → {after}`; on the right, `{booked.quantity} {booked.unit} · {money(booked.net)}`. For `included` and `po_level` cards without a PO line, show `Included with this delivery` / `Booked to this PO without a product line`;
- a note line: green `✓ {working}`, or amber `{decision}`.
5. **Amber fix buttons** (all `size="sm"`, wrapping):
- `Book at PO rate`, when `card.priceProblem && card.po && card.booked.quantity!==null`. Calls `onSave(index,{confirmed:true,poItemId:card.po.id,quantity:card.booked.quantity,unit:card.booked.unit||card.po.unit,usePoRate:true,acceptExcess:false,reason:`Booked at the PO rate; ${card.decision}`})`.
- `Not delivered`. Calls `onSave(index,{confirmed:true,disposition:'not_delivered',reason:'Nothing delivered for this line'})`.
- `Fix this line`. Toggles `<ConnectionEditor>` inline under the card, with that allocation (same props as `DeliveryMatchingResult` uses: `preview`, `allocation`, `choices`, `disabled`, `onSave`).
6. **Totals:**
- blue row: `This delivery note · booking` | `{money(booking.net)} excl VAT · VAT {money(booking.vat)} · incl {money(booking.gross)}`;
- purple row: `PO · ordered {money(po.ordered)} · received before {money(po.before)}` | `after this {money(po.after)} ({afterPct}%)`. Use `—` for null values.
7. **Buttons:** `Post delivery · {money(booking.net)}`, primary, full width on a phone. Disabled unless `onConfirm && model.readyToPost && !stale && !busy`. Next to it, when amber: `{amber} {decision|decisions} left`.
- [ ] **Step 1: Write the failing tests** (static markup, like `delivery-matching.test.tsx`)
```tsx
// delivery-review.test.tsx
import {it,expect} from 'vitest';
import {renderToStaticMarkup} from 'react-dom/server';
import {DeliveryReview} from './delivery-review';
import {context} from '@/lib/dn-pipeline/preview-fixture';
import {buildPreview} from '@/lib/dn-pipeline/ai-preview';
import {applyPlan} from '@/lib/dn-pipeline/matching';
const choices=[{id:'itemabcdefghijk',description:'1050 pipe',unit:'m',rate:10,discount:0,ordered:70,received:5,vatPercent:15}];
const pipe={confirmed:true as const,poItemId:'itemabcdefghijk',quantity:27.5,unit:'m',usePoRate:true,reason:'11 pipes x 2.5 m'};
const ready=()=>{const c=context();return applyPlan(c,buildPreview(c,{allocations:[]}),{entries:[{sourceLineIds:['pipe'],confirmation:pipe},{sourceLineIds:['ring'],confirmation:{confirmed:true,disposition:'not_delivered',reason:'free'}}],closeNil:false}).preview;};
it('shows sure lines folded, the slip line over its PO line, totals, and an enabled Post',()=>{
const html=renderToStaticMarkup(<DeliveryReview preview={ready()} stale={false} choices={choices} onSave={async()=>{}} onConfirm={async()=>{}}/>);
expect(html).toContain('2 lines sure');
expect(html).toContain('1050 x 2.5m pipe');expect(html).toContain('PO · 1050 pipe');
expect(html).toContain('per 2,5 m length');
expect(html).toContain('This delivery note · booking');
expect(html).toMatch(/<button(?![^>]*disabled)[^>]*>Post delivery · R275,00/);
});
it('shows an amber decision with its fix buttons and disables Post',()=>{
const c=context();const raw=buildPreview(c,{allocations:[]});
const html=renderToStaticMarkup(<DeliveryReview preview={raw} stale={false} choices={choices} onSave={async()=>{}} onConfirm={async()=>{}}/>);
expect(html).toContain('How should this delivery line be booked?');
expect(html).toContain('Fix this line');expect(html).toContain('Not delivered');
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Post delivery/);
expect(html).toMatch(/\d+ decisions? left/);
});
it('offers Book at PO rate only for a price problem',()=>{
const c=context();c.lines[0]={...c.lines[0],unit_price:43};
const raw=buildPreview(c,{allocations:[{sourceLineIds:['pipe'],purchaseOrderItemId:'itemabcdefghijk',quantityBasis:'converted',factor:2.5,amountBasis:'po_rate',sourceAmountBasis:'unknown',ruleKeys:[],explanation:'Pipe',reviewQuestion:null}]});
const html=renderToStaticMarkup(<DeliveryReview preview={raw} stale={false} choices={choices} onSave={async()=>{}} onConfirm={async()=>{}}/>);
expect(html).toContain("Price doesn&#x27;t add up");expect(html).toContain('Book at PO rate');
});
it('never enables Post on a stale match',()=>{
const html=renderToStaticMarkup(<DeliveryReview preview={ready()} stale choices={choices} onConfirm={async()=>{}}/>);
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>Post delivery/);
});
```
- [ ] **Step 2: Run them and see them fail.** `npx vitest run "src/app/dashboard/financial/delivery-notes/document/[id]/delivery-review.test.tsx"` fails with module not found.
- [ ] **Step 3: Implement `delivery-review.tsx`**
```tsx
"use client";
import * as React from 'react';
import {Button} from '@/components/ui/button';
import {formatNumber} from '@/lib/utils';
import {buildReviewModel,type ReviewCard} from '@/lib/dn-pipeline/review-model';
import {ConnectionEditor,type MatchingChoice} from './delivery-matching';
import type {ProcessingPreview} from '@/lib/dn-pipeline/ai-preview';
import type {LineConfirmation} from '@/lib/dn-pipeline/line-booking';
const money=(n:number|null)=>n===null?'—':`R${formatNumber(n,{minimumFractionDigits:2,maximumFractionDigits:2})}`;
const qty=(n:number|null)=>n===null?'?':formatNumber(n,{maximumFractionDigits:4});
const BLUE='flex flex-wrap justify-between gap-x-3 gap-y-1 border-l-4 border-blue-500 bg-blue-50 px-3 py-2 dark:bg-blue-950/40';
const PURPLE='flex flex-wrap justify-between gap-x-3 gap-y-1 border-l-4 border-violet-500 bg-violet-50 px-3 py-2 dark:bg-violet-950/40';
type Props={preview:ProcessingPreview;stale:boolean;choices:MatchingChoice[];onSave?:(index:number,c:LineConfirmation,remember?:boolean)=>Promise<void>;onConfirm?:()=>Promise<void>;busy?:boolean};
/** The clerk's view of one delivery note: what is booked against which PO line; only lines that need a decision stand out. */
export function DeliveryReview({preview,stale,choices,onSave,onConfirm,busy=false}:Props){
const model=buildReviewModel(preview,choices);
const [editing,setEditing]=React.useState<number|null>(null);
const disabled=stale||busy||!onSave;
const greens=model.cards.filter(c=>c.colour==='green'),ambers=model.cards.filter(c=>c.colour==='amber'),posted=model.cards.filter(c=>c.colour==='posted');
const view=(c:ReviewCard)=><ReviewCardView key={c.key} card={c} disabled={disabled} editing={editing===c.index}
onEdit={()=>setEditing(editing===c.index?null:c.index)}
onSave={async conf=>{await onSave?.(c.index,conf);setEditing(null);}}
editor={editing===c.index&&onSave?<ConnectionEditor preview={preview} allocation={preview.allocations[c.index]} choices={choices} disabled={disabled} onSave={async(conf,remember)=>{await onSave(c.index,conf,remember);setEditing(null);}}/>:null}/>;
return <section aria-label="Delivery review" className="space-y-3 text-sm">
{stale&&<p role="status" className="rounded-lg border p-3">The order or delivery changed. Refreshing the match…</p>}
{model.blockers.map(b=><p key={b} role="status" className="rounded-lg border p-3">{b}</p>)}
{greens.length>0&&<details className="rounded-lg border border-green-300" open={model.amber===0}>
<summary className="cursor-pointer p-3 font-medium text-green-700 dark:text-green-400">✓ {greens.length} {greens.length===1?'line':'lines'} sure · {money(model.green.net)}</summary>
<div className="space-y-2 p-2">{greens.map(view)}</div>
</details>}
{ambers.map(view)}
{posted.length>0&&<div className="space-y-2"><p className="text-xs text-muted-foreground">Already received</p>{posted.map(view)}</div>}
<div className="overflow-hidden rounded-lg border">
<div className={BLUE}><span>This delivery note · booking</span><span className="font-semibold tabular-nums">{money(model.booking.net)} excl VAT · VAT {money(model.booking.vat)} · incl {money(model.booking.gross)}</span></div>
<div className={PURPLE}><span>PO · ordered {money(model.po.ordered)} · received before {money(model.po.before)}</span><span className="font-semibold tabular-nums">after this {money(model.po.after)}{model.po.afterPct!==null&&` (${model.po.afterPct}%)`}</span></div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
{model.amber>0&&<span className="text-amber-700 dark:text-amber-300">{model.amber} {model.amber===1?'decision':'decisions'} left</span>}
{onConfirm&&<Button size="lg" className="w-full sm:w-auto" disabled={!model.readyToPost||stale||busy} onClick={onConfirm}>{busy?'Saving…':`Post delivery · ${money(model.booking.net)}`}</Button>}
</div>
</section>;
}
function ReviewCardView({card,disabled,editing,onEdit,onSave,editor}:{card:ReviewCard;disabled:boolean;editing:boolean;onEdit:()=>void;onSave:(c:LineConfirmation)=>Promise<void>;editor:React.ReactNode}){
const frame=card.colour==='amber'?'border-2 border-amber-500':card.colour==='green'?'border border-green-300':'border opacity-60';
return <div className={`overflow-hidden rounded-lg ${frame}`}>
{card.slip.map(l=><div key={l.sourceLineId} className={BLUE}>
<span><strong>{l.description||'Description unclear — use the photo'}</strong> · {l.nil?'NIL':<>read {qty(l.qty)} {l.unit}{l.handwritten&&' ✍'}</>}{l.unitPrice!==null&&` · ${money(l.unitPrice)} each`}</span>
{l.amount!==null&&<span className="text-xs text-muted-foreground tabular-nums">{money(l.amount)} on slip</span>}
</div>)}
<div className={PURPLE}>
<span>{card.po?<>PO · {card.po.description} <span className="text-xs text-muted-foreground">· ordered {qty(card.po.ordered)} {card.po.unit} @ {money(card.po.rate)} = {money(card.po.lineValue)} · received {qty(card.po.receivedBefore)} → {qty(card.po.after)}</span></>:card.kind==='included'?'Included with this delivery':card.kind==='nil'?'Not delivered':'Booked to this PO without a product line'}</span>
{card.kind!=='nil'&&<span className="font-semibold tabular-nums">{card.booked.quantity!==null?`${qty(card.booked.quantity)} ${card.booked.unit} · `:''}{money(card.booked.net)}</span>}
</div>
{card.working&&<p className="px-3 py-1 text-xs text-green-700 dark:text-green-400">✓ {card.working}</p>}
{card.decision&&<p className="px-3 py-1 text-xs text-amber-700 dark:text-amber-300">{card.decision}</p>}
{card.colour==='amber'&&<div className="flex flex-wrap gap-2 px-3 pb-3">
{card.priceProblem&&card.po&&card.booked.quantity!==null&&<Button size="sm" disabled={disabled} onClick={()=>onSave({confirmed:true,poItemId:card.po!.id,quantity:card.booked.quantity!,unit:card.booked.unit||card.po!.unit,usePoRate:true,acceptExcess:false,reason:`Booked at the PO rate; ${card.decision}`})}>Book at PO rate</Button>}
<Button size="sm" variant="outline" disabled={disabled} onClick={onEdit}>{editing?'Close':'Fix this line'}</Button>
<Button size="sm" variant="outline" disabled={disabled} onClick={()=>onSave({confirmed:true,disposition:'not_delivered',reason:'Nothing delivered for this line'})}>Not delivered</Button>
</div>}
{editor&&<div className="border-t p-3">{editor}</div>}
</div>;
}
```
- [ ] **Step 4: Run the tests until they pass.** Also re-run `delivery-matching.test.tsx` to confirm the export change broke nothing.
- [ ] **Step 5: Commit**
```bash
git add "src/app/dashboard/financial/delivery-notes/document/[id]/delivery-review.tsx" "src/app/dashboard/financial/delivery-notes/document/[id]/delivery-review.test.tsx" "src/app/dashboard/financial/delivery-notes/document/[id]/delivery-matching.tsx"
git commit -m "feat(dn): review cards — slip line over its PO line, green/amber, totals vs PO, Post when all green"
```
---
### Task 4: New/Classic switch
**Files:**
- Create: `src/app/dashboard/financial/delivery-notes/document/[id]/review-view.ts` (pure helper)
- Modify: `src/app/dashboard/financial/delivery-notes/document/[id]/processing-preview.tsx`
- Modify: `src/app/dashboard/financial/delivery-notes/document/[id]/document-detail-view.tsx` (pass the prop through; one prop and one usage)
- Modify: `src/app/dashboard/financial/delivery-notes/document/[id]/page.tsx` (read the env default)
- Test: `src/app/dashboard/financial/delivery-notes/document/[id]/review-view.test.ts`
**Interfaces:**
- Produces:
- `export type ReviewView='new'|'classic'`
- `export const REVIEW_VIEW_KEY='dn-review-view'`
- `export function resolveReviewView(stored:string|null|undefined,fallback:ReviewView):ReviewView` (a stored `'new'` or `'classic'` wins; anything else → fallback)
- `export function defaultReviewView(env:string|undefined):ReviewView` (`'classic'` only when env is exactly `classic`, otherwise `'new'`).
Behaviour:
- `page.tsx` reads `process.env.DN_REVIEW_DEFAULT_VIEW` through `defaultReviewView` and passes `reviewView` to `DocumentDetailView`, which passes it to `ProcessingPreviewPanel` as `defaultView`.
- In `ProcessingPreviewPanel`: `const [view,setView]=React.useState<ReviewView>(defaultView)`. In a `useEffect` (after mount, so the server markup matches), `setView(resolveReviewView(localStorage.getItem(REVIEW_VIEW_KEY),defaultView))`.
- A small text button in the card header: `Classic view` / `New view`. It sets state and `localStorage.setItem(REVIEW_VIEW_KEY,next)`.
- Render `view==='new' ? <DeliveryReview …/> : <DeliveryMatchingResult …/>` with the same props.
- The default is New. Setting `DN_REVIEW_DEFAULT_VIEW=classic` in Coolify (restart) puts everyone back on the classic page; each person can still switch for themselves.
- [ ] **Step 1: Failing tests for the helper**
```ts
import {expect,it} from 'vitest';
import {resolveReviewView,defaultReviewView} from './review-view';
it('uses the stored choice, else the default',()=>{
expect(resolveReviewView('classic','new')).toBe('classic');
expect(resolveReviewView('new','classic')).toBe('new');
expect(resolveReviewView('weird','classic')).toBe('classic');
expect(resolveReviewView(null,'new')).toBe('new');
});
it('defaults to the new view unless the environment says classic',()=>{
expect(defaultReviewView(undefined)).toBe('new');
expect(defaultReviewView('classic')).toBe('classic');
expect(defaultReviewView('CLASSIC ')).toBe('new');
});
```
- [ ] **Step 2: Run and see them fail.**
- [ ] **Step 3: Implement the helper and wire the panel, detail view and page** as described. `review-view.ts` must not use `localStorage` itself, so it stays pure.
- [ ] **Step 4: Run** the helper tests, `processing-preview.test.tsx`, `delivery-matching.test.tsx` and `delivery-review.test.tsx`, then tsc.
- [ ] **Step 5: Commit**
```bash
git add "src/app/dashboard/financial/delivery-notes/document/[id]/review-view.ts" "src/app/dashboard/financial/delivery-notes/document/[id]/review-view.test.ts" "src/app/dashboard/financial/delivery-notes/document/[id]/processing-preview.tsx" "src/app/dashboard/financial/delivery-notes/document/[id]/document-detail-view.tsx" "src/app/dashboard/financial/delivery-notes/document/[id]/page.tsx"
git commit -m "feat(dn): New/Classic switch for the delivery review (DN_REVIEW_DEFAULT_VIEW, per-browser override)"
```
---
### Task 5: Refresh instead of re-match
When another slip on the same PO is booked, today's page marks this slip "out of date". The only button left is "Try matching again", a full AI re-match that throws away the clerk's saved decisions. Since 9 Oct, `refreshPreview` in `src/lib/dn-pipeline/matching.ts` rebuilds the match and re-applies saved decisions without AI. Wire it up.
**Files:**
- Modify: `src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.ts` (new `action:'refresh'`)
- Modify: `src/app/dashboard/financial/delivery-notes/document/[id]/processing-preview.tsx` (call it automatically)
- Test: `src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.test.ts` (extend)
**Interfaces:**
- Consumes: `refreshPreview(context,stored)` from `@/lib/dn-pipeline/matching`; `deliveryReady`; `decisionHash`.
- Produces: `POST …/matching` with body `{action:'refresh'}` → `200 {preview:{...refreshed,decisionHash}}`, or `409 {error:'lines_changed',detail:'The slip lines changed. Run matching again.'}`.
Route behaviour:
- Accept `'refresh'` alongside `'save'` and `'post'` in the action check.
- Inside the existing lease, before the snapshot and decision-hash check (refresh doesn't need client hashes), handle `refresh`:
1. load context and stored;
2. if `!stored` → 409 `preview_missing`;
3. compute `refreshPreview` (catch `/Slip lines changed/` → 409 `lines_changed`);
4. save `reconciliation_preview` and `status`/`stage` from `deliveryReady`, plus a `dn_review_events` row `{action:'refresh_preview',reason:'Re-applied saved decisions after the PO changed; no receipt posted.'}`;
5. return the preview with its `decisionHash`.
When it's not stale, return the stored preview unchanged and write nothing.
Client: in the initial `fetch(url)` effect, when `body.stale` is true, immediately `POST` `{action:'refresh'}` to the matching URL. On 200, use the returned preview and set `stale=false`. On 409 `lines_changed`, keep today's stale message and "Try matching again".
- [ ] **Step 1: Failing route test.** In `route.test.ts`, reuse the setup from the existing test "keeps saved decisions when another slip on the PO was booked in between". Build a saved preview with `applyPlan`, make the context stale with `c.priorItems=[…]`, and POST `{action:'refresh'}`. Expect:
- 200;
- `m.batch` called once, with the note update first (`data.reconciliation_preview.snapshotHash` ≠ the old one) and a `dn_review_events` create;
- `m.post` not called.
Second test: change a line's `sourceLineId` in the context → 409 and body `error:'lines_changed'`.
- [ ] **Step 2: Run and see them fail** (400 `invalid_action`).
- [ ] **Step 3: Implement** the route action and the client auto-refresh.
- [ ] **Step 4: Run** the route tests and the document-page tests, then tsc.
- [ ] **Step 5: Commit**
```bash
git add "src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.ts" "src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.test.ts" "src/app/dashboard/financial/delivery-notes/document/[id]/processing-preview.tsx"
git commit -m "fix(dn): an out-of-date match refreshes and keeps saved decisions instead of asking for a re-match"
```
---
### Task 6: Money on the inbox cards
**Files:**
- Modify: `src/app/dashboard/financial/delivery-notes/inbox/inbox-board.tsx` (`DocumentCard.money`; export and extend `DocumentTile`)
- Modify: `src/app/dashboard/financial/delivery-notes/inbox/page.tsx` (fill `money`)
- Test: `src/app/dashboard/financial/delivery-notes/inbox/document-tile.test.tsx`
**Interfaces:**
- Consumes: `previewMoney` (Task 2).
- Produces: `DocumentCard.money?: {net:number;afterPct:number|null}`, and `export function DocumentTile({card}:{card:DocumentCard})`.
Behaviour:
- `page.tsx` sets `money: previewMoney(note.reconciliation_preview as ProcessingPreview|null)` (type-only import) for every document card.
- `DocumentTile` shows, under the supplier line, when `card.money && card.money.net>0 && card.stage!=='posted'`: `{money(net)} excl VAT` and, if `afterPct!==null`, ` · PO {afterPct}% after this`. Style: `text-xs font-medium tabular-nums`.
- When `card.progress?.step==='ready_to_post'`, also show the badge `Check & post`.
- [ ] **Step 1: Failing test**
```tsx
import {it,expect,vi} from 'vitest';
vi.mock('next/navigation',()=>({useRouter:()=>({refresh(){}})}));
import {renderToStaticMarkup} from 'react-dom/server';
import {DocumentTile} from './inbox-board';
const card={id:'noteabcdefghijk',batchId:'batchabcdefghij',siteName:'The Hills',documentNumber:'IO019340',supplierName:'Avidan',purchaseOrderNumber:'MEGCPO9373',stage:'ready_to_post' as const,pageCount:1,quantity:0,unit:'',unresolvedLines:0,reason:'',money:{net:117569.2,afterPct:99.9}};
it('shows the booking value and the PO position on the card',()=>{
const html=renderToStaticMarkup(<DocumentTile card={card}/>);
expect(html).toContain('R117');expect(html).toContain('569,20 excl VAT');expect(html).toContain('PO 99.9% after this');
});
it('shows no money line when nothing is being booked',()=>{
expect(renderToStaticMarkup(<DocumentTile card={{...card,money:{net:0,afterPct:null}}}/>)).not.toContain('excl VAT');
});
```
(`99.9` is printed with a plain `toString` so the percentage reads `99.9%`. If you format it with `formatNumber`, the en-ZA output is `99,9%`. Either is acceptable, but match the test to what you choose.)
- [ ] **Step 2: Run and see it fail.**
- [ ] **Step 3: Implement** the type change, the export, the money line and the page wiring. Keep the inbox's existing columns and grouping unchanged.
- [ ] **Step 4: Run** the inbox tests (`inbox-board.test.ts`, the new tile test) and tsc.
- [ ] **Step 5: Commit**
```bash
git add src/app/dashboard/financial/delivery-notes/inbox/inbox-board.tsx src/app/dashboard/financial/delivery-notes/inbox/page.tsx src/app/dashboard/financial/delivery-notes/inbox/document-tile.test.tsx
git commit -m "feat(dn): inbox cards show the booking value and the PO position"
```
---
## Not in this phase (later phases of the spec)
- The colours here are worked out on the page from today's match. Phase 2 moves the checking step to the server (with the MiniMax review) and auto-prepares all-green slips. Until then, Classic Confirm can still post a price-mismatch line, as it does today.
- Highlighting a line on the photo needs positions from the reader (phase 4).
- Decisions for the site ("Something's wrong -> ask a question"), deletion flags and moving receipts as buttons (phase 5).