JEI_Import_Documents-App/app/order/OrderClient.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

826 lines
No EOL
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 &ldquo;{search}&rdquo;.
</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&#10;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>
</>
);
}