feat(order): new /order tab — stock-code picker + qty, cost preview, PDF export

Quick order-builder for the user. Pick a stock code, set a qty, hit Add.
Default unit price is the HIGHEST price ever paid for each code (worst-
case budget), with the reference invoice [no + date] shown in the table
so the price source is auditable. Ties broken by most-recent date so
the source reflects current market, not an old historical peak.

UI:
- Stock code dropdown shows code, description, default price, ref inv
- Inline-editable qty + unit price per row
- Running grand total at bottom
- Clear button (with confirm) wipes the list
- Export PDF button calls window.print() — browser "Save as PDF" gives
  a clean table with no chrome (header/nav/controls hidden via @media
  print stylesheet)

Persistence:
- List kept in localStorage key 'jei:order-list' across reloads

Files:
- app/order/page.tsx       [server: fetches stock codes + lines, builds highest-price map]
- app/order/OrderClient.tsx [client: UI, state, print stylesheet]
- app/layout.tsx           [nav: added Order link]

PB query note: when using `fields=...` to limit the response, you must
also include 'expand.invoice.invoice_no' and 'expand.invoice.invoice_date'
in the field selector, otherwise PB drops the expand data entirely.
This commit is contained in:
Mavis 2026-09-19 01:13:22 +02:00
parent 346a60cf97
commit 7cf8d4b2e2
3 changed files with 406 additions and 0 deletions

View file

@ -26,6 +26,7 @@ export default function RootLayout({
<Link href="/unpaid" className="hover:underline">Unpaid</Link>
<Link href="/remittance" className="hover:underline">Remittance</Link>
<Link href="/stock-codes" className="hover:underline">Stock Codes</Link>
<Link href="/order" className="hover:underline">Order</Link>
</nav>
</div>
</header>

318
app/order/OrderClient.tsx Normal file
View file

@ -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<string>("");
const [qty, setQty] = useState<string>("1");
const [lines, setLines] = useState<Line[]>(() => {
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<string, StockWithHighestPrice>();
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<Pick<Line, "qty" | "unitPrice">>) {
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 (
<>
<div className="no-print space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm text-slate-500">
{stockCodes.length} stock codes loaded
</div>
<div className="flex gap-2">
<button
type="button"
onClick={clearAll}
disabled={lines.length === 0}
className="px-3 py-2 rounded border border-slate-300 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
>
Clear
</button>
<button
type="button"
onClick={exportPdf}
disabled={lines.length === 0}
className="px-3 py-2 rounded bg-brand text-white text-sm hover:bg-brand-dark disabled:opacity-50"
>
Export PDF
</button>
</div>
</div>
<div className="flex flex-wrap items-end gap-3 bg-white border border-slate-200 rounded p-4">
<div className="flex-1 min-w-[260px]">
<label className="block text-xs text-slate-500 mb-1">Stock code</label>
<select
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
className="w-full border border-slate-300 rounded px-3 py-2 text-sm"
>
<option value="">— pick a code —</option>
{stockCodes.map((s) => {
const ref = s.refInvoiceNo
? ` · ref inv ${s.refInvoiceNo}${s.refInvoiceDate ? ` (${s.refInvoiceDate})` : ""}`
: "";
return (
<option key={s.id} value={s.id}>
{s.code}
{s.description ? ` — ${s.description}` : ""}
{s.highestUnitPrice != null
? ` (£${s.highestUnitPrice.toFixed(2)}${ref})`
: ""}
</option>
);
})}
</select>
</div>
<div className="w-24">
<label className="block text-xs text-slate-500 mb-1">Qty</label>
<input
type="number"
min={1}
value={qty}
onChange={(e) => 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"
/>
</div>
<button
type="button"
onClick={addLine}
disabled={!selectedId || !Number.isFinite(parseInt(qty, 10)) || parseInt(qty, 10) <= 0}
className="px-4 py-2 rounded bg-brand text-white text-sm hover:bg-brand-dark disabled:opacity-50"
>
Add
</button>
</div>
<div className="text-xs text-slate-500">
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.
</div>
</div>
<div className="print-area bg-white border border-slate-200 rounded overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="text-left px-3 py-2 font-semibold w-24">Code</th>
<th className="text-left px-3 py-2 font-semibold">Description</th>
<th className="text-right px-3 py-2 font-semibold w-16">Qty</th>
<th className="text-right px-3 py-2 font-semibold w-24">Unit price</th>
<th className="text-right px-3 py-2 font-semibold w-28">Line total</th>
<th className="text-left px-3 py-2 font-semibold w-36">Ref invoice</th>
<th className="no-print w-10" />
</tr>
</thead>
<tbody>
{lines.length === 0 ? (
<tr>
<td colSpan={7} className="text-center text-slate-400 px-3 py-8">
No items yet. Pick a stock code above and click Add.
</td>
</tr>
) : (
lines.map((l) => (
<tr key={l.rowId} className="border-t border-slate-100 align-top">
<td className="px-3 py-2 font-mono">{l.code}</td>
<td className="px-3 py-2">{l.description}</td>
<td className="px-3 py-2 text-right">
<input
type="number"
min={0}
step={1}
value={l.qty}
onChange={(e) =>
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"
/>
<span className="hidden print-only">{l.qty}</span>
</td>
<td className="px-3 py-2 text-right">
<input
type="number"
min={0}
step={0.01}
value={l.unitPrice}
onChange={(e) =>
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"
/>
<span className="hidden print-only">{l.unitPrice.toFixed(2)}</span>
</td>
<td className="px-3 py-2 text-right font-mono">
{fmtGBP(l.qty * l.unitPrice)}
</td>
<td className="px-3 py-2 text-xs text-slate-600">
{l.refInvoiceNo ? (
<>
<span className="font-mono">{l.refInvoiceNo}</span>
{l.refInvoiceDate ? (
<span className="text-slate-400"> ({l.refInvoiceDate})</span>
) : null}
</>
) : (
<span className="text-slate-300">—</span>
)}
</td>
<td className="px-3 py-2 text-right no-print">
<button
type="button"
onClick={() => removeLine(l.rowId)}
className="text-red-600 hover:text-red-800 text-xs"
aria-label="Remove line"
>
✕
</button>
</td>
</tr>
))
)}
</tbody>
{lines.length > 0 && (
<tfoot>
<tr className="border-t-2 border-slate-300 bg-slate-50 font-semibold">
<td className="px-3 py-2" colSpan={2}>
Total ({grandQty} items)
</td>
<td className="px-3 py-2 text-right">{grandQty}</td>
<td className="px-3 py-2" />
<td className="px-3 py-2 text-right font-mono">{fmtGBP(grandTotal)}</td>
<td className="no-print" />
<td className="no-print" />
</tr>
</tfoot>
)}
</table>
<div className="hidden print-only text-xs text-slate-500 px-3 py-2 border-t border-slate-200">
Date: {today}
</div>
</div>
<style>{`
@media print {
body { background: #fff !important; }
header, nav, .no-print { display: none !important; }
main { padding: 0 !important; max-width: none !important; }
.print-area {
border: none !important;
border-radius: 0 !important;
page-break-inside: avoid;
}
.print-area table { width: 100% !important; }
.print-area th { background: #f1f5f9 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.print-area tfoot td { background: #f1f5f9 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.print-only { display: inline !important; }
input { border: none !important; background: transparent !important; padding: 0 !important; }
}
`}</style>
</>
);
}

87
app/order/page.tsx Normal file
View file

@ -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.<field>`; `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<string, Highest>();
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 (
<div className="space-y-6">
{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>
) : (
<OrderClient stockCodes={stockCodes} />
)}
</div>
);
}