feat: edit button on remittance batches (batch_ref, payment_date, notes, zar_paid)
This commit is contained in:
parent
551baeeb80
commit
bcc8dfdfbc
3 changed files with 299 additions and 47 deletions
113
app/api/payments/[id]/edit/route.ts
Normal file
113
app/api/payments/[id]/edit/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ type Invoice = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type BatchWithInvoices = {
|
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[];
|
invoices: Invoice[];
|
||||||
popUrl: string | null;
|
popUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
@ -24,6 +24,15 @@ export default function RemittanceClient({ batches }: { batches: BatchWithInvoic
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [error, setError] = 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) {
|
function toggle(id: string) {
|
||||||
setExpanded((prev) => {
|
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) {
|
async function replacePop(batchId: string, file: File) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setBusyId(batchId);
|
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 total = invoices.reduce((s, inv) => s + (inv.invoice_total || 0), 0);
|
||||||
const isExpanded = expanded.has(batch.id);
|
const isExpanded = expanded.has(batch.id);
|
||||||
const isBusy = busyId === batch.id;
|
const isBusy = busyId === batch.id;
|
||||||
|
const isEditing = editingId === batch.id;
|
||||||
return (
|
return (
|
||||||
<div key={batch.id} id={`batch-${batch.id}`} className="bg-white rounded-lg shadow">
|
<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">
|
{isEditing ? (
|
||||||
<div>
|
// EDIT MODE
|
||||||
<div className="font-mono font-semibold text-brand-dark">{batch.batch_ref}</div>
|
<div className="px-6 py-4 border-b">
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-sm font-semibold text-brand-dark mb-3">Edit batch</div>
|
||||||
{fmtDate(batch.payment_date)} · {invoices.length} invoice{invoices.length === 1 ? "" : "s"} ·{" "}
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<span className="tabular-nums">{fmtGBP(total)}</span>
|
<div>
|
||||||
{batch.zar_paid && batch.zar_paid > 0 ? (
|
<label className="text-xs text-slate-500">Batch reference</label>
|
||||||
<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>
|
<input
|
||||||
) : null}
|
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>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap">
|
) : (
|
||||||
{popUrl && (
|
// READ MODE
|
||||||
<a
|
<div className="px-6 py-4 border-b flex items-center justify-between gap-4 flex-wrap">
|
||||||
href={popUrl}
|
<div>
|
||||||
target="_blank"
|
<div className="font-mono font-semibold text-brand-dark">{batch.batch_ref}</div>
|
||||||
rel="noopener noreferrer"
|
<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"
|
className="text-sm border rounded px-3 py-1 hover:bg-slate-50"
|
||||||
|
disabled={isBusy || editingId !== null}
|
||||||
|
onClick={() => startEdit(batch)}
|
||||||
>
|
>
|
||||||
Open POP
|
Edit
|
||||||
</a>
|
</button>
|
||||||
)}
|
<button
|
||||||
<label className="text-sm border rounded px-3 py-1 hover:bg-slate-50 cursor-pointer">
|
className="text-sm border border-red-200 text-red-700 rounded px-3 py-1 hover:bg-red-50 disabled:opacity-50"
|
||||||
{isBusy ? "Uploading…" : "Replace POP"}
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
|
|
||||||
className="hidden"
|
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onChange={(e) => {
|
onClick={() => unpay(batch.id, batch.batch_ref)}
|
||||||
const f = e.target.files?.[0];
|
>
|
||||||
if (f) replacePop(batch.id, f);
|
{isBusy ? "Working…" : "Unpay batch"}
|
||||||
e.target.value = ""; // allow re-selecting same file
|
</button>
|
||||||
}}
|
<button
|
||||||
/>
|
className="text-sm border rounded px-3 py-1 hover:bg-slate-50"
|
||||||
</label>
|
onClick={() => toggle(batch.id)}
|
||||||
<button
|
>
|
||||||
className="text-sm border border-red-200 text-red-700 rounded px-3 py-1 hover:bg-red-50 disabled:opacity-50"
|
{isExpanded ? "Hide" : "Show"} invoices
|
||||||
disabled={isBusy}
|
</button>
|
||||||
onClick={() => unpay(batch.id, batch.batch_ref)}
|
</div>
|
||||||
>
|
|
||||||
{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 && (
|
{isExpanded && (
|
||||||
<div className="px-6 py-3">
|
<div className="px-6 py-3">
|
||||||
{invoices.length === 0 ? (
|
{invoices.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default async function RemittancePage() {
|
||||||
try {
|
try {
|
||||||
const r = await pb.collection("JEI_payment_batches").getList(1, 500, {
|
const r = await pb.collection("JEI_payment_batches").getList(1, 500, {
|
||||||
sort: "-payment_date",
|
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;
|
batches = r.items;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue