The previous rule ^[A-Za-z0-9_-]{1,50}$ rejected common bank/POP
references that include punctuation [e.g. 'Bank ref 26/09/18',
'INV/2026.09:18 #3', 'POP 12345']. New rule permits letters, digits,
space, underscore, hyphen, slash, dot, comma, colon, parens,
ampersand, hash, apostrophe, plus; length 1-100. Still rejects
characters that break filesystems/URLs [e.g. < > " ? * | \ ;].
Files updated:
- app/api/payments/create/route.ts [inline regex + new error msg]
- app/api/payments/[id]/edit/route.ts [BATCH_REF_PATTERN + new error msg]
- app/unpaid/UnpaidClient.tsx [HTML pattern attribute]
- app/remittance/RemittanceClient.tsx [HTML pattern attribute]
- docs/superpowers/plans/2026-09-18-jei-payments-and-remittance.md [plan doc]
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
"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; zar_paid?: number; notes?: string };
|
|
invoices: Invoice[];
|
|
popUrl: string | null;
|
|
};
|
|
|
|
export default function RemittanceClient({ batches }: { batches: BatchWithInvoices[] }) {
|
|
const router = useRouter();
|
|
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) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
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);
|
|
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 (
|
|
<div className="space-y-4">
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-800 rounded p-3 text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{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;
|
|
const isEditing = editingId === batch.id;
|
|
return (
|
|
<div key={batch.id} id={`batch-${batch.id}`} className="bg-white rounded-lg shadow">
|
|
{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,100}"
|
|
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>
|
|
) : (
|
|
// 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)}
|
|
>
|
|
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}
|
|
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>
|
|
)}
|
|
{isExpanded && (
|
|
<div className="px-6 py-3">
|
|
{invoices.length === 0 ? (
|
|
<div className="text-sm text-slate-500 py-2">No invoices in this batch.</div>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className="text-left text-slate-500">
|
|
<tr>
|
|
<th className="py-1">Date</th>
|
|
<th className="py-1">Invoice No</th>
|
|
<th className="py-1">Buyer</th>
|
|
<th className="py-1 text-right">Total (£)</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{invoices.map((inv) => (
|
|
<tr key={inv.id} className="border-t">
|
|
<td className="py-2">{fmtDate(inv.invoice_date)}</td>
|
|
<td className="py-2 font-medium">{inv.invoice_no}</td>
|
|
<td className="py-2">{inv.buyer_name_variant}</td>
|
|
<td className="py-2 text-right tabular-nums">{fmtGBP(inv.invoice_total)}</td>
|
|
<td className="py-2 text-right">
|
|
<Link href={`/invoices/${inv.id}`} className="text-brand hover:underline text-xs">
|
|
View invoice →
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|