diff --git a/backend/src/invoices/invoices.controller.ts b/backend/src/invoices/invoices.controller.ts index 6802991..355f246 100644 --- a/backend/src/invoices/invoices.controller.ts +++ b/backend/src/invoices/invoices.controller.ts @@ -43,11 +43,13 @@ export class InvoicesController { @CurrentUser() user: User, @Query("page") page?: string, @Query("limit") limit?: string, + @Query("search") search?: string, + @Query("status") status?: string, ): Promise { const p = page ? parseInt(page, 10) : 1; const l = limit ? parseInt(limit, 10) : 20; const result = await this.prisma.runWithMerchantScope(user.merchantId, () => - this.invoicesService.findAll(user.merchantId, p, l), + this.invoicesService.findAll(user.merchantId, p, l, search, status), ); return result.items; } diff --git a/backend/src/invoices/invoices.service.ts b/backend/src/invoices/invoices.service.ts index 894a74e..2489df3 100644 --- a/backend/src/invoices/invoices.service.ts +++ b/backend/src/invoices/invoices.service.ts @@ -51,6 +51,8 @@ export class InvoicesService implements OnModuleInit { merchantId: string, page = 1, limit = 20, + search?: string, + status?: string, ): Promise<{ items: Invoice[]; total: number; @@ -59,14 +61,32 @@ export class InvoicesService implements OnModuleInit { hasMore: boolean; }> { const skip = (page - 1) * limit; + + // Build where clause from filters + const where: Record = { merchantId }; + + if (search && search.trim().length > 0) { + const term = search.trim(); + where["OR"] = [ + { invoiceNumber: { contains: term, mode: "insensitive" } }, + { clientName: { contains: term, mode: "insensitive" } }, + { clientEmail: { contains: term, mode: "insensitive" } }, + { memo: { contains: term, mode: "insensitive" } }, + ]; + } + + if (status && status.trim().length > 0) { + where["status"] = status; + } + const [items, total] = await Promise.all([ this.prisma.invoice.findMany({ - where: { merchantId }, + where, skip, take: limit, orderBy: { createdAt: "desc" }, }), - this.prisma.invoice.count({ where: { merchantId } }), + this.prisma.invoice.count({ where }), ]); const normalizedItems = items.map((inv) => this.normalizeInvoice(inv)); diff --git a/mobile/app/dashboard.tsx b/mobile/app/dashboard.tsx index 2497b23..ecedc2a 100644 --- a/mobile/app/dashboard.tsx +++ b/mobile/app/dashboard.tsx @@ -8,18 +8,55 @@ import { Alert, RefreshControl, AppState, + TextInput, + ScrollView, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { useWalletAuth } from "../hooks/use-wallet-auth"; import { useInvoicesList, Invoice } from "../lib/invoices"; -import { useMemo, useEffect, useState } from "react"; +import { useMemo, useEffect, useState, useRef } from "react"; import { getInvoicesLastSynced } from "../lib/cache"; +import { + useInvoiceFilters, + STATUS_OPTIONS, +} from "../hooks/use-invoice-filters"; + +/** Debounce delay for search input (ms) */ +const DEBOUNCE_MS = 350; export default function DashboardScreen() { const router = useRouter(); const { publicKey, disconnectWallet } = useWalletAuth(); + + // Filter state from zustand store (persists across navigation) + const { + search, + searchDraft, + status, + setSearchDraft, + commitDraft, + setStatus, + clearFilters, + } = useInvoiceFilters(); + + // Debounce search commits + const debounceRef = useRef | null>(null); + const handleSearchChange = (text: string) => { + setSearchDraft(text); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + commitDraft(); + }, DEBOUNCE_MS); + }; + // Clean up debounce timer on unmount + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + const { data, isLoading, isFetching, refetch, hasNextPage, fetchNextPage } = - useInvoicesList(20); + useInvoicesList(20, search, status); const [lastSynced, setLastSynced] = useState(undefined); @@ -40,10 +77,9 @@ export default function DashboardScreen() { }, []); useEffect(() => { - const sub = AppState.addEventListener("change", (state) => { - if (state === "active") { + const sub = AppState.addEventListener("change", (appState) => { + if (appState === "active") { void refetch(); - // refresh stored timestamp after a short delay to allow onSuccess setTimeout(() => { void (async () => { try { @@ -81,6 +117,8 @@ export default function DashboardScreen() { return { pending, paid }; }, [invoices]); + const hasActiveFilters = search.trim().length > 0 || status !== "all"; + const handleLogout = () => { Alert.alert("Logout", "Are you sure you want to logout?", [ { text: "Cancel", style: "cancel" }, @@ -108,7 +146,7 @@ export default function DashboardScreen() { ListHeaderComponent={ - + Base-native receivables in one glass dashboard. @@ -124,7 +162,7 @@ export default function DashboardScreen() { { router.push("/settings"); }} @@ -133,18 +171,18 @@ export default function DashboardScreen() { className="text-sm text-white" style={{ fontFamily: "SpaceGrotesk_600SemiBold" }} > - Settings + ⚙️ - Logout + ↗ @@ -157,42 +195,29 @@ export default function DashboardScreen() { Connected: {publicKey.slice(0, 6)}...{publicKey.slice(-4)} )} - - Monitor liquidity, financing pipelines, and live settlement - metrics in real time. - + {/* Summary cards */} 0 - ? `$${totals.pending.toLocaleString(undefined, { - maximumFractionDigits: 2, - })}` + ? `$${totals.pending.toLocaleString(undefined, { maximumFractionDigits: 2 })}` : "$0", }, { label: "Total Paid", value: totals.paid > 0 - ? `$${totals.paid.toLocaleString(undefined, { - maximumFractionDigits: 2, - })}` + ? `$${totals.paid.toLocaleString(undefined, { maximumFractionDigits: 2 })}` : "$0", }, ]} keyExtractor={(item) => item.label} horizontal showsHorizontalScrollIndicator={false} - contentContainerStyle={{ - paddingVertical: 20, - gap: 16, - }} + contentContainerStyle={{ paddingVertical: 20, gap: 16 }} renderItem={({ item }) => ( + {/* Search bar */} + + 🔍 + { + if (debounceRef.current) clearTimeout(debounceRef.current); + commitDraft(); + }} + autoCapitalize="none" + autoCorrect={false} + /> + {searchDraft.length > 0 && ( + { + setSearchDraft(""); + if (debounceRef.current) clearTimeout(debounceRef.current); + commitDraft(); + }} + > + + + )} + + + {/* Status filter chips */} + + {STATUS_OPTIONS.map((opt) => { + const isActive = status === opt.value; + return ( + { + setStatus(opt.value); + }} + > + + {opt.label} + + + ); + })} + {hasActiveFilters && ( + + + Clear all + + + )} + + + {/* Section header */} - Active invoices + {hasActiveFilters ? "Filtered results" : "Active invoices"} {item.clientName ?? "Invoice"} + {item.clientEmail ? ( + + {item.clientEmail} + + ) : null} - {item.status === "pending" - ? "Pending" - : item.status === "paid" - ? "Paid" - : "Expired"} + {statusLabel} @@ -345,16 +463,50 @@ export default function DashboardScreen() { /> ))} + ) : hasActiveFilters ? ( + + + No matching invoices + + + {search.trim().length > 0 + ? `No results for "${search.trim()}".` + : "No invoices match the selected filter."} + + + + Clear filters + + + ) : ( - + + + No invoices yet + - No invoices found + Create your first invoice to start tracking payments on-chain. - + void; + setSearchDraft: (value: string) => void; + commitDraft: () => void; + setStatus: (status: StatusFilter) => void; + clearFilters: () => void; +} + +/** + * Persistent filter state for the invoice list. + * Survives screen navigation since zustand stores are module-scoped singletons. + */ +export const useInvoiceFilters = create((set) => ({ + search: "", + status: "all", + searchDraft: "", + + setSearch: (value) => { + set({ search: value }); + }, + setSearchDraft: (value) => { + set({ searchDraft: value }); + }, + commitDraft: () => { + set((state) => ({ search: state.searchDraft })); + }, + setStatus: (status) => { + set({ status }); + }, + clearFilters: () => { + set({ search: "", searchDraft: "", status: "all" }); + }, +})); diff --git a/mobile/lib/invoices.ts b/mobile/lib/invoices.ts index 5f0d399..07c8beb 100644 --- a/mobile/lib/invoices.ts +++ b/mobile/lib/invoices.ts @@ -5,7 +5,7 @@ import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { setCachedInvoices, getCachedInvoices } from "./cache"; -export type InvoiceStatus = "pending" | "paid" | "expired"; +export type InvoiceStatus = "pending" | "paid" | "overdue" | "cancelled"; export interface Invoice { id: string; @@ -39,6 +39,8 @@ async function fetchInvoicesPage( page: number, pageSize: number, token: string | null, + search?: string, + status?: string, ): Promise { const headers = token != null @@ -50,8 +52,14 @@ async function fetchInvoicesPage( const params = new URLSearchParams({ page: String(page), limit: String(pageSize), - }).toString(); - const url = `${API_URL}/invoices?${params}`; + }); + if (search && search.trim().length > 0) { + params.set("search", search.trim()); + } + if (status && status !== "all") { + params.set("status", status); + } + const url = `${API_URL}/invoices?${params.toString()}`; const response = await axios.get(url, headers ? { headers } : undefined); const data: unknown = response.data; @@ -88,10 +96,19 @@ async function fetchInvoicesPage( : { items, page, pageSize, hasMore }; } -export function useInvoicesList(pageSize = 20) { +export function useInvoicesList( + pageSize = 20, + search?: string, + status?: string, +) { const { accessToken } = useAuthStore(); const queryClient = useQueryClient(); + // Derive effective filter values + const effectiveSearch = + search && search.trim().length > 0 ? search.trim() : undefined; + const effectiveStatus = status && status !== "all" ? status : undefined; + // seed cached data (if any) into the query cache so UI can show offline data // without blocking the hook caller. Read AsyncStorage in effect and set // the query data so components render immediately from cache when offline. @@ -102,10 +119,19 @@ export function useInvoicesList(pageSize = 20) { const cached = await getCachedInvoices(); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- cancelled may be mutated by cleanup if (!cancelled && cached?.pages) { - queryClient.setQueryData(["invoices", pageSize, accessToken], { - pages: cached.pages, - pageParams: cached.pages.map((_, i) => i + 1), - }); + queryClient.setQueryData( + [ + "invoices", + pageSize, + accessToken, + effectiveSearch, + effectiveStatus, + ], + { + pages: cached.pages, + pageParams: cached.pages.map((_, i) => i + 1), + }, + ); } } catch (err) { console.error("seed cache error:", err); @@ -114,12 +140,24 @@ export function useInvoicesList(pageSize = 20) { return () => { cancelled = true; }; - }, [accessToken, pageSize, queryClient]); + }, [accessToken, pageSize, queryClient, effectiveSearch, effectiveStatus]); const query = useInfiniteQuery({ - queryKey: ["invoices", pageSize, accessToken], + queryKey: [ + "invoices", + pageSize, + accessToken, + effectiveSearch, + effectiveStatus, + ], queryFn: async ({ pageParam }: { pageParam: number }) => - fetchInvoicesPage(pageParam, pageSize, accessToken), + fetchInvoicesPage( + pageParam, + pageSize, + accessToken, + effectiveSearch, + effectiveStatus, + ), initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.page + 1 : undefined,