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>
This commit is contained in:
David 2026-10-29 20:29:08 +02:00
parent cfd54385b3
commit 5f86657849
15 changed files with 1602 additions and 18 deletions

View file

@ -0,0 +1,63 @@
import { Store } from "lucide-react";
import { serverAuth } from "@/lib/pb";
import { buildSupplierSpend } from "@/lib/supplier-spend";
import { Suppliers2View } from "@/components/Suppliers2View";
export const dynamic = "force-dynamic";
/**
* Suppliers 2 — the Income Overview treatment for suppliers: one card per company with a
* 12-month spend chart, this month's spend, and what is payable. Cash suppliers are hidden
* by default. Same data as /expenses/suppliers; nothing here is written.
*/
export default async function Suppliers2Page() {
const pb = await serverAuth();
const [suppliers, purchases, invoices, slips, payments] = await Promise.all([
pb.collection("suppliers").getFullList({ sort: "name" }),
pb.collection("purchases").getFullList({ fields: "id,po_no,po_date,total,supplier_ref" }),
pb.collection("purchase_invoices").getFullList({ fields: "purchase,invoice_no,invoice_date,amount_incl" }).catch(() => [] as any[]),
pb.collection("cash_float_entries").getFullList({ filter: 'kind = "expense"', fields: "amount,occurred_on,created,supplier_ref" }),
// Created by scripts/setup-supplier-payments-collection.mjs; absent on a fresh database.
pb.collection("supplier_payments").getFullList().catch(() => [] as any[]),
]);
const invByPo = new Map<string, any[]>();
for (const v of invoices) invByPo.set(v.purchase, [...(invByPo.get(v.purchase) ?? []), v]);
const now = new Date();
const rows = buildSupplierSpend({
year: now.getFullYear(),
month: now.getMonth() + 1,
suppliers: suppliers.map((s) => ({ id: s.id, name: String(s.name), code: String(s.code || ""), terms: String(s.terms || "") })),
purchases: purchases.map((p) => ({
id: p.id, po_no: String(p.po_no || ""), po_date: String(p.po_date || ""), total: Number(p.total || 0), supplier_ref: String(p.supplier_ref || ""),
invoices: (invByPo.get(p.id) ?? []).map((v) => ({
invoice_no: String(v.invoice_no || ""), invoice_date: String(v.invoice_date || ""),
amount_incl: typeof v.amount_incl === "number" && v.amount_incl > 0 ? v.amount_incl : null,
})),
})),
payments: payments.map((x) => ({
id: x.id, supplier_ref: String(x.supplier || ""), paid_on: String(x.paid_on || ""),
description: String(x.description || ""), amount: Number(x.amount || 0),
})),
slips: slips.map((s) => ({ amount: Number(s.amount || 0), at: String(s.occurred_on || s.created || ""), supplier_ref: String(s.supplier_ref || "") })),
});
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
<Suppliers2View
rows={rows}
year={now.getFullYear()}
month={now.getMonth() + 1}
title={
<div className="flex items-center gap-3">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#00798C]/15 grid place-items-center text-[#30638E]"><Store className="w-5 h-5" /></div>
<div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Suppliers 2</h1>
<p className="text-gray-500 dark:text-gray-400 text-sm">Spend and payables per supplier</p>
</div>
</div>
}
/>
</div>
);
}

View file

@ -79,6 +79,7 @@ const navItems: NavItem[] = [
// Vehicle Costs and Finance (Bastion) are hidden: Running Costs (Insurance, Financing) already // Vehicle Costs and Finance (Bastion) are hidden: Running Costs (Insurance, Financing) already
// covers them. The pages still exist at /expenses/vehicles and /expenses/finance. // covers them. The pages still exist at /expenses/vehicles and /expenses/finance.
{ href: "/expenses/suppliers", label: "Suppliers" }, { href: "/expenses/suppliers", label: "Suppliers" },
{ href: "/expenses/suppliers-2", label: "Suppliers 2" },
{ href: "/expenses/personnel", label: "Personnel" }, { href: "/expenses/personnel", label: "Personnel" },
{ href: "/expenses/subscriptions", label: "Subscriptions" }, { href: "/expenses/subscriptions", label: "Subscriptions" },
{ href: "/expenses/rent", label: "Rent & Utilities" }, { href: "/expenses/rent", label: "Rent & Utilities" },

View file

@ -22,11 +22,19 @@ export const dynamic = "force-dynamic";
*/ */
export default async function PurchasesPage() { export default async function PurchasesPage() {
const pb = await serverAuth(); const pb = await serverAuth();
const [assets, purchases, lines] = await Promise.all([ const [assets, purchases, lines, supplierInvoices] = await Promise.all([
getAssets(), getAssets(),
pb.collection("purchases").getFullList<Purchase>({ sort: "-created" }), pb.collection("purchases").getFullList<Purchase>({ sort: "-created" }),
pb.collection("purchase_lines").getFullList<PurchaseLine>({ sort: "line_no" }), pb.collection("purchase_lines").getFullList<PurchaseLine>({ sort: "line_no" }),
// Created by scripts/setup-purchase-invoices-collection.mjs; absent on a fresh database.
pb.collection("purchase_invoices").getFullList({ sort: "created" }).catch(() => [] as any[]),
]); ]);
const invoicesByPurchase = new Map<string, any[]>();
for (const i of supplierInvoices) {
const list = invoicesByPurchase.get(i.purchase) || [];
list.push(i);
invoicesByPurchase.set(i.purchase, list);
}
const byId = new Map(assets.map((a) => [a.id, a])); const byId = new Map(assets.map((a) => [a.id, a]));
const linesByPurchase = new Map<string, typeof lines>(); const linesByPurchase = new Map<string, typeof lines>();
@ -49,6 +57,15 @@ export default async function PurchasesPage() {
vat: p.vat || 0, vat: p.vat || 0,
total: p.total || 0, total: p.total || 0,
source_file: p.source_file || "", source_file: p.source_file || "",
invoices: (invoicesByPurchase.get(p.id) || []).map((i) => ({
id: i.id,
invoice_no: i.invoice_no,
invoice_date: i.invoice_date || "",
// null = nobody typed an amount; that is not R0.
amount_incl: typeof i.amount_incl === "number" && i.amount_incl > 0 ? i.amount_incl : null,
note: i.note || "",
hasPdf: !!i.source_pdf,
})),
lines: (linesByPurchase.get(p.id) || []).map((l) => { lines: (linesByPurchase.get(p.id) || []).map((l) => {
const a = l.asset ? byId.get(l.asset) : null; const a = l.asset ? byId.get(l.asset) : null;
return { return {

View file

@ -0,0 +1,33 @@
import { serverAuth } from "@/lib/pb";
export const dynamic = "force-dynamic";
// GET /api/purchases/:id/invoices/:invoiceId/document — proxies the invoice PDF, for the
// same reason /api/invoices/:id/document does: the collection is rule-locked.
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string; invoiceId: string }> },
) {
const { id, invoiceId } = await params;
try {
const pb = await serverAuth();
const inv = await pb.collection("purchase_invoices").getOne(invoiceId).catch(() => null);
if (!inv || inv.purchase !== id || !inv.source_pdf) {
return Response.json({ ok: false, error: "No document attached" }, { status: 404 });
}
const res = await fetch(pb.files.getURL(inv, inv.source_pdf), {
headers: pb.authStore.token ? { Authorization: pb.authStore.token } : undefined,
});
if (!res.ok) return Response.json({ ok: false, error: "Document could not be read from storage" }, { status: 502 });
return new Response(res.body, {
status: 200,
headers: {
"Content-Type": res.headers.get("Content-Type") ?? "application/pdf",
"Content-Disposition": `inline; filename="${String(inv.invoice_no).replace(/[^\w.-]/g, "_")}.pdf"`,
"Cache-Control": "private, max-age=0, must-revalidate",
},
});
} catch (e: any) {
return Response.json({ ok: false, error: e?.message || "Could not load document" }, { status: 500 });
}
}

View file

@ -0,0 +1,24 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
export const dynamic = "force-dynamic";
// DELETE /api/purchases/:id/invoices/:invoiceId — remove a supplier invoice from a PO.
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string; invoiceId: string }> },
) {
const { id, invoiceId } = await params;
try {
const pb = await serverAuth();
const inv = await pb.collection("purchase_invoices").getOne(invoiceId).catch(() => null);
if (!inv || inv.purchase !== id) {
return Response.json({ ok: false, error: "Invoice not found on that purchase order" }, { status: 404 });
}
await pb.collection("purchase_invoices").delete(invoiceId);
revalidatePath("/running/purchases");
return Response.json({ ok: true });
} catch (e: any) {
return Response.json({ ok: false, error: e?.message || "Could not delete the invoice" }, { status: 500 });
}
}

View file

@ -0,0 +1,81 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
export const dynamic = "force-dynamic";
// POST /api/purchases/:id/invoices
// multipart/form-data: invoice_no (required), invoice_date, amount_incl, note, file (PDF, optional)
//
// Records a supplier invoice against a purchase order. A PO can carry several. These are
// typed in, not parsed, and they never touch the PO's own figures — the PO says what was
// ordered, the invoices say what the supplier has billed so far.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let form: FormData;
try {
form = await req.formData();
} catch {
return Response.json({ ok: false, error: "Expected a multipart upload" }, { status: 400 });
}
const invoiceNo = String(form.get("invoice_no") || "").trim();
if (!invoiceNo) return Response.json({ ok: false, error: "Enter the invoice number" }, { status: 400 });
const amountRaw = String(form.get("amount_incl") || "").trim().replace(/\s/g, "").replace(",", ".");
let amount: number | null = null;
if (amountRaw) {
amount = Number(amountRaw);
if (!Number.isFinite(amount) || amount < 0) {
return Response.json({ ok: false, error: "The amount must be a positive number" }, { status: 400 });
}
}
const file = form.get("file");
const hasFile = file instanceof File && file.size > 0;
if (hasFile) {
if (!file.name.toLowerCase().endsWith(".pdf")) {
return Response.json({ ok: false, error: "Invoices must be PDF files" }, { status: 400 });
}
if (file.size > 20 * 1024 * 1024) {
return Response.json({ ok: false, error: "That PDF is larger than the 20 MB limit" }, { status: 400 });
}
}
try {
const pb = await serverAuth();
const purchase = await pb.collection("purchases").getOne(id).catch(() => null);
if (!purchase) return Response.json({ ok: false, error: "Purchase order not found" }, { status: 404 });
// The same supplier invoice booked twice would double the "invoiced" total.
const existing = await pb.collection("purchase_invoices").getFullList({ filter: `purchase = "${id}"` });
if (existing.some((i) => (i.invoice_no || "").toUpperCase() === invoiceNo.toUpperCase())) {
return Response.json({ ok: false, error: `${invoiceNo} is already on ${purchase.po_no}` }, { status: 409 });
}
const row = await pb.collection("purchase_invoices").create({
purchase: id,
invoice_no: invoiceNo,
invoice_date: String(form.get("invoice_date") || "").trim(),
...(amount != null ? { amount_incl: amount } : {}),
note: String(form.get("note") || "").trim(),
source_file: hasFile ? file.name : "",
});
// PDF last, so a storage failure cannot lose the row (same rule as the PO intake).
let warning: string | undefined;
if (hasFile) {
try {
await pb.collection("purchase_invoices").update(row.id, { source_pdf: file });
} catch (e) {
console.error(`[purchase-invoices] ${invoiceNo}: PDF not stored — ${(e as Error).message}`);
warning = "The invoice was saved but its PDF could not be attached.";
}
}
revalidatePath("/running/purchases");
return Response.json({ ok: true, warning, invoice: { id: row.id, invoice_no: invoiceNo } });
} catch (e: any) {
return Response.json({ ok: false, error: e?.message || "Could not save the invoice" }, { status: 500 });
}
}

View file

@ -0,0 +1,20 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { refuseUnlessFull } from "@/lib/require-full";
export const dynamic = "force-dynamic";
// DELETE /api/supplier-payments/:id — remove a payment entered by mistake.
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const refused = await refuseUnlessFull();
if (refused) return refused;
const { id } = await params;
try {
const pb = await serverAuth();
await pb.collection("supplier_payments").delete(id);
revalidatePath("/expenses/suppliers-2");
return Response.json({ ok: true });
} catch (e: any) {
return Response.json({ ok: false, error: e?.message || "Could not delete the payment" }, { status: 500 });
}
}

View file

@ -0,0 +1,48 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { refuseUnlessFull } from "@/lib/require-full";
import { isAccount } from "@/lib/supplier";
export const dynamic = "force-dynamic";
// POST /api/supplier-payments { supplier_id, paid_on (YYYY-MM-DD), description?, amount }
// Records a payment made to an ACCOUNT supplier. Cash suppliers are paid on the spot, so a
// payment against one is refused; so is a supplier whose terms are not set, because it is
// not known whether it is an account at all.
export async function POST(req: Request) {
const refused = await refuseUnlessFull();
if (refused) return refused;
let body: { supplier_id?: unknown; paid_on?: unknown; description?: unknown; amount?: unknown } = {};
try { body = await req.json(); } catch { return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); }
const supplierId = String(body.supplier_id ?? "");
const paidOn = String(body.paid_on ?? "").slice(0, 10);
const amount = Number(String(body.amount ?? "").replace(/\s/g, "").replace(",", "."));
if (!supplierId) return Response.json({ ok: false, error: "Choose the supplier." }, { status: 400 });
if (!/^\d{4}-\d{2}-\d{2}$/.test(paidOn) || Number.isNaN(new Date(`${paidOn}T00:00:00Z`).getTime())) {
return Response.json({ ok: false, error: "Enter the date paid." }, { status: 400 });
}
if (!Number.isFinite(amount) || amount <= 0) return Response.json({ ok: false, error: "Enter the amount paid." }, { status: 400 });
try {
const pb = await serverAuth();
const supplier = await pb.collection("suppliers").getOne(supplierId).catch(() => null);
if (!supplier) return Response.json({ ok: false, error: "Supplier not found." }, { status: 404 });
if (!isAccount(String(supplier.terms || ""))) {
return Response.json({
ok: false,
error: supplier.terms === "cash"
? `${supplier.name} is a cash supplier — payments are only tracked for accounts.`
: `${supplier.name} has no payment terms yet — set them to 30 or 60 days first.`,
}, { status: 400 });
}
const row = await pb.collection("supplier_payments").create({
supplier: supplierId, paid_on: paidOn, description: String(body.description ?? "").trim().slice(0, 300),
amount, source: "Manual",
});
revalidatePath("/expenses/suppliers-2");
return Response.json({ ok: true, id: row.id });
} catch (e: any) {
return Response.json({ ok: false, error: e?.message || "Could not save the payment" }, { status: 500 });
}
}

View file

@ -1,12 +1,13 @@
"use client"; "use client";
import { useState, useRef, useTransition } from "react"; import { useState, useRef, useTransition, useMemo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Upload, FileText, ChevronDown, ChevronRight, Loader2, TriangleAlert, Upload, FileText, ChevronDown, ChevronRight, Loader2, TriangleAlert,
Check, ShoppingCart, X, Check, ShoppingCart, X, Plus, Trash2, Search, Receipt, ChevronLeft,
} from "lucide-react"; } from "lucide-react";
import { ZAR, ZAR2 } from "@/lib/utils"; import { ZAR, ZAR2 } from "@/lib/utils";
import { invoiceStatusOf, STATUS_STYLE } from "@/lib/purchase-invoice-status";
const ACCENT = "#30638E"; const ACCENT = "#30638E";
@ -38,10 +39,20 @@ export type PurchaseView = {
vat: number; vat: number;
total: number; total: number;
source_file: string; source_file: string;
invoices: PurchaseInvoiceView[];
lines: PurchaseLineView[]; lines: PurchaseLineView[];
}; };
export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) { 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[] }) {
const router = useRouter(); const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@ -49,6 +60,47 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [open, setOpen] = useState<string | null>(null); const [open, setOpen] = useState<string | null>(null);
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);
// 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]);
async function upload(files: FileList | null) { async function upload(files: FileList | null) {
if (!files || !files.length) return; if (!files || !files.length) return;
@ -89,7 +141,14 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
); );
return ( return (
<div> <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>
)}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-5"> <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"> <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 }}> <span className="px-2.5 py-1 rounded-md font-medium" style={{ background: `${ACCENT}1a`, color: ACCENT }}>
@ -105,7 +164,15 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
)} )}
</div> </div>
<div> <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>
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
@ -127,6 +194,7 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
{busy ? "Reading…" : "Upload purchase orders"} {busy ? "Reading…" : "Upload purchase orders"}
</label> </label>
</div> </div>
{invoiceModal && <InvoiceUploadModal purchases={allPurchases} onClose={() => setInvoiceModal(false)} />}
</div> </div>
{error && ( {error && (
@ -149,21 +217,76 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
</div> </div>
)} )}
{purchases.length === 0 ? ( <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>
)}
<label className="inline-flex items-center gap-2 min-h-[44px] text-xs text-gray-600 dark:text-gray-300 cursor-pointer sm:ml-auto">
<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 ? (
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-12 shadow-sm text-center"> <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" /> <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-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"> <div className="text-xs text-gray-400 dark:text-text-soft mt-1">
Upload a PO PDF above. Each line is allocated to a machine by its cost code. Upload a PO PDF above, or drag it onto this page. Each line is allocated to a machine by its cost code.
</div> </div>
</section> </section>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-5">
{purchases.map((p) => { {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) => {
const isOpen = open === p.id; const isOpen = open === p.id;
const unalloc = p.lines.filter((l) => !l.assetCode).length; const unalloc = p.lines.filter((l) => !l.assetCode).length;
const inv = invoiceStatus(p);
const sty = STATUS_STYLE[inv.status];
return ( return (
<div key={p.id} className="bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm"> <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}
/>
<button <button
onClick={() => setOpen(isOpen ? null : p.id)} onClick={() => setOpen(isOpen ? null : p.id)}
aria-expanded={isOpen} aria-expanded={isOpen}
@ -182,7 +305,10 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <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"} ·{" "} {p.po_date || "no date"} · {p.lines.length} line{p.lines.length !== 1 && "s"} ·{" "}
{p.lines.filter((l) => l.assetCode).length} allocated {p.lines.filter((l) => l.assetCode).length} allocated ·{" "}
<span style={{ color: sty.color }} className="font-medium">
{inv.status === "complete" ? "fully invoiced — can be closed" : inv.status === "partial" ? `${ZAR2(inv.invoiced)} of ${ZAR2(p.total)} invoiced` : "no invoice yet"}
</span>
</div> </div>
</div> </div>
<div className="text-right shrink-0"> <div className="text-right shrink-0">
@ -207,6 +333,8 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
<Meta label="VAT" value={ZAR2(p.vat)} /> <Meta label="VAT" value={ZAR2(p.vat)} />
</dl> </dl>
<SupplierInvoices purchase={p} />
<div className="overflow-x-auto rounded-lg border border-gray-100 dark:border-border bg-white dark:bg-surface"> <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"> <table className="w-full text-xs">
<thead> <thead>
@ -255,6 +383,9 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
</div> </div>
); );
})} })}
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>
@ -268,4 +399,384 @@ function Meta({ label, value }: { label: string; value: string }) {
<dd className="text-gray-700 dark:text-gray-300 mt-0.5 break-words">{value}</dd> <dd className="text-gray-700 dark:text-gray-300 mt-0.5 break-words">{value}</dd>
</div> </div>
); );
} }
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.
*/
function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
const router = useRouter();
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
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;
}
if (body.warning) setError(body.warning);
form.reset();
setAdding(false);
router.refresh();
} catch {
setError("Could not save — check your connection");
} finally {
setBusy(false);
}
}
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">
{ZAR2(invoiced)} invoiced · {ZAR2(outstanding)} of PO {outstanding < 0 ? "over" : "not yet invoiced"}
{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">
{p.invoices.map((i) => (
<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>
<div className="text-xs tabular-nums font-semibold text-[#003D5B] dark:text-white shrink-0">
{i.amount_incl == null ? <span className="font-normal italic text-gray-400">no amount</span> : ZAR2(i.amount_incl)}
</div>
{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>
)}
<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>
</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
<input name="amount_incl" inputMode="decimal" placeholder="0.00" className={inputCls} />
</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);
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
<input name="amount_incl" inputMode="decimal" placeholder="0.00" className={inputCls} />
{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)}` };
}

View file

@ -0,0 +1,381 @@
"use client";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight, Plus, Trash2, X, Loader2 } from "lucide-react";
import { Sparkline } from "@/components/Sparkline";
import { MONTH_NAMES } from "@/lib/data";
import { ZAR, ZAR2 } from "@/lib/utils";
import { termsLabel, isAccount } from "@/lib/supplier";
import { STATUS_STYLE } from "@/lib/purchase-invoice-status";
import type { SupplierSpendRow } from "@/lib/supplier-spend";
const SPARK = "#30638E";
const monthLabel = (ym: string) => {
const m = /^(\d{4})-(\d{2})$/.exec(ym);
return m ? `${MONTH_NAMES[Number(m[2]) - 1]} ${m[1]}` : "—";
};
export function Suppliers2View({
rows, year, month, title,
}: {
rows: SupplierSpendRow[];
year: number;
month: number;
title: React.ReactNode;
}) {
// Cash suppliers are paid on the spot — nothing to chase — so they stay out of the way until asked for.
const [show, setShow] = useState<"account" | "all" | "cash">("account");
const [expanded, setExpanded] = useState<string | null>(null);
const [paying, setPaying] = useState(false);
const cashN = rows.filter((r) => r.isCash).length;
const visible = useMemo(
() => rows.filter((r) => (show === "all" ? true : show === "cash" ? r.isCash : !r.isCash))
.sort((a, b) => b.thisMonth - a.thisMonth || b.ytd - a.ytd),
[rows, show],
);
// Summed over what is VISIBLE, so the headline never disagrees with the list under it.
const spendNow = visible.reduce((s, r) => s + r.thisMonth, 0);
const payableNow = visible.reduce((s, r) => s + r.payableNow, 0);
// Remaining = what is still owed this month; surplus = paid above what fell due. Per supplier,
// so one supplier's overpayment never quietly hides another's shortfall.
const remainingNow = visible.reduce((s, r) => s + Math.max(r.payableNow - r.paidNow, 0), 0);
const surplusNow = visible.reduce((s, r) => s + Math.max(r.paidNow - r.payableNow, 0), 0);
const paidNowTotal = visible.reduce((s, r) => s + r.paidNow, 0);
const payableNext = visible.reduce((s, r) => s + r.payableNext, 0);
const payableLater = visible.reduce((s, r) => s + r.payableLater, 0);
const unsetInvoiced = visible.reduce((s, r) => s + r.termsUnsetInvoiced, 0);
return (
<>
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-4 mb-6">
{title}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="bg-gradient-to-br from-[#30638E] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-blue-900/20">
<div className="text-xs text-white/80">Spend in {MONTH_NAMES[month - 1]} {year}</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(spendNow)}</div>
</div>
<div className="bg-gradient-to-br from-[#EDAE49] to-[#D1495B] rounded-2xl px-5 py-3 text-white shadow-lg">
<div className="text-xs text-white/80">Payable in {MONTH_NAMES[month - 1]}</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(remainingNow)}</div>
<div className="text-xs text-white/80">
{ZAR(paidNowTotal)} paid of {ZAR(payableNow)}{surplusNow > 0 && ` · ${ZAR(surplusNow)} surplus`}
</div>
</div>
<div className="bg-gradient-to-br from-[#00798C] to-[#30638E] rounded-2xl px-5 py-3 text-white shadow-lg">
<div className="text-xs text-white/80">Payable in {MONTH_NAMES[month % 12]}{month === 12 ? ` ${year + 1}` : ""}</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(payableNext)}</div>
<div className="text-xs text-white/80">{ZAR(payableLater)} after that</div>
</div>
</div>
</div>
<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 bg-[#30638E]/10 text-[#30638E] font-medium">{visible.length} supplier{visible.length !== 1 && "s"}</span>
{show === "account" && cashN > 0 && (
<span className="px-2.5 py-1 rounded-md bg-gray-100 dark:bg-white/5 font-medium">{cashN} cash supplier{cashN !== 1 && "s"} hidden</span>
)}
{unsetInvoiced > 0 && (
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/15 text-[#8a6420] dark:text-[#EDAE49] font-medium">
{ZAR(unsetInvoiced)} invoiced with terms not set — not counted as payable
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<button onClick={() => setPaying(true)}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 bg-[#00798C]">
<Plus className="w-4 h-4" /> Add payment
</button>
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-white dark:bg-white/5 border border-gray-200 dark:border-white/5 self-start">
{([
{ id: "account", label: "Accounts", n: rows.length - cashN },
{ id: "all", label: "All", n: rows.length },
{ id: "cash", label: "Cash", n: cashN },
] as const).map((o) => (
<button
key={o.id}
onClick={() => { setShow(o.id); setExpanded(null); }}
aria-pressed={show === o.id}
className={`min-h-[44px] px-3 rounded-md text-xs font-medium transition ${
show === o.id ? "bg-[#003D5B] text-white dark:bg-[#00798C]" : "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5"
}`}
>
{o.label} <span className="opacity-60">({o.n})</span>
</button>
))}
</div>
</div>
</div>
{paying && <AddPaymentModal rows={rows} month={month} onClose={() => setPaying(false)} />}
<div className="flex items-center gap-3 flex-wrap text-xs text-gray-500 dark:text-gray-400 mb-3">
{(["none", "partial", "complete"] as const).map((k) => (
<span key={k} className="inline-flex items-center gap-1.5">
<span className="w-3 h-1.5 rounded" style={{ background: STATUS_STYLE[k].color }} /> {STATUS_STYLE[k].label}
</span>
))}
</div>
{visible.length === 0 ? (
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-12 text-center text-sm text-gray-500">No suppliers in this view.</div>
) : (
<div className="space-y-2">
{visible.map((r) => {
const isOpen = expanded === r.id;
const stripe = r.status === "n/a" ? "#d1d5db" : STATUS_STYLE[r.status].color;
return (
<div key={r.id} className="relative bg-white dark:bg-surface border border-gray-100 dark:border-border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition">
<span className="absolute left-0 inset-y-0 w-1.5" style={{ background: stripe }} aria-hidden />
<button onClick={() => setExpanded(isOpen ? null : r.id)} aria-expanded={isOpen} className="w-full text-left p-4 pl-5">
<div className="flex items-center gap-3 sm:gap-4 min-h-[44px]">
{r.code && (
<span className="hidden sm:inline font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold shrink-0">{r.code}</span>
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[#003D5B] dark:text-white truncate">{r.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{r.terms ? termsLabel(r.terms) : <span className="text-[#EDAE49]">Terms not set</span>}
</div>
</div>
<div className="hidden sm:block shrink-0"><Sparkline data={r.months} color={SPARK} /></div>
<div className="text-right shrink-0 w-32 hidden md:block">
<div className="text-xs text-gray-400 dark:text-text-soft uppercase tracking-wider">Spend this month</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.thisMonth)}</div>
</div>
<div className="text-right shrink-0 w-32 hidden md:block">
<div className="text-xs uppercase tracking-wider text-[#EDAE49]">Payable now</div>
<div className={`text-sm font-semibold tabular-nums ${r.payableNow - r.paidNow > 0.005 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
{r.payableNow - r.paidNow > 0.005 ? ZAR2(r.payableNow - r.paidNow) : "—"}
</div>
</div>
<div className="text-right shrink-0 w-32 hidden md:block">
<div className="text-xs uppercase tracking-wider text-[#00798C]">Paid</div>
<div className={`text-sm font-semibold tabular-nums ${r.paidNow > 0 ? "text-[#00798C]" : "text-gray-300 dark:text-text-soft"}`}>
{r.paidNow > 0 ? ZAR2(r.paidNow) : "—"}
</div>
</div>
<div className="text-right shrink-0 w-32 hidden md:block">
<div className="text-xs text-gray-400 dark:text-text-soft uppercase tracking-wider">YTD spend</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.ytd)}</div>
</div>
<ChevronRight className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`} />
</div>
<div className="flex md:hidden items-center justify-between gap-3 pt-2 text-xs">
<Sparkline data={r.months} color={SPARK} width={72} height={28} />
<div className="text-right min-w-0">
<div className="text-xs text-gray-400 uppercase tracking-wider">Spend this month</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.thisMonth)}</div>
</div>
<div className="text-right min-w-0">
<div className="text-xs uppercase tracking-wider text-[#EDAE49]">Payable</div>
<div className={`text-sm font-semibold tabular-nums ${r.payableNow - r.paidNow > 0.005 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
{r.payableNow - r.paidNow > 0.005 ? ZAR2(r.payableNow - r.paidNow) : "—"}
</div>
{r.paidNow > 0 && <div className="text-xs text-[#00798C] tabular-nums">paid {ZAR(r.paidNow)}</div>}
</div>
<div className="text-right min-w-0">
<div className="text-xs text-gray-400 uppercase tracking-wider">YTD</div>
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">{ZAR2(r.ytd)}</div>
</div>
</div>
</button>
{isOpen && (
<div className="border-t border-gray-100 dark:border-border px-4 py-4 pl-5 bg-gray-50/50 dark:bg-white/[0.04]">
<div className="grid grid-cols-3 md:grid-cols-6 lg:grid-cols-12 gap-2 text-xs">
{r.months.map((v, i) => (
<div key={i} className="text-center">
<div className="text-gray-400 dark:text-text-soft text-xs mb-1">{MONTH_NAMES[i]}</div>
<div className={`py-1.5 rounded tabular-nums ${v ? "bg-[#30638E]/5 text-[#30638E] dark:bg-[#30638E]/15" : "text-gray-300 dark:text-text-soft"}`}>
{v ? ZAR2(v) : "—"}
</div>
<MonthPayLine due={r.dueByMonth[i]} paid={r.paidByMonth[i]} past={i < month} />
</div>
))}
</div>
{isAccount(r.terms) && (
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-border">
<div className="flex items-baseline justify-between gap-3 mb-2">
<div className="text-xs uppercase tracking-wider text-gray-400">Payments</div>
<div className={`text-xs font-semibold tabular-nums ${r.owingToDate > 0.005 ? "text-[#D1495B]" : "text-[#00798C]"}`}>
{r.owingToDate > 0.005 ? `${ZAR2(r.owingToDate)} still owing to date` : r.owingToDate < -0.005 ? `${ZAR2(-r.owingToDate)} paid ahead` : "Settled to date"}
</div>
</div>
{r.payments.length === 0 ? (
<div className="text-xs text-gray-400">No payments entered yet.</div>
) : (
<ul className="divide-y divide-gray-100 dark:divide-border">
{r.payments.map((p) => <PaymentRow key={p.id} p={p} />)}
</ul>
)}
</div>
)}
<div className="mt-4 pt-3 border-t border-gray-100 dark:border-border">
<div className="text-xs uppercase tracking-wider text-gray-400 mb-2">Purchase orders</div>
{r.pos.length === 0 ? (
<div className="text-xs text-gray-400">No purchase orders — only till slips{r.isCash ? "" : " so far"}.</div>
) : (
<ul className="space-y-1">
{r.pos.map((p) => (
<li key={p.id} className="relative flex items-center gap-3 rounded-lg bg-white dark:bg-surface border border-gray-100 dark:border-border pl-4 pr-3 py-2 overflow-hidden text-xs">
<span className="absolute left-0 inset-y-0 w-1.5" style={{ background: STATUS_STYLE[p.status].color }} title={STATUS_STYLE[p.status].label} />
<span className="font-mono font-semibold text-[#003D5B] dark:text-white">{p.po_no}</span>
<span className="flex-1 min-w-0 truncate text-gray-500 dark:text-gray-400">
{p.invoiceCount === 0 ? "no invoice yet" : `${ZAR2(p.invoiced)} of ${ZAR2(p.total)} invoiced`}
{p.dueMonth && ` · pay ${monthLabel(p.dueMonth)}`}
</span>
<span className="tabular-nums font-semibold shrink-0">{ZAR2(p.total)}</span>
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</>
);
}
/** Under a month's spend: what fell due, what was paid (green) and the surplus / shortfall. */
function MonthPayLine({ due, paid, past }: { due: number; paid: number; past: boolean }) {
if (!due && !paid) return null;
const diff = paid - due;
return (
<div className="mt-1 space-y-0.5 text-xs tabular-nums">
{due > 0 && <div className="text-[#EDAE49]" title="Fell due this month">due {ZAR(due)}</div>}
{paid > 0 && <div className="text-[#00798C] font-medium" title="Paid this month">paid {ZAR(paid)}</div>}
{past && Math.abs(diff) > 0.005 && (
diff > 0
? <div className="text-[#00798C] font-semibold">+{ZAR(diff)} surplus</div>
: <div className="text-[#D1495B] font-semibold">{ZAR(-diff)} short</div>
)}
</div>
);
}
function PaymentRow({ p }: { p: { id: string; paid_on: string; description: string; amount: number } }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function remove() {
if (!confirm(`Remove the ${ZAR2(p.amount)} payment of ${p.paid_on}?`)) return;
setBusy(true);
const res = await fetch(`/api/supplier-payments/${p.id}`, { method: "DELETE" });
const body = await res.json().catch(() => ({ ok: false, error: "Could not remove" }));
setBusy(false);
if (!body.ok) alert(body.error);
else router.refresh();
}
return (
<li className="flex items-center gap-3 py-1 text-xs">
<span className="tabular-nums text-gray-500 dark:text-gray-400 shrink-0">{p.paid_on}</span>
<span className="flex-1 min-w-0 truncate text-gray-600 dark:text-gray-300">{p.description || "—"}</span>
<span className="font-semibold tabular-nums text-[#00798C] shrink-0">{ZAR2(p.amount)}</span>
<button onClick={remove} disabled={busy} aria-label="Remove payment"
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 disabled:opacity-50">
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button>
</li>
);
}
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";
/** Global "Add payment": pick an ACCOUNT supplier, then date, description and amount. */
function AddPaymentModal({ rows, month, onClose }: { rows: SupplierSpendRow[]; month: number; onClose: () => void }) {
const router = useRouter();
const accounts = useMemo(() => rows.filter((r) => isAccount(r.terms)).sort((a, b) => a.name.localeCompare(b.name)), [rows]);
const [supplierId, setSupplierId] = useState("");
const [paidOn, setPaidOn] = useState(() => new Date().toISOString().slice(0, 10));
const [description, setDescription] = useState("");
const [amount, setAmount] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const sup = accounts.find((a) => a.id === supplierId) || null;
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await fetch("/api/supplier-payments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ supplier_id: supplierId, paid_on: paidOn, description, amount }),
});
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="Add payment"
className="w-full max-w-md 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">Add payment</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>
<label className="block text-[12px] text-gray-500 dark:text-gray-400">
Supplier (accounts only)
<select value={supplierId} onChange={(e) => setSupplierId(e.target.value)} required className={inputCls}>
<option value="">Choose a supplier…</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} · {termsLabel(a.terms)}</option>)}
</select>
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label className="block text-[12px] text-gray-500 dark:text-gray-400">
Date paid
<input type="date" value={paidOn} onChange={(e) => setPaidOn(e.target.value)} required className={inputCls} />
</label>
<label className="block text-[12px] text-gray-500 dark:text-gray-400">
Amount
<input value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" placeholder="0.00" required className={inputCls} />
</label>
</div>
{sup && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{MONTH_NAMES[month - 1]}: <span className="text-[#EDAE49] font-medium">{ZAR2(sup.payableNow)} due</span>
{" · "}<span className="text-[#00798C] font-medium">{ZAR2(sup.paidNow)} paid</span>
{" · "}
{sup.owingToDate > 0.005
? <span className="text-[#D1495B] font-medium">{ZAR2(sup.owingToDate)} owing to date</span>
: <span className="text-[#00798C] font-medium">settled to date</span>}
</div>
)}
<label className="block text-[12px] text-gray-500 dark:text-gray-400">
Description
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="e.g. EFT — September invoices" className={inputCls} />
</label>
{accounts.length === 0 && <div className="text-xs text-[#EDAE49]">No supplier is on 30 or 60 day terms yet — set terms on the Suppliers page first.</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 || !supplierId}
className="min-h-[44px] px-4 rounded-lg text-xs font-medium text-white inline-flex items-center gap-2 bg-[#00798C] disabled:opacity-50">
{busy && <Loader2 className="w-4 h-4 animate-spin" />} Save payment
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,24 @@
/** How far a purchase order has been invoiced. Pure — client-safe. Shared by Purchases and Suppliers 2. */
/** Invoiced within this many rand of the PO total counts as fully invoiced (rounding). */
export const CLOSE_TOLERANCE = 1;
export type InvoiceStatus = "none" | "partial" | "complete";
/**
* red = no invoice; amber = some invoiced but the money does not cover the PO (an invoice with
* no amount typed in also lands here, since its value is unknown); teal = invoiced to within R1
* of the PO total, so it can be closed.
*/
export function invoiceStatusOf(total: number, amounts: (number | null)[]): { status: InvoiceStatus; invoiced: number } {
const invoiced = amounts.reduce<number>((s, a) => s + (a ?? 0), 0);
if (amounts.length === 0) return { status: "none", invoiced };
if (invoiced > 0 && total - invoiced <= CLOSE_TOLERANCE) return { status: "complete", invoiced };
return { status: "partial", invoiced };
}
export const STATUS_STYLE: Record<InvoiceStatus, { color: string; label: string }> = {
none: { color: "#D1495B", label: "No invoice yet" },
partial: { color: "#EDAE49", label: "Partly invoiced" },
complete: { color: "#00798C", label: "Fully invoiced — can be closed" },
};

View file

@ -156,7 +156,8 @@ function extractLines(rows: Row[]): ParsedPurchaseLine[] {
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const r = rows[i]; const r = rows[i];
if (headerY !== null && r.y <= headerY) continue; // header block / column headings // PDF y grows UPWARD, so the header block and the column headings are at y >= headerY.
if (headerY !== null && r.y >= headerY) continue; // header block / column headings
const no = num(wordIn(r, COL.NO)?.str); const no = num(wordIn(r, COL.NO)?.str);
const amount = num(wordIn(r, COL.AMOUNT)?.str); const amount = num(wordIn(r, COL.AMOUNT)?.str);
@ -235,19 +236,40 @@ export async function parsePurchasePdf(data: Uint8Array): Promise<ParsedPurchase
} }
const doc = await pdfjs.getDocument({ data, useSystemFonts: true }).promise; const doc = await pdfjs.getDocument({ data, useSystemFonts: true }).promise;
const all: Word[] = []; // Each page is clustered on its OWN. Every PDF page restarts y at its own bottom edge, so
// pooling the words of a two-page PO put page 2's rows at the same heights as page 1's:
// the rows interleaved, the one column-heading row (found on page 1) then filtered page 2's
// lines out as "above the header", and the PO failed to reconcile. Pages are only joined
// afterwards, stacked with an offset, for the header fields and the totals block.
const pages: Row[][] = [];
const stacked: Word[] = [];
const pageH: number[] = [];
for (let p = 1; p <= doc.numPages; p++) { for (let p = 1; p <= doc.numPages; p++) {
const page = await doc.getPage(p); const page = await doc.getPage(p);
const tc = await page.getTextContent(); const tc = await page.getTextContent();
const words: Word[] = [];
for (const item of tc.items as any[]) { for (const item of tc.items as any[]) {
if (!item.str || !item.str.trim()) continue; if (!item.str || !item.str.trim()) continue;
all.push({ str: item.str, x: item.transform[4], y: item.transform[5] }); words.push({ str: item.str, x: item.transform[4], y: item.transform[5] });
} }
pages.push(clusterRows(words));
pageH.push(page.view[3] - page.view[1]);
} }
// Page 1 on top: later pages get a smaller y by a whole page height each.
let offset = 0;
pages.forEach((rows, i) => {
for (const r of rows) for (const w of r.words) stacked.push({ ...w, y: w.y - offset });
offset += pageH[i];
});
const rows = clusterRows(all); // Lines: page by page, each against its own column-heading row. A continuation page may
const lines = extractLines(rows); // repeat the headings or may not; without them, every row on it is considered.
const lines: ParsedPurchaseLine[] = [];
for (const rows of pages) lines.push(...extractLines(rows));
const rows = clusterRows(stacked);
if (!lines.length) throw new PurchaseParseError("No purchase-order lines found — is this a PO document?"); if (!lines.length) throw new PurchaseParseError("No purchase-order lines found — is this a PO document?");
const dup = lines.find((l, i) => lines.findIndex((m) => m.line_no === l.line_no) !== i);
if (dup) throw new PurchaseParseError(`Line ${dup.line_no} appears twice — the pages of this PO could not be read in order`);
const po = { const po = {
po_no: poNumberOf(rows), po_no: poNumberOf(rows),

126
lib/supplier-spend.ts Normal file
View file

@ -0,0 +1,126 @@
/**
* Suppliers 2: spend and payables per supplier. Pure — client-safe.
*
* Spend = purchase orders (by PO date) + petty-cash slips (by slip date), per month.
* Payable = supplier invoices on an ACCOUNT supplier, due `terms` days after the invoice
* month: a 30-day account invoiced in October is paid in November, 60 days in December.
* Cash suppliers are paid on the spot, so they owe nothing. A supplier whose terms are not
* set yet is reported separately — never guessed into a month.
* Payments made to account suppliers are entered by hand (`supplier_payments`) and netted
* against what fell due IN THE SAME MONTH: paid − due is a surplus when positive and a
* shortfall when negative. A shortfall in a past month is money still owing.
*/
import { invoiceStatusOf, type InvoiceStatus } from "@/lib/purchase-invoice-status";
import { isAccount } from "@/lib/supplier";
export type SpendInput = {
suppliers: { id: string; name: string; code: string; terms: string }[];
purchases: {
id: string; po_no: string; po_date: string; total: number; supplier_ref: string;
invoices: { invoice_no: string; invoice_date: string; amount_incl: number | null }[];
}[];
slips: { amount: number; at: string; supplier_ref: string }[];
payments: { id: string; supplier_ref: string; paid_on: string; description: string; amount: number }[];
year: number;
/** 1–12 */
month: number;
};
export type SupplierPo = {
id: string; po_no: string; po_date: string; total: number;
invoiced: number; invoiceCount: number; status: InvoiceStatus;
/** "2026-11" the invoices fall due, "" when none or terms not applicable. */
dueMonth: string;
};
export type SupplierPayment = { id: string; paid_on: string; description: string; amount: number };
export type SupplierSpendRow = {
id: string; name: string; code: string; terms: string; isCash: boolean;
months: (number | null)[];
/** Fell due per month this year (invoice month + terms). */
dueByMonth: number[];
/** Paid per month this year. */
paidByMonth: number[];
paidNow: number;
/** Due up to and including this month, minus paid in the same months: positive = still owing. */
owingToDate: number;
payments: SupplierPayment[];
ytd: number; thisMonth: number;
payableNow: number; payableNext: number; payableLater: number; termsUnsetInvoiced: number;
status: InvoiceStatus | "n/a";
pos: SupplierPo[];
};
const ym = (iso: string): { y: number; m: number } | null => {
const r = /^(\d{4})-(\d{2})/.exec(iso || "");
return r ? { y: Number(r[1]), m: Number(r[2]) } : null;
};
const key = (y: number, m: number) => `${y}-${String(m).padStart(2, "0")}`;
export function buildSupplierSpend(i: SpendInput): SupplierSpendRow[] {
const nowIdx = i.year * 12 + (i.month - 1);
return i.suppliers.map((s) => {
const months: number[] = Array(12).fill(0);
const pos: SupplierPo[] = [];
const dueByMonth: number[] = Array(12).fill(0);
const paidByMonth: number[] = Array(12).fill(0);
let payableNow = 0, payableNext = 0, payableLater = 0, termsUnsetInvoiced = 0;
const lag = isAccount(s.terms) ? Number(s.terms) / 30 : 0;
for (const p of i.purchases.filter((x) => x.supplier_ref === s.id)) {
const d = ym(p.po_date);
if (d && d.y === i.year) months[d.m - 1] += p.total;
const st = invoiceStatusOf(p.total, p.invoices.map((v) => v.amount_incl));
let dueMonth = "";
for (const v of p.invoices) {
if (v.amount_incl == null) continue;
// Invoice date, falling back to the PO date when none was typed in.
const base = ym(v.invoice_date) ?? d;
if (!isAccount(s.terms)) {
if (s.terms === "") termsUnsetInvoiced += v.amount_incl;
continue;
}
if (!base) continue;
const idx = base.y * 12 + (base.m - 1) + lag;
dueMonth = key(Math.floor(idx / 12), (idx % 12) + 1);
if (Math.floor(idx / 12) === i.year) dueByMonth[idx % 12] += v.amount_incl;
if (idx === nowIdx) payableNow += v.amount_incl;
else if (idx === nowIdx + 1) payableNext += v.amount_incl;
else if (idx > nowIdx) payableLater += v.amount_incl;
}
pos.push({
id: p.id, po_no: p.po_no, po_date: p.po_date, total: p.total,
invoiced: st.invoiced, invoiceCount: p.invoices.length, status: st.status, dueMonth,
});
}
for (const sl of i.slips.filter((x) => x.supplier_ref === s.id)) {
const d = ym(sl.at);
if (d && d.y === i.year) months[d.m - 1] += sl.amount;
}
const payments = i.payments.filter((x) => x.supplier_ref === s.id);
for (const pm of payments) {
const d = ym(pm.paid_on);
if (d && d.y === i.year) paidByMonth[d.m - 1] += pm.amount;
}
const upTo = (a: number[]) => a.slice(0, i.month).reduce((x, y) => x + y, 0);
const owingToDate = upTo(dueByMonth) - upTo(paidByMonth);
// Supplier colour: every PO fully invoiced → teal; nothing invoiced anywhere → red.
const status: SupplierSpendRow["status"] = pos.length === 0 ? "n/a"
: pos.every((p) => p.status === "complete") ? "complete"
: pos.every((p) => p.status === "none") ? "none" : "partial";
return {
id: s.id, name: s.name, code: s.code, terms: s.terms, isCash: s.terms === "cash",
months: months.map((v) => (v > 0 ? v : null)),
dueByMonth, paidByMonth, paidNow: paidByMonth[i.month - 1], owingToDate,
payments: payments.map((x) => ({ id: x.id, paid_on: x.paid_on, description: x.description, amount: x.amount }))
.sort((a, b) => b.paid_on.localeCompare(a.paid_on)),
ytd: months.reduce((a, b) => a + b, 0), thisMonth: months[i.month - 1],
payableNow, payableNext, payableLater, termsUnsetInvoiced, status,
pos: pos.sort((a, b) => b.po_date.localeCompare(a.po_date)),
};
});
}

View file

@ -0,0 +1,118 @@
// Create the `purchase_invoices` PocketBase collection.
//
// Supplier invoices received against a purchase order. One PO can have several (part
// deliveries, a final invoice). Typed in by a person with the PDF attached — they are
// NOT parsed, and they do not change the PO's own figures or the running-cost maths.
//
// Idempotent: safe to re-run.
//
// Usage: node scripts/setup-purchase-invoices-collection.mjs
import PocketBase from "pocketbase";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
const envPath = resolve(ROOT, ".env.local");
if (!existsSync(envPath)) {
console.error("✗ .env.local not found. Copy .env.example to .env.local and fill it in.");
process.exit(1);
}
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const i = t.indexOf("=");
if (i > 0) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
}
const PB_URL = process.env.NEXT_PUBLIC_PB_URL;
const EMAIL = process.env.PB_ADMIN;
const PASS = process.env.PB_PASS;
const missing = [];
if (!PB_URL) missing.push("NEXT_PUBLIC_PB_URL");
if (!EMAIL) missing.push("PB_ADMIN");
if (!PASS) missing.push("PB_PASS");
if (missing.length) {
console.error(`✗ Missing required env: ${missing.join(", ")}`);
process.exit(1);
}
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
console.log(`→ Connecting to ${PB_URL} as ${EMAIL}`);
await pb.collection("_superusers").authWithPassword(EMAIL, PASS);
console.log(" ✓ authenticated");
const reference = await pb.collections.getOne("monthly_income");
const RULES = {
listRule: reference.listRule,
viewRule: reference.viewRule,
createRule: reference.createRule ?? null,
updateRule: reference.updateRule ?? null,
deleteRule: reference.deleteRule ?? null,
};
console.log(` · rules mirrored from monthly_income (list="${RULES.listRule}")`);
async function ensureCollection(name, fields) {
const existing = await pb.collections.getFullList({ filter: `name = "${name}"`, perPage: 1 });
if (existing.length) {
const have = new Set(existing[0].fields.map((f) => f.name));
const absent = fields.filter((f) => !have.has(f.name));
if (!absent.length) {
console.log(` · ${name} already present (${existing[0].fields.length} fields)`);
} else {
await updateSchema(name, (cur) => [...cur, ...absent], `added to ${name}: ${absent.map((f) => f.name).join(", ")}`);
}
return existing[0].id;
}
const created = await pb.send("/api/collections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name, type: "base", fields,
listRule: RULES.listRule ?? "", viewRule: RULES.viewRule ?? "",
createRule: RULES.createRule ?? "", updateRule: RULES.updateRule ?? "", deleteRule: RULES.deleteRule ?? "",
}),
});
console.log(` ✓ created ${name} (${created.id}) with ${created.fields.length} fields`);
return created.id;
}
async function updateSchema(collectionName, transform, label) {
const coll = await pb.collections.getOne(collectionName);
const next = transform([...coll.fields]);
if (JSON.stringify(next) === JSON.stringify(coll.fields)) {
console.log(` · ${collectionName} schema already current`);
return;
}
await pb.send(`/api/collections/${coll.name}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: next }),
});
console.log(` ✓ ${label}`);
}
console.log("\n→ Ensuring collection");
const purchasesId = (await pb.collections.getOne("purchases")).id;
await ensureCollection("purchase_invoices", [
{ name: "purchase", type: "relation", required: true, collectionId: purchasesId, minSelect: 1, maxSelect: 1, cascadeDelete: true },
{ name: "invoice_no", type: "text", required: true },
{ name: "invoice_date", type: "text" },
// What the supplier invoiced, INCL VAT. Not `required`: PocketBase treats 0 as blank.
{ name: "amount_incl", type: "number" },
{ name: "note", type: "text" },
{ name: "source_pdf", type: "file", maxSelect: 1, maxSize: 20971520 },
{ name: "source_file", type: "text" },
{ name: "created", type: "date", onCreate: true, presentable: false },
{ name: "updated", type: "date", onUpdate: true, presentable: false },
]);
console.log("\n✓ purchase_invoices ready.");
process.exit(0);

View file

@ -0,0 +1,115 @@
// Create the `purchase_invoices` PocketBase collection.
//
// Payments made to ACCOUNT suppliers (30 / 60 days). Typed in by a person; a bank statement
// import can allocate rows here later. `paid_on` is an ISO date string, like `po_date`.
//
// Idempotent: safe to re-run.
//
// Usage: node scripts/setup-supplier-payments-collection.mjs
import PocketBase from "pocketbase";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..");
const envPath = resolve(ROOT, ".env.local");
if (!existsSync(envPath)) {
console.error("✗ .env.local not found. Copy .env.example to .env.local and fill it in.");
process.exit(1);
}
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const i = t.indexOf("=");
if (i > 0) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
}
const PB_URL = process.env.NEXT_PUBLIC_PB_URL;
const EMAIL = process.env.PB_ADMIN;
const PASS = process.env.PB_PASS;
const missing = [];
if (!PB_URL) missing.push("NEXT_PUBLIC_PB_URL");
if (!EMAIL) missing.push("PB_ADMIN");
if (!PASS) missing.push("PB_PASS");
if (missing.length) {
console.error(`✗ Missing required env: ${missing.join(", ")}`);
process.exit(1);
}
const pb = new PocketBase(PB_URL);
pb.autoCancellation(false);
console.log(`→ Connecting to ${PB_URL} as ${EMAIL}`);
await pb.collection("_superusers").authWithPassword(EMAIL, PASS);
console.log(" ✓ authenticated");
const reference = await pb.collections.getOne("monthly_income");
const RULES = {
listRule: reference.listRule,
viewRule: reference.viewRule,
createRule: reference.createRule ?? null,
updateRule: reference.updateRule ?? null,
deleteRule: reference.deleteRule ?? null,
};
console.log(` · rules mirrored from monthly_income (list="${RULES.listRule}")`);
async function ensureCollection(name, fields) {
const existing = await pb.collections.getFullList({ filter: `name = "${name}"`, perPage: 1 });
if (existing.length) {
const have = new Set(existing[0].fields.map((f) => f.name));
const absent = fields.filter((f) => !have.has(f.name));
if (!absent.length) {
console.log(` · ${name} already present (${existing[0].fields.length} fields)`);
} else {
await updateSchema(name, (cur) => [...cur, ...absent], `added to ${name}: ${absent.map((f) => f.name).join(", ")}`);
}
return existing[0].id;
}
const created = await pb.send("/api/collections", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name, type: "base", fields,
listRule: RULES.listRule ?? "", viewRule: RULES.viewRule ?? "",
createRule: RULES.createRule ?? "", updateRule: RULES.updateRule ?? "", deleteRule: RULES.deleteRule ?? "",
}),
});
console.log(` ✓ created ${name} (${created.id}) with ${created.fields.length} fields`);
return created.id;
}
async function updateSchema(collectionName, transform, label) {
const coll = await pb.collections.getOne(collectionName);
const next = transform([...coll.fields]);
if (JSON.stringify(next) === JSON.stringify(coll.fields)) {
console.log(` · ${collectionName} schema already current`);
return;
}
await pb.send(`/api/collections/${coll.name}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: next }),
});
console.log(` ✓ ${label}`);
}
console.log("\n→ Ensuring collection");
const suppliersId = (await pb.collections.getOne("suppliers")).id;
await ensureCollection("supplier_payments", [
{ name: "supplier", type: "relation", required: true, collectionId: suppliersId, minSelect: 1, maxSelect: 1, cascadeDelete: false },
{ name: "paid_on", type: "text", required: true },
{ name: "description", type: "text" },
{ name: "amount", type: "number" },
// "Manual" for now; a statement import would stamp its own source and file name.
{ name: "source", type: "text" },
{ name: "created", type: "date", onCreate: true, presentable: false },
{ name: "updated", type: "date", onUpdate: true, presentable: false },
]);
console.log("\n✓ supplier_payments ready.");
process.exit(0);