Implemented 100-invoice pagination

X-Lovable-Edit-ID: edt-95ec7931-b29f-4a6f-be9d-8cf8c0f1ad75
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 19:00:27 +00:00
co-authored by renee-png
+67 -61
View File
@@ -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<string>("all");
const [page, setPage] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const [time, setTime] = useState<any[]>([]);
const [expenses, setExpenses] = useState<any[]>([]);
@@ -47,27 +51,46 @@ function InvoicesIndex() {
// Map of placeholder invoice id -> true (placeholder = MARKED-INVOICED-* or IMPORT-PREBILLED-*)
const [placeholderIds, setPlaceholderIds] = useState<Set<string>>(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<string>(
(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<string>(
(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() {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input className="pl-9" placeholder="Search by invoice #, client, case…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<Select value={status} onValueChange={setStatus}>
<Select value={status} onValueChange={(v) => { setPage(0); setStatus(v); }}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
@@ -376,6 +366,22 @@ function InvoicesIndex() {
</table>
</CardContent>
</Card>
{totalCount > PAGE_SIZE && (
<div className="flex items-center justify-between mt-3 text-sm">
<span className="text-muted-foreground">
Showing {totalCount === 0 ? 0 : page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, totalCount)} of {totalCount}
</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page === 0 || loading} onClick={() => setPage((p) => Math.max(0, p - 1))}>
Previous
</Button>
<span className="text-muted-foreground">Page {page + 1} of {totalPages}</span>
<Button variant="outline" size="sm" disabled={page + 1 >= totalPages || loading} onClick={() => setPage((p) => p + 1)}>
Next
</Button>
</div>
</div>
)}
</TabsContent>
<TabsContent value="time" className="mt-4">