diff --git a/src/routes/invoices.index.tsx b/src/routes/invoices.index.tsx index 37503ea..c165f72 100644 --- a/src/routes/invoices.index.tsx +++ b/src/routes/invoices.index.tsx @@ -26,6 +26,8 @@ export const Route = createFileRoute("/invoices/")({ type UnbilledStatus = "unbilled" | "marked" | "all"; +const PAGE_SIZE = 100; + function InvoicesIndex() { const navigate = useNavigate(); const { user } = useAuth(); @@ -33,6 +35,8 @@ function InvoicesIndex() { const [loading, setLoading] = useState(true); const [q, setQ] = useState(""); const [status, setStatus] = useState("all"); + const [page, setPage] = useState(0); + const [totalCount, setTotalCount] = useState(0); const [time, setTime] = useState([]); const [expenses, setExpenses] = useState([]); @@ -47,27 +51,46 @@ function InvoicesIndex() { // Map of placeholder invoice id -> true (placeholder = MARKED-INVOICED-* or IMPORT-PREBILLED-*) const [placeholderIds, setPlaceholderIds] = useState>(new Set()); - useEffect(() => { - (async () => { - const { data } = await supabase - .from("invoices") - .select("*, client:clients(id, name), case:cases(id, case_number, title)") - .order("created_at", { ascending: false }); - setInvoices(data ?? []); - setLoading(false); + const fetchInvoices = async () => { + setLoading(true); + let query = supabase + .from("invoices") + .select("*, client:clients(id, name), case:cases(id, case_number, title)", { count: "exact" }) + .order("created_at", { ascending: false }); - // Track placeholder invoices so we can identify "marked-invoiced" items - const placeholders = new Set( - (data ?? []) - .filter((i: any) => - typeof i.invoice_number === "string" && - (i.invoice_number.startsWith("MARKED-INVOICED-") || i.invoice_number.startsWith("IMPORT-PREBILLED-")) - ) - .map((i: any) => i.id), - ); - setPlaceholderIds(placeholders); - })(); - }, []); + if (status !== "all") query = query.eq("status", status as any); + if (q.trim()) { + const s = q.trim().replace(/[%,]/g, " "); + query = query.or(`invoice_number.ilike.%${s}%`); + } + + const from = page * PAGE_SIZE; + const to = from + PAGE_SIZE - 1; + const { data, count } = await query.range(from, to); + + setInvoices(data ?? []); + setTotalCount(count ?? 0); + setLoading(false); + + const placeholders = new Set( + (data ?? []) + .filter((i: any) => + typeof i.invoice_number === "string" && + (i.invoice_number.startsWith("MARKED-INVOICED-") || i.invoice_number.startsWith("IMPORT-PREBILLED-")) + ) + .map((i: any) => i.id), + ); + setPlaceholderIds(placeholders); + }; + + useEffect(() => { fetchInvoices(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [page, status]); + + // Debounce search + useEffect(() => { + const t = setTimeout(() => { setPage(0); fetchInvoices(); }, 300); + return () => clearTimeout(t); + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [q]); const loadUnbilled = async () => { const [{ data: t }, { data: e }] = await Promise.all([ @@ -91,24 +114,15 @@ function InvoicesIndex() { useEffect(() => { loadUnbilled(); }, []); - const filtered = useMemo(() => { - return invoices.filter((i) => { - if (status !== "all" && i.status !== status) return false; - if (!q) return true; - const s = q.toLowerCase(); - return ( - i.invoice_number?.toLowerCase().includes(s) || - i.client?.name?.toLowerCase().includes(s) || - i.case?.case_number?.toLowerCase().includes(s) - ); - }); - }, [invoices, q, status]); + const filtered = invoices; const totals = useMemo(() => { const outstanding = filtered.reduce((s, i) => s + (Number(i.total) - Number(i.amount_paid)), 0); const paid = filtered.reduce((s, i) => s + Number(i.amount_paid), 0); - return { outstanding, paid, count: filtered.length }; - }, [filtered]); + return { outstanding, paid, count: totalCount }; + }, [filtered, totalCount]); + + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); // Classify each row: unbilled (no invoice_id), marked (linked to placeholder), real (linked to real invoice — not shown) const isPlaceholder = (invId: string | null) => !!invId && placeholderIds.has(invId); @@ -184,19 +198,7 @@ function InvoicesIndex() { toast.success(`Marked ${idArr.length} item(s) as unbilled`); } // Refetch invoices to refresh placeholder set + unbilled lists - const { data: inv } = await supabase - .from("invoices") - .select("*, client:clients(id, name), case:cases(id, case_number, title)") - .order("created_at", { ascending: false }); - setInvoices(inv ?? []); - setPlaceholderIds(new Set( - (inv ?? []) - .filter((i: any) => - typeof i.invoice_number === "string" && - (i.invoice_number.startsWith("MARKED-INVOICED-") || i.invoice_number.startsWith("IMPORT-PREBILLED-")) - ) - .map((i: any) => i.id), - )); + await fetchInvoices(); await loadUnbilled(); if (table === "time_entries") setSelectedTime(new Set()); else setSelectedExp(new Set()); } catch (err: any) { @@ -207,19 +209,7 @@ function InvoicesIndex() { }; const refreshInvoices = async () => { - const { data } = await supabase - .from("invoices") - .select("*, client:clients(id, name), case:cases(id, case_number, title)") - .order("created_at", { ascending: false }); - setInvoices(data ?? []); - setPlaceholderIds(new Set( - (data ?? []) - .filter((i: any) => - typeof i.invoice_number === "string" && - (i.invoice_number.startsWith("MARKED-INVOICED-") || i.invoice_number.startsWith("IMPORT-PREBILLED-")) - ) - .map((i: any) => i.id), - )); + await fetchInvoices(); }; const bulkInvoiceAction = async (action: "delete" | "sent" | "void") => { @@ -284,7 +274,7 @@ function InvoicesIndex() { setQ(e.target.value)} /> - { setPage(0); setStatus(v); }}> All statuses @@ -376,6 +366,22 @@ function InvoicesIndex() { + {totalCount > PAGE_SIZE && ( +
+ + Showing {totalCount === 0 ? 0 : page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, totalCount)} of {totalCount} + +
+ + Page {page + 1} of {totalPages} + +
+
+ )}