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:
parent
cfd54385b3
commit
5f86657849
15 changed files with 1602 additions and 18 deletions
63
app/(dashboard)/expenses/suppliers-2/page.tsx
Normal file
63
app/(dashboard)/expenses/suppliers-2/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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<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>();
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
24
app/api/purchases/[id]/invoices/[invoiceId]/route.ts
Normal file
24
app/api/purchases/[id]/invoices/[invoiceId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
81
app/api/purchases/[id]/invoices/route.ts
Normal file
81
app/api/purchases/[id]/invoices/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
20
app/api/supplier-payments/[id]/route.ts
Normal file
20
app/api/supplier-payments/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
48
app/api/supplier-payments/route.ts
Normal file
48
app/api/supplier-payments/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HTMLInputElement>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
|
@ -49,6 +60,47 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = 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) {
|
||||
if (!files || !files.length) return;
|
||||
|
|
@ -89,7 +141,14 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
|
|||
);
|
||||
|
||||
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 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 }}>
|
||||
|
|
@ -105,7 +164,15 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
|
|||
)}
|
||||
</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
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
|
|
@ -127,6 +194,7 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
|
|||
{busy ? "Reading…" : "Upload purchase orders"}
|
||||
</label>
|
||||
</div>
|
||||
{invoiceModal && <InvoiceUploadModal purchases={allPurchases} onClose={() => setInvoiceModal(false)} />}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
|
@ -149,21 +217,76 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
|
|||
</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">
|
||||
<ShoppingCart className="w-8 h-8 mx-auto mb-3 text-gray-300" />
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">No purchase orders yet.</div>
|
||||
<div className="text-xs text-gray-400 dark:text-text-soft mt-1">
|
||||
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>
|
||||
</section>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{purchases.map((p) => {
|
||||
<div className="space-y-5">
|
||||
{groups.map((g) => (
|
||||
<div key={g.key || "all"}>
|
||||
{groupBySupplier && (
|
||||
<div className="flex items-baseline justify-between gap-3 mb-2 px-1">
|
||||
<h3 className="text-sm font-semibold text-[#003D5B] dark:text-white truncate">{g.name}</h3>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 shrink-0 tabular-nums">
|
||||
{g.items.length} PO{g.items.length !== 1 && "s"} · {ZAR2(g.items.reduce((t, x) => t + x.total, 0))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{g.items.map((p) => {
|
||||
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 (
|
||||
<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
|
||||
onClick={() => setOpen(isOpen ? null : p.id)}
|
||||
aria-expanded={isOpen}
|
||||
|
|
@ -182,7 +305,10 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
|
|||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{p.po_date || "no date"} · {p.lines.length} line{p.lines.length !== 1 && "s"} ·{" "}
|
||||
{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 className="text-right shrink-0">
|
||||
|
|
@ -207,6 +333,8 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
|
|||
<Meta label="VAT" value={ZAR2(p.vat)} />
|
||||
</dl>
|
||||
|
||||
<SupplierInvoices purchase={p} />
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-100 dark:border-border bg-white dark:bg-surface">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
|
|
@ -255,6 +383,9 @@ export function PurchaseIntake({ purchases }: { purchases: PurchaseView[] }) {
|
|||
</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>
|
||||
</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)}` };
|
||||
}
|
||||
|
|
|
|||
381
components/Suppliers2View.tsx
Normal file
381
components/Suppliers2View.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
lib/purchase-invoice-status.ts
Normal file
24
lib/purchase-invoice-status.ts
Normal 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" },
|
||||
};
|
||||
|
|
@ -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<ParsedPurchase
|
|||
}
|
||||
|
||||
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++) {
|
||||
const page = await doc.getPage(p);
|
||||
const tc = await page.getTextContent();
|
||||
const words: Word[] = [];
|
||||
for (const item of tc.items as any[]) {
|
||||
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);
|
||||
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),
|
||||
|
|
|
|||
126
lib/supplier-spend.ts
Normal file
126
lib/supplier-spend.ts
Normal 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)),
|
||||
};
|
||||
});
|
||||
}
|
||||
118
scripts/setup-purchase-invoices-collection.mjs
Normal file
118
scripts/setup-purchase-invoices-collection.mjs
Normal 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);
|
||||
115
scripts/setup-supplier-payments-collection.mjs
Normal file
115
scripts/setup-supplier-payments-collection.mjs
Normal 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);
|
||||
Loading…
Reference in a new issue