- /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
826 lines
No EOL
30 KiB
TypeScript
826 lines
No EOL
30 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, 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;
|
||
refInvoiceDate: string;
|
||
};
|
||
|
||
type SavedOrder = {
|
||
id: string;
|
||
name: string;
|
||
notes: string;
|
||
lineCount: number;
|
||
updated: string;
|
||
created: string;
|
||
};
|
||
|
||
const STORAGE_KEY = "jei:order-list";
|
||
const HEADER_KEY = "jei:order-header";
|
||
const FOOTNOTES_KEY = "jei:order-footnotes";
|
||
|
||
function fmtGBP(n: number): string {
|
||
if (!isFinite(n)) return "£0.00";
|
||
return "£" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||
}
|
||
|
||
function fmtDate(iso: string | undefined | null): string {
|
||
if (!iso) return "";
|
||
return iso.substring(0, 10);
|
||
}
|
||
|
||
function stripRowIds(lines: Line[]): any[] {
|
||
return lines.map((l) => ({
|
||
stockId: l.stockId,
|
||
code: l.code,
|
||
description: l.description,
|
||
qty: l.qty,
|
||
unitPrice: l.unitPrice,
|
||
refInvoiceNo: l.refInvoiceNo,
|
||
refInvoiceDate: l.refInvoiceDate,
|
||
}));
|
||
}
|
||
|
||
function highlight(text: string, q: string): React.ReactNode {
|
||
if (!q) return text;
|
||
const lower = text.toLowerCase();
|
||
const needle = q.toLowerCase();
|
||
const i = lower.indexOf(needle);
|
||
if (i < 0) return text;
|
||
return (
|
||
<>
|
||
{text.slice(0, i)}
|
||
<mark className="bg-yellow-200 rounded px-0.5">{text.slice(i, i + needle.length)}</mark>
|
||
{text.slice(i + needle.length)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function OrderClient({ stockCodes }: { stockCodes: StockWithHighestPrice[] }) {
|
||
const [search, setSearch] = useState<string>("");
|
||
const [selectedId, setSelectedId] = useState<string>("");
|
||
const [showList, setShowList] = useState(false);
|
||
const [qty, setQty] = useState<string>("1");
|
||
|
||
// Start empty on BOTH server and client so the first client render matches the
|
||
// server HTML. Reading localStorage in a useState lazy initialiser made the
|
||
// first client render differ from the server, which threw a React hydration
|
||
// error and forced the whole tab into client-side rendering. We hydrate from
|
||
// localStorage in an effect instead, right after mount.
|
||
const [mounted, setMounted] = useState(false);
|
||
const [lines, setLines] = useState<Line[]>([]);
|
||
|
||
// Saved orders state
|
||
const [headerText, setHeaderText] = useState<string>("");
|
||
const [footnotes, setFootnotes] = useState<string>("");
|
||
const [savedOrders, setSavedOrders] = useState<SavedOrder[]>([]);
|
||
const [saveName, setSaveName] = useState<string>("");
|
||
const [saveNotes, setSaveNotes] = useState<string>("");
|
||
const [editingOrderId, setEditingOrderId] = useState<string | null>(null);
|
||
const [savingBusy, setSavingBusy] = useState(false);
|
||
const [savedError, setSavedError] = useState<string | null>(null);
|
||
|
||
const wrapRef = useRef<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Restore the working list / header / footnotes from localStorage after mount.
|
||
useEffect(() => {
|
||
try {
|
||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||
if (raw) setLines(JSON.parse(raw) as Line[]);
|
||
setHeaderText(window.localStorage.getItem(HEADER_KEY) || "");
|
||
setFootnotes(window.localStorage.getItem(FOOTNOTES_KEY) || "");
|
||
} catch {
|
||
/* ignore unreadable localStorage */
|
||
}
|
||
setMounted(true);
|
||
}, []);
|
||
|
||
// Persist to localStorage whenever they change — but never before mount, or we
|
||
// would immediately overwrite the stored order with the initial empty state.
|
||
useEffect(() => {
|
||
if (!mounted) return;
|
||
try {
|
||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(lines));
|
||
window.localStorage.setItem(HEADER_KEY, headerText);
|
||
window.localStorage.setItem(FOOTNOTES_KEY, footnotes);
|
||
} catch {
|
||
/* ignore quota errors */
|
||
}
|
||
}, [mounted, lines, headerText, footnotes]);
|
||
|
||
// ---- Unsaved-changes tracking ----
|
||
//
|
||
// "Export PDF" only calls window.print(); it never writes to PocketBase. Edits
|
||
// therefore existed in localStorage but NOT on the saved order, so reopening
|
||
// the order later showed the old version and the edits looked lost. Detect
|
||
// the divergence from the last known-saved state so we can say so out loud.
|
||
const snapshot = useMemo(
|
||
() => JSON.stringify({ lines: stripRowIds(lines), headerText, footnotes }),
|
||
[lines, headerText, footnotes],
|
||
);
|
||
const baselineRef = useRef<string>("");
|
||
// Bumped whenever we know the current state matches what is stored server-side
|
||
// (after hydrate, load, save, clear).
|
||
const [cleanToken, setCleanToken] = useState(0);
|
||
const [dirty, setDirty] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!mounted) return;
|
||
baselineRef.current = snapshot;
|
||
setDirty(false);
|
||
}, [mounted, cleanToken]);
|
||
|
||
useEffect(() => {
|
||
if (!mounted) return;
|
||
setDirty(snapshot !== baselineRef.current);
|
||
}, [mounted, snapshot]);
|
||
|
||
const markClean = useCallback(() => setCleanToken((t) => t + 1), []);
|
||
|
||
const stockMap = useMemo(() => {
|
||
const m = new Map<string, StockWithHighestPrice>();
|
||
stockCodes.forEach((s) => m.set(s.id, s));
|
||
return m;
|
||
}, [stockCodes]);
|
||
|
||
const filtered = useMemo(() => {
|
||
const q = search.trim().toLowerCase();
|
||
if (!q) return stockCodes;
|
||
return stockCodes.filter(
|
||
(s) =>
|
||
s.code.toLowerCase().includes(q) ||
|
||
(s.description || "").toLowerCase().includes(q),
|
||
);
|
||
}, [stockCodes, search]);
|
||
|
||
const grandTotal = lines.reduce((s, l) => s + l.qty * l.unitPrice, 0);
|
||
const grandQty = lines.reduce((s, l) => s + l.qty, 0);
|
||
|
||
// Click outside the combobox wrapper closes the dropdown.
|
||
useEffect(() => {
|
||
function onMouseDown(e: MouseEvent) {
|
||
if (!wrapRef.current) return;
|
||
if (!wrapRef.current.contains(e.target as Node)) {
|
||
setShowList(false);
|
||
}
|
||
}
|
||
document.addEventListener("mousedown", onMouseDown);
|
||
return () => document.removeEventListener("mousedown", onMouseDown);
|
||
}, []);
|
||
|
||
// Fetch saved orders on mount
|
||
const refreshSavedOrders = useCallback(async () => {
|
||
try {
|
||
const r = await fetch("/api/orders/list", { cache: "no-store" });
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
||
setSavedOrders(data.orders || []);
|
||
} catch (e: any) {
|
||
setSavedError(`Could not load saved orders: ${e?.message || e}`);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
refreshSavedOrders();
|
||
}, [refreshSavedOrders]);
|
||
|
||
function pickStock(id: string) {
|
||
const sc = stockMap.get(id);
|
||
if (!sc) return;
|
||
setSelectedId(sc.id);
|
||
setSearch(sc.code);
|
||
setShowList(false);
|
||
inputRef.current?.focus();
|
||
}
|
||
|
||
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("");
|
||
setSearch("");
|
||
setQty("1");
|
||
inputRef.current?.focus();
|
||
}
|
||
|
||
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([]);
|
||
setEditingOrderId(null);
|
||
markClean();
|
||
}
|
||
|
||
// Export is PRINT ONLY. It has never saved anything to PocketBase, which is
|
||
// why editing a saved order, hitting Export, and coming back later looked
|
||
// like the edits had been thrown away. Offer to save first instead of
|
||
// silently discarding the work.
|
||
async function exportPdf() {
|
||
if (!dirty) {
|
||
window.print();
|
||
return;
|
||
}
|
||
|
||
if (editingOrderId) {
|
||
const save = window.confirm(
|
||
"You have unsaved changes on this order.\n\n" +
|
||
"Save them before exporting? Choose Cancel to export anyway — " +
|
||
"the PDF will include them, but the saved order will not.",
|
||
);
|
||
if (save) {
|
||
const ok = await saveCurrentToEditing();
|
||
if (!ok) return;
|
||
window.print();
|
||
return;
|
||
}
|
||
window.print();
|
||
return;
|
||
}
|
||
|
||
// Not a saved order yet — there is nothing to update, only to create.
|
||
window.confirm(
|
||
"This order has unsaved changes and has not been saved yet.\n\n" +
|
||
"Exporting produces the PDF, but it does NOT store the order. " +
|
||
"Name it in “Save current list as new order” below to keep it.\n\n" +
|
||
"Continue to print?",
|
||
);
|
||
window.print();
|
||
}
|
||
|
||
// ----- Saved-orders handlers -----
|
||
|
||
async function saveNew() {
|
||
if (lines.length === 0) {
|
||
setSavedError("Add some lines first.");
|
||
return;
|
||
}
|
||
const name = saveName.trim();
|
||
if (!name) {
|
||
setSavedError("Enter a name for this saved order.");
|
||
return;
|
||
}
|
||
setSavingBusy(true);
|
||
setSavedError(null);
|
||
try {
|
||
const r = await fetch("/api/orders/create", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name,
|
||
notes: saveNotes,
|
||
header_text: headerText,
|
||
footnotes,
|
||
lines: stripRowIds(lines),
|
||
}),
|
||
});
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
||
setSaveName("");
|
||
setSaveNotes("");
|
||
await refreshSavedOrders();
|
||
} catch (e: any) {
|
||
setSavedError(`Save failed: ${e?.message || e}`);
|
||
} finally {
|
||
setSavingBusy(false);
|
||
}
|
||
}
|
||
|
||
async function loadOrder(id: string) {
|
||
setSavedError(null);
|
||
try {
|
||
const r = await fetch(`/api/orders/${id}`, { cache: "no-store" });
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
||
const incoming: Line[] = (data.lines || []).map((l: any, idx: number) => ({
|
||
rowId: `r${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
||
stockId: String(l.stockId || ""),
|
||
code: String(l.code || ""),
|
||
description: String(l.description || ""),
|
||
qty: Number(l.qty) || 0,
|
||
unitPrice: Number(l.unitPrice) || 0,
|
||
refInvoiceNo: String(l.refInvoiceNo || ""),
|
||
refInvoiceDate: String(l.refInvoiceDate || ""),
|
||
}));
|
||
if (lines.length > 0) {
|
||
const ok = window.confirm(
|
||
`Load "${data.name}"? This will replace the ${lines.length} line(s) currently in the table.`,
|
||
);
|
||
if (!ok) return;
|
||
}
|
||
setLines(incoming);
|
||
// Restore THIS order's header + footnotes. Previously these were left
|
||
// untouched, so loading an order kept showing the footnote text of
|
||
// whichever order had been loaded before.
|
||
setHeaderText(String(data.header_text || ""));
|
||
setFootnotes(String(data.footnotes || ""));
|
||
setEditingOrderId(id);
|
||
markClean();
|
||
} catch (e: any) {
|
||
setSavedError(`Load failed: ${e?.message || e}`);
|
||
}
|
||
}
|
||
|
||
async function saveCurrentToEditing(): Promise<boolean> {
|
||
if (!editingOrderId || lines.length === 0) return false;
|
||
setSavingBusy(true);
|
||
setSavedError(null);
|
||
try {
|
||
const r = await fetch(`/api/orders/${editingOrderId}/update`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
lines: stripRowIds(lines),
|
||
header_text: headerText,
|
||
footnotes,
|
||
}),
|
||
});
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
||
markClean();
|
||
await refreshSavedOrders();
|
||
return true;
|
||
} catch (e: any) {
|
||
setSavedError(`Update failed: ${e?.message || e}`);
|
||
return false;
|
||
} finally {
|
||
setSavingBusy(false);
|
||
}
|
||
}
|
||
|
||
function newDraft() {
|
||
setEditingOrderId(null);
|
||
setLines([]);
|
||
}
|
||
|
||
async function deleteOrder(id: string, name: string) {
|
||
if (!window.confirm(`Delete saved order "${name}"? This cannot be undone.`)) return;
|
||
setSavedError(null);
|
||
try {
|
||
const r = await fetch(`/api/orders/${id}/delete`, { method: "POST" });
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
||
if (editingOrderId === id) setEditingOrderId(null);
|
||
await refreshSavedOrders();
|
||
} catch (e: any) {
|
||
setSavedError(`Delete failed: ${e?.message || e}`);
|
||
}
|
||
}
|
||
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const canAdd =
|
||
!!selectedId &&
|
||
Number.isFinite(parseInt(qty, 10)) &&
|
||
parseInt(qty, 10) > 0;
|
||
const editingName = savedOrders.find((o) => o.id === editingOrderId)?.name || null;
|
||
|
||
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
|
||
{dirty && (
|
||
<span className="ml-3 inline-flex items-center gap-1 rounded bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-800">
|
||
● Unsaved changes — not stored on this order
|
||
</span>
|
||
)}
|
||
</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"
|
||
title="Export prints the order to PDF. It does not save it — use Save as new / Save changes below to store it."
|
||
>
|
||
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] relative" ref={wrapRef}>
|
||
<label className="block text-xs text-slate-500 mb-1">Stock code</label>
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => {
|
||
setSearch(e.target.value);
|
||
setSelectedId("");
|
||
setShowList(true);
|
||
}}
|
||
onFocus={() => setShowList(true)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") {
|
||
setShowList(false);
|
||
inputRef.current?.blur();
|
||
} else if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
if (filtered.length === 1) {
|
||
pickStock(filtered[0].id);
|
||
} else if (selectedId) {
|
||
addLine();
|
||
}
|
||
}
|
||
}}
|
||
placeholder="Type to filter — code or description…"
|
||
className="w-full border border-slate-300 rounded px-3 py-2 text-sm font-mono"
|
||
/>
|
||
{showList && filtered.length > 0 && (
|
||
<div className="absolute z-10 mt-1 left-0 right-0 max-h-72 overflow-y-auto bg-white border border-slate-300 rounded shadow-lg">
|
||
{filtered.map((s) => {
|
||
const ref = s.refInvoiceNo
|
||
? ` · ref inv ${s.refInvoiceNo}${s.refInvoiceDate ? ` (${s.refInvoiceDate})` : ""}`
|
||
: "";
|
||
return (
|
||
<button
|
||
key={s.id}
|
||
type="button"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => pickStock(s.id)}
|
||
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-slate-100 ${
|
||
s.id === selectedId ? "bg-slate-100" : ""
|
||
}`}
|
||
>
|
||
<span className="font-mono">{highlight(s.code, search.trim())}</span>
|
||
{s.description ? (
|
||
<span className="text-slate-600">
|
||
{" — "}
|
||
{highlight(s.description, search.trim())}
|
||
</span>
|
||
) : null}
|
||
{s.highestUnitPrice != null ? (
|
||
<span className="text-slate-500">
|
||
{" "}(£{s.highestUnitPrice.toFixed(2)}
|
||
{ref})
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{showList && filtered.length === 0 && search.trim() !== "" && (
|
||
<div className="absolute z-10 mt-1 left-0 right-0 bg-white border border-slate-300 rounded shadow-lg p-3 text-sm text-slate-500">
|
||
No matches for “{search}”.
|
||
</div>
|
||
)}
|
||
<div className="text-xs text-slate-400 mt-1">
|
||
{filtered.length === stockCodes.length
|
||
? `${stockCodes.length} codes`
|
||
: `${filtered.length} of ${stockCodes.length} match`}
|
||
</div>
|
||
</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" && canAdd) {
|
||
e.preventDefault();
|
||
addLine();
|
||
}
|
||
}}
|
||
className="w-full border border-slate-300 rounded px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={addLine}
|
||
disabled={!canAdd}
|
||
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="no-print bg-white border border-slate-200 rounded p-4 space-y-2">
|
||
<div>
|
||
<label className="block text-xs text-slate-500 mb-1">
|
||
Header text <span className="text-slate-400">(appears at the top of the exported PDF)</span>
|
||
</label>
|
||
<textarea
|
||
value={headerText}
|
||
onChange={(e) => setHeaderText(e.target.value)}
|
||
rows={2}
|
||
placeholder="e.g. JEI South Africa (PTY) Ltd 47 Station Street, Oberholzer, Carletonville, 2499"
|
||
className="w-full border border-slate-300 rounded px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="print-area bg-white border border-slate-200 rounded overflow-hidden">
|
||
{headerText.trim() !== "" && (
|
||
<div className="print-only border-b border-slate-300 px-3 py-3 text-[15pt] font-semibold leading-snug whitespace-pre-wrap">
|
||
{headerText}
|
||
</div>
|
||
)}
|
||
<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-24">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>
|
||
{footnotes.trim() !== "" && (
|
||
<div className="print-only border-t border-slate-300 px-3 py-2 text-[9pt] italic text-slate-600 whitespace-pre-wrap">
|
||
{footnotes}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="no-print bg-white border border-slate-200 rounded p-4 space-y-2">
|
||
<div>
|
||
<label className="block text-xs text-slate-500 mb-1">
|
||
Footnotes <span className="text-slate-400">(for the reader, printed at the bottom of the PDF)</span>
|
||
</label>
|
||
<textarea
|
||
value={footnotes}
|
||
onChange={(e) => setFootnotes(e.target.value)}
|
||
rows={3}
|
||
placeholder={"e.g. All prices ex VAT and ex delivery.\nLead time approximately 2–3 weeks.\nSubject to stock availability at time of order."}
|
||
className="w-full border border-slate-300 rounded px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Saved-orders panel */}
|
||
<div className="no-print bg-white border border-slate-200 rounded p-4 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-base font-semibold text-brand-dark">Saved orders</h3>
|
||
{editingOrderId && (
|
||
<div className="flex items-center gap-2 text-xs text-amber-700">
|
||
<span>
|
||
Editing <span className="font-mono">{editingName}</span>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={newDraft}
|
||
className="px-2 py-1 border border-slate-300 rounded hover:bg-slate-50"
|
||
>
|
||
New draft
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{savedError && (
|
||
<div className="bg-red-50 border border-red-200 text-red-800 rounded p-2 text-sm">
|
||
{savedError}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-wrap items-end gap-2 border-t border-slate-100 pt-3">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<label className="block text-xs text-slate-500 mb-1">
|
||
{editingOrderId ? "Save changes back to this order" : "Save current list as new order"}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={saveName}
|
||
onChange={(e) => setSaveName(e.target.value)}
|
||
placeholder="e.g. Shipley Sept restock"
|
||
disabled={!!editingOrderId}
|
||
className="w-full border border-slate-300 rounded px-3 py-2 text-sm disabled:bg-slate-50 disabled:text-slate-400"
|
||
/>
|
||
</div>
|
||
{editingOrderId ? (
|
||
<button
|
||
type="button"
|
||
onClick={saveCurrentToEditing}
|
||
disabled={lines.length === 0 || savingBusy}
|
||
className="px-4 py-2 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 disabled:opacity-50"
|
||
>
|
||
{savingBusy ? "Saving…" : "Save changes"}
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={saveNew}
|
||
disabled={lines.length === 0 || !saveName.trim() || savingBusy}
|
||
className="px-4 py-2 rounded bg-brand text-white text-sm hover:bg-brand-dark disabled:opacity-50"
|
||
>
|
||
{savingBusy ? "Saving…" : "Save as new"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{savedOrders.length === 0 ? (
|
||
<p className="text-sm text-slate-500 border-t border-slate-100 pt-3">
|
||
No saved orders yet. Build a list above, give it a name, and click Save.
|
||
</p>
|
||
) : (
|
||
<ul className="divide-y divide-slate-100 border-t border-slate-100 pt-2">
|
||
{savedOrders.map((o) => (
|
||
<li
|
||
key={o.id}
|
||
className={`flex items-center justify-between py-2 ${
|
||
o.id === editingOrderId ? "bg-amber-50 -mx-2 px-2 rounded" : ""
|
||
}`}
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="font-medium text-sm truncate">{o.name}</div>
|
||
<div className="text-xs text-slate-500">
|
||
{o.lineCount} line{o.lineCount === 1 ? "" : "s"}
|
||
{fmtDate(o.updated) ? ` · updated ${fmtDate(o.updated)}` : ""}
|
||
{o.notes ? ` · ${o.notes}` : ""}
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-1 shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => loadOrder(o.id)}
|
||
className="px-2 py-1 text-xs border border-slate-300 rounded hover:bg-slate-50"
|
||
>
|
||
Load
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => deleteOrder(o.id, o.name)}
|
||
className="px-2 py-1 text-xs border border-red-200 text-red-700 rounded hover:bg-red-50"
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</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>
|
||
</>
|
||
);
|
||
} |