mega-plant/app/(dashboard)/running/purchases/page.tsx
David 5f86657849 Purchase invoices, Suppliers 2 with payments, multi-page PO parsing
- Supplier invoices on purchase orders (several per PO), global Upload invoice with search and drag-drop
- PO invoice status line (red/orange/teal), week/month filter, group by supplier, drag-drop PO upload
- Suppliers 2: spend, payable by terms, cash hidden by default, manual payments with surplus/shortfall
- Fix multi-page PO parsing (per-page rows, header filter direction)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 20:29:08 +02:00

145 lines
No EOL
6.3 KiB
TypeScript

import Link from "next/link";
import { getAssets } from "@/lib/server-data";
import { ZAR } from "@/lib/utils";
import { ShoppingCart, ArrowLeft } from "lucide-react";
import { PurchaseIntake, type PurchaseView } from "@/components/PurchaseIntake";
import { serverAuth } from "@/lib/pb";
import type { Purchase, PurchaseLine } from "@/lib/data";
// Reads PocketBase on every request. Without this Next tries to prerender the
// page at build time, which makes `docker build` depend on the database being
// reachable and then serves a frozen build-time snapshot to users.
export const dynamic = "force-dynamic";
/**
* Purchase orders: parts, repairs and servicing bought against a machine.
*
* Separate from invoices, which are money against a SITE. A PO's link to the plant is
* the COST CODE printed on each line — the machine code the client types, which may be
* hyphenated ("SS-02") where the register writes it plain ("SS02"). Every figure here is
* transcribed by `lib/purchase-parser.ts` and the PDF is kept on the record, so any
* number can be checked against its source.
*/
export default async function PurchasesPage() {
const pb = await serverAuth();
const [assets, purchases, lines, supplierInvoices] = await Promise.all([
getAssets(),
pb.collection("purchases").getFullList<Purchase>({ sort: "-created" }),
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 linesByPurchase = new Map<string, typeof lines>();
for (const l of lines) {
const list = linesByPurchase.get(l.purchase) || [];
list.push(l);
linesByPurchase.set(l.purchase, list);
}
const views: PurchaseView[] = purchases.map((p) => ({
id: p.id,
po_no: p.po_no,
supplier: p.supplier || "",
supplier_code: p.supplier_code || "",
po_date: p.po_date || "",
required_by: p.required_by || "",
req_no: p.req_no || "",
currency: p.currency || "",
subtotal: p.subtotal || 0,
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 {
id: l.id,
line_no: l.line_no,
material_code: l.material_code || "",
cost_code: l.cost_code || "",
description: l.description || "",
qty: l.qty ?? null,
rate: l.rate ?? null,
amount_excl: l.amount_excl || 0,
vat: l.vat ?? null,
total_incl: l.total_incl ?? null,
// EMPTY is a real state: the cost code matched no machine in the register, so the
// spend exists but is not yet on a plant item.
assetCode: a?.code || "",
assetName: a?.name || "",
};
}),
}));
const totalSpend = views.reduce((s, p) => s + p.total, 0);
const unallocated = views.reduce((s, p) => s + p.lines.filter((l) => !l.assetCode).length, 0);
return (
<div className="p-4 sm:p-8 max-w-[1400px]">
<Link
href="/running"
className="inline-flex min-h-[44px] items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-[#30638E] dark:hover:text-[#00798C] mb-3"
>
<ArrowLeft className="w-3 h-3" /> Back to Running Costs
</Link>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between mb-6">
<div className="min-w-0">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#30638E]/15 to-[#003D5B]/15 grid place-items-center text-[#30638E]">
<ShoppingCart className="w-5 h-5" />
</div>
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Purchases</h1>
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
Purchase orders allocated to a machine by their cost code
</p>
<p className="text-xs text-gray-400 dark:text-text-soft mt-1">
A cost code is the machine code as printed on the PO — <span className="font-medium">SS-02</span>{" "}
matches <span className="font-medium">SS02</span> in the register. A code that matches no
machine is left unallocated and reported, never guessed.
</p>
</div>
<div className="bg-gradient-to-br from-[#30638E] to-[#003D5B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-blue-900/20 self-start sm:self-auto">
<div className="text-xs text-white/80">Total purchased</div>
<div className="text-2xl font-semibold tabular-nums">{ZAR(totalSpend)}</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<StatCard label="Purchase orders" value={`${views.length}`} color="#30638E" />
<StatCard label="Lines allocated" value={`${views.reduce((s, p) => s + p.lines.filter((l) => l.assetCode).length, 0)}`} color="#00798C" />
<StatCard label="No machine matched" value={`${unallocated}`} color="#EDAE49" />
</div>
<PurchaseIntake purchases={views} />
</div>
);
}
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-5 shadow-sm">
<div className="text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">{label}</div>
<div className="text-2xl font-semibold mt-2 tabular-nums" style={{ color }}>
{value}
</div>
</div>
);
}