Suppliers: replace the menu entry with the new page, flag over-invoiced POs, audited invoice amount corrections
- Menu: old Suppliers leaf hidden (route kept), Suppliers 2 renamed Suppliers - PO invoice status: new 'over' state (never closed), live over-PO warning when typing - Admin correction of invoice amount/date/number with mandatory reason, logged to purchase_invoice_edits - Payable now carries unpaid balance forward month to month Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5f86657849
commit
642d23dd24
9 changed files with 315 additions and 24 deletions
|
|
@ -52,7 +52,7 @@ export default async function Suppliers2Page() {
|
|||
<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>
|
||||
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Suppliers</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">Spend and payables per supplier</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,8 +78,9 @@ const navItems: NavItem[] = [
|
|||
{ href: "/expenses", label: "Overview" },
|
||||
// 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" },
|
||||
// The original list at /expenses/suppliers still exists but is hidden from the menu;
|
||||
// this page replaced it.
|
||||
{ href: "/expenses/suppliers-2", label: "Suppliers" },
|
||||
{ href: "/expenses/personnel", label: "Personnel" },
|
||||
{ href: "/expenses/subscriptions", label: "Subscriptions" },
|
||||
{ href: "/expenses/rent", label: "Rent & Utilities" },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { revalidatePath } from "next/cache";
|
||||
import { serverAuth } from "@/lib/pb";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { refuseUnlessFull } from "@/lib/require-full";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -17,8 +19,74 @@ export async function DELETE(
|
|||
}
|
||||
await pb.collection("purchase_invoices").delete(invoiceId);
|
||||
revalidatePath("/running/purchases");
|
||||
revalidatePath("/expenses/suppliers-2");
|
||||
return Response.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
return Response.json({ ok: false, error: e?.message || "Could not delete the invoice" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/purchases/:id/invoices/:invoiceId { amount_incl?, invoice_date?, invoice_no?, reason }
|
||||
// Admin correction of a typed-in invoice. A REASON is mandatory, and every changed field is
|
||||
// written to `purchase_invoice_edits` (old value, new value, reason, who) — append-only.
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string; invoiceId: string }> },
|
||||
) {
|
||||
const refused = await refuseUnlessFull();
|
||||
if (refused) return refused;
|
||||
const { id, invoiceId } = await params;
|
||||
let body: { amount_incl?: unknown; invoice_date?: unknown; invoice_no?: unknown; reason?: unknown } = {};
|
||||
try { body = await req.json(); } catch { return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 }); }
|
||||
|
||||
const reason = String(body.reason ?? "").trim();
|
||||
if (reason.length < 3) return Response.json({ ok: false, error: "Give a reason for the change." }, { status: 400 });
|
||||
|
||||
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 });
|
||||
const po = await pb.collection("purchases").getOne(id);
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
const changes: { field: string; old: string; next: string }[] = [];
|
||||
if ("amount_incl" in body) {
|
||||
const raw = String(body.amount_incl ?? "").trim().replace(/\s/g, "").replace(",", ".");
|
||||
const n = Number(raw);
|
||||
if (!raw || !Number.isFinite(n) || n < 0) return Response.json({ ok: false, error: "The amount must be a positive number." }, { status: 400 });
|
||||
if (n !== (inv.amount_incl ?? 0)) { patch.amount_incl = n; changes.push({ field: "amount_incl", old: String(inv.amount_incl ?? ""), next: String(n) }); }
|
||||
}
|
||||
if ("invoice_date" in body) {
|
||||
const d = String(body.invoice_date ?? "").trim().slice(0, 10);
|
||||
if (d && !/^\d{4}-\d{2}-\d{2}$/.test(d)) return Response.json({ ok: false, error: "Enter a valid date." }, { status: 400 });
|
||||
if (d !== (inv.invoice_date || "")) { patch.invoice_date = d; changes.push({ field: "invoice_date", old: inv.invoice_date || "", next: d }); }
|
||||
}
|
||||
if ("invoice_no" in body) {
|
||||
const no = String(body.invoice_no ?? "").trim();
|
||||
if (!no) return Response.json({ ok: false, error: "The invoice number cannot be empty." }, { status: 400 });
|
||||
if (no !== inv.invoice_no) {
|
||||
const clash = (await pb.collection("purchase_invoices").getFullList({ filter: `purchase = "${id}"` }))
|
||||
.find((i) => i.id !== invoiceId && (i.invoice_no || "").toUpperCase() === no.toUpperCase());
|
||||
if (clash) return Response.json({ ok: false, error: `${no} is already on ${po.po_no}` }, { status: 409 });
|
||||
patch.invoice_no = no; changes.push({ field: "invoice_no", old: inv.invoice_no || "", next: no });
|
||||
}
|
||||
}
|
||||
if (!changes.length) return Response.json({ ok: false, error: "Nothing was changed." }, { status: 400 });
|
||||
|
||||
const session = await getSession();
|
||||
const who = session ? `${session.user.name || session.user.email} <${session.user.email}>` : "unknown";
|
||||
// Log FIRST: if the log cannot be written the edit must not happen unrecorded.
|
||||
for (const c of changes) {
|
||||
await pb.collection("purchase_invoice_edits").create({
|
||||
invoice: invoiceId, po_no: po.po_no, invoice_no: inv.invoice_no, field: c.field,
|
||||
old_value: c.old, new_value: c.next, reason: reason.slice(0, 500), edited_by: who,
|
||||
});
|
||||
}
|
||||
await pb.collection("purchase_invoices").update(invoiceId, patch);
|
||||
revalidatePath("/running/purchases");
|
||||
revalidatePath("/expenses/suppliers-2");
|
||||
return Response.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
return Response.json({ ok: false, error: e?.message || "Could not save the change" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,12 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
|||
}
|
||||
|
||||
revalidatePath("/running/purchases");
|
||||
return Response.json({ ok: true, warning, invoice: { id: row.id, invoice_no: invoiceNo } });
|
||||
revalidatePath("/expenses/suppliers-2");
|
||||
// Not blocked (an order can legitimately be amended), but never silent: an invoice above
|
||||
// the PO total is how a missed decimal point (R2 210,83 → R221 083) looks.
|
||||
const invoicedBefore = existing.reduce((t, i) => t + (i.amount_incl || 0), 0);
|
||||
const over = amount != null ? invoicedBefore + amount - (purchase.total || 0) : 0;
|
||||
return Response.json({ ok: true, warning, over: over > 1 ? over : 0, 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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useState, useRef, useTransition, useMemo } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Upload, FileText, ChevronDown, ChevronRight, Loader2, TriangleAlert,
|
||||
Check, ShoppingCart, X, Plus, Trash2, Search, Receipt, ChevronLeft,
|
||||
Check, ShoppingCart, X, Plus, Trash2, Search, Receipt, ChevronLeft, Pencil,
|
||||
} from "lucide-react";
|
||||
import { ZAR, ZAR2 } from "@/lib/utils";
|
||||
import { invoiceStatusOf, STATUS_STYLE } from "@/lib/purchase-invoice-status";
|
||||
|
|
@ -307,7 +307,7 @@ export function PurchaseIntake({ purchases: allPurchases }: { purchases: Purchas
|
|||
{p.po_date || "no date"} · {p.lines.length} line{p.lines.length !== 1 && "s"} ·{" "}
|
||||
{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"}
|
||||
{inv.status === "over" ? `invoiced ${ZAR2(inv.invoiced - p.total)} OVER the PO — check the amount` : 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>
|
||||
|
|
@ -414,6 +414,10 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
|
|||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [amt, setAmt] = useState("");
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [editAmt, setEditAmt] = useState("");
|
||||
const [editReason, setEditReason] = useState("");
|
||||
|
||||
const invoiced = p.invoices.reduce((s, i) => s + (i.amount_incl ?? 0), 0);
|
||||
const unpriced = p.invoices.filter((i) => i.amount_incl == null).length;
|
||||
|
|
@ -431,8 +435,10 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
|
|||
setError(body.error);
|
||||
return;
|
||||
}
|
||||
if (body.warning) setError(body.warning);
|
||||
if (body.over > 0) setError(`Saved — but this takes the invoices ${ZAR2(body.over)} OVER the PO total. Check the amount.`);
|
||||
else if (body.warning) setError(body.warning);
|
||||
form.reset();
|
||||
setAmt("");
|
||||
setAdding(false);
|
||||
router.refresh();
|
||||
} catch {
|
||||
|
|
@ -442,6 +448,33 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
|
|||
}
|
||||
}
|
||||
|
||||
function startEdit(i: PurchaseInvoiceView) {
|
||||
setEditId(i.id);
|
||||
setEditAmt(i.amount_incl == null ? "" : String(i.amount_incl));
|
||||
setEditReason("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function saveEdit(i: PurchaseInvoiceView) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/purchases/${p.id}/invoices/${i.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ amount_incl: editAmt, reason: editReason }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!body.ok) { setError(body.error); return; }
|
||||
setEditId(null);
|
||||
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" });
|
||||
|
|
@ -457,7 +490,7 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
|
|||
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"}
|
||||
{ZAR2(invoiced)} invoiced · {ZAR2(Math.abs(outstanding))} of PO {outstanding < 0 ? "over" : "not yet invoiced"}
|
||||
{unpriced > 0 && ` · ${unpriced} with no amount`}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -479,7 +512,28 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
|
|||
|
||||
{p.invoices.length > 0 && (
|
||||
<ul className="divide-y divide-gray-100 dark:divide-border">
|
||||
{p.invoices.map((i) => (
|
||||
{p.invoices.map((i) => editId === i.id ? (
|
||||
<li key={i.id} className="py-2 space-y-2">
|
||||
<div className="text-xs font-semibold text-[#003D5B] dark:text-white">Correct invoice {i.invoice_no}</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<label className="text-[12px] text-gray-500 dark:text-gray-400">
|
||||
Amount incl VAT (PO is {ZAR2(p.total)})
|
||||
<input value={editAmt} onChange={(e) => setEditAmt(e.target.value)} inputMode="decimal" className={inputCls} autoFocus />
|
||||
</label>
|
||||
<label className="text-[12px] text-gray-500 dark:text-gray-400 sm:col-span-2">
|
||||
Reason for the change (kept in the log)
|
||||
<input value={editReason} onChange={(e) => setEditReason(e.target.value)} placeholder="e.g. decimal point missed when captured" className={inputCls} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => saveEdit(i)} disabled={busy || editReason.trim().length < 3}
|
||||
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: ACCENT }}>
|
||||
{busy && <Loader2 className="w-4 h-4 animate-spin" />} Save correction
|
||||
</button>
|
||||
<button onClick={() => setEditId(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>
|
||||
</li>
|
||||
) : (
|
||||
<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>
|
||||
|
|
@ -488,9 +542,20 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
|
|||
{i.note && ` · ${i.note}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs tabular-nums font-semibold text-[#003D5B] dark:text-white shrink-0">
|
||||
<div
|
||||
onDoubleClick={() => startEdit(i)}
|
||||
title="Double-click to correct this amount"
|
||||
className="text-xs tabular-nums font-semibold text-[#003D5B] dark:text-white shrink-0 cursor-text select-none"
|
||||
>
|
||||
{i.amount_incl == null ? <span className="font-normal italic text-gray-400">no amount</span> : ZAR2(i.amount_incl)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => startEdit(i)}
|
||||
aria-label={`Correct the amount of ${i.invoice_no}`}
|
||||
className="min-h-[44px] min-w-[44px] grid place-items-center rounded-lg text-gray-400 hover:text-[#30638E] hover:bg-gray-50 dark:hover:bg-white/5"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
{i.hasPdf ? (
|
||||
<a
|
||||
href={`/api/purchases/${p.id}/invoices/${i.id}/document`}
|
||||
|
|
@ -530,7 +595,8 @@ function SupplierInvoices({ purchase: p }: { purchase: PurchaseView }) {
|
|||
</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} />
|
||||
<input name="amount_incl" value={amt} onChange={(e) => setAmt(e.target.value)} inputMode="decimal" placeholder="0.00" className={inputCls} />
|
||||
<OverWarning po={p} typed={amt} />
|
||||
</label>
|
||||
<label className="text-[12px] text-gray-500 dark:text-gray-400">
|
||||
Note
|
||||
|
|
@ -580,6 +646,7 @@ function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[];
|
|||
const [query, setQuery] = useState("");
|
||||
const [poId, setPoId] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [amt, setAmt] = useState("");
|
||||
const [drag, setDrag] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -706,7 +773,8 @@ function InvoiceUploadModal({ purchases, onClose }: { purchases: PurchaseView[];
|
|||
</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} />
|
||||
<input name="amount_incl" value={amt} onChange={(e) => setAmt(e.target.value)} inputMode="decimal" placeholder="0.00" className={inputCls} />
|
||||
{po && <OverWarning po={po} typed={amt} />}
|
||||
{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>
|
||||
|
|
@ -780,3 +848,18 @@ function rangeWindow(range: "week" | "month", offset: number): { from: string; t
|
|||
const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6);
|
||||
return { from: isoDay(monday), to: isoDay(sunday), label: `${fmtDay(monday)} – ${fmtDay(sunday)}` };
|
||||
}
|
||||
|
||||
|
||||
/** Live check while typing an invoice amount: anything that takes the PO over its total is flagged before it is saved. */
|
||||
function OverWarning({ po, typed }: { po: PurchaseView; typed: string }) {
|
||||
const n = Number(typed.trim().replace(/\s/g, "").replace(",", "."));
|
||||
if (!typed.trim() || !Number.isFinite(n)) return null;
|
||||
const over = po.invoices.reduce((t, i) => t + (i.amount_incl ?? 0), 0) + n - po.total;
|
||||
if (over <= 1) return null;
|
||||
return (
|
||||
<span className="mt-1 flex items-start gap-1.5 text-xs font-medium text-[#8B5CF6]">
|
||||
<TriangleAlert className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
This is {ZAR2(over)} MORE than the PO ({ZAR2(po.total)}). Check for a typo before saving.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,9 @@ export function Suppliers2View({
|
|||
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 remainingNow = visible.reduce((s, r) => s + r.payableNet, 0);
|
||||
const surplusNow = visible.reduce((s, r) => s + r.surplus, 0);
|
||||
const broughtFwd = visible.reduce((s, r) => s + r.broughtForward, 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);
|
||||
|
|
@ -62,7 +63,7 @@ export function Suppliers2View({
|
|||
<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`}
|
||||
{ZAR(paidNowTotal)} paid this month{broughtFwd > 0 && ` · ${ZAR(broughtFwd)} brought forward`}{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">
|
||||
|
|
@ -113,7 +114,7 @@ export function Suppliers2View({
|
|||
{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) => (
|
||||
{(["none", "partial", "complete", "over"] 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>
|
||||
|
|
@ -148,8 +149,8 @@ export function Suppliers2View({
|
|||
</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 className={`text-sm font-semibold tabular-nums ${r.payableNet > 0.005 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
|
||||
{r.payableNet > 0.005 ? ZAR2(r.payableNet) : "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0 w-32 hidden md:block">
|
||||
|
|
@ -172,8 +173,8 @@ export function Suppliers2View({
|
|||
</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 className={`text-sm font-semibold tabular-nums ${r.payableNet > 0.005 ? "text-[#EDAE49]" : "text-gray-300 dark:text-text-soft"}`}>
|
||||
{r.payableNet > 0.005 ? ZAR2(r.payableNet) : "—"}
|
||||
</div>
|
||||
{r.paidNow > 0 && <div className="text-xs text-[#00798C] tabular-nums">paid {ZAR(r.paidNow)}</div>}
|
||||
</div>
|
||||
|
|
@ -227,7 +228,9 @@ export function Suppliers2View({
|
|||
<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.invoiceCount === 0 ? "no invoice yet" : p.status === "over"
|
||||
? <span className="font-semibold text-[#8B5CF6]">{ZAR2(p.invoiced)} invoiced — {ZAR2(p.invoiced - p.total)} OVER the PO</span>
|
||||
: `${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>
|
||||
|
|
|
|||
|
|
@ -3,16 +3,18 @@
|
|||
/** 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";
|
||||
export type InvoiceStatus = "none" | "partial" | "complete" | "over";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* of the PO total, so it can be closed; purple = invoiced MORE than the PO by over R1. That is
|
||||
* never "closed": it is a typo, a duplicate or a changed order, and a person has to look.
|
||||
*/
|
||||
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 - total > CLOSE_TOLERANCE) return { status: "over", invoiced };
|
||||
if (invoiced > 0 && total - invoiced <= CLOSE_TOLERANCE) return { status: "complete", invoiced };
|
||||
return { status: "partial", invoiced };
|
||||
}
|
||||
|
|
@ -21,4 +23,5 @@ 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" },
|
||||
over: { color: "#8B5CF6", label: "Invoiced MORE than the PO — check" },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ export type SupplierSpendRow = {
|
|||
paidNow: number;
|
||||
/** Due up to and including this month, minus paid in the same months: positive = still owing. */
|
||||
owingToDate: number;
|
||||
/** What is payable NOW: everything due up to and including this month, less everything paid, so an unpaid month rolls into the next on the 1st. 0 when settled or ahead. */
|
||||
payableNet: number;
|
||||
/** The part of payableNet that fell due in EARLIER months. */
|
||||
broughtForward: number;
|
||||
/** Paid above what has fallen due. */
|
||||
surplus: number;
|
||||
payments: SupplierPayment[];
|
||||
ytd: number; thisMonth: number;
|
||||
payableNow: number; payableNext: number; payableLater: number; termsUnsetInvoiced: number;
|
||||
|
|
@ -106,9 +112,12 @@ export function buildSupplierSpend(i: SpendInput): SupplierSpendRow[] {
|
|||
}
|
||||
const upTo = (a: number[]) => a.slice(0, i.month).reduce((x, y) => x + y, 0);
|
||||
const owingToDate = upTo(dueByMonth) - upTo(paidByMonth);
|
||||
const upToPrev = (a: number[]) => a.slice(0, i.month - 1).reduce((x, y) => x + y, 0);
|
||||
const broughtForward = Math.max(Math.min(upToPrev(dueByMonth) - upToPrev(paidByMonth), owingToDate), 0);
|
||||
|
||||
// Supplier colour: every PO fully invoiced → teal; nothing invoiced anywhere → red.
|
||||
// Supplier colour: any PO over-invoiced → purple (needs a look); every PO fully invoiced → teal; nothing invoiced anywhere → red.
|
||||
const status: SupplierSpendRow["status"] = pos.length === 0 ? "n/a"
|
||||
: pos.some((p) => p.status === "over") ? "over"
|
||||
: pos.every((p) => p.status === "complete") ? "complete"
|
||||
: pos.every((p) => p.status === "none") ? "none" : "partial";
|
||||
|
||||
|
|
@ -116,6 +125,7 @@ export function buildSupplierSpend(i: SpendInput): SupplierSpendRow[] {
|
|||
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,
|
||||
payableNet: Math.max(owingToDate, 0), broughtForward, surplus: Math.max(-owingToDate, 0),
|
||||
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],
|
||||
|
|
|
|||
118
scripts/setup-invoice-edits-collection.mjs
Normal file
118
scripts/setup-invoice-edits-collection.mjs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Create the `purchase_invoices` PocketBase collection.
|
||||
//
|
||||
// Audit log of edits to a supplier invoice (amount / date / number). Append-only: the app
|
||||
// never updates or deletes a row. The invoice and PO numbers are copied as TEXT so the log
|
||||
// still reads correctly if the invoice itself is later deleted.
|
||||
//
|
||||
// Idempotent: safe to re-run.
|
||||
//
|
||||
// Usage: node scripts/setup-invoice-edits-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 invId = (await pb.collections.getOne("purchase_invoices")).id;
|
||||
|
||||
await ensureCollection("purchase_invoice_edits", [
|
||||
{ name: "invoice", type: "relation", collectionId: invId, minSelect: 0, maxSelect: 1, cascadeDelete: false },
|
||||
{ name: "po_no", type: "text" },
|
||||
{ name: "invoice_no", type: "text" },
|
||||
{ name: "field", type: "text" },
|
||||
{ name: "old_value", type: "text" },
|
||||
{ name: "new_value", type: "text" },
|
||||
{ name: "reason", type: "text", required: true },
|
||||
{ name: "edited_by", type: "text" },
|
||||
{ name: "created", type: "date", onCreate: true, presentable: false },
|
||||
{ name: "updated", type: "date", onUpdate: true, presentable: false },
|
||||
]);
|
||||
|
||||
console.log("\n✓ purchase_invoice_edits ready.");
|
||||
process.exit(0);
|
||||
Loading…
Reference in a new issue