feat: edit button on remittance batches (batch_ref, payment_date, notes, zar_paid)

This commit is contained in:
Ysterbaard 2026-09-18 23:14:19 +02:00
parent 551baeeb80
commit bcc8dfdfbc
3 changed files with 299 additions and 47 deletions

View file

@ -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 });
}

View file

@ -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<Set<string>>(new Set());
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// editingId + editForm: when editingId === batch.id, render an inline form
// instead of the read-only header.
const [editingId, setEditingId] = useState<string | null>(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 (
<div key={batch.id} id={`batch-${batch.id}`} className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b flex items-center justify-between gap-4 flex-wrap">
<div>
<div className="font-mono font-semibold text-brand-dark">{batch.batch_ref}</div>
<div className="text-xs text-slate-500">
{fmtDate(batch.payment_date)} · {invoices.length} invoice{invoices.length === 1 ? "" : "s"} ·{" "}
<span className="tabular-nums">{fmtGBP(total)}</span>
{batch.zar_paid && batch.zar_paid > 0 ? (
<span className="ml-1">· ZAR Paid <span className="tabular-nums font-semibold text-slate-700">R{batch.zar_paid.toLocaleString("en-ZA", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span></span>
) : null}
{isEditing ? (
// EDIT MODE
<div className="px-6 py-4 border-b">
<div className="text-sm font-semibold text-brand-dark mb-3">Edit batch</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-500">Batch reference</label>
<input
type="text"
value={editForm.batch_ref}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-slate-500">Payment date</label>
<input
type="date"
value={editForm.payment_date}
onChange={(e) => setEditForm({ ...editForm, payment_date: e.target.value })}
required
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label className="text-xs text-slate-500">ZAR Paid <span className="text-slate-400">(optional, blank = 0)</span></label>
<input
type="number"
step="0.01"
min="0"
inputMode="decimal"
value={editForm.zar_paid}
onChange={(e) => setEditForm({ ...editForm, zar_paid: e.target.value })}
placeholder="e.g. 12500.00"
className="w-full border rounded px-3 py-2 tabular-nums"
/>
</div>
<div className="sm:col-span-2">
<label className="text-xs text-slate-500">Notes</label>
<textarea
value={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
rows={2}
maxLength={1000}
className="w-full border rounded px-3 py-2"
placeholder="Optional notes about this batch…"
/>
</div>
</div>
<div className="flex gap-2 justify-end mt-3">
<button
type="button"
className="text-sm border rounded px-3 py-1 hover:bg-slate-50"
onClick={cancelEdit}
disabled={isBusy}
>
Cancel
</button>
<button
type="button"
className="text-sm bg-brand text-white rounded px-3 py-1 hover:bg-brand-dark disabled:opacity-50"
disabled={isBusy}
onClick={() => saveEdit(batch.id)}
>
{isBusy ? "Saving…" : "Save"}
</button>
</div>
</div>
<div className="flex gap-2 flex-wrap">
{popUrl && (
<a
href={popUrl}
target="_blank"
rel="noopener noreferrer"
) : (
// READ MODE
<div className="px-6 py-4 border-b flex items-center justify-between gap-4 flex-wrap">
<div>
<div className="font-mono font-semibold text-brand-dark">{batch.batch_ref}</div>
<div className="text-xs text-slate-500">
{fmtDate(batch.payment_date)} · {invoices.length} invoice{invoices.length === 1 ? "" : "s"} ·{" "}
<span className="tabular-nums">{fmtGBP(total)}</span>
{batch.zar_paid && batch.zar_paid > 0 ? (
<span className="ml-1">· ZAR Paid <span className="tabular-nums font-semibold text-slate-700">R{batch.zar_paid.toLocaleString("en-ZA", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span></span>
) : null}
{batch.notes ? (
<div className="mt-1 italic text-slate-600 max-w-md truncate" title={batch.notes}>
{batch.notes}
</div>
) : null}
</div>
</div>
<div className="flex gap-2 flex-wrap">
{popUrl && (
<a
href={popUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm border rounded px-3 py-1 hover:bg-slate-50"
>
Open POP
</a>
)}
<label className="text-sm border rounded px-3 py-1 hover:bg-slate-50 cursor-pointer">
{isBusy ? "Uploading…" : "Replace POP"}
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
className="hidden"
disabled={isBusy}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) replacePop(batch.id, f);
e.target.value = ""; // allow re-selecting same file
}}
/>
</label>
<button
className="text-sm border rounded px-3 py-1 hover:bg-slate-50"
disabled={isBusy || editingId !== null}
onClick={() => startEdit(batch)}
>
Open POP
</a>
)}
<label className="text-sm border rounded px-3 py-1 hover:bg-slate-50 cursor-pointer">
{isBusy ? "Uploading…" : "Replace POP"}
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
className="hidden"
Edit
</button>
<button
className="text-sm border border-red-200 text-red-700 rounded px-3 py-1 hover:bg-red-50 disabled:opacity-50"
disabled={isBusy}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) replacePop(batch.id, f);
e.target.value = ""; // allow re-selecting same file
}}
/>
</label>
<button
className="text-sm border border-red-200 text-red-700 rounded px-3 py-1 hover:bg-red-50 disabled:opacity-50"
disabled={isBusy}
onClick={() => unpay(batch.id, batch.batch_ref)}
>
{isBusy ? "Working…" : "Unpay batch"}
</button>
<button
className="text-sm border rounded px-3 py-1 hover:bg-slate-50"
onClick={() => toggle(batch.id)}
>
{isExpanded ? "Hide" : "Show"} invoices
</button>
onClick={() => unpay(batch.id, batch.batch_ref)}
>
{isBusy ? "Working…" : "Unpay batch"}
</button>
<button
className="text-sm border rounded px-3 py-1 hover:bg-slate-50"
onClick={() => toggle(batch.id)}
>
{isExpanded ? "Hide" : "Show"} invoices
</button>
</div>
</div>
</div>
)}
{isExpanded && (
<div className="px-6 py-3">
{invoices.length === 0 ? (

View file

@ -23,7 +23,7 @@ export default async function RemittancePage() {
try {
const r = await pb.collection("JEI_payment_batches").getList(1, 500, {
sort: "-payment_date",
fields: "id,batch_ref,payment_date,pop_file,zar_paid",
fields: "id,batch_ref,payment_date,pop_file,zar_paid,notes",
});
batches = r.items;
} catch (e: any) {