feat: /unpaid page with selection, sticky footer, and Create Payment modal
This commit is contained in:
parent
39641e805a
commit
29240cc051
2 changed files with 317 additions and 0 deletions
277
app/unpaid/UnpaidClient.tsx
Normal file
277
app/unpaid/UnpaidClient.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"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 [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)));
|
||||
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,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={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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
app/unpaid/page.tsx
Normal file
40
app/unpaid/page.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import Link from "next/link";
|
||||
import { getPb, fmtGBP, fmtDate } from "../lib/pb";
|
||||
import UnpaidClient from "./UnpaidClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function UnpaidPage() {
|
||||
const pb = getPb();
|
||||
let records: any[] = [];
|
||||
let errorMsg: string | null = null;
|
||||
|
||||
try {
|
||||
const r = await pb.collection("JEI_invoices").getList(1, 500, {
|
||||
filter: 'payment_batch=""',
|
||||
sort: "-invoice_date",
|
||||
fields: "id,invoice_no,invoice_date,buyer_name_variant,customer_order_no,invoice_total,payment_batch",
|
||||
});
|
||||
records = r.items;
|
||||
} catch (e: any) {
|
||||
errorMsg = String(e?.message || e);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-brand-dark">Unpaid Invoices</h1>
|
||||
<div className="text-sm text-slate-500">{records.length} unpaid</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 && <UnpaidClient initialRecords={records} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue