Skip to content

Commit c197df7

Browse files
authored
Merge pull request #114 from Luluameh/invoicelist-page
Invoices List Page — Fetch, Filter, Status Badges
2 parents d07b66a + e63e469 commit c197df7

4 files changed

Lines changed: 155 additions & 38 deletions

File tree

web/app/invoices/page.tsx

Lines changed: 115 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11

22
'use client';
33

4+
import { useState, useMemo } from 'react';
45
import Link from 'next/link';
56
import { useInfiniteQuery } from '@tanstack/react-query';
67
import { apiClient } from '@/lib/api-client';
@@ -65,30 +66,44 @@ async function fetchInvoicesPage(page: number, pageSize: number): Promise<Invoic
6566
}
6667

6768
export default function InvoicesPage() {
69+
const [searchQuery, setSearchQuery] = useState('');
70+
const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'paid' | 'overdue' | 'cancelled'>('all');
6871
const pageSize = 20;
6972

70-
const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
73+
const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = useInfiniteQuery({
7174
queryKey: ['invoices', pageSize],
7275
queryFn: async ({ pageParam }: { pageParam: number }) =>
7376
fetchInvoicesPage(pageParam, pageSize),
7477
initialPageParam: 1,
7578
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.page + 1 : undefined),
7679
});
7780

78-
const invoices = data?.pages.flatMap((page) => page.items) ?? [];
81+
const filteredInvoices = useMemo(() => {
82+
const invoices = data?.pages.flatMap((page) => page.items) ?? [];
83+
return invoices.filter((invoice) => {
84+
const matchesSearch =
85+
invoice.clientName.toLowerCase().includes(searchQuery.toLowerCase()) ||
86+
(invoice.invoiceNumber && invoice.invoiceNumber.toLowerCase().includes(searchQuery.toLowerCase())) ||
87+
invoice.id.toLowerCase().includes(searchQuery.toLowerCase());
88+
89+
const matchesStatus = statusFilter === 'all' || invoice.status === statusFilter;
90+
91+
return matchesSearch && matchesStatus;
92+
});
93+
}, [data, searchQuery, statusFilter]);
7994

8095
const getStatusColor = (status: string) => {
8196
switch (status) {
8297
case 'paid':
83-
return 'bg-green-100 text-green-800';
98+
return 'bg-emerald-50 text-emerald-700 ring-emerald-600/20';
8499
case 'pending':
85-
return 'bg-yellow-100 text-yellow-800';
100+
return 'bg-amber-50 text-amber-700 ring-amber-600/20';
86101
case 'overdue':
87-
return 'bg-red-100 text-red-800';
102+
return 'bg-rose-50 text-rose-700 ring-rose-600/20';
88103
case 'cancelled':
89-
return 'bg-gray-100 text-gray-800';
104+
return 'bg-slate-50 text-slate-600 ring-slate-500/10';
90105
default:
91-
return 'bg-gray-100 text-gray-800';
106+
return 'bg-gray-50 text-gray-600 ring-gray-500/10';
92107
}
93108
};
94109

@@ -109,8 +124,42 @@ export default function InvoicesPage() {
109124
<p className="mt-2 text-gray-600">View and manage your invoices</p>
110125
</div>
111126

112-
<div className="mb-6">
127+
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
113128
<WalletAuthControls />
129+
<div className="flex gap-2">
130+
<button
131+
onClick={() => refetch()}
132+
className="inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
133+
>
134+
Refresh
135+
</button>
136+
</div>
137+
</div>
138+
139+
{/* Filters */}
140+
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
141+
<div className="relative">
142+
<input
143+
type="text"
144+
placeholder="Search by invoice # or client..."
145+
value={searchQuery}
146+
onChange={(e) => setSearchQuery(e.target.value)}
147+
className="block w-full rounded-md border-0 py-2 pl-3 pr-10 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
148+
/>
149+
</div>
150+
<div>
151+
<select
152+
value={statusFilter}
153+
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'pending' | 'paid' | 'overdue' | 'cancelled')}
154+
className="block w-full rounded-md border-0 py-2 pl-3 pr-10 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
155+
>
156+
<option value="all">All Statuses</option>
157+
<option value="pending">Pending</option>
158+
<option value="paid">Paid</option>
159+
<option value="overdue">Overdue</option>
160+
<option value="cancelled">Cancelled</option>
161+
</select>
162+
</div>
114163
</div>
115164

116165
{/* Error State */}
@@ -129,9 +178,10 @@ export default function InvoicesPage() {
129178
<div key={i} className="h-20 animate-pulse rounded-lg bg-gray-200" />
130179
))}
131180
</div>
132-
) : invoices.length === 0 ? (
133-
<div className="text-center">
134-
<p className="text-gray-500">No invoices found</p>
181+
) : filteredInvoices.length === 0 ? (
182+
<div className="rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
183+
<p className="text-sm font-medium text-gray-900">No invoices found</p>
184+
<p className="mt-1 text-sm text-gray-500">Try adjusting your filters or search terms.</p>
135185
</div>
136186
) : (
137187
<>
@@ -162,15 +212,15 @@ export default function InvoicesPage() {
162212
</tr>
163213
</thead>
164214
<tbody className="divide-y divide-gray-200">
165-
{invoices.map((invoice) => (
215+
{filteredInvoices.map((invoice) => (
166216
<tr key={invoice.id} className="hover:bg-gray-50">
167-
<td className="px-6 py-4 text-sm font-medium text-gray-900">
217+
<td className="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">
168218
{invoice.invoiceNumber || `#${invoice.id.slice(0, 8)}`}
169219
</td>
170-
<td className="px-6 py-4 text-sm text-gray-600">
220+
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-600">
171221
{invoice.clientName}
172222
</td>
173-
<td className="px-6 py-4 text-right text-sm font-medium text-gray-900">
223+
<td className="whitespace-nowrap px-6 py-4 text-right text-sm font-medium text-gray-900">
174224
{invoice.amount.toLocaleString('en-US', {
175225
minimumFractionDigits: 2,
176226
maximumFractionDigits: 7,
@@ -180,9 +230,9 @@ export default function InvoicesPage() {
180230
<td className="px-6 py-4 text-sm text-gray-600">
181231
{formatDate(invoice.createdAt)}
182232
</td>
183-
<td className="px-6 py-4 text-sm">
233+
<td className="whitespace-nowrap px-6 py-4 text-sm">
184234
<span
185-
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${getStatusColor(
235+
className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset ${getStatusColor(
186236
invoice.status,
187237
)}`}
188238
>
@@ -204,9 +254,55 @@ export default function InvoicesPage() {
204254
</div>
205255
</div>
206256

207-
{/* Load More Button */}
257+
{/* Pagination Placeholder & Load More */}
258+
<div className="mt-8 flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg shadow-sm">
259+
<div className="flex flex-1 justify-between sm:hidden">
260+
<button
261+
disabled
262+
className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-300 cursor-not-allowed hover:bg-gray-50"
263+
>
264+
Previous
265+
</button>
266+
<button
267+
disabled
268+
className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-300 cursor-not-allowed hover:bg-gray-50"
269+
>
270+
Next
271+
</button>
272+
</div>
273+
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
274+
<div>
275+
<p className="text-sm text-gray-700">
276+
Showing <span className="font-medium">{filteredInvoices.length}</span> results
277+
</p>
278+
</div>
279+
<div>
280+
<nav className="isolate inline-flex -space-gap-px rounded-md shadow-sm" aria-label="Pagination">
281+
<button
282+
disabled
283+
className="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-300 ring-1 ring-inset ring-gray-300 cursor-not-allowed hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
284+
>
285+
<span className="sr-only">Previous</span>
286+
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
287+
<path fillRule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clipRule="evenodd" />
288+
</svg>
289+
</button>
290+
<button
291+
disabled
292+
className="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-300 ring-1 ring-inset ring-gray-300 cursor-not-allowed hover:bg-gray-50 focus:z-20 focus:outline-offset-0"
293+
>
294+
<span className="sr-only">Next</span>
295+
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
296+
<path fillRule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clipRule="evenodd" />
297+
</svg>
298+
</button>
299+
</nav>
300+
</div>
301+
</div>
302+
</div>
303+
208304
{hasNextPage && (
209-
<div className="mt-6 text-center">
305+
<div className="mt-4 text-center">
210306
<button
211307
onClick={() => fetchNextPage()}
212308
disabled={isFetchingNextPage}

web/hooks/use-poll-invoice-status.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function usePollInvoiceStatus(
5959
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6060
const [invoice, setInvoice] = useState<any | null>(null);
6161
const [isLoading, setIsLoading] = useState(false);
62-
const [isPolling, setIsPolling] = useState(false);
62+
const [isPolling, setIsPollingState] = useState(false);
6363
const [error, setError] = useState<string | null>(null);
6464
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
6565
const [pollCount, setPollCount] = useState(0);
@@ -68,6 +68,12 @@ export function usePollInvoiceStatus(
6868
const currentIntervalRef = useRef(initialInterval);
6969
const retryCountRef = useRef(0);
7070
const isMountedRef = useRef(true);
71+
const isPollingRef = useRef(false);
72+
73+
const setIsPolling = useCallback((val: boolean) => {
74+
isPollingRef.current = val;
75+
setIsPollingState(val);
76+
}, []);
7177

7278
const fetchInvoice = useCallback(
7379
async (manual = false) => {
@@ -88,7 +94,10 @@ export function usePollInvoiceStatus(
8894
// Stop polling if paid and stopOnPaid is true
8995
if (stopOnPaid && data?.status === 'paid') {
9096
setIsPolling(false);
91-
if (intervalRef.current) clearInterval(intervalRef.current);
97+
if (intervalRef.current) {
98+
clearInterval(intervalRef.current);
99+
intervalRef.current = null;
100+
}
92101
}
93102
} catch (err) {
94103
if (!isMountedRef.current) return;
@@ -100,7 +109,10 @@ export function usePollInvoiceStatus(
100109
retryCountRef.current++;
101110
if (retryCountRef.current >= maxRetries) {
102111
setIsPolling(false);
103-
if (intervalRef.current) clearInterval(intervalRef.current);
112+
if (intervalRef.current) {
113+
clearInterval(intervalRef.current);
114+
intervalRef.current = null;
115+
}
104116
return;
105117
}
106118

@@ -113,27 +125,28 @@ export function usePollInvoiceStatus(
113125
if (!manual) setIsLoading(false);
114126
}
115127
},
116-
[invoiceId, fetchFn, initialInterval, maxRetries, backoffMultiplier, maxInterval, stopOnPaid],
128+
[invoiceId, fetchFn, initialInterval, maxRetries, backoffMultiplier, maxInterval, stopOnPaid, setIsPolling],
117129
);
118130

119131
const startPolling = useCallback(() => {
120-
if (isPolling || !invoiceId) return;
132+
if (isPollingRef.current || !invoiceId) return;
121133

122134
setIsPolling(true);
123135
retryCountRef.current = 0;
124136
currentIntervalRef.current = initialInterval;
125137

126138
// Initial fetch
127139
fetchInvoice(false).then(() => {
128-
if (!isMountedRef.current) return;
140+
if (!isMountedRef.current || !isPollingRef.current) return;
129141

130142
// Set up interval for subsequent fetches
143+
if (intervalRef.current) clearInterval(intervalRef.current);
131144
intervalRef.current = setInterval(() => {
132145
setPollCount((c: number) => c + 1);
133146
fetchInvoice(false);
134147
}, currentIntervalRef.current);
135148
});
136-
}, [invoiceId, isPolling, initialInterval, fetchInvoice]);
149+
}, [invoiceId, initialInterval, fetchInvoice, setIsPolling]);
137150

138151
const stop = useCallback(() => {
139152
setIsPolling(false);
@@ -143,25 +156,27 @@ export function usePollInvoiceStatus(
143156
}
144157
retryCountRef.current = 0;
145158
currentIntervalRef.current = initialInterval;
146-
}, [initialInterval]);
159+
}, [initialInterval, setIsPolling]);
147160

148161
const refreshStatus = useCallback(async () => {
149162
await fetchInvoice(true);
150163
}, [fetchInvoice]);
151164

152-
// Auto-start polling when component mounts
165+
// Handle lifecycle and auto-start
153166
useEffect(() => {
154-
if (invoiceId && !isPolling) {
167+
isMountedRef.current = true;
168+
169+
if (invoiceId && !isPollingRef.current) {
155170
startPolling();
156171
}
157172

158173
return () => {
159174
stop();
160175
isMountedRef.current = false;
161176
};
162-
}, [invoiceId, isPolling, startPolling, stop]);
177+
}, [invoiceId, startPolling, stop]);
163178

164-
// Update interval when status changes (or retry count changes)
179+
// Update interval when status changes (or fetchInvoice logic triggers it)
165180
useEffect(() => {
166181
if (intervalRef.current && isPolling) {
167182
clearInterval(intervalRef.current);
@@ -170,12 +185,6 @@ export function usePollInvoiceStatus(
170185
fetchInvoice(false);
171186
}, currentIntervalRef.current);
172187
}
173-
174-
return () => {
175-
if (intervalRef.current) {
176-
clearInterval(intervalRef.current);
177-
}
178-
};
179188
}, [isPolling, fetchInvoice]);
180189

181190
return {

web/lib/api-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import axios, { AxiosError } from 'axios';
22

3-
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
3+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
44

55
let accessToken: string | null = null;
66

0 commit comments

Comments
 (0)