diff --git a/app/layout.tsx b/app/layout.tsx index e0ba933..3bee4b5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -26,6 +26,7 @@ export default function RootLayout({ Unpaid Remittance Stock Codes + Order diff --git a/app/order/OrderClient.tsx b/app/order/OrderClient.tsx new file mode 100644 index 0000000..7d60b55 --- /dev/null +++ b/app/order/OrderClient.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { useMemo, useState } from "react"; + +type StockWithHighestPrice = { + id: string; + code: string; + description: string; + highestUnitPrice: number | null; + refInvoiceNo: string | null; // invoice_no where the highest price was paid + refInvoiceDate: string | null; // invoice_date of that invoice +}; + +type Line = { + rowId: string; // local-only id for React keys / delete + stockId: string; // JEI_stock_codes.id + code: string; // cached for display + description: string; // cached for display + qty: number; + unitPrice: number; + refInvoiceNo: string; // which invoice the price was sourced from (cached) + refInvoiceDate: string; // date of that invoice (cached) +}; + +const STORAGE_KEY = "jei:order-list"; + +function fmtGBP(n: number): string { + if (!isFinite(n)) return "£0.00"; + return "£" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +export default function OrderClient({ stockCodes }: { stockCodes: StockWithHighestPrice[] }) { + const [selectedId, setSelectedId] = useState(""); + const [qty, setQty] = useState("1"); + const [lines, setLines] = useState(() => { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw) as Line[]; + } catch { + /* ignore */ + } + return []; + }); + + // Persist list to localStorage on every change + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(lines)); + } catch { + /* ignore quota errors */ + } + } + + const stockMap = useMemo(() => { + const m = new Map(); + stockCodes.forEach((s) => m.set(s.id, s)); + return m; + }, [stockCodes]); + + const grandTotal = lines.reduce((s, l) => s + l.qty * l.unitPrice, 0); + const grandQty = lines.reduce((s, l) => s + l.qty, 0); + + function addLine() { + if (!selectedId) return; + const sc = stockMap.get(selectedId); + if (!sc) return; + const n = parseInt(qty, 10); + if (!Number.isFinite(n) || n <= 0) return; + setLines((prev) => [ + ...prev, + { + rowId: `r${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + stockId: sc.id, + code: sc.code, + description: sc.description, + qty: n, + unitPrice: sc.highestUnitPrice ?? 0, + refInvoiceNo: sc.refInvoiceNo || "", + refInvoiceDate: sc.refInvoiceDate || "", + }, + ]); + setSelectedId(""); + setQty("1"); + } + + function removeLine(rowId: string) { + setLines((prev) => prev.filter((l) => l.rowId !== rowId)); + } + + function updateLine(rowId: string, patch: Partial>) { + setLines((prev) => + prev.map((l) => (l.rowId === rowId ? { ...l, ...patch } : l)), + ); + } + + function clearAll() { + if (typeof window !== "undefined") { + const ok = window.confirm("Clear the whole order list?"); + if (!ok) return; + } + setLines([]); + } + + function exportPdf() { + if (typeof window !== "undefined") window.print(); + } + + const today = new Date().toISOString().slice(0, 10); + + return ( + <> +
+
+
+ {stockCodes.length} stock codes loaded +
+
+ + +
+
+ +
+
+ + +
+
+ + setQty(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addLine(); + } + }} + className="w-full border border-slate-300 rounded px-3 py-2 text-sm" + /> +
+ +
+ +
+ Default unit price = highest price ever paid for each code; the reference + invoice (number + date) is shown in the table. Edit any cell before exporting. +
+
+ +
+ + + + + + + + + + + + + {lines.length === 0 ? ( + + + + ) : ( + lines.map((l) => ( + + + + + + + + + + )) + )} + + {lines.length > 0 && ( + + + + + + + + )} +
CodeDescriptionQtyUnit priceLine totalRef invoice +
+ No items yet. Pick a stock code above and click Add. +
{l.code}{l.description} + + updateLine(l.rowId, { + qty: Math.max(0, parseInt(e.target.value, 10) || 0), + }) + } + className="w-full text-right border border-slate-200 rounded px-2 py-1 no-print" + /> + {l.qty} + + + updateLine(l.rowId, { + unitPrice: Math.max(0, parseFloat(e.target.value) || 0), + }) + } + className="w-full text-right border border-slate-200 rounded px-2 py-1 no-print" + /> + {l.unitPrice.toFixed(2)} + + {fmtGBP(l.qty * l.unitPrice)} + + {l.refInvoiceNo ? ( + <> + {l.refInvoiceNo} + {l.refInvoiceDate ? ( + ({l.refInvoiceDate}) + ) : null} + + ) : ( + — + )} + + +
+ Total ({grandQty} items) + {grandQty} + {fmtGBP(grandTotal)} + +
+
+ Date: {today} +
+
+ + + + ); +} \ No newline at end of file diff --git a/app/order/page.tsx b/app/order/page.tsx new file mode 100644 index 0000000..5236e5a --- /dev/null +++ b/app/order/page.tsx @@ -0,0 +1,87 @@ +import { getPb } from "../lib/pb"; +import OrderClient from "./OrderClient"; + +export const dynamic = "force-dynamic"; + +type StockWithHighestPrice = { + id: string; + code: string; + description: string; + highestUnitPrice: number | null; + refInvoiceNo: string | null; // invoice_no where the highest price was paid + refInvoiceDate: string | null; // invoice_date of that invoice +}; + +export default async function OrderPage() { + const pb = getPb(); + let stockCodes: StockWithHighestPrice[] = []; + let errorMsg: string | null = null; + + try { + // 1) All stock codes (just the fields we need) + const scRes = await pb.collection("JEI_stock_codes").getList(1, 5000, { + sort: "code", + fields: "id,code,description_goods", + }); + + // 2) All invoice lines + their parent invoice (so we can grab the + // invoice_no + invoice_date of whichever line carries the MAX + // unit_price for each stock code). + // PB only returns the expanded record when the field selector + // includes `expand.invoice.`; `invoice` alone just returns + // the relation id and drops the expansion. + const linesRes = await pb.collection("JEI_invoice_lines").getList(1, 5000, { + expand: "invoice", + fields: "id,stock_code,unit_price,expand.invoice.invoice_no,expand.invoice.invoice_date", + }); + + // 3) For each stock_code, keep the line with the highest unit_price + // and remember which invoice (no + date) it came from. Ties broken + // by most-recent invoice_date so the price source reflects the + // current market rather than an old historical peak. + type Highest = { price: number; date: string; invNo: string }; + const highestByStock = new Map(); + for (const line of linesRes.items) { + const scId = line.stock_code as string; + if (!scId) continue; + const inv = line.expand?.invoice as any; + const invNo: string = inv?.invoice_no || ""; + const invDate: string = inv?.invoice_date || ""; + const price = Number(line.unit_price) || 0; + const cur = highestByStock.get(scId); + const isHigher = !cur || price > cur.price; + const isTieNewer = + cur && price === cur.price && invDate > cur.date; + if (isHigher || isTieNewer) { + highestByStock.set(scId, { price, date: invDate, invNo }); + } + } + + stockCodes = scRes.items.map((s: any) => { + const h = highestByStock.get(s.id); + return { + id: s.id, + code: s.code, + description: s.description_goods || "", + highestUnitPrice: h?.price ?? null, + refInvoiceNo: h?.invNo || null, + refInvoiceDate: h?.date ? h.date.slice(0, 10) : null, + }; + }); + } catch (e: any) { + errorMsg = String(e?.message || e); + } + + return ( +
+ {errorMsg ? ( +
+
Cannot reach PocketBase
+
{errorMsg}
+
+ ) : ( + + )} +
+ ); +} \ No newline at end of file