From 529028b8d1b9af8ee96088b009165dce87e5dfc5 Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 9 Oct 2026 19:59:39 +0200 Subject: [PATCH] =?UTF-8?q?docs(dn):=20phase=201=20plan=20=E2=80=94=20the?= =?UTF-8?q?=20delivery=20review=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-10-09-dn-review-page-phase-1.md | 669 ++++++++++++++++++ 1 file changed, 669 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-09-dn-review-page-phase-1.md diff --git a/docs/superpowers/plans/2026-10-09-dn-review-page-phase-1.md b/docs/superpowers/plans/2026-10-09-dn-review-page-phase-1.md new file mode 100644 index 0000000..17c57ad --- /dev/null +++ b/docs/superpowers/plans/2026-10-09-dn-review-page-phase-1.md @@ -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 `. `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;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 incl VAT = R excl = PO rate` | +| L | L = length in metres from the description (`/(\d+(?:[.,]\d+)?)\s*m\b/i`, last match, 0 < L ≤ 12) | `R per m length = R/m = PO rate` | +| L×1.15 | same L | `R per m length incl VAT = PO rate` | +| 1000 | — | `R per 1000 = PO rate` | +| 0.001 | — | `R 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;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 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|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 `; + - 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: `. If `!ok` and there **is** a confirmation → green, working `Decided: `. If `ok` → green, 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 `; + - `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|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;onConfirm?:()=>Promise;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 `

`. +2. **Green fold:** `

` 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 `
` 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 `` 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({}} 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(/]*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({}} 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(/]*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({}} onConfirm={async()=>{}}/>); + expect(html).toContain("Price doesn't add up");expect(html).toContain('Book at PO rate'); +}); +it('never enables Post on a stale match',()=>{ + const html=renderToStaticMarkup({}}/>); + expect(html).toMatch(/]*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;onConfirm?:()=>Promise;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(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)=>setEditing(editing===c.index?null:c.index)} + onSave={async conf=>{await onSave?.(c.index,conf);setEditing(null);}} + editor={editing===c.index&&onSave?{await onSave(c.index,conf,remember);setEditing(null);}}/>:null}/>; + return
+ {stale&&

The order or delivery changed. Refreshing the match…

} + {model.blockers.map(b=>

{b}

)} + {greens.length>0&&
+ ✓ {greens.length} {greens.length===1?'line':'lines'} sure · {money(model.green.net)} +
{greens.map(view)}
+
} + {ambers.map(view)} + {posted.length>0&&

Already received

{posted.map(view)}
} +
+
This delivery note · booking{money(model.booking.net)} excl VAT · VAT {money(model.booking.vat)} · incl {money(model.booking.gross)}
+
PO · ordered {money(model.po.ordered)} · received before {money(model.po.before)}after this {money(model.po.after)}{model.po.afterPct!==null&&` (${model.po.afterPct}%)`}
+
+
+ {model.amber>0&&{model.amber} {model.amber===1?'decision':'decisions'} left} + {onConfirm&&} +
+
; +} +function ReviewCardView({card,disabled,editing,onEdit,onSave,editor}:{card:ReviewCard;disabled:boolean;editing:boolean;onEdit:()=>void;onSave:(c:LineConfirmation)=>Promise;editor:React.ReactNode}){ + const frame=card.colour==='amber'?'border-2 border-amber-500':card.colour==='green'?'border border-green-300':'border opacity-60'; + return
+ {card.slip.map(l=>
+ {l.description||'Description unclear — use the photo'} · {l.nil?'NIL':<>read {qty(l.qty)} {l.unit}{l.handwritten&&' ✍'}}{l.unitPrice!==null&&` · ${money(l.unitPrice)} each`} + {l.amount!==null&&{money(l.amount)} on slip} +
)} +
+ {card.po?<>PO · {card.po.description} · 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)}:card.kind==='included'?'Included with this delivery':card.kind==='nil'?'Not delivered':'Booked to this PO without a product line'} + {card.kind!=='nil'&&{card.booked.quantity!==null?`${qty(card.booked.quantity)} ${card.booked.unit} · `:''}{money(card.booked.net)}} +
+ {card.working&&

✓ {card.working}

} + {card.decision&&

{card.decision}

} + {card.colour==='amber'&&
+ {card.priceProblem&&card.po&&card.booked.quantity!==null&&} + + +
} + {editor&&
{editor}
} +
; +} +``` + +- [ ] **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(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' ? : ` 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(); + 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()).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).