diff --git a/components/BookingSelect.tsx b/components/BookingSelect.tsx deleted file mode 100644 index 5cfd206..0000000 --- a/components/BookingSelect.tsx +++ /dev/null @@ -1,105 +0,0 @@ -"use client"; - -import { useState, useMemo } from "react"; -import { useRouter } from "next/navigation"; -import { Loader2, AlertCircle } from "lucide-react"; - -type AssetOption = { id: string; code: string; name: string; category: string }; - -// Books one invoice line to a plant item from the register. -// -// Saves immediately on change — there is no separate "save" button to miss on a phone. -// On success it refreshes the router so the server-rendered header, banner and totals -// re-read from PocketBase rather than showing stale booking state. -export function BookingSelect({ - lineId, - value, - assets, -}: { - lineId: string; - value: string; - assets: AssetOption[]; -}) { - const router = useRouter(); - const [current, setCurrent] = useState(value); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - // 44 plant items flat in one dropdown is unusable — group them by category. - const groups = useMemo(() => { - const byCategory = new Map(); - for (const a of assets) { - const key = a.category || "Other"; - if (!byCategory.has(key)) byCategory.set(key, []); - byCategory.get(key)!.push(a); - } - return Array.from(byCategory.entries()) - .map(([category, items]) => ({ category, items: items.sort((x, y) => x.code.localeCompare(y.code)) })) - .sort((a, b) => a.category.localeCompare(b.category)); - }, [assets]); - - async function book(assetId: string) { - const previous = current; - setSaving(true); - setError(null); - setCurrent(assetId); // optimistic - try { - const res = await fetch(`/api/invoice-lines/${lineId}/book`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ assetId }), - }); - const data = await res.json().catch(() => ({ ok: false, error: "Unexpected response" })); - if (!data.ok) { - setCurrent(previous); // roll back so the UI never claims a booking that failed - setError(data.error || "Booking failed"); - } else { - router.refresh(); // re-read server-rendered banner/totals - } - } catch { - setCurrent(previous); - setError("Could not reach the server"); - } finally { - setSaving(false); - } - } - - return ( -
-
- - - {saving && } -
- {error && ( -

- - {error} -

- )} -
- ); -} \ No newline at end of file diff --git a/components/InvoiceTable.tsx b/components/InvoiceTable.tsx index 1fc0686..8ddd8f2 100644 --- a/components/InvoiceTable.tsx +++ b/components/InvoiceTable.tsx @@ -1,7 +1,7 @@ import type { Asset, BookedLine, Invoice } from "@/lib/data"; import { ZAR2 } from "@/lib/utils"; import { AlertTriangle, CheckCircle2 } from "lucide-react"; -import { BookingSelect } from "@/components/BookingSelect"; +import { MachineCombobox } from "@/components/MachineCombobox"; import { ScrollFade } from "@/components/ScrollFade"; import { SiteBookingSelect, SiteBookingLegend } from "@/components/SiteBookingSelect"; import { InvoiceUploader, AttachedDocument } from "@/components/InvoiceUploader"; @@ -223,9 +223,9 @@ function TotalRow({ label, value, strong }: { label: string; value: number; stro } export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: Asset[] }) { - const options = assets + const machines = assets .filter((a) => a.is_active !== false) - .map((a) => ({ id: a.id, code: a.code || "", name: a.name, category: a.category })); + .map((a) => ({ id: a.id, code: a.code || "", name: a.name, category: a.category || "Other" })); return ( // Rule 1 + 4: the table scrolls inside its own container, so the page itself never @@ -284,7 +284,7 @@ export function InvoiceTable({ lines, assets }: { lines: BookedLine[]; assets: A
- +
{(!l.isBooked || l.booked_site) && ( diff --git a/components/MachineCombobox.tsx b/components/MachineCombobox.tsx new file mode 100644 index 0000000..7c1af7b --- /dev/null +++ b/components/MachineCombobox.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useState, useRef, useEffect, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, AlertCircle, ChevronDown, Check, Search, X } from "lucide-react"; + +type Machine = { id: string; code: string; name: string; category: string }; + +// A searchable replacement for the native setOpen(true)} + onChange={(e) => { + setQuery(e.target.value); + setOpen(true); + setHighlight(0); + }} + onKeyDown={onKeyDown} + className={`min-h-[44px] w-full pl-8 pr-8 rounded-lg border text-sm transition focus:outline-none focus:border-[#30638E] ${ + selected + ? "border-[#00798C]/30 bg-[#00798C]/5 text-[#003D5B] dark:text-white font-medium" + : "border-[#D1495B]/40 bg-[#D1495B]/5 text-[#D1495B]" + }`} + /> + {busy ? ( + + ) : ( + + )} +
+ {selected && !busy && ( + + )} + + + {open && ( +
+
+ {groups.flat.length} of {machines.length} machines + {!selected && " · type a code or name to narrow it"} +
+ + {value && ( +
    +
  • + +
  • +
+ )} + + {groups.flat.length === 0 ? ( +

+ No machine matches “{query}”. +

+ ) : ( +
    + {groups.list.map((g) => ( +
  • +
    + {g.category} +
    +
      + {g.items.map((m) => { + const idx = groups.flat.indexOf(m); + const isSel = m.id === value; + return ( +
    • + +
    • + ); + })} +
    +
  • + ))} +
+ )} +
+ )} + + {error && ( +

+ + {error} +

+ )} + + ); +}