JEI_Import_Documents-App/app/unpaid/UnpaidClient.tsx
Mavis 346a60cf97 feat(payments): relax batch_ref regex to allow spaces, slashes, dots, colons, etc.
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]
2026-09-19 01:13:15 +02:00

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