feat: /remittance page with batch cards, Replace POP and Unpay buttons
This commit is contained in:
parent
29240cc051
commit
39b649f035
2 changed files with 258 additions and 0 deletions
183
app/remittance/RemittanceClient.tsx
Normal file
183
app/remittance/RemittanceClient.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"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 };
|
||||
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);
|
||||
|
||||
function toggle(id: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
</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 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>
|
||||
);
|
||||
}
|
||||
75
app/remittance/page.tsx
Normal file
75
app/remittance/page.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import Link from "next/link";
|
||||
import { getPb, fmtGBP, fmtDate } from "../lib/pb";
|
||||
import RemittanceClient from "./RemittanceClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PB_URL = process.env.NEXT_PUBLIC_PB_URL || process.env.PB_URL || "https://pb.agent.apphub.co.za";
|
||||
|
||||
async function fetchBatchWithInvoices(pb: any, batchId: string) {
|
||||
const inv = await pb.collection("JEI_invoices").getList(1, 500, {
|
||||
filter: `payment_batch="${batchId}"`,
|
||||
sort: "-invoice_date",
|
||||
fields: "id,invoice_no,invoice_date,buyer_name_variant,invoice_total",
|
||||
});
|
||||
return inv.items as any[];
|
||||
}
|
||||
|
||||
export default async function RemittancePage() {
|
||||
const pb = getPb();
|
||||
let batches: any[] = [];
|
||||
let errorMsg: string | null = null;
|
||||
|
||||
try {
|
||||
const r = await pb.collection("JEI_payment_batches").getList(1, 500, {
|
||||
sort: "-payment_date,-created",
|
||||
});
|
||||
batches = r.items;
|
||||
} catch (e: any) {
|
||||
errorMsg = String(e?.message || e);
|
||||
}
|
||||
|
||||
// Fetch invoices per batch in parallel
|
||||
let batchWithInvoices: { batch: any; invoices: any[]; popUrl: string | null }[] = [];
|
||||
if (!errorMsg) {
|
||||
batchWithInvoices = await Promise.all(
|
||||
batches.map(async (b) => {
|
||||
try {
|
||||
const invoices = await fetchBatchWithInvoices(pb, b.id);
|
||||
const popUrl = b.pop_file ? `${PB_URL}/api/files/JEI_payment_batches/${b.id}/${b.pop_file}` : null;
|
||||
return { batch: b, invoices, popUrl };
|
||||
} catch (_) {
|
||||
return { batch: b, invoices: [], popUrl: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-brand-dark">Remittance</h1>
|
||||
<div className="text-sm text-slate-500">{batches.length} batch(es)</div>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-800 rounded p-4">
|
||||
<div className="font-semibold">Cannot reach PocketBase</div>
|
||||
<div className="text-sm mt-1">{errorMsg}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!errorMsg && batches.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-slate-500">
|
||||
No payment batches yet. Tick invoices on the{" "}
|
||||
<Link href="/unpaid" className="text-brand hover:underline">Unpaid tab</Link>{" "}
|
||||
and click <strong>Create Payment</strong>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!errorMsg && batches.length > 0 && (
|
||||
<RemittanceClient batches={batchWithInvoices} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue