diff --git a/app/unpaid/UnpaidClient.tsx b/app/unpaid/UnpaidClient.tsx new file mode 100644 index 0000000..e3762fe --- /dev/null +++ b/app/unpaid/UnpaidClient.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { fmtGBP, fmtDate } from "../lib/pb"; + +type Invoice = { + id: string; + invoice_no: string; + invoice_date: string; + buyer_name_variant: string; + customer_order_no: string; + invoice_total: number; +}; + +const STORAGE_KEY = "jei:selected-invoices"; + +function todayYYYYMMDD(): string { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}${m}${day}`; +} + +function nextBatchRefSuggestion(): string { + // We don't fetch today's count server-side; this is a client suggestion only. + // Server validates uniqueness on submit. + return `BATCH-${todayYYYYMMDD()}-001`; +} + +export default function UnpaidClient({ initialRecords }: { initialRecords: Invoice[] }) { + const router = useRouter(); + const records = initialRecords; + const [selected, setSelected] = useState>(new Set()); + const [hydrated, setHydrated] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [batchRef, setBatchRef] = useState(nextBatchRefSuggestion()); + const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().slice(0, 10)); + const [popFile, setPopFile] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Hydrate selection from localStorage + useEffect(() => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const arr = JSON.parse(raw); + if (Array.isArray(arr)) { + // Only keep IDs that are still in the unpaid list + const validIds = new Set(records.map((r) => r.id)); + setSelected(new Set(arr.filter((id: any) => validIds.has(id)))); + } + } + } catch (_) { + // ignore parse errors + } + setHydrated(true); + }, [records]); + + // Persist on change + useEffect(() => { + if (!hydrated) return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(selected))); + } catch (_) { + // localStorage may be unavailable (e.g. private mode) + } + }, [selected, hydrated]); + + const selectedRecords = useMemo( + () => records.filter((r) => selected.has(r.id)), + [records, selected], + ); + const total = useMemo( + () => selectedRecords.reduce((s, r) => s + (r.invoice_total || 0), 0), + [selectedRecords], + ); + + function toggleAll(checked: boolean) { + if (checked) setSelected(new Set(records.map((r) => r.id))); + else setSelected(new Set()); + } + + function toggleOne(id: string, checked: boolean) { + setSelected((prev) => { + const next = new Set(prev); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + } + + const allChecked = records.length > 0 && selected.size === records.length; + + async function submitPayment(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!popFile) { + setError("POP file is required"); + return; + } + setSubmitting(true); + try { + const fd = new FormData(); + fd.set("batch_ref", batchRef); + fd.set("payment_date", paymentDate); + fd.set("pop_file", popFile); + fd.set("invoice_ids", JSON.stringify(Array.from(selected))); + const resp = await fetch("/api/payments/create", { method: "POST", body: fd }); + const json = await resp.json().catch(() => ({})); + if (!resp.ok) { + setError(json?.error || `HTTP ${resp.status}`); + return; + } + // Success: clear selection, close modal, refresh + setSelected(new Set()); + setModalOpen(false); + router.refresh(); + } catch (e: any) { + setError(String(e?.message || e)); + } finally { + setSubmitting(false); + } + } + + return ( + <> +
+ + + + + + + + + + + + + {records.length === 0 && ( + + + + )} + {records.map((r) => ( + + + + + + + + + ))} + +
+ toggleAll(e.target.checked)} + /> + DateInvoice NoBuyerPOTotal (£)
+ No unpaid invoices. All caught up 🎉 +
+ toggleOne(r.id, e.target.checked)} + /> + {fmtDate(r.invoice_date)}{r.invoice_no}{r.buyer_name_variant}{r.customer_order_no || "—"}{fmtGBP(r.invoice_total)}
+
+ + {selected.size > 0 && ( +
+
+ {selected.size} selected ·{" "} + {fmtGBP(total)} +
+ +
+ )} + {selected.size === 0 && records.length > 0 && ( +
+ Tick invoices above to create a payment batch. +
+ )} + + {modalOpen && ( +
+
+

Create Payment Batch

+ +
+ Bundling {selectedRecords.length} invoices for{" "} + {fmtGBP(total)}. +
+ +
+
+ + setBatchRef(e.target.value)} + pattern="[A-Za-z0-9_-]{1,50}" + required + className="w-full border rounded px-3 py-2 font-mono" + /> +
+
+ + setPaymentDate(e.target.value)} + required + className="w-full border rounded px-3 py-2" + /> +
+
+ + setPopFile(e.target.files?.[0] || null)} + className="w-full border rounded px-3 py-2" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ )} + + ); +} \ No newline at end of file diff --git a/app/unpaid/page.tsx b/app/unpaid/page.tsx new file mode 100644 index 0000000..afda78c --- /dev/null +++ b/app/unpaid/page.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; +import { getPb, fmtGBP, fmtDate } from "../lib/pb"; +import UnpaidClient from "./UnpaidClient"; + +export const dynamic = "force-dynamic"; + +export default async function UnpaidPage() { + const pb = getPb(); + let records: any[] = []; + let errorMsg: string | null = null; + + try { + const r = await pb.collection("JEI_invoices").getList(1, 500, { + filter: 'payment_batch=""', + sort: "-invoice_date", + fields: "id,invoice_no,invoice_date,buyer_name_variant,customer_order_no,invoice_total,payment_batch", + }); + records = r.items; + } catch (e: any) { + errorMsg = String(e?.message || e); + } + + return ( +
+
+

Unpaid Invoices

+
{records.length} unpaid
+
+ + {errorMsg && ( +
+
Cannot reach PocketBase
+
{errorMsg}
+
+ )} + + {!errorMsg && } +
+ ); +} \ No newline at end of file