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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user