From 5f86657849e80360902fe3c910007c659990adc8 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 29 Oct 2026 20:29:08 +0200 Subject: [PATCH] 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 --- app/(dashboard)/expenses/suppliers-2/page.tsx | 63 +++ app/(dashboard)/layout.tsx | 1 + app/(dashboard)/running/purchases/page.tsx | 19 +- .../invoices/[invoiceId]/document/route.ts | 33 ++ .../[id]/invoices/[invoiceId]/route.ts | 24 + app/api/purchases/[id]/invoices/route.ts | 81 +++ app/api/supplier-payments/[id]/route.ts | 20 + app/api/supplier-payments/route.ts | 48 ++ components/PurchaseIntake.tsx | 535 +++++++++++++++++- components/Suppliers2View.tsx | 381 +++++++++++++ lib/purchase-invoice-status.ts | 24 + lib/purchase-parser.ts | 32 +- lib/supplier-spend.ts | 126 +++++ .../setup-purchase-invoices-collection.mjs | 118 ++++ .../setup-supplier-payments-collection.mjs | 115 ++++ 15 files changed, 1602 insertions(+), 18 deletions(-) create mode 100644 app/(dashboard)/expenses/suppliers-2/page.tsx create mode 100644 app/api/purchases/[id]/invoices/[invoiceId]/document/route.ts create mode 100644 app/api/purchases/[id]/invoices/[invoiceId]/route.ts create mode 100644 app/api/purchases/[id]/invoices/route.ts create mode 100644 app/api/supplier-payments/[id]/route.ts create mode 100644 app/api/supplier-payments/route.ts create mode 100644 components/Suppliers2View.tsx create mode 100644 lib/purchase-invoice-status.ts create mode 100644 lib/supplier-spend.ts create mode 100644 scripts/setup-purchase-invoices-collection.mjs create mode 100644 scripts/setup-supplier-payments-collection.mjs diff --git a/app/(dashboard)/expenses/suppliers-2/page.tsx b/app/(dashboard)/expenses/suppliers-2/page.tsx new file mode 100644 index 0000000..907efc1 --- /dev/null +++ b/app/(dashboard)/expenses/suppliers-2/page.tsx @@ -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(); + 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 ( +
+ +
+
+

Suppliers 2

+

Spend and payables per supplier

+
+
+ } + /> + + ); +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index f7b7da5..da0f328 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -79,6 +79,7 @@ const navItems: NavItem[] = [ // Vehicle Costs and Finance (Bastion) are hidden: Running Costs (Insurance, Financing) already // covers them. The pages still exist at /expenses/vehicles and /expenses/finance. { href: "/expenses/suppliers", label: "Suppliers" }, + { href: "/expenses/suppliers-2", label: "Suppliers 2" }, { href: "/expenses/personnel", label: "Personnel" }, { href: "/expenses/subscriptions", label: "Subscriptions" }, { href: "/expenses/rent", label: "Rent & Utilities" }, diff --git a/app/(dashboard)/running/purchases/page.tsx b/app/(dashboard)/running/purchases/page.tsx index 0f8b74e..d1b3d29 100644 --- a/app/(dashboard)/running/purchases/page.tsx +++ b/app/(dashboard)/running/purchases/page.tsx @@ -22,11 +22,19 @@ export const dynamic = "force-dynamic"; */ export default async function PurchasesPage() { const pb = await serverAuth(); - const [assets, purchases, lines] = await Promise.all([ + const [assets, purchases, lines, supplierInvoices] = await Promise.all([ getAssets(), pb.collection("purchases").getFullList({ sort: "-created" }), pb.collection("purchase_lines").getFullList({ 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(); + 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 linesByPurchase = new Map(); @@ -49,6 +57,15 @@ export default async function PurchasesPage() { vat: p.vat || 0, total: p.total || 0, 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) => { const a = l.asset ? byId.get(l.asset) : null; return { diff --git a/app/api/purchases/[id]/invoices/[invoiceId]/document/route.ts b/app/api/purchases/[id]/invoices/[invoiceId]/document/route.ts new file mode 100644 index 0000000..6ada3db --- /dev/null +++ b/app/api/purchases/[id]/invoices/[invoiceId]/document/route.ts @@ -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 }); + } +} diff --git a/app/api/purchases/[id]/invoices/[invoiceId]/route.ts b/app/api/purchases/[id]/invoices/[invoiceId]/route.ts new file mode 100644 index 0000000..92c4203 --- /dev/null +++ b/app/api/purchases/[id]/invoices/[invoiceId]/route.ts @@ -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 }); + } +} diff --git a/app/api/purchases/[id]/invoices/route.ts b/app/api/purchases/[id]/invoices/route.ts new file mode 100644 index 0000000..fc32d3f --- /dev/null +++ b/app/api/purchases/[id]/invoices/route.ts @@ -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 }); + } +} diff --git a/app/api/supplier-payments/[id]/route.ts b/app/api/supplier-payments/[id]/route.ts new file mode 100644 index 0000000..5c01c9e --- /dev/null +++ b/app/api/supplier-payments/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/supplier-payments/route.ts b/app/api/supplier-payments/route.ts new file mode 100644 index 0000000..f9d179b --- /dev/null +++ b/app/api/supplier-payments/route.ts @@ -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 }); + } +} diff --git a/components/PurchaseIntake.tsx b/components/PurchaseIntake.tsx index 7e7efde..4518736 100644 --- a/components/PurchaseIntake.tsx +++ b/components/PurchaseIntake.tsx @@ -1,12 +1,13 @@ "use client"; -import { useState, useRef, useTransition } from "react"; +import { useState, useRef, useTransition, useMemo } from "react"; import { useRouter } from "next/navigation"; import { Upload, FileText, ChevronDown, ChevronRight, Loader2, TriangleAlert, - Check, ShoppingCart, X, + Check, ShoppingCart, X, Plus, Trash2, Search, Receipt, ChevronLeft, } from "lucide-react"; import { ZAR, ZAR2 } from "@/lib/utils"; +import { invoiceStatusOf, STATUS_STYLE } from "@/lib/purchase-invoice-status"; const ACCENT = "#30638E"; @@ -38,10 +39,20 @@ export type PurchaseView = { vat: number; total: number; source_file: string; + invoices: PurchaseInvoiceView[]; 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 inputRef = useRef(null); const [pending, startTransition] = useTransition(); @@ -49,6 +60,47 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) { const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [open, setOpen] = useState(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(); + 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) { if (!files || !files.length) return; @@ -89,7 +141,14 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) { ); return ( -
+
+ {dragging && ( +
+
+ Drop purchase order PDFs to upload +
+
+ )}
@@ -105,7 +164,15 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) { )}
-
+
+
+ {invoiceModal && setInvoiceModal(false)} />}
{error && ( @@ -149,21 +217,76 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
)} - {purchases.length === 0 ? ( +
+
+ {([["all", "All"], ["week", "Week"], ["month", "Month"]] as const).map(([id, label]) => ( + + ))} +
+ {win && ( +
+ + {win.label} + +
+ )} + +
+ {undated > 0 && ( +
+ {undated} purchase order{undated !== 1 && "s"} with no readable date {undated !== 1 ? "are" : "is"} hidden while a range is chosen. +
+ )} + + {allPurchases.length > 0 && purchases.length === 0 ? ( +
+ No purchase orders dated in this {range}. +
+ ) : purchases.length === 0 ? (
No purchase orders yet.
- 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.
) : ( -
- {purchases.map((p) => { +
+ {groups.map((g) => ( +
+ {groupBySupplier && ( +
+

{g.name}

+ + {g.items.length} PO{g.items.length !== 1 && "s"} · {ZAR2(g.items.reduce((t, x) => t + x.total, 0))} + +
+ )} +
+ {g.items.map((p) => { const isOpen = open === p.id; const unalloc = p.lines.filter((l) => !l.assetCode).length; + const inv = invoiceStatus(p); + const sty = STATUS_STYLE[inv.status]; return ( -
+
+
{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 ·{" "} + + {inv.status === "complete" ? "fully invoiced — can be closed" : inv.status === "partial" ? `${ZAR2(inv.invoiced)} of ${ZAR2(p.total)} invoiced` : "no invoice yet"} +
@@ -207,6 +333,8 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) { + +
@@ -255,6 +383,9 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) { ); })} + + + ))} )} @@ -268,4 +399,384 @@ function Meta({ label, value }: { label: string; value: string }) {
{value}
); -} \ No newline at end of file +} + +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(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) { + 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 ( +
+
+
+ Supplier invoices + {p.invoices.length > 0 && ( + + {ZAR2(invoiced)} invoiced · {ZAR2(outstanding)} of PO {outstanding < 0 ? "over" : "not yet invoiced"} + {unpriced > 0 && ` · ${unpriced} with no amount`} + + )} +
+ {!adding && ( + + )} +
+ + {p.invoices.length === 0 && !adding && ( +
No invoices attached to this PO yet.
+ )} + + {p.invoices.length > 0 && ( +
    + {p.invoices.map((i) => ( +
  • +
    + {i.invoice_no} + + {" "}· {i.invoice_date || "no date"} + {i.note && ` · ${i.note}`} + +
    +
    + {i.amount_incl == null ? no amount : ZAR2(i.amount_incl)} +
    + {i.hasPdf ? ( + + + + ) : ( + no PDF + )} + +
  • + ))} +
+ )} + + {error &&
{error}
} + + {adding && ( +
+ + + + + +
+ + +
+ + )} +
+ ); +} + + +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(null); + const [query, setQuery] = useState(""); + const [poId, setPoId] = useState(""); + const [file, setFile] = useState(null); + const [drag, setDrag] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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) { + 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 ( +
+
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" + > +
+

Upload invoice

+ +
+ +
+ +
+ + { setQuery(e.target.value); setPoId(""); }} + placeholder="e.g. MEGPPO49676, tyres, 12500" autoComplete="off" + className={`${inputCls} pl-9`} /> +
+ {!po && ( +
    + {matches.length === 0 &&
  • No purchase order matches.
  • } + {matches.map((p) => ( +
  • + +
  • + ))} +
+ )} + {po && st && ( +
+ + + {po.po_no} + · {po.supplier} + + +
+ )} +
+ +
+ + + +
+ +
{ 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" + }`} + > + pick(e.target.files?.[0])} /> + {file ? ( +
+ {file.name} + +
+ ) : ( + <> + +
Drag the invoice PDF here, or
+ + + )} +
+ + {error &&
{error}
} + +
+ + +
+ +
+ ); +} + + +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)}` }; +} diff --git a/components/Suppliers2View.tsx b/components/Suppliers2View.tsx new file mode 100644 index 0000000..59f683f --- /dev/null +++ b/components/Suppliers2View.tsx @@ -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(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 ( + <> +
+ {title} +
+
+
Spend in {MONTH_NAMES[month - 1]} {year}
+
{ZAR(spendNow)}
+
+
+
Payable in {MONTH_NAMES[month - 1]}
+
{ZAR(remainingNow)}
+
+ {ZAR(paidNowTotal)} paid of {ZAR(payableNow)}{surplusNow > 0 && ` · ${ZAR(surplusNow)} surplus`} +
+
+
+
Payable in {MONTH_NAMES[month % 12]}{month === 12 ? ` ${year + 1}` : ""}
+
{ZAR(payableNext)}
+
{ZAR(payableLater)} after that
+
+
+
+ +
+
+ {visible.length} supplier{visible.length !== 1 && "s"} + {show === "account" && cashN > 0 && ( + {cashN} cash supplier{cashN !== 1 && "s"} hidden + )} + {unsetInvoiced > 0 && ( + + {ZAR(unsetInvoiced)} invoiced with terms not set — not counted as payable + + )} +
+
+ +
+ {([ + { 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) => ( + + ))} +
+
+
+ {paying && setPaying(false)} />} + +
+ {(["none", "partial", "complete"] as const).map((k) => ( + + {STATUS_STYLE[k].label} + + ))} +
+ + {visible.length === 0 ? ( +
No suppliers in this view.
+ ) : ( +
+ {visible.map((r) => { + const isOpen = expanded === r.id; + const stripe = r.status === "n/a" ? "#d1d5db" : STATUS_STYLE[r.status].color; + return ( +
+ + + + {isOpen && ( +
+
+ {r.months.map((v, i) => ( +
+
{MONTH_NAMES[i]}
+
+ {v ? ZAR2(v) : "—"} +
+ +
+ ))} +
+ + {isAccount(r.terms) && ( +
+
+
Payments
+
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"} +
+
+ {r.payments.length === 0 ? ( +
No payments entered yet.
+ ) : ( +
    + {r.payments.map((p) => )} +
+ )} +
+ )} + +
+
Purchase orders
+ {r.pos.length === 0 ? ( +
No purchase orders — only till slips{r.isCash ? "" : " so far"}.
+ ) : ( +
    + {r.pos.map((p) => ( +
  • + + {p.po_no} + + {p.invoiceCount === 0 ? "no invoice yet" : `${ZAR2(p.invoiced)} of ${ZAR2(p.total)} invoiced`} + {p.dueMonth && ` · pay ${monthLabel(p.dueMonth)}`} + + {ZAR2(p.total)} +
  • + ))} +
+ )} +
+
+ )} +
+ ); + })} +
+ )} + + ); +} + + +/** 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 ( +
+ {due > 0 &&
due {ZAR(due)}
} + {paid > 0 &&
paid {ZAR(paid)}
} + {past && Math.abs(diff) > 0.005 && ( + diff > 0 + ?
+{ZAR(diff)} surplus
+ :
{ZAR(-diff)} short
+ )} +
+ ); +} + +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 ( +
  • + {p.paid_on} + {p.description || "—"} + {ZAR2(p.amount)} + +
  • + ); +} + +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(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 ( +
    +
    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"> +
    +

    Add payment

    + +
    + +
    + + +
    + {sup && ( +
    + {MONTH_NAMES[month - 1]}: {ZAR2(sup.payableNow)} due + {" · "}{ZAR2(sup.paidNow)} paid + {" · "} + {sup.owingToDate > 0.005 + ? {ZAR2(sup.owingToDate)} owing to date + : settled to date} +
    + )} + + {accounts.length === 0 &&
    No supplier is on 30 or 60 day terms yet — set terms on the Suppliers page first.
    } + {error &&
    {error}
    } +
    + + +
    + +
    + ); +} diff --git a/lib/purchase-invoice-status.ts b/lib/purchase-invoice-status.ts new file mode 100644 index 0000000..2a2ee9e --- /dev/null +++ b/lib/purchase-invoice-status.ts @@ -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((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 = { + none: { color: "#D1495B", label: "No invoice yet" }, + partial: { color: "#EDAE49", label: "Partly invoiced" }, + complete: { color: "#00798C", label: "Fully invoiced — can be closed" }, +}; diff --git a/lib/purchase-parser.ts b/lib/purchase-parser.ts index dc9c360..11bc1a7 100644 --- a/lib/purchase-parser.ts +++ b/lib/purchase-parser.ts @@ -156,7 +156,8 @@ function extractLines(rows: Row[]): ParsedPurchaseLine[] { for (let i = 0; i < rows.length; 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 amount = num(wordIn(r, COL.AMOUNT)?.str); @@ -235,19 +236,40 @@ export async function parsePurchasePdf(data: Uint8Array): Promise { + 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); - const lines = extractLines(rows); + // Lines: page by page, each against its own column-heading row. A continuation page may + // 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?"); + 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 = { po_no: poNumberOf(rows), diff --git a/lib/supplier-spend.ts b/lib/supplier-spend.ts new file mode 100644 index 0000000..f715caf --- /dev/null +++ b/lib/supplier-spend.ts @@ -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)), + }; + }); +} diff --git a/scripts/setup-purchase-invoices-collection.mjs b/scripts/setup-purchase-invoices-collection.mjs new file mode 100644 index 0000000..809b78a --- /dev/null +++ b/scripts/setup-purchase-invoices-collection.mjs @@ -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); diff --git a/scripts/setup-supplier-payments-collection.mjs b/scripts/setup-supplier-payments-collection.mjs new file mode 100644 index 0000000..1bd8611 --- /dev/null +++ b/scripts/setup-supplier-payments-collection.mjs @@ -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);