JEI_Import_Documents-App/app/remittance/RemittanceClient.tsx

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