- /fedex: batch create, document upload/replace/delete, save shipment, imported list
- /api/orders, /api/invoices, /api/pb-file, /api/pb/refresh-token, /api/stock-codes
- /api/payments/bundle: generate + download-all remittance bundles
- shared app/components/DocCell.tsx and app/lib/{invoice-docs,pb-fetch,pb-token}.ts
- recon: invoice reconciliation reports and PowerShell fix-plan scripts
- .gitignore: exclude one-off scripts/_* debugging artefacts, recon/pdfs+txts, data backups
221 lines
No EOL
10 KiB
TypeScript
221 lines
No EOL
10 KiB
TypeScript
import Link from "next/link";
|
|
import { getPb, fmtGBP, fmtDate } from "./lib/pb";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
function fmtZAR(n: number): string {
|
|
if (!isFinite(n) || isNaN(n)) return "R0.00";
|
|
return "R" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
}
|
|
|
|
export default async function Dashboard() {
|
|
const pb = getPb();
|
|
|
|
let invoiceCount = 0;
|
|
let totalStock = 0; // sum of total_net across invoices (goods only)
|
|
let totalFreight = 0; // sum of carriage_net across invoices
|
|
let totalSpent = 0; // sum of invoice_total (stock + freight)
|
|
let totalZarPaid = 0; // sum of zar_paid across all payment batches
|
|
let stockCodeCount = 0;
|
|
let latest: any[] = []; // 5 rows with their per-invoice ZAR share pre-computed
|
|
let errorMsg: string | null = null;
|
|
|
|
try {
|
|
// NOTE: PB JS SDK auto-cancels any pending request on the same client when
|
|
// a new request is fired (https://github.com/pocketbase/js-sdk#auto-cancellation).
|
|
// This applies to ALL collections on the same client, not just the same
|
|
// collection. To avoid "Dashboard query was auto-cancelled" errors, every
|
|
// PB call here runs sequentially with a fresh await.
|
|
const invoicesResp = await pb.collection("JEI_invoices").getList(1, 5000, {
|
|
fields: "id,invoice_no,invoice_date,buyer_name_variant,total_net,carriage_net,invoice_total,payment_batch",
|
|
});
|
|
invoiceCount = invoicesResp.totalItems;
|
|
const sc = await pb.collection("JEI_stock_codes").getList(1, 1);
|
|
stockCodeCount = sc.totalItems;
|
|
|
|
// Latest 5 by invoice_date
|
|
const sorted = [...invoicesResp.items].sort((a: any, b: any) =>
|
|
(b.invoice_date || "").localeCompare(a.invoice_date || "")
|
|
);
|
|
const latest5 = sorted.slice(0, 5);
|
|
|
|
// Per-currency sums across all invoices
|
|
for (const inv of invoicesResp.items) {
|
|
totalStock += Number(inv.total_net) || 0;
|
|
totalFreight += Number(inv.carriage_net) || 0;
|
|
totalSpent += Number(inv.invoice_total) || 0;
|
|
}
|
|
|
|
// For ZAR: pull every batch once (id + zar_paid) and build a map.
|
|
const batchesResp = await pb.collection("JEI_payment_batches").getList(1, 5000, {
|
|
fields: "id,zar_paid",
|
|
});
|
|
totalZarPaid = batchesResp.items.reduce(
|
|
(s: number, r: any) => s + (Number(r.zar_paid) || 0),
|
|
0,
|
|
);
|
|
const zarByBatch = new Map<string, number>();
|
|
batchesResp.items.forEach((b: any) =>
|
|
zarByBatch.set(b.id, Number(b.zar_paid) || 0),
|
|
);
|
|
|
|
// For each of the latest 5, count how many invoices share its batch so
|
|
// we can show the proportional share (the recorded amount divided across
|
|
// the invoices paid in the same batch).
|
|
const batchInvoiceCount = new Map<string, number>();
|
|
for (const inv of invoicesResp.items) {
|
|
if (inv.payment_batch) {
|
|
batchInvoiceCount.set(
|
|
inv.payment_batch,
|
|
(batchInvoiceCount.get(inv.payment_batch) || 0) + 1,
|
|
);
|
|
}
|
|
}
|
|
|
|
latest = latest5.map((inv: any) => {
|
|
const batchId: string = inv.payment_batch || "";
|
|
if (!batchId) {
|
|
return { ...inv, zarShare: null, batchInvoiceCount: 0 };
|
|
}
|
|
const batchZar = zarByBatch.get(batchId) || 0;
|
|
const count = batchInvoiceCount.get(batchId) || 1;
|
|
return {
|
|
...inv,
|
|
zarShare: count > 0 ? batchZar / count : 0,
|
|
batchInvoiceCount: count,
|
|
};
|
|
});
|
|
} catch (e: any) {
|
|
const msg = String(e?.message || e);
|
|
if (/autocancelled|auto-cancelled|aborted/i.test(msg)) {
|
|
errorMsg = "Dashboard query was auto-cancelled (concurrent same-collection calls). Refresh the page.";
|
|
} else if (/401|Unauthorized/i.test(msg)) {
|
|
errorMsg = "PB token expired — refresh in PB admin UI and update .env.local";
|
|
} else {
|
|
errorMsg = msg;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h1 className="text-3xl font-bold text-brand-dark">Dashboard</h1>
|
|
|
|
{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 className="text-xs mt-2">
|
|
Check PB_TOKEN in <code>.env.local</code> — it expires every ~24h.
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Row 1: count + GBP breakdown (stock / freight / total) */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-slate-500">Total invoices</div>
|
|
<div className="text-3xl font-semibold mt-2 tabular-nums">{invoiceCount}</div>
|
|
<Link href="/invoices" className="text-xs text-brand hover:underline mt-2 inline-block">View all →</Link>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-slate-500">Stock (£)</div>
|
|
<div className="text-3xl font-semibold mt-2 tabular-nums">{fmtGBP(totalStock)}</div>
|
|
<div className="text-xs text-slate-400 mt-2">Sum of <code>total_net</code> across all records</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-slate-500">Freight (£)</div>
|
|
<div className="text-3xl font-semibold mt-2 tabular-nums">{fmtGBP(totalFreight)}</div>
|
|
<div className="text-xs text-slate-400 mt-2">Sum of <code>carriage_net</code> across all records</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-slate-500">Total spent (£)</div>
|
|
<div className="text-3xl font-semibold mt-2 tabular-nums">{fmtGBP(totalSpent)}</div>
|
|
<div className="text-xs text-slate-400 mt-2">Stock + Freight (= <code>invoice_total</code>)</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: ZAR (with stock / freight split provision — calculation pending) +
|
|
distinct stock codes. ZAR cards mirror the GBP stock/freight/total
|
|
triplet so when the split logic lands the values just fill in. */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-slate-500">Paid in ZAR — Stock</div>
|
|
<div className="text-3xl font-semibold mt-2 tabular-nums text-slate-300">—</div>
|
|
<div className="text-xs text-amber-600 mt-2">split calculation pending</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-slate-500">Paid in ZAR — Freight</div>
|
|
<div className="text-3xl font-semibold mt-2 tabular-nums text-slate-300">—</div>
|
|
<div className="text-xs text-amber-600 mt-2">split calculation pending</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-slate-500">Paid in ZAR</div>
|
|
<div className="text-3xl font-semibold mt-2 tabular-nums">{fmtZAR(totalZarPaid)}</div>
|
|
<div className="text-xs text-slate-400 mt-2">
|
|
Sum of <code>zar_paid</code> across all payment batches
|
|
</div>
|
|
<Link href="/remittance" className="text-xs text-brand hover:underline mt-2 inline-block">View remittance →</Link>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-slate-500">Distinct stock codes</div>
|
|
<div className="text-3xl font-semibold mt-2 tabular-nums">{stockCodeCount}</div>
|
|
<Link href="/stock-codes" className="text-xs text-brand hover:underline mt-2 inline-block">View all →</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h2 className="text-lg font-semibold mb-3">Latest 5 invoices</h2>
|
|
{latest.length === 0 ? (
|
|
<p className="text-slate-500 text-sm">
|
|
No data yet. To populate: <code className="bg-slate-100 px-1 rounded">python scripts/extract_invoices.py</code> then <code className="bg-slate-100 px-1 rounded">python scripts/import_to_pb.py</code>.
|
|
</p>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className="text-left text-slate-500">
|
|
<tr>
|
|
<th className="pb-2">Date</th>
|
|
<th className="pb-2">No</th>
|
|
<th className="pb-2">Buyer</th>
|
|
<th className="pb-2 text-right">Stock (£)</th>
|
|
<th className="pb-2 text-right">Freight (£)</th>
|
|
<th className="pb-2 text-right">Total (£)</th>
|
|
<th className="pb-2 text-right">Paid (ZAR)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{latest.map((r) => (
|
|
<tr key={r.id} className="border-t">
|
|
<td className="py-2">{fmtDate(r.invoice_date)}</td>
|
|
<td className="py-2">
|
|
<Link href={`/invoices/${r.id}`} className="text-brand hover:underline">{r.invoice_no}</Link>
|
|
</td>
|
|
<td className="py-2 text-slate-600">{r.buyer_name_variant}</td>
|
|
<td className="py-2 text-right tabular-nums">{fmtGBP(r.total_net)}</td>
|
|
<td className="py-2 text-right tabular-nums">{fmtGBP(r.carriage_net)}</td>
|
|
<td className="py-2 text-right tabular-nums font-semibold">{fmtGBP(r.invoice_total)}</td>
|
|
<td className="py-2 text-right tabular-nums text-slate-600">
|
|
{r.zarShare != null ? (
|
|
<>
|
|
{fmtZAR(r.zarShare)}
|
|
{r.batchInvoiceCount > 1 ? (
|
|
<span className="text-xs text-slate-400"> ({r.batchInvoiceCount} shared)</span>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<span className="text-slate-300">—</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
<div className="text-xs text-slate-400 mt-3">
|
|
Total billed {fmtGBP(totalSpent)}
|
|
{" "}(stock {fmtGBP(totalStock)} + freight {fmtGBP(totalFreight)})
|
|
{" · "}Total paid {fmtZAR(totalZarPaid)}.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |