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:
parent
346a60cf97
commit
7cf8d4b2e2
3 changed files with 406 additions and 0 deletions
|
|
@ -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
318
app/order/OrderClient.tsx
Normal 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
87
app/order/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue