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]
296 lines
No EOL
10 KiB
TypeScript
296 lines
No EOL
10 KiB
TypeScript
"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<Set<string>>(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<File | null>(null);
|
|
const [zarPaid, setZarPaid] = useState<string>(""); // optional ZAR amount actually paid; "" means omitted
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(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)));
|
|
// Optional ZAR paid: only set when the user typed something, so the API
|
|
// treats missing as "no ZAR amount recorded" rather than as 0.
|
|
if (zarPaid.trim() !== "") {
|
|
fd.set("zar_paid", zarPaid.trim());
|
|
}
|
|
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 (
|
|
<>
|
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-slate-100 text-left">
|
|
<tr>
|
|
<th className="px-3 py-2 w-10">
|
|
<input
|
|
type="checkbox"
|
|
aria-label="Select all"
|
|
checked={allChecked}
|
|
onChange={(e) => toggleAll(e.target.checked)}
|
|
/>
|
|
</th>
|
|
<th className="px-3 py-2">Date</th>
|
|
<th className="px-3 py-2">Invoice No</th>
|
|
<th className="px-3 py-2">Buyer</th>
|
|
<th className="px-3 py-2">PO</th>
|
|
<th className="px-3 py-2 text-right">Total (£)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{records.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="px-3 py-8 text-center text-slate-500">
|
|
No unpaid invoices. All caught up 🎉
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{records.map((r) => (
|
|
<tr key={r.id} className="border-t hover:bg-blue-50">
|
|
<td className="px-3 py-2">
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`Select invoice ${r.invoice_no}`}
|
|
checked={selected.has(r.id)}
|
|
onChange={(e) => toggleOne(r.id, e.target.checked)}
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2">{fmtDate(r.invoice_date)}</td>
|
|
<td className="px-3 py-2 font-medium">{r.invoice_no}</td>
|
|
<td className="px-3 py-2">{r.buyer_name_variant}</td>
|
|
<td className="px-3 py-2 text-slate-600">{r.customer_order_no || "—"}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums">{fmtGBP(r.invoice_total)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{selected.size > 0 && (
|
|
<div className="sticky bottom-0 bg-white border-t shadow-lg p-4 flex items-center justify-between">
|
|
<div className="text-sm">
|
|
<span className="font-semibold">{selected.size}</span> selected ·{" "}
|
|
<span className="font-semibold tabular-nums">{fmtGBP(total)}</span>
|
|
</div>
|
|
<button
|
|
className="bg-brand text-white rounded px-4 py-2 hover:bg-brand-dark disabled:opacity-50"
|
|
disabled={selected.size === 0}
|
|
onClick={() => {
|
|
setBatchRef(nextBatchRefSuggestion());
|
|
setModalOpen(true);
|
|
}}
|
|
>
|
|
Create Payment
|
|
</button>
|
|
</div>
|
|
)}
|
|
{selected.size === 0 && records.length > 0 && (
|
|
<div className="text-sm text-slate-500 text-center py-4">
|
|
Tick invoices above to create a payment batch.
|
|
</div>
|
|
)}
|
|
|
|
{modalOpen && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
|
|
<h2 className="text-lg font-semibold">Create Payment Batch</h2>
|
|
|
|
<div className="text-sm bg-slate-50 rounded p-3">
|
|
Bundling <strong>{selectedRecords.length}</strong> invoices for{" "}
|
|
<strong className="tabular-nums">{fmtGBP(total)}</strong>.
|
|
</div>
|
|
|
|
<form onSubmit={submitPayment} className="space-y-3">
|
|
<div>
|
|
<label className="text-xs text-slate-500">Batch reference</label>
|
|
<input
|
|
type="text"
|
|
value={batchRef}
|
|
onChange={(e) => setBatchRef(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={paymentDate}
|
|
onChange={(e) => setPaymentDate(e.target.value)}
|
|
required
|
|
className="w-full border rounded px-3 py-2"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-slate-500">POP file (PDF, JPG, PNG — max 8 MB)</label>
|
|
<input
|
|
type="file"
|
|
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
|
required
|
|
onChange={(e) => setPopFile(e.target.files?.[0] || null)}
|
|
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)</span></label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
inputMode="decimal"
|
|
value={zarPaid}
|
|
onChange={(e) => setZarPaid(e.target.value)}
|
|
placeholder="e.g. 12500.00"
|
|
className="w-full border rounded px-3 py-2 tabular-nums"
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-800 rounded p-3 text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
type="button"
|
|
className="px-4 py-2 rounded border hover:bg-slate-50"
|
|
onClick={() => {
|
|
setModalOpen(false);
|
|
setError(null);
|
|
}}
|
|
disabled={submitting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="bg-brand text-white rounded px-4 py-2 hover:bg-brand-dark disabled:opacity-50"
|
|
disabled={submitting}
|
|
>
|
|
{submitting ? "Saving…" : "Confirm & Save"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
} |