JEI_Import_Documents-App/app/page.tsx
Mavis 5f73d2ea71 feat: Fedex import flow, orders + invoice APIs, payment bundles
- /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
2026-10-18 17:27:40 +02:00

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>
);
}