From bcc8dfdfbc983c692bd8c31ab6de31e67c29589c Mon Sep 17 00:00:00 2001 From: Ysterbaard Date: Fri, 18 Sep 2026 23:14:19 +0200 Subject: [PATCH] feat: edit button on remittance batches (batch_ref, payment_date, notes, zar_paid) --- app/api/payments/[id]/edit/route.ts | 113 ++++++++++++++ app/remittance/RemittanceClient.tsx | 231 ++++++++++++++++++++++------ app/remittance/page.tsx | 2 +- 3 files changed, 299 insertions(+), 47 deletions(-) create mode 100644 app/api/payments/[id]/edit/route.ts diff --git a/app/api/payments/[id]/edit/route.ts b/app/api/payments/[id]/edit/route.ts new file mode 100644 index 0000000..b4926b2 --- /dev/null +++ b/app/api/payments/[id]/edit/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; +import { getPb } from "../../../../lib/pb"; + +const BATCHES = "JEI_payment_batches"; +const BATCH_REF_PATTERN = /^[A-Za-z0-9_-]{1,50}$/; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export const dynamic = "force-dynamic"; + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } }, +) { + const pb = getPb(); + const id = params.id; + + // Confirm batch exists + try { + await pb.collection(BATCHES).getOne(id); + } catch (e: any) { + return NextResponse.json({ error: `Batch ${id} not found` }, { status: 404 }); + } + + let body: any; + try { + body = await req.json(); + } catch (e: any) { + return NextResponse.json({ error: `Invalid JSON body: ${e?.message || e}` }, { status: 400 }); + } + + const batchRef = String(body.batch_ref || "").trim(); + const paymentDate = String(body.payment_date || "").trim(); + const notes = String(body.notes || "").trim(); + const zarPaidRaw = body.zar_paid; + + // Validate + if (!BATCH_REF_PATTERN.test(batchRef)) { + return NextResponse.json( + { error: "batch_ref must match ^[A-Za-z0-9_-]{1,50}$" }, + { status: 400 }, + ); + } + if (!DATE_PATTERN.test(paymentDate)) { + return NextResponse.json( + { error: "payment_date must be YYYY-MM-DD" }, + { status: 400 }, + ); + } + if (notes.length > 1000) { + return NextResponse.json( + { error: "notes must be <= 1000 chars" }, + { status: 400 }, + ); + } + + // zar_paid: optional number >= 0. Empty/null/missing => 0. Anything else + // that doesn't parse as a finite non-negative number => 400. + let zarPaid: number; + if (zarPaidRaw === undefined || zarPaidRaw === null || zarPaidRaw === "") { + zarPaid = 0; + } else { + const n = Number(zarPaidRaw); + if (!Number.isFinite(n) || n < 0) { + return NextResponse.json( + { error: `zar_paid must be a non-negative number (got ${JSON.stringify(zarPaidRaw)})` }, + { status: 400 }, + ); + } + zarPaid = n; + } + + // Uniqueness check: if batch_ref changed, make sure no other batch already + // has this ref. + try { + const dupe = await pb.collection(BATCHES).getList(1, 1, { + filter: `batch_ref="${batchRef.replace(/"/g, '\\"')}" && id!="${id}"`, + }); + if (dupe.totalItems > 0) { + return NextResponse.json( + { error: `Batch ref "${batchRef}" already exists on another batch` }, + { status: 409 }, + ); + } + } catch (e: any) { + return NextResponse.json( + { error: `batch_ref uniqueness check failed: ${e?.message || e}` }, + { status: 500 }, + ); + } + + // PATCH the batch + try { + await pb.collection(BATCHES).update(id, { + batch_ref: batchRef, + payment_date: paymentDate, + notes: notes, + zar_paid: zarPaid, + }); + } catch (e: any) { + return NextResponse.json( + { error: `Failed to update batch: ${e?.message || e}` }, + { status: 500 }, + ); + } + + // Invalidate the affected pages so the next navigation fetches fresh data. + revalidatePath("/remittance"); + revalidatePath("/invoices"); + revalidatePath("/unpaid"); + + return NextResponse.json({ ok: true }); +} diff --git a/app/remittance/RemittanceClient.tsx b/app/remittance/RemittanceClient.tsx index 73f1343..a8f770a 100644 --- a/app/remittance/RemittanceClient.tsx +++ b/app/remittance/RemittanceClient.tsx @@ -14,7 +14,7 @@ type Invoice = { }; type BatchWithInvoices = { - batch: { id: string; batch_ref: string; payment_date: string; pop_file: string; zar_paid?: number }; + batch: { id: string; batch_ref: string; payment_date: string; pop_file: string; zar_paid?: number; notes?: string }; invoices: Invoice[]; popUrl: string | null; }; @@ -24,6 +24,15 @@ export default function RemittanceClient({ batches }: { batches: BatchWithInvoic const [expanded, setExpanded] = useState>(new Set()); const [busyId, setBusyId] = useState(null); const [error, setError] = useState(null); + // editingId + editForm: when editingId === batch.id, render an inline form + // instead of the read-only header. + const [editingId, setEditingId] = useState(null); + const [editForm, setEditForm] = useState<{ batch_ref: string; payment_date: string; notes: string; zar_paid: string }>({ + batch_ref: "", + payment_date: "", + notes: "", + zar_paid: "", + }); function toggle(id: string) { setExpanded((prev) => { @@ -34,6 +43,50 @@ export default function RemittanceClient({ batches }: { batches: BatchWithInvoic }); } + function startEdit(batch: BatchWithInvoices["batch"]) { + setEditingId(batch.id); + setEditForm({ + batch_ref: batch.batch_ref, + payment_date: batch.payment_date ? batch.payment_date.slice(0, 10) : "", + notes: batch.notes ?? "", + zar_paid: typeof batch.zar_paid === "number" && batch.zar_paid > 0 ? String(batch.zar_paid) : "", + }); + setError(null); + } + + function cancelEdit() { + setEditingId(null); + setError(null); + } + + async function saveEdit(batchId: string) { + setError(null); + setBusyId(batchId); + try { + const resp = await fetch(`/api/payments/${batchId}/edit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + batch_ref: editForm.batch_ref, + payment_date: editForm.payment_date, + notes: editForm.notes, + zar_paid: editForm.zar_paid.trim() === "" ? 0 : Number(editForm.zar_paid), + }), + }); + const json = await resp.json().catch(() => ({})); + if (!resp.ok) { + setError(json?.error || `HTTP ${resp.status}`); + return; + } + setEditingId(null); + router.refresh(); + } catch (e: any) { + setError(String(e?.message || e)); + } finally { + setBusyId(null); + } + } + async function replacePop(batchId: string, file: File) { setError(null); setBusyId(batchId); @@ -91,59 +144,145 @@ export default function RemittanceClient({ batches }: { batches: BatchWithInvoic const total = invoices.reduce((s, inv) => s + (inv.invoice_total || 0), 0); const isExpanded = expanded.has(batch.id); const isBusy = busyId === batch.id; + const isEditing = editingId === batch.id; return (
-
-
-
{batch.batch_ref}
-
- {fmtDate(batch.payment_date)} · {invoices.length} invoice{invoices.length === 1 ? "" : "s"} ·{" "} - {fmtGBP(total)} - {batch.zar_paid && batch.zar_paid > 0 ? ( - · ZAR Paid R{batch.zar_paid.toLocaleString("en-ZA", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} - ) : null} + {isEditing ? ( + // EDIT MODE +
+
Edit batch
+
+
+ + setEditForm({ ...editForm, batch_ref: e.target.value })} + pattern="[A-Za-z0-9_-]{1,50}" + required + className="w-full border rounded px-3 py-2 font-mono" + /> +
+
+ + setEditForm({ ...editForm, payment_date: e.target.value })} + required + className="w-full border rounded px-3 py-2" + /> +
+
+ + setEditForm({ ...editForm, zar_paid: e.target.value })} + placeholder="e.g. 12500.00" + className="w-full border rounded px-3 py-2 tabular-nums" + /> +
+
+ +