- 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>
865 lines
41 KiB
TypeScript
865 lines
41 KiB
TypeScript
"use client";
|
||
|
||
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, Pencil,
|
||
} from "lucide-react";
|
||
import { ZAR, ZAR2 } from "@/lib/utils";
|
||
import { invoiceStatusOf, STATUS_STYLE } from "@/lib/purchase-invoice-status";
|
||
|
||
const ACCENT = "#30638E";
|
||
|
||
export type PurchaseLineView = {
|
||
id: string;
|
||
line_no: number;
|
||
material_code: string;
|
||
cost_code: string;
|
||
description: string;
|
||
qty: number | null;
|
||
rate: number | null;
|
||
amount_excl: number;
|
||
vat: number | null;
|
||
total_incl: number | null;
|
||
assetCode: string;
|
||
assetName: string;
|
||
};
|
||
|
||
export type PurchaseView = {
|
||
id: string;
|
||
po_no: string;
|
||
supplier: string;
|
||
supplier_code: string;
|
||
po_date: string;
|
||
required_by: string;
|
||
req_no: string;
|
||
currency: string;
|
||
subtotal: number;
|
||
vat: number;
|
||
total: number;
|
||
source_file: string;
|
||
invoices: PurchaseInvoiceView[];
|
||
lines: PurchaseLineView[];
|
||
};
|
||
|
||
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();
|
||
const [busy, setBusy] = useState(false);
|
||
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;
|
||
setBusy(true);
|
||
setError(null);
|
||
setNotice(null);
|
||
try {
|
||
for (const f of Array.from(files)) {
|
||
const fd = new FormData();
|
||
fd.append("file", f);
|
||
const res = await fetch("/api/purchases/intake", { method: "POST", body: fd });
|
||
const body = await res.json();
|
||
if (!body.ok) {
|
||
setError(`${f.name}: ${body.error}`);
|
||
break;
|
||
}
|
||
// A line that found no machine is money that exists but is not yet on one.
|
||
// Named, not buried — that is the whole point of reading the cost code.
|
||
setNotice(
|
||
body.unallocated?.length
|
||
? `${body.purchase.po_no}: ${body.lines} line(s) imported, but no machine matched ${body.unallocated.join(", ")} — left unallocated.`
|
||
: `${body.purchase.po_no}: ${body.lines} line(s) imported and allocated.`,
|
||
);
|
||
}
|
||
startTransition(() => router.refresh());
|
||
} catch {
|
||
setError("Upload failed — check your connection");
|
||
} finally {
|
||
setBusy(false);
|
||
if (inputRef.current) inputRef.current.value = "";
|
||
}
|
||
}
|
||
|
||
const totalSpend = purchases.reduce((s, p) => s + p.total, 0);
|
||
const unallocatedLines = purchases.reduce(
|
||
(s, p) => s + p.lines.filter((l) => !l.assetCode).length,
|
||
0,
|
||
);
|
||
|
||
return (
|
||
<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 }}>
|
||
{purchases.length} purchase order{purchases.length !== 1 && "s"}
|
||
</span>
|
||
<span className="px-2.5 py-1 rounded-md bg-gray-100 text-gray-500 dark:bg-white/5 dark:text-gray-400 font-medium">
|
||
{ZAR(totalSpend)} total
|
||
</span>
|
||
{unallocatedLines > 0 && (
|
||
<span className="px-2.5 py-1 rounded-md bg-[#EDAE49]/10 text-[#EDAE49] font-medium">
|
||
{unallocatedLines} line{unallocatedLines !== 1 && "s"} with no machine
|
||
</span>
|
||
)}
|
||
</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"
|
||
accept="application/pdf,.pdf"
|
||
multiple
|
||
// The input stays sr-only and the LABEL is the 44px control. It is still
|
||
// focusable, so `peer-focus-visible` puts the focus ring on the label —
|
||
// otherwise a keyboard user tabs onto an invisible 1px target.
|
||
className="sr-only peer"
|
||
id="po-upload"
|
||
onChange={(e) => upload(e.target.files)}
|
||
/>
|
||
<label
|
||
htmlFor="po-upload"
|
||
className="min-h-[44px] px-4 rounded-lg text-xs font-medium inline-flex items-center gap-2 text-white cursor-pointer peer-focus-visible:ring-2 peer-focus-visible:ring-[#30638E] peer-focus-visible:ring-offset-2"
|
||
style={{ background: ACCENT }}
|
||
>
|
||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||
{busy ? "Reading…" : "Upload purchase orders"}
|
||
</label>
|
||
</div>
|
||
{invoiceModal && <InvoiceUploadModal purchases={allPurchases} onClose={() => setInvoiceModal(false)} />}
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mb-4 flex items-start gap-2 rounded-2xl border border-[#D1495B]/30 bg-[#D1495B]/[0.06] p-4">
|
||
<TriangleAlert className="w-4 h-4 mt-0.5 shrink-0 text-[#D1495B] dark:text-[#D94F60]" />
|
||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||
<span className="font-semibold text-[#003D5B] dark:text-white">Could not import</span>
|
||
<div className="mt-1">{error}</div>
|
||
</div>
|
||
<button onClick={() => setError(null)} aria-label="Dismiss"
|
||
className="ml-auto min-h-[44px] min-w-[44px] grid place-items-center rounded-lg hover:bg-white dark:hover:bg-white/5">
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
{notice && (
|
||
<div className="mb-4 flex items-start gap-2 rounded-2xl border border-[#00798C]/30 bg-[#00798C]/[0.06] p-4 text-xs text-gray-600 dark:text-gray-400">
|
||
<Check className="w-4 h-4 mt-0.5 shrink-0 text-[#00798C]" />
|
||
<span>{notice}</span>
|
||
</div>
|
||
)}
|
||
|
||
<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, or drag it onto this page. Each line is allocated to a machine by its cost code.
|
||
</div>
|
||
</section>
|
||
) : (
|
||
<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="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}
|
||
className="w-full flex items-center gap-3 sm:gap-4 p-4 text-left min-h-[44px]"
|
||
>
|
||
<FileText className="w-4 h-4 shrink-0" style={{ color: ACCENT }} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-mono text-xs px-2 py-1 rounded-md bg-[#00798C]/10 text-[#00798C] font-semibold">
|
||
{p.po_no}
|
||
</span>
|
||
<span className="text-sm font-medium text-[#003D5B] dark:text-white truncate">{p.supplier}</span>
|
||
{unalloc > 0 && (
|
||
<TriangleAlert className="w-3.5 h-3.5 text-[#EDAE49]" aria-label="Some lines have no machine" />
|
||
)}
|
||
</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 ·{" "}
|
||
<span style={{ color: sty.color }} className="font-medium">
|
||
{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>
|
||
<div className="text-right shrink-0">
|
||
<div className="text-[12px] text-gray-400 uppercase tracking-wider">Total</div>
|
||
<div className="text-sm font-semibold tabular-nums text-[#003D5B] dark:text-white">
|
||
{ZAR(p.total)}
|
||
</div>
|
||
</div>
|
||
{isOpen ? <ChevronDown className="w-4 h-4 text-gray-400 shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-400 shrink-0" />}
|
||
</button>
|
||
|
||
{isOpen && (
|
||
<div className="border-t border-gray-100 dark:border-border px-4 py-4 bg-gray-50/50 dark:bg-white/[0.04]">
|
||
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2 text-xs mb-4">
|
||
<Meta label="Supplier" value={p.supplier} />
|
||
<Meta label="Supplier code" value={p.supplier_code || "—"} />
|
||
<Meta label="PO date" value={p.po_date || "—"} />
|
||
<Meta label="Required by" value={p.required_by || "—"} />
|
||
<Meta label="Requisition" value={p.req_no || "—"} />
|
||
<Meta label="Currency" value={p.currency || "—"} />
|
||
<Meta label="Sub-total" value={ZAR2(p.subtotal)} />
|
||
<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>
|
||
<tr className="text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-surface-muted">
|
||
<th className="text-left py-2 px-3 font-medium">#</th>
|
||
<th className="text-left py-2 px-3 font-medium">Description</th>
|
||
<th className="text-left py-2 px-3 font-medium">Cost code</th>
|
||
<th className="text-left py-2 px-3 font-medium">Machine</th>
|
||
<th className="text-right py-2 px-3 font-medium">Amount</th>
|
||
<th className="text-right py-2 px-3 font-medium">Incl VAT</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{p.lines.map((l) => (
|
||
<tr key={l.id} className="border-t border-gray-100 dark:border-border">
|
||
<td className="py-2 px-3 text-gray-400">{l.line_no}</td>
|
||
<td className="py-2 px-3 text-[#003D5B] dark:text-gray-300">
|
||
{l.description || <span className="italic text-gray-400">no description</span>}
|
||
</td>
|
||
<td className="py-2 px-3 font-mono text-gray-500 dark:text-gray-400">{l.cost_code || "—"}</td>
|
||
<td className="py-2 px-3">
|
||
{l.assetCode ? (
|
||
<span className="inline-flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||
<span className="font-mono text-[12px] px-1.5 py-0.5 rounded bg-[#00798C]/10 text-[#00798C]">
|
||
{l.assetCode}
|
||
</span>
|
||
<span className="truncate">{l.assetName}</span>
|
||
</span>
|
||
) : (
|
||
<span className="text-[12px] text-[#EDAE49] italic">no matching machine</span>
|
||
)}
|
||
</td>
|
||
<td className="py-2 px-3 text-right tabular-nums text-gray-600 dark:text-gray-400">
|
||
{ZAR2(l.amount_excl)}
|
||
</td>
|
||
<td className="py-2 px-3 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">
|
||
{l.total_incl == null ? "—" : ZAR2(l.total_incl)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Meta({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div>
|
||
<dt className="text-gray-400 uppercase tracking-wider text-[12px]">{label}</dt>
|
||
<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 [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;
|
||
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.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 {
|
||
setError("Could not save — check your connection");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
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" });
|
||
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(Math.abs(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) => 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>
|
||
<span className="text-gray-500 dark:text-gray-400">
|
||
{" "}· {i.invoice_date || "no date"}
|
||
{i.note && ` · ${i.note}`}
|
||
</span>
|
||
</div>
|
||
<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`}
|
||
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" 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
|
||
<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 [amt, setAmt] = useState("");
|
||
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" 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>
|
||
{" · "}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)}` };
|
||
}
|
||
|
||
|
||
/** 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>
|
||
);
|
||
}
|