- 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>
145 lines
No EOL
6.3 KiB
TypeScript
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>
|
|
);
|
|
} |