From 39b649f03586346212010a05a0d429a3447eab70 Mon Sep 17 00:00:00 2001 From: Ysterbaard Date: Fri, 18 Sep 2026 19:51:37 +0200 Subject: [PATCH] feat: /remittance page with batch cards, Replace POP and Unpay buttons --- app/remittance/RemittanceClient.tsx | 183 ++++++++++++++++++++++++++++ app/remittance/page.tsx | 75 ++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 app/remittance/RemittanceClient.tsx create mode 100644 app/remittance/page.tsx diff --git a/app/remittance/RemittanceClient.tsx b/app/remittance/RemittanceClient.tsx new file mode 100644 index 0000000..0da0145 --- /dev/null +++ b/app/remittance/RemittanceClient.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { fmtGBP, fmtDate } from "../lib/pb"; + +type Invoice = { + id: string; + invoice_no: string; + invoice_date: string; + buyer_name_variant: string; + invoice_total: number; +}; + +type BatchWithInvoices = { + batch: { id: string; batch_ref: string; payment_date: string; pop_file: string }; + invoices: Invoice[]; + popUrl: string | null; +}; + +export default function RemittanceClient({ batches }: { batches: BatchWithInvoices[] }) { + const router = useRouter(); + const [expanded, setExpanded] = useState>(new Set()); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + + function toggle(id: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + async function replacePop(batchId: string, file: File) { + setError(null); + setBusyId(batchId); + try { + const fd = new FormData(); + fd.set("pop_file", file); + const resp = await fetch(`/api/payments/${batchId}/replace-pop`, { method: "POST", body: fd }); + const json = await resp.json().catch(() => ({})); + if (!resp.ok) { + setError(json?.error || `HTTP ${resp.status}`); + return; + } + router.refresh(); + } catch (e: any) { + setError(String(e?.message || e)); + } finally { + setBusyId(null); + } + } + + async function unpay(batchId: string, batchRef: string) { + if (!window.confirm(`Unpay batch ${batchRef}? All ${countInvoices(batchId)} invoices will return to unpaid and the batch will be deleted.`)) { + return; + } + setError(null); + setBusyId(batchId); + try { + const resp = await fetch(`/api/payments/${batchId}/unpay`, { method: "POST" }); + const json = await resp.json().catch(() => ({})); + if (!resp.ok) { + setError(json?.error || `HTTP ${resp.status}`); + return; + } + router.refresh(); + } catch (e: any) { + setError(String(e?.message || e)); + } finally { + setBusyId(null); + } + } + + function countInvoices(id: string): number { + return batches.find((b) => b.batch.id === id)?.invoices.length ?? 0; + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + + {batches.map(({ batch, invoices, popUrl }) => { + const total = invoices.reduce((s, inv) => s + (inv.invoice_total || 0), 0); + const isExpanded = expanded.has(batch.id); + const isBusy = busyId === batch.id; + return ( +
+
+
+
{batch.batch_ref}
+
+ {fmtDate(batch.payment_date)} · {invoices.length} invoice{invoices.length === 1 ? "" : "s"} ·{" "} + {fmtGBP(total)} +
+
+
+ {popUrl && ( + + Open POP + + )} + + + +
+
+ {isExpanded && ( +
+ {invoices.length === 0 ? ( +
No invoices in this batch.
+ ) : ( + + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + ))} + +
DateInvoice NoBuyerTotal (£)
{fmtDate(inv.invoice_date)}{inv.invoice_no}{inv.buyer_name_variant}{fmtGBP(inv.invoice_total)} + + View invoice → + +
+ )} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/app/remittance/page.tsx b/app/remittance/page.tsx new file mode 100644 index 0000000..ba9b4e9 --- /dev/null +++ b/app/remittance/page.tsx @@ -0,0 +1,75 @@ +import Link from "next/link"; +import { getPb, fmtGBP, fmtDate } from "../lib/pb"; +import RemittanceClient from "./RemittanceClient"; + +export const dynamic = "force-dynamic"; + +const PB_URL = process.env.NEXT_PUBLIC_PB_URL || process.env.PB_URL || "https://pb.agent.apphub.co.za"; + +async function fetchBatchWithInvoices(pb: any, batchId: string) { + const inv = await pb.collection("JEI_invoices").getList(1, 500, { + filter: `payment_batch="${batchId}"`, + sort: "-invoice_date", + fields: "id,invoice_no,invoice_date,buyer_name_variant,invoice_total", + }); + return inv.items as any[]; +} + +export default async function RemittancePage() { + const pb = getPb(); + let batches: any[] = []; + let errorMsg: string | null = null; + + try { + const r = await pb.collection("JEI_payment_batches").getList(1, 500, { + sort: "-payment_date,-created", + }); + batches = r.items; + } catch (e: any) { + errorMsg = String(e?.message || e); + } + + // Fetch invoices per batch in parallel + let batchWithInvoices: { batch: any; invoices: any[]; popUrl: string | null }[] = []; + if (!errorMsg) { + batchWithInvoices = await Promise.all( + batches.map(async (b) => { + try { + const invoices = await fetchBatchWithInvoices(pb, b.id); + const popUrl = b.pop_file ? `${PB_URL}/api/files/JEI_payment_batches/${b.id}/${b.pop_file}` : null; + return { batch: b, invoices, popUrl }; + } catch (_) { + return { batch: b, invoices: [], popUrl: null }; + } + }), + ); + } + + return ( +
+
+

Remittance

+
{batches.length} batch(es)
+
+ + {errorMsg && ( +
+
Cannot reach PocketBase
+
{errorMsg}
+
+ )} + + {!errorMsg && batches.length === 0 && ( +
+ No payment batches yet. Tick invoices on the{" "} + Unpaid tab{" "} + and click Create Payment. +
+ )} + + {!errorMsg && batches.length > 0 && ( + + )} +
+ ); +}