Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
"use client" ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
import { useState , useRef , useTransition , useMemo } from "react" ;
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
import { useRouter } from "next/navigation" ;
import {
Upload , FileText , ChevronDown , ChevronRight , Loader2 , TriangleAlert ,
2026-10-29 18:49:05 +00:00
Check , ShoppingCart , X , Plus , Trash2 , Search , Receipt , ChevronLeft , Pencil ,
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
} from "lucide-react" ;
import { ZAR , ZAR2 } from "@/lib/utils" ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
import { invoiceStatusOf , STATUS_STYLE } from "@/lib/purchase-invoice-status" ;
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
const ACCENT = "#30638E" ;
export type PurchaseLineView = {
id : string ;
line_no : number ;
material_code : string ;
cost_code : string ;
description : string ;
qty : number | null ;
rate : number | null ;
amount_excl : number ;
vat : number | null ;
total_incl : number | null ;
assetCode : string ;
assetName : string ;
} ;
export type PurchaseView = {
id : string ;
po_no : string ;
supplier : string ;
supplier_code : string ;
po_date : string ;
required_by : string ;
req_no : string ;
currency : string ;
subtotal : number ;
vat : number ;
total : number ;
source_file : string ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
invoices : PurchaseInvoiceView [ ] ;
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
lines : PurchaseLineView [ ] ;
} ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
export type PurchaseInvoiceView = {
id : string ;
invoice_no : string ;
invoice_date : string ;
amount_incl : number | null ;
note : string ;
hasPdf : boolean ;
} ;
export function PurchaseIntake ( { purchases : allPurchases } : { purchases : PurchaseView [ ] } ) {
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
const router = useRouter ( ) ;
const inputRef = useRef < HTMLInputElement > ( null ) ;
const [ pending , startTransition ] = useTransition ( ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const [ notice , setNotice ] = useState < string | null > ( null ) ;
const [ open , setOpen ] = useState < string | null > ( null ) ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
const [ invoiceModal , setInvoiceModal ] = useState ( false ) ;
const [ range , setRange ] = useState < "all" | "week" | "month" > ( "all" ) ;
// 0 = the current week/month, -1 = the one before, …
const [ offset , setOffset ] = useState ( 0 ) ;
const [ groupBySupplier , setGroupBySupplier ] = useState ( true ) ;
const [ dragging , setDragging ] = useState ( false ) ;
2026-10-29 18:54:34 +00:00
// Deleting and correcting are hidden until this is switched on, and it resets on reload.
const [ editMode , setEditMode ] = useState ( false ) ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
// Drop a PO anywhere on the page. Not while the invoice window is open: that has its own
// drop area, and an invoice PDF must never be read as a purchase order.
const dropProps = {
onDragOver : ( e : React.DragEvent ) = > {
if ( invoiceModal || busy || ! e . dataTransfer . types . includes ( "Files" ) ) return ;
e . preventDefault ( ) ;
setDragging ( true ) ;
} ,
onDragLeave : ( e : React.DragEvent ) = > {
if ( ! e . currentTarget . contains ( e . relatedTarget as Node ) ) setDragging ( false ) ;
} ,
onDrop : ( e : React.DragEvent ) = > {
if ( invoiceModal || busy ) return ;
e . preventDefault ( ) ;
setDragging ( false ) ;
upload ( e . dataTransfer . files ) ;
} ,
} ;
const win = useMemo ( ( ) = > ( range === "all" ? null : rangeWindow ( range , offset ) ) , [ range , offset ] ) ;
// A PO with no readable date cannot be placed in a week or month, so a range hides it —
// and says so, rather than letting it quietly vanish.
const purchases = useMemo (
( ) = > ( win ? allPurchases . filter ( ( p ) = > /^\d{4}-\d{2}-\d{2}/ . test ( p . po_date ) && p . po_date . slice ( 0 , 10 ) >= win . from && p . po_date . slice ( 0 , 10 ) <= win . to ) : allPurchases ) ,
[ allPurchases , win ] ,
) ;
const undated = win ? allPurchases . filter ( ( p ) = > ! /^\d{4}-\d{2}-\d{2}/ . test ( p . po_date ) ) . length : 0 ;
const groups = useMemo ( ( ) = > {
if ( ! groupBySupplier ) return [ { key : "" , name : "" , items : purchases } ] ;
const m = new Map < string , PurchaseView [ ] > ( ) ;
for ( const p of purchases ) m . set ( p . supplier || "No supplier" , [ . . . ( m . get ( p . supplier || "No supplier" ) ? ? [ ] ) , p ] ) ;
return [ . . . m . entries ( ) ]
. sort ( ( a , b ) = > a [ 0 ] . localeCompare ( b [ 0 ] ) )
. map ( ( [ name , items ] ) = > ( { key : name , name , items } ) ) ;
} , [ purchases , groupBySupplier ] ) ;
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
async function upload ( files : FileList | null ) {
if ( ! files || ! files . length ) return ;
setBusy ( true ) ;
setError ( null ) ;
setNotice ( null ) ;
try {
for ( const f of Array . from ( files ) ) {
const fd = new FormData ( ) ;
fd . append ( "file" , f ) ;
const res = await fetch ( "/api/purchases/intake" , { method : "POST" , body : fd } ) ;
const body = await res . json ( ) ;
if ( ! body . ok ) {
setError ( ` ${ f . name } : ${ body . error } ` ) ;
break ;
}
// A line that found no machine is money that exists but is not yet on one.
// Named, not buried — that is the whole point of reading the cost code.
setNotice (
body . unallocated ? . length
? ` ${ body . purchase . po_no } : ${ body . lines } line(s) imported, but no machine matched ${ body . unallocated . join ( ", " ) } — left unallocated. `
: ` ${ body . purchase . po_no } : ${ body . lines } line(s) imported and allocated. ` ,
) ;
}
startTransition ( ( ) = > router . refresh ( ) ) ;
} catch {
setError ( "Upload failed — check your connection" ) ;
} finally {
setBusy ( false ) ;
if ( inputRef . current ) inputRef . current . value = "" ;
}
}
const totalSpend = purchases . reduce ( ( s , p ) = > s + p . total , 0 ) ;
const unallocatedLines = purchases . reduce (
( s , p ) = > s + p . lines . filter ( ( l ) = > ! l . assetCode ) . length ,
0 ,
) ;
return (
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< div { ...dropProps } className = { ` relative rounded-2xl transition ${ dragging ? "ring-2 ring-dashed ring-[#30638E] ring-offset-4 bg-[#30638E]/5" : "" } ` } >
{ dragging && (
< div className = "pointer-events-none absolute inset-0 z-10 grid place-items-center" >
< div className = "rounded-xl bg-[#30638E] text-white text-sm font-medium px-5 py-3 shadow-lg inline-flex items-center gap-2" >
< Upload className = "w-4 h-4" / > Drop purchase order PDFs to upload
< / div >
< / div >
) }
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
< div className = "flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-5" >
< div className = "flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap" >
< span className = "px-2.5 py-1 rounded-md font-medium" style = { { background : ` ${ ACCENT } 1a ` , color : ACCENT } } >
{ purchases . length } purchase order { purchases . length !== 1 && "s" }
< / span >
< span className = "px-2.5 py-1 rounded-md bg-gray-100 text-gray-500 dark:bg-white/5 dark:text-gray-400 font-medium" >
{ ZAR ( totalSpend ) } total
< / span >
{ unallocatedLines > 0 && (
< span className = "px-2.5 py-1 rounded-md bg-[#EDAE49]/10 text-[#EDAE49] font-medium" >
{ unallocatedLines } line { unallocatedLines !== 1 && "s" } with no machine
< / span >
) }
< / div >
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< div className = "flex flex-wrap gap-2" >
< button
type = "button"
onClick = { ( ) = > setInvoiceModal ( true ) }
className = "min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 text-white"
style = { { background : BRAND_ORANGE } }
>
< Receipt className = "w-4 h-4" / > Upload invoice
< / button >
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
< input
ref = { inputRef }
type = "file"
accept = "application/pdf,.pdf"
multiple
// The input stays sr-only and the LABEL is the 44px control. It is still
// focusable, so `peer-focus-visible` puts the focus ring on the label —
// otherwise a keyboard user tabs onto an invisible 1px target.
className = "sr-only peer"
id = "po-upload"
onChange = { ( e ) = > upload ( e . target . files ) }
/ >
< label
htmlFor = "po-upload"
className = "min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 text-white cursor-pointer peer-focus-visible:ring-2 peer-focus-visible:ring-[#30638E] peer-focus-visible:ring-offset-2"
style = { { background : ACCENT } }
>
{ busy ? < Loader2 className = "w-4 h-4 animate-spin" / > : < Upload className = "w-4 h-4" / > }
{ busy ? "Reading…" : "Upload purchase orders" }
< / label >
< / div >
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
{ invoiceModal && < InvoiceUploadModal purchases = { allPurchases } onClose = { ( ) = > setInvoiceModal ( false ) } / > }
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
< / div >
{ error && (
< div className = "mb-4 flex items-start gap-2 rounded-2xl border border-[#D1495B]/30 bg-[#D1495B]/[0.06] p-4" >
< TriangleAlert className = "w-4 h-4 mt-0.5 shrink-0 text-[#D1495B] dark:text-[#D94F60]" / >
< div className = "text-xs text-gray-600 dark:text-gray-400" >
< span className = "font-semibold text-[#003D5B] dark:text-white" > Could not import < / span >
< div className = "mt-1" > { error } < / div >
< / div >
< button onClick = { ( ) = > setError ( null ) } aria-label = "Dismiss"
className = "ml-auto min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-white dark:hover:bg-white/5" >
< X className = "w-4 h-4" / >
< / button >
< / div >
) }
{ notice && (
< div className = "mb-4 flex items-start gap-2 rounded-2xl border border-[#00798C]/30 bg-[#00798C]/[0.06] p-4 text-xs text-gray-600 dark:text-gray-400" >
< Check className = "w-4 h-4 mt-0.5 shrink-0 text-[#00798C]" / >
< span > { notice } < / span >
< / div >
) }
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< div className = "flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-3 mb-4" >
< div className = "flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-border self-start" >
{ ( [ [ "all" , "All" ] , [ "week" , "Week" ] , [ "month" , "Month" ] ] as const ) . map ( ( [ id , label ] ) = > (
< button key = { id } onClick = { ( ) = > { setRange ( id ) ; setOffset ( 0 ) ; } } aria-pressed = { range === id }
className = { ` min-h-[44px] px-3 rounded-md text-xs font-medium transition ${
range === id ? "bg-[#003D5B] text-white dark:bg-[#00798C]" : "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
} ` }>
{ label }
< / button >
) ) }
< / div >
{ win && (
< div className = "flex items-center gap-1" >
< button onClick = { ( ) = > setOffset ( offset - 1 ) } aria-label = "Previous"
className = "min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5" > < ChevronLeft className = "w-4 h-4" / > < / button >
< span className = "text-xs font-medium text-[#003D5B] dark:text-white min-w-[150px] text-center" > { win . label } < / span >
< button onClick = { ( ) = > setOffset ( offset + 1 ) } aria-label = "Next" disabled = { offset >= 0 }
className = "min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 disabled:opacity-30" > < ChevronRight className = "w-4 h-4" / > < / button >
< / div >
) }
2026-10-29 18:54:34 +00:00
< button
onClick = { ( ) = > setEditMode ( ! editMode ) }
aria - pressed = { editMode }
className = { ` min-h-[44px] px-3 rounded-lg text-xs font-medium inline-flex items-center gap-1.5 border transition sm:ml-auto ${
editMode ? "bg-[#EDAE49] border-[#EDAE49] text-white" : "border-gray-200 dark:border-border text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5"
} ` }
>
< Pencil className = "w-4 h-4" / > { editMode ? "Editing — delete enabled" : "Edit mode" }
< / button >
< label className = "inline-flex items-center gap-2 min-h-[44px] text-xs text-gray-600 dark:text-gray-300 cursor-pointer" >
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< input type = "checkbox" checked = { groupBySupplier } onChange = { ( e ) = > setGroupBySupplier ( e . target . checked ) } className = "w-4 h-4" / >
Group by supplier
< / label >
< / div >
{ undated > 0 && (
< div className = "mb-3 text-xs text-[#8a6420] dark:text-[#EDAE49]" >
{ undated } purchase order { undated !== 1 && "s" } with no readable date { undated !== 1 ? "are" : "is" } hidden while a range is chosen .
< / div >
) }
{ allPurchases . length > 0 && purchases . length === 0 ? (
< section className = "bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-10 shadow-sm text-center text-sm text-gray-500 dark:text-gray-400" >
No purchase orders dated in this { range } .
< / section >
) : purchases . length === 0 ? (
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
< section className = "bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-12 shadow-sm text-center" >
< ShoppingCart className = "w-8 h-8 mx-auto mb-3 text-gray-300" / >
< div className = "text-sm text-gray-500 dark:text-gray-400" > No purchase orders yet . < / div >
< div className = "text-xs text-gray-400 dark:text-text-soft mt-1" >
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
Upload a PO PDF above , or drag it onto this page . Each line is allocated to a machine by its cost code .
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
< / div >
< / section >
) : (
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< div className = "space-y-5" >
{ groups . map ( ( g ) = > (
< div key = { g . key || "all" } >
{ groupBySupplier && (
< div className = "flex items-baseline justify-between gap-3 mb-2 px-1" >
< h3 className = "text-sm font-semibold text-[#003D5B] dark:text-white truncate" > { g . name } < / h3 >
< span className = "text-xs text-gray-500 dark:text-gray-400 shrink-0 tabular-nums" >
{ g . items . length } PO { g . items . length !== 1 && "s" } · { ZAR2 ( g . items . reduce ( ( t , x ) = > t + x . total , 0 ) ) }
< / span >
< / div >
) }
< div className = "space-y-2" >
{ g . items . map ( ( p ) = > {
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
const isOpen = open === p . id ;
const unalloc = p . lines . filter ( ( l ) = > ! l . assetCode ) . length ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
const inv = invoiceStatus ( p ) ;
const sty = STATUS_STYLE [ inv . status ] ;
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
return (
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< div key = { p . id } className = "relative bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm" >
< span
className = "absolute left-0 inset-y-0 w-1.5"
style = { { background : sty.color } }
title = { sty . label }
role = "img"
aria - label = { sty . label }
/ >
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
< button
onClick = { ( ) = > setOpen ( isOpen ? null : p . id ) }
aria - expanded = { isOpen }
className = "w-full flex items-center gap-3 sm:gap-4 p-4 text-left min-h-[44px]"
>
< FileText className = "w-4 h-4 shrink-0" style = { { color : ACCENT } } / >
< div className = "flex-1 min-w-0" >
< div className = "flex items-center gap-2 flex-wrap" >
< span className = "font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold" >
{ p . po_no }
< / span >
< span className = "text-sm font-medium text-[#003D5B] dark:text-white truncate" > { p . supplier } < / span >
{ unalloc > 0 && (
< TriangleAlert className = "w-3.5 h-3.5 text-[#EDAE49]" aria-label = "Some lines have no machine" / >
) }
< / div >
< div className = "text-xs text-gray-500 dark:text-gray-400 mt-1" >
{ p . po_date || "no date" } · { p . lines . length } line { p . lines . length !== 1 && "s" } · { " " }
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
{ p . lines . filter ( ( l ) = > l . assetCode ) . length } allocated · { " " }
< span style = { { color : sty.color } } className = "font-medium" >
2026-10-29 18:49:05 +00:00
{ inv . status === "over" ? ` invoiced ${ ZAR2 ( inv . invoiced - p . total ) } OVER the PO — check the amount ` : inv . status === "complete" ? "fully invoiced — can be closed" : inv . status === "partial" ? ` ${ ZAR2 ( inv . invoiced ) } of ${ ZAR2 ( p . total ) } invoiced ` : "no invoice yet" }
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< / span >
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
< / div >
< / div >
< div className = "text-right shrink-0" >
< div className = "text-[12px] text-gray-400 uppercase tracking-wider" > Total < / div >
< div className = "text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white" >
{ ZAR ( p . total ) }
< / div >
< / div >
{ isOpen ? < ChevronDown className = "w-4 h-4 text-gray-400 shrink-0" / > : < ChevronRight className = "w-4 h-4 text-gray-400 shrink-0" / > }
< / button >
{ isOpen && (
< div className = "border-t border-gray-100 dark:border-border px-4 py-4 bg-gray-50/50 dark:bg-white/[0.04]" >
< dl className = "grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2 text-xs mb-4" >
< Meta label = "Supplier" value = { p . supplier } / >
< Meta label = "Supplier code" value = { p . supplier_code || "—" } / >
< Meta label = "PO date" value = { p . po_date || "—" } / >
< Meta label = "Required by" value = { p . required_by || "—" } / >
< Meta label = "Requisition" value = { p . req_no || "—" } / >
< Meta label = "Currency" value = { p . currency || "—" } / >
< Meta label = "Sub-total" value = { ZAR2 ( p . subtotal ) } / >
< Meta label = "VAT" value = { ZAR2 ( p . vat ) } / >
< / dl >
2026-10-29 18:54:34 +00:00
< SupplierInvoices purchase = { p } editMode = { editMode } / >
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
< div className = "overflow-x-auto rounded-lg border border-gray-100 dark:border-border bg-white dark:bg-surface" >
< table className = "w-full text-xs" >
< thead >
< tr className = "text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-surface-muted" >
< th className = "text-left py-2 px-3 font-medium" > # < / th >
< th className = "text-left py-2 px-3 font-medium" > Description < / th >
< th className = "text-left py-2 px-3 font-medium" > Cost code < / th >
< th className = "text-left py-2 px-3 font-medium" > Machine < / th >
< th className = "text-right py-2 px-3 font-medium" > Amount < / th >
< th className = "text-right py-2 px-3 font-medium" > Incl VAT < / th >
< / tr >
< / thead >
< tbody >
{ p . lines . map ( ( l ) = > (
< tr key = { l . id } className = "border-t border-gray-100 dark:border-border" >
< td className = "py-2 px-3 text-gray-400" > { l . line_no } < / td >
< td className = "py-2 px-3 text-[#003D5B] dark:text-gray-300" >
{ l . description || < span className = "italic text-gray-400" > no description < / span > }
< / td >
< td className = "py-2 px-3 font-mono text-gray-500 dark:text-gray-400" > { l . cost_code || "—" } < / td >
< td className = "py-2 px-3" >
{ l . assetCode ? (
< span className = "inline-flex items-center gap-1.5 text-gray-700 dark:text-gray-300" >
< span className = "font-mono text-[12px] px-1.5 py-0.5 rounded bg-[#00798C]/10 text-[#00798C]" >
{ l . assetCode }
< / span >
< span className = "truncate" > { l . assetName } < / span >
< / span >
) : (
< span className = "text-[12px] text-[#EDAE49] italic" > no matching machine < / span >
) }
< / td >
< td className = "py-2 px-3 text-right tabular-nums text-gray-600 dark:text-gray-400" >
{ ZAR2 ( l . amount_excl ) }
< / td >
< td className = "py-2 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white" >
{ l . total_incl == null ? "—" : ZAR2 ( l . total_incl ) }
< / td >
< / tr >
) ) }
< / tbody >
< / table >
< / div >
< / div >
) }
< / div >
) ;
} ) }
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< / div >
< / div >
) ) }
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
< / div >
) }
< / div >
) ;
}
function Meta ( { label , value } : { label : string ; value : string } ) {
return (
< div >
< dt className = "text-gray-400 uppercase tracking-wider text-[12px]" > { label } < / dt >
< dd className = "text-gray-700 dark:text-gray-300 mt-0.5 break-words" > { value } < / dd >
< / div >
) ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
}
const inputCls =
"min-h-[44px] w-full rounded-lg border border-gray-200 dark:border-border bg-white dark:bg-surface px-3 text-sm text-[#003D5B] dark:text-white" ;
/ * *
* Supplier invoices received against one PO . A PO can have several . Typed in by a person
* with the PDF attached ; the "invoiced" figure sums only the amounts actually entered , and
* an invoice with no amount is listed but never counted as R0 .
* /
2026-10-29 18:54:34 +00:00
function SupplierInvoices ( { purchase : p , editMode } : { purchase : PurchaseView ; editMode : boolean } ) {
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
const router = useRouter ( ) ;
const [ adding , setAdding ] = useState ( false ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
2026-10-29 18:49:05 +00:00
const [ amt , setAmt ] = useState ( "" ) ;
const [ editId , setEditId ] = useState < string | null > ( null ) ;
const [ editAmt , setEditAmt ] = useState ( "" ) ;
const [ editReason , setEditReason ] = useState ( "" ) ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
const invoiced = p . invoices . reduce ( ( s , i ) = > s + ( i . amount_incl ? ? 0 ) , 0 ) ;
const unpriced = p . invoices . filter ( ( i ) = > i . amount_incl == null ) . length ;
const outstanding = p . total - invoiced ;
async function add ( e : React.FormEvent < HTMLFormElement > ) {
e . preventDefault ( ) ;
const form = e . currentTarget ;
setBusy ( true ) ;
setError ( null ) ;
try {
const res = await fetch ( ` /api/purchases/ ${ p . id } /invoices ` , { method : "POST" , body : new FormData ( form ) } ) ;
const body = await res . json ( ) ;
if ( ! body . ok ) {
setError ( body . error ) ;
return ;
}
2026-10-29 18:49:05 +00:00
if ( body . over > 0 ) setError ( ` Saved — but this takes the invoices ${ ZAR2 ( body . over ) } OVER the PO total. Check the amount. ` ) ;
else if ( body . warning ) setError ( body . warning ) ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
form . reset ( ) ;
2026-10-29 18:49:05 +00:00
setAmt ( "" ) ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
setAdding ( false ) ;
router . refresh ( ) ;
} catch {
setError ( "Could not save — check your connection" ) ;
} finally {
setBusy ( false ) ;
}
}
2026-10-29 18:49:05 +00:00
function startEdit ( i : PurchaseInvoiceView ) {
setEditId ( i . id ) ;
setEditAmt ( i . amount_incl == null ? "" : String ( i . amount_incl ) ) ;
setEditReason ( "" ) ;
setError ( null ) ;
}
async function saveEdit ( i : PurchaseInvoiceView ) {
setBusy ( true ) ;
setError ( null ) ;
try {
const res = await fetch ( ` /api/purchases/ ${ p . id } /invoices/ ${ i . id } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( { amount_incl : editAmt , reason : editReason } ) ,
} ) ;
const body = await res . json ( ) ;
if ( ! body . ok ) { setError ( body . error ) ; return ; }
setEditId ( null ) ;
router . refresh ( ) ;
} catch {
setError ( "Could not save — check your connection" ) ;
} finally {
setBusy ( false ) ;
}
}
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
async function remove ( i : PurchaseInvoiceView ) {
if ( ! confirm ( ` Remove invoice ${ i . invoice_no } from ${ p . po_no } ? ` ) ) return ;
const res = await fetch ( ` /api/purchases/ ${ p . id } /invoices/ ${ i . id } ` , { method : "DELETE" } ) ;
const body = await res . json ( ) . catch ( ( ) = > ( { ok : false , error : "Could not remove" } ) ) ;
if ( ! body . ok ) setError ( body . error ) ;
else router . refresh ( ) ;
}
return (
< div className = "mb-4 rounded-lg border border-gray-100 dark:border-border bg-white dark:bg-surface p-3" >
< div className = "flex flex-wrap items-center justify-between gap-2 mb-2" >
< div className = "text-xs font-semibold text-[#003D5B] dark:text-white" >
Supplier invoices
{ p . invoices . length > 0 && (
< span className = "ml-2 font-normal text-gray-500 dark:text-gray-400" >
2026-10-29 18:49:05 +00:00
{ ZAR2 ( invoiced ) } invoiced · { ZAR2 ( Math . abs ( outstanding ) ) } of PO { outstanding < 0 ? "over" : "not yet invoiced" }
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
{ unpriced > 0 && ` · ${ unpriced } with no amount ` }
< / span >
) }
< / div >
{ ! adding && (
< button
onClick = { ( ) = > setAdding ( true ) }
className = "min-h-[44px] px-3 rounded-lg text-xs font-medium text-white inline-flex items-center gap-1.5"
style = { { background : ACCENT } }
>
< Plus className = "w-4 h-4" / > Add invoice
< / button >
) }
< / div >
{ p . invoices . length === 0 && ! adding && (
< div className = "text-xs text-gray-400" > No invoices attached to this PO yet . < / div >
) }
{ p . invoices . length > 0 && (
< ul className = "divide-y divide-gray-100 dark:divide-border" >
2026-10-29 18:49:05 +00:00
{ p . invoices . map ( ( i ) = > editId === i . id ? (
< li key = { i . id } className = "py-2 space-y-2" >
< div className = "text-xs font-semibold text-[#003D5B] dark:text-white" > Correct invoice { i . invoice_no } < / div >
< div className = "grid grid-cols-1 sm:grid-cols-3 gap-2" >
< label className = "text-[12px] text-gray-500 dark:text-gray-400" >
Amount incl VAT ( PO is { ZAR2 ( p . total ) } )
< input value = { editAmt } onChange = { ( e ) = > setEditAmt ( e . target . value ) } inputMode = "decimal" className = { inputCls } autoFocus / >
< / label >
< label className = "text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2" >
Reason for the change ( kept in the log )
< input value = { editReason } onChange = { ( e ) = > setEditReason ( e . target . value ) } placeholder = "e.g. decimal point missed when captured" className = { inputCls } / >
< / label >
< / div >
< div className = "flex gap-2" >
< button onClick = { ( ) = > saveEdit ( i ) } disabled = { busy || editReason . trim ( ) . length < 3 }
className = "min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 disabled:opacity-50" style = { { background : ACCENT } } >
{ busy && < Loader2 className = "w-4 h-4 animate-spin" / > } Save correction
< / button >
< button onClick = { ( ) = > setEditId ( null ) } className = "min-h-[44px] px-4 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-50 dark:hover:bg-white/5" > Cancel < / button >
< / div >
< / li >
) : (
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< li key = { i . id } className = "flex items-center gap-3 py-1" >
< div className = "flex-1 min-w-0 text-xs" >
< span className = "font-mono font-semibold text-[#003D5B] dark:text-white" > { i . invoice_no } < / span >
< span className = "text-gray-500 dark:text-gray-400" >
{ " " } · { i . invoice_date || "no date" }
{ i . note && ` · ${ i . note } ` }
< / span >
< / div >
2026-10-29 18:49:05 +00:00
< div
2026-10-29 18:54:34 +00:00
onDoubleClick = { ( ) = > editMode && startEdit ( i ) }
title = { editMode ? "Double-click to correct this amount" : "Switch on Edit mode to correct this amount" }
2026-10-29 18:49:05 +00:00
className = "text-xs tabular-nums font-semibold text-[#003D5B] dark:text-white shrink-0 cursor-text select-none"
>
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
{ i . amount_incl == null ? < span className = "font-normal italic text-gray-400" > no amount < / span > : ZAR2 ( i . amount_incl ) }
< / div >
2026-10-29 18:54:34 +00:00
{ editMode && (
2026-10-29 18:49:05 +00:00
< button
onClick = { ( ) = > startEdit ( i ) }
aria - label = { ` Correct the amount of ${ i . invoice_no } ` }
className = "min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-400 hover:text-[#30638E] hover:bg-gray-50 dark:hover:bg-white/5"
>
< Pencil className = "w-4 h-4" / >
< / button >
2026-10-29 18:54:34 +00:00
) }
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
{ i . hasPdf ? (
< a
href = { ` /api/purchases/ ${ p . id } /invoices/ ${ i . id } /document ` }
target = "_blank"
rel = "noreferrer"
aria - label = { ` Open PDF for ${ i . invoice_no } ` }
className = "min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-[#30638E] hover:bg-gray-50 dark:hover:bg-white/5"
>
< FileText className = "w-4 h-4" / >
< / a >
) : (
< span className = "min-w-[44px] text-center text-[12px] text-gray-400" > no PDF < / span >
) }
2026-10-29 18:54:34 +00:00
{ editMode && (
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< button
onClick = { ( ) = > remove ( i ) }
aria - label = { ` Remove invoice ${ i . invoice_no } ` }
className = "min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-400 hover:text-[#D1495B] hover:bg-gray-50 dark:hover:bg-white/5"
>
< Trash2 className = "w-4 h-4" / >
< / button >
2026-10-29 18:54:34 +00:00
) }
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< / li >
) ) }
< / ul >
) }
{ error && < div className = "mt-2 text-xs text-[#D1495B] dark:text-[#D94F60]" > { error } < / div > }
{ adding && (
< form onSubmit = { add } className = "mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3" >
< label className = "text-[12px] text-gray-500 dark:text-gray-400" >
Invoice number *
< input name = "invoice_no" required className = { inputCls } / >
< / label >
< label className = "text-[12px] text-gray-500 dark:text-gray-400" >
Invoice date
< input name = "invoice_date" type = "date" className = { inputCls } / >
< / label >
< label className = "text-[12px] text-gray-500 dark:text-gray-400" >
Amount incl VAT
2026-10-29 18:49:05 +00:00
< input name = "amount_incl" value = { amt } onChange = { ( e ) = > setAmt ( e . target . value ) } inputMode = "decimal" placeholder = "0.00" className = { inputCls } / >
< OverWarning po = { p } typed = { amt } / >
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
< / label >
< label className = "text-[12px] text-gray-500 dark:text-gray-400" >
Note
< input name = "note" placeholder = "e.g. part delivery" className = { inputCls } / >
< / label >
< label className = "text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2" >
Invoice PDF
< input name = "file" type = "file" accept = "application/pdf,.pdf" className = { ` ${ inputCls } py-2 ` } / >
< / label >
< div className = "sm:col-span-2 flex gap-2" >
< button
type = "submit"
disabled = { busy }
className = "min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 disabled:opacity-60"
style = { { background : ACCENT } }
>
{ busy && < Loader2 className = "w-4 h-4 animate-spin" / > } Save invoice
< / button >
< button
type = "button"
onClick = { ( ) = > { setAdding ( false ) ; setError ( null ) ; } }
className = "min-h-[44px] px-4 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-50 dark:hover:bg-white/5"
>
Cancel
< / button >
< / div >
< / form >
) }
< / div >
) ;
}
function invoiceStatus ( p : PurchaseView ) {
return invoiceStatusOf ( p . total , p . invoices . map ( ( i ) = > i . amount_incl ) ) ;
}
const BRAND_ORANGE = "#EDAE49" ;
/ * *
* Global "Upload invoice" : find the PO by number , supplier , amount or line description ,
* then give the invoice number , date , amount and PDF ( drag and drop or browse ) .
* /
function InvoiceUploadModal ( { purchases , onClose } : { purchases : PurchaseView [ ] ; onClose : ( ) = > void } ) {
const router = useRouter ( ) ;
const fileRef = useRef < HTMLInputElement > ( null ) ;
const [ query , setQuery ] = useState ( "" ) ;
const [ poId , setPoId ] = useState ( "" ) ;
const [ file , setFile ] = useState < File | null > ( null ) ;
2026-10-29 18:49:05 +00:00
const [ amt , setAmt ] = useState ( "" ) ;
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
const [ drag , setDrag ] = useState ( false ) ;
const [ busy , setBusy ] = useState ( false ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const q = query . trim ( ) . toLowerCase ( ) . replace ( /[\s,]/g , "" ) ;
const matches = purchases
. filter ( ( p ) = > {
if ( ! q ) return true ;
const hay = [
p . po_no , p . supplier , p . supplier_code ,
p . total . toFixed ( 2 ) , String ( Math . round ( p . total ) ) ,
. . . p . lines . map ( ( l ) = > ` ${ l . description } ${ l . cost_code } ` ) ,
] . join ( " " ) . toLowerCase ( ) . replace ( /[\s,]/g , "" ) ;
return hay . includes ( q ) ;
} )
. slice ( 0 , 8 ) ;
const po = purchases . find ( ( p ) = > p . id === poId ) || null ;
const st = po ? invoiceStatus ( po ) : null ;
function pick ( f : File | undefined ) {
if ( ! f ) return ;
if ( ! f . name . toLowerCase ( ) . endsWith ( ".pdf" ) ) {
setError ( "Invoices must be PDF files" ) ;
return ;
}
setError ( null ) ;
setFile ( f ) ;
}
async function submit ( e : React.FormEvent < HTMLFormElement > ) {
e . preventDefault ( ) ;
if ( ! po ) {
setError ( "Choose the purchase order first" ) ;
return ;
}
const fd = new FormData ( e . currentTarget ) ;
if ( file ) fd . set ( "file" , file ) ;
else fd . delete ( "file" ) ;
setBusy ( true ) ;
setError ( null ) ;
try {
const res = await fetch ( ` /api/purchases/ ${ po . id } /invoices ` , { method : "POST" , body : fd } ) ;
const body = await res . json ( ) ;
if ( ! body . ok ) {
setError ( body . error ) ;
return ;
}
router . refresh ( ) ;
onClose ( ) ;
} catch {
setError ( "Could not save — check your connection" ) ;
} finally {
setBusy ( false ) ;
}
}
return (
< div className = "fixed inset-0 z-50 bg-black/40 grid place-items-center p-4 overflow-y-auto" onClick = { onClose } >
< form
onSubmit = { submit }
onClick = { ( e ) = > e . stopPropagation ( ) }
role = "dialog"
aria - label = "Upload invoice"
className = "w-full max-w-lg bg-white dark:bg-surface rounded-2xl p-5 shadow-xl space-y-3"
>
< div className = "flex items-center justify-between" >
< h2 className = "text-base font-semibold text-[#003D5B] dark:text-white" > Upload invoice < / h2 >
< button type = "button" onClick = { onClose } aria-label = "Close"
className = "min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-gray-50 dark:hover:bg-white/5" >
< X className = "w-4 h-4" / >
< / button >
< / div >
< div >
< label className = "text-[12px] text-gray-500 dark:text-gray-400" htmlFor = "inv-search" >
Find the purchase order — number , supplier , amount or description
< / label >
< div className = "relative" >
< Search className = "w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" / >
< input id = "inv-search" value = { query } onChange = { ( e ) = > { setQuery ( e . target . value ) ; setPoId ( "" ) ; } }
placeholder = "e.g. MEGPPO49676, tyres, 12500" autoComplete = "off"
className = { ` ${ inputCls } pl-9 ` } / >
< / div >
{ ! po && (
< ul className = "mt-1 max-h-56 overflow-y-auto rounded-lg border border-gray-100 dark:border-border divide-y divide-gray-100 dark:divide-border" >
{ matches . length === 0 && < li className = "p-3 text-xs text-gray-400" > No purchase order matches . < / li > }
{ matches . map ( ( p ) = > (
< li key = { p . id } >
< button type = "button" onClick = { ( ) = > setPoId ( p . id ) }
className = "w-full min-h-[44px] flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-white/5" >
< span className = "w-1 self-stretch rounded" style = { { background : STATUS_STYLE [ invoiceStatus ( p ) . status ] . color } } / >
< span className = "flex-1 min-w-0 text-xs" >
< span className = "font-mono font-semibold text-[#003D5B] dark:text-white" > { p . po_no } < / span >
< span className = "text-gray-500 dark:text-gray-400" > · { p . supplier } < / span >
< span className = "block truncate text-gray-400" > { p . lines [ 0 ] ? . description } < / span >
< / span >
< span className = "text-xs tabular-nums font-semibold shrink-0" > { ZAR2 ( p . total ) } < / span >
< / button >
< / li >
) ) }
< / ul >
) }
{ po && st && (
< div className = "mt-1 flex items-center gap-2 rounded-lg bg-gray-50 dark:bg-white/5 p-2 text-xs" >
< span className = "w-1 self-stretch rounded" style = { { background : STATUS_STYLE [ st . status ] . color } } / >
< span className = "flex-1 min-w-0" >
< span className = "font-mono font-semibold text-[#003D5B] dark:text-white" > { po . po_no } < / span >
< span className = "text-gray-500 dark:text-gray-400" > · { po . supplier } < / span >
< / span >
< button type = "button" onClick = { ( ) = > setPoId ( "" ) }
className = "min-h-[44px] px-2 text-[#30638E] font-medium" > Change < / button >
< / div >
) }
< / div >
< div className = "grid grid-cols-1 sm:grid-cols-2 gap-3" >
< label className = "text-[12px] text-gray-500 dark:text-gray-400" >
Invoice number *
< input name = "invoice_no" required className = { inputCls } / >
< / label >
< label className = "text-[12px] text-gray-500 dark:text-gray-400" >
Invoice date
< input name = "invoice_date" type = "date" className = { inputCls } / >
< / label >
< label className = "text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2" >
Amount incl VAT
2026-10-29 18:49:05 +00:00
< input name = "amount_incl" value = { amt } onChange = { ( e ) = > setAmt ( e . target . value ) } inputMode = "decimal" placeholder = "0.00" className = { inputCls } / >
{ po && < OverWarning po = { po } typed = { amt } / > }
Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:29:08 +00:00
{ po && st && (
< span className = "block mt-1 text-xs text-gray-500 dark:text-gray-400" >
PO value < span className = "font-semibold text-[#003D5B] dark:text-white tabular-nums" > { ZAR2 ( po . total ) } < / span >
{ " · " } already invoiced { ZAR2 ( st . invoiced ) }
{ " · " } left < span className = "font-semibold tabular-nums" > { ZAR2 ( Math . max ( po . total - st . invoiced , 0 ) ) } < / span >
< / span >
) }
< / label >
< / div >
< div
onDragOver = { ( e ) = > { e . preventDefault ( ) ; setDrag ( true ) ; } }
onDragLeave = { ( ) = > setDrag ( false ) }
onDrop = { ( e ) = > { e . preventDefault ( ) ; setDrag ( false ) ; pick ( e . dataTransfer . files [ 0 ] ) ; } }
className = { ` rounded-xl border-2 border-dashed p-4 text-center text-xs transition-colors ${
drag ? "border-[#EDAE49] bg-[#EDAE49]/10" : "border-gray-200 dark:border-border"
} ` }
>
< input ref = { fileRef } type = "file" accept = "application/pdf,.pdf" className = "sr-only"
onChange = { ( e ) = > pick ( e . target . files ? . [ 0 ] ) } / >
{ file ? (
< div className = "flex items-center justify-center gap-2 text-[#003D5B] dark:text-white" >
< FileText className = "w-4 h-4" / > { file . name }
< button type = "button" onClick = { ( ) = > setFile ( null ) } aria-label = "Remove file"
className = "min-h-[44px] min-w-[44px] grid place-items-center" > < X className = "w-4 h-4" / > < / button >
< / div >
) : (
< >
< Upload className = "w-5 h-5 mx-auto mb-1 text-gray-400" / >
< div className = "text-gray-500 dark:text-gray-400" > Drag the invoice PDF here , or < / div >
< button type = "button" onClick = { ( ) = > fileRef . current ? . click ( ) }
className = "mt-1 min-h-[44px] px-4 rounded-lg text-xs font-medium text-white"
style = { { background : BRAND_ORANGE } } >
Choose file
< / button >
< / >
) }
< / div >
{ error && < div className = "text-xs text-[#D1495B] dark:text-[#D94F60]" > { error } < / div > }
< div className = "flex gap-2 justify-end" >
< button type = "button" onClick = { onClose }
className = "min-h-[44px] px-4 rounded-lg text-xs font-medium text-gray-500 hover:bg-gray-50 dark:hover:bg-white/5" >
Cancel
< / button >
< button type = "submit" disabled = { busy || ! po }
className = "min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 disabled:opacity-50"
style = { { background : BRAND_ORANGE } } >
{ busy && < Loader2 className = "w-4 h-4 animate-spin" / > } Save invoice
< / button >
< / div >
< / form >
< / div >
) ;
}
const isoDay = ( d : Date ) = > ` ${ d . getFullYear ( ) } - ${ String ( d . getMonth ( ) + 1 ) . padStart ( 2 , "0" ) } - ${ String ( d . getDate ( ) ) . padStart ( 2 , "0" ) } ` ;
const fmtDay = ( d : Date ) = > d . toLocaleDateString ( "en-ZA" , { day : "numeric" , month : "short" } ) ;
/** The week (Monday– Sunday) or calendar month `offset` periods from the current one. */
function rangeWindow ( range : "week" | "month" , offset : number ) : { from : string ; to : string ; label : string } {
const now = new Date ( ) ;
if ( range === "month" ) {
const first = new Date ( now . getFullYear ( ) , now . getMonth ( ) + offset , 1 ) ;
const last = new Date ( first . getFullYear ( ) , first . getMonth ( ) + 1 , 0 ) ;
return { from : isoDay ( first ) , to : isoDay ( last ) , label : first.toLocaleDateString ( "en-ZA" , { month : "long" , year : "numeric" } ) } ;
}
const monday = new Date ( now . getFullYear ( ) , now . getMonth ( ) , now . getDate ( ) - ( ( now . getDay ( ) + 6 ) % 7 ) + offset * 7 ) ;
const sunday = new Date ( monday . getFullYear ( ) , monday . getMonth ( ) , monday . getDate ( ) + 6 ) ;
return { from : isoDay ( monday ) , to : isoDay ( sunday ) , label : ` ${ fmtDay ( monday ) } – ${ fmtDay ( sunday ) } ` } ;
}
2026-10-29 18:49:05 +00:00
/** Live check while typing an invoice amount: anything that takes the PO over its total is flagged before it is saved. */
function OverWarning ( { po , typed } : { po : PurchaseView ; typed : string } ) {
const n = Number ( typed . trim ( ) . replace ( /\s/g , "" ) . replace ( "," , "." ) ) ;
if ( ! typed . trim ( ) || ! Number . isFinite ( n ) ) return null ;
const over = po . invoices . reduce ( ( t , i ) = > t + ( i . amount_incl ? ? 0 ) , 0 ) + n - po . total ;
if ( over <= 1 ) return null ;
return (
< span className = "mt-1 flex items-start gap-1.5 text-xs font-medium text-[#8B5CF6]" >
< TriangleAlert className = "w-3.5 h-3.5 mt-0.5 shrink-0" / >
This is { ZAR2 ( over ) } MORE than the PO ( { ZAR2 ( po . total ) } ) . Check for a typo before saving .
< / span >
) ;
}