EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
Invoices.jsx
Go to the documentation of this file.
1import { useState, useEffect } from 'react';
2import { useNavigate } from 'react-router-dom';
3import MainLayout from '../components/Layout/MainLayout';
4// import removed: TenantSelector
5import '../styles/theme.css';
6import './Invoices.css';
7import { apiFetch } from '../lib/api';
8import { isAdmin } from '../utils/auth';
9import PayInvoiceModal from '../components/PayInvoiceModal';
10
11function Invoices() {
12 const [invoices, setInvoices] = useState([]);
13 const [loading, setLoading] = useState(true);
14 const [error, setError] = useState(null);
15 const [page, setPage] = useState(1);
16 const [totalPages, setTotalPages] = useState(1);
17 const [search, setSearch] = useState('');
18 const [dateFrom, setDateFrom] = useState('');
19 const [dateTo, setDateTo] = useState('');
20 const [paymentStatus, setPaymentStatus] = useState('all');
21 const [showFilters, setShowFilters] = useState(false);
22 const [isRootTenant, setIsRootTenant] = useState(false);
23 const [tenantMap, setTenantMap] = useState({});
24 const [showPayModal, setShowPayModal] = useState(false);
25 const [selectedInvoice, setSelectedInvoice] = useState(null);
26 // Remove selectedTenantId and use tenantId from JWT only
27 const token = localStorage.getItem('token');
28 let tenantId = '';
29 if (token) {
30 try {
31 const payload = token.split('.')[1];
32 const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
33 tenantId = decoded.tenant_id || decoded.tenantId || '';
34 } catch { }
35 }
36
37 // Reload invoices when tenantId changes
38 useEffect(() => {
39 refetch();
40 }, [tenantId]);
41 const navigate = useNavigate();
42
43 const refetch = async () => {
44 try {
45 const token = localStorage.getItem('token');
46 let url = '/invoices';
47 const params = new URLSearchParams();
48 params.set('page', page);
49 params.set('limit', 10);
50 if (search) params.set('search', search);
51 if (dateFrom) params.set('date_from', dateFrom);
52 if (dateTo) params.set('date_to', dateTo);
53 if (paymentStatus && paymentStatus !== 'all') params.set('payment_status', paymentStatus);
54 const queryString = params.toString();
55 if (queryString) url += `?${queryString}`;
56 const response = await apiFetch(url, { headers: { Authorization: `Bearer ${token}` } });
57 if (!response.ok) throw new Error('Failed to load invoices');
58 const data = await response.json();
59 setInvoices(data.invoices);
60 setTotalPages(Math.ceil(data.total / 10));
61 setError(null);
62 } catch (err) {
63 setError(err.message);
64 }
65 };
66
67 const voidInvoice = async (invoice) => {
68 if (!confirm(`Void invoice #${invoice.invoice_id}? This will restock items and mark it as void.`)) return;
69 const reason = window.prompt('Enter a reason for voiding this invoice (optional):', '') || '';
70 try {
71 const token = localStorage.getItem('token');
72 const url = `/invoices/${invoice.invoice_id}/void`;
73 const res = await apiFetch(url, {
74 method: 'POST',
75 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
76 body: JSON.stringify({ reason })
77 });
78 if (!res.ok) {
79 const txt = await res.text();
80 throw new Error(txt || 'Failed to void invoice');
81 }
82 await refetch();
83 alert('Invoice voided');
84 } catch (err) {
85 alert(err.message || 'Failed to void invoice');
86 }
87 };
88
89 const deleteInvoice = async (invoice) => {
90 console.log('[Delete] Attempting to delete invoice:', invoice);
91
92 // Check if delete is allowed
93 const isDraftOrVoid = invoice.status === 'draft' || invoice.status === 'void';
94 const isPaid = invoice.payment_status === 'paid';
95
96 if (!isDraftOrVoid) {
97 alert(`Cannot delete invoice with status: ${invoice.status}. Only draft or void invoices can be deleted.`);
98 return;
99 }
100
101 if (isPaid) {
102 alert('Cannot delete a paid invoice.');
103 return;
104 }
105
106 if (!confirm(`Delete invoice #${invoice.invoice_id}? This will permanently remove it and restock any products.`)) {
107 console.log('[Delete] User cancelled deletion');
108 return;
109 }
110
111 console.log('[Delete] Confirmed - making DELETE request');
112 try {
113 const token = localStorage.getItem('token');
114 const url = `/invoices/${invoice.invoice_id}`;
115 console.log('[Delete] DELETE request to:', url);
116
117 const res = await apiFetch(url, {
118 method: 'DELETE',
119 headers: { Authorization: `Bearer ${token}` }
120 });
121
122 console.log('[Delete] Response status:', res.status, res.ok);
123
124 if (!res.ok) {
125 const txt = await res.text();
126 console.error('[Delete] Error response:', txt);
127 throw new Error(txt || 'Failed to delete invoice');
128 }
129
130 // Remove locally to avoid extra fetch latency
131 setInvoices(list => list.filter(i => i.invoice_id !== invoice.invoice_id));
132 alert(`Invoice #${invoice.invoice_id} deleted successfully`);
133 console.log('[Delete] Success - invoice removed from list');
134 } catch (err) {
135 console.error('[Delete] Exception:', err);
136 alert(err.message || 'Failed to delete invoice');
137 }
138 };
139
140 const markAsUnpaid = async (invoice) => {
141 if (!confirm(`Mark invoice #${invoice.invoice_id} as unpaid? This will allow you to void or delete it. Use only for test invoices.`)) {
142 return;
143 }
144
145 try {
146 const token = localStorage.getItem('token');
147 const res = await apiFetch(`/invoices/${invoice.invoice_id}`, {
148 method: 'PUT',
149 headers: {
150 Authorization: `Bearer ${token}`,
151 'Content-Type': 'application/json'
152 },
153 body: JSON.stringify({
154 ...invoice,
155 payment_status: 'pending'
156 })
157 });
158
159 if (!res.ok) {
160 const txt = await res.text();
161 throw new Error(txt || 'Failed to update invoice');
162 }
163
164 // Update locally
165 setInvoices(list => list.map(i =>
166 i.invoice_id === invoice.invoice_id
167 ? { ...i, payment_status: 'pending' }
168 : i
169 ));
170
171 alert(`Invoice #${invoice.invoice_id} marked as unpaid. You can now void or delete it.`);
172 } catch (err) {
173 console.error('[Mark Unpaid] Error:', err);
174 alert(err.message || 'Failed to mark invoice as unpaid');
175 }
176 };
177
178 // Detect if user is root tenant (MSP)
179 useEffect(() => {
180 const detectRootTenant = async () => {
181 try {
182 const token = localStorage.getItem('token');
183 const res = await apiFetch('/tenants', {
184 headers: { Authorization: `Bearer ${token}` }
185 });
186 if (res.ok) {
187 setIsRootTenant(true);
188 const data = await res.json();
189 const map = {};
190 data.tenants?.forEach(t => {
191 map[t.tenant_id] = t.name;
192 });
193 setTenantMap(map);
194 }
195 } catch (err) {
196 // Silently fail - 403 is expected for non-MSP users
197 }
198 };
199 detectRootTenant();
200 }, []);
201
202 useEffect(() => {
203 const fetchInvoices = async () => {
204 try {
205 const token = localStorage.getItem('token');
206 let url = '/invoices';
207 const params = new URLSearchParams();
208 params.set('page', page);
209 params.set('limit', 10);
210 if (search) params.set('search', search);
211 if (dateFrom) params.set('date_from', dateFrom);
212 if (dateTo) params.set('date_to', dateTo);
213 if (paymentStatus && paymentStatus !== 'all') params.set('payment_status', paymentStatus);
214 const queryString = params.toString();
215 if (queryString) url += `?${queryString}`;
216 const response = await apiFetch(url, {
217 headers: { Authorization: `Bearer ${token}` }
218 });
219 if (!response.ok) throw new Error('Failed to load invoices');
220 const data = await response.json();
221 setInvoices(data.invoices);
222 setTotalPages(Math.ceil(data.total / 10));
223 setError(null);
224 } catch (err) {
225 setError(err.message);
226 } finally {
227 setLoading(false);
228 }
229 };
230
231 fetchInvoices();
232 }, [page, search, dateFrom, dateTo, paymentStatus]);
233
234 return (
235 <MainLayout>
236 <div className="page-container">
237 <div className="page-header">
238 <div className="header-content">
239 <h1>Invoices</h1>
240 <div className="header-actions">
241 <div className="search-box">
242 <span className="material-symbols-outlined">search</span>
243 <input
244 type="text"
245 placeholder="Search invoice#, customer..."
246 value={search}
247 onChange={(e) => { setSearch(e.target.value); setPage(1); }}
248 />
249 </div>
250 <button className="btn btn-secondary" onClick={() => setShowFilters(!showFilters)}>
251 <span className="material-symbols-outlined">filter_list</span>
252 Filters
253 </button>
254 <button className="btn" onClick={() => navigate('/invoices/new')}>
255 <span className="material-symbols-outlined">add</span>
256 New Invoice
257 </button>
258 </div>
259 </div>
260 </div>
261
262 {showFilters && (
263 <div className="filters-panel">
264 <div className="filter-group">
265 <label>From Date:</label>
266 <input
267 type="date"
268 value={dateFrom}
269 onChange={(e) => { setDateFrom(e.target.value); setPage(1); }}
270 />
271 </div>
272 <div className="filter-group">
273 <label>To Date:</label>
274 <input
275 type="date"
276 value={dateTo}
277 onChange={(e) => { setDateTo(e.target.value); setPage(1); }}
278 />
279 </div>
280 <div className="filter-group">
281 <label>Payment Status:</label>
282 <select
283 value={paymentStatus}
284 onChange={(e) => { setPaymentStatus(e.target.value); setPage(1); }}
285 >
286 <option value="all">All Statuses</option>
287 <option value="unpaid">Unpaid</option>
288 <option value="partial">Partially Paid</option>
289 <option value="paid">Paid</option>
290 <option value="overdue">Overdue</option>
291 </select>
292 </div>
293 <button className="btn btn-sm" onClick={() => {
294 setSearch('');
295 setDateFrom('');
296 setDateTo('');
297 setPaymentStatus('all');
298 setPage(1);
299 }}>
300 <span className="material-symbols-outlined">clear</span>
301 Clear Filters
302 </button>
303 </div>
304 )}
305
306 {loading && <div>Loading...</div>}
307 {error && <div className="error">Error: {error}</div>}
308
309 {!loading && !error && (
310 <div className="table-container">
311 {invoices.length === 0 ? (
312 <div className="empty-state">
313 <span className="material-symbols-outlined">receipt_long</span>
314 <h3>No invoices found</h3>
315 <p>Try adjusting your search or filters, or create a new invoice.</p>
316 </div>
317 ) : (
318 <>
319 <table className="data-table">
320 <thead>
321 <tr>
322 <th>Invoice #</th>
323 {isRootTenant && <th>Tenant</th>}
324 <th>Customer</th>
325 <th>Issued</th>
326 <th>Due</th>
327 <th>Amount</th>
328 <th>Payment</th>
329 <th>Status</th>
330 <th>Actions</th>
331 </tr>
332 </thead>
333 <tbody>
334 {invoices.map(invoice => (
335 <tr key={invoice.invoice_id}>
336 <td className="invoice-number">{invoice.invoice_number || `#${invoice.invoice_id}`}</td>
337 {isRootTenant && <td>{invoice.tenant_id === 1 ? 'Root Tenant' : (invoice.tenant_name || tenantMap[invoice.tenant_id] || '-')}</td>}
338 <td>{invoice.customer_name || 'N/A'}</td>
339 <td>{invoice.issued_date ? new Date(invoice.issued_date).toLocaleDateString() : '-'}</td>
340 <td>{invoice.due_date ? new Date(invoice.due_date).toLocaleDateString() : '-'}</td>
341 <td className="amount">${parseFloat(invoice.total || invoice.amount || 0).toFixed(2)}</td>
342 <td><span className={`status-badge ${invoice.payment_status || 'pending'}`}>{invoice.payment_status || 'pending'}</span></td>
343 <td><span className={`status-badge ${invoice.status}`}>{invoice.status}</span></td>
344 <td className="actions"> {/* Pay Now button - only show for unpaid invoices */}
345 {invoice.payment_status !== 'paid' && invoice.status !== 'void' && (
346 <button
347 className="btn-icon"
348 onClick={() => {
349 setSelectedInvoice(invoice);
350 setShowPayModal(true);
351 }}
352 title="Pay Now"
353 style={{ color: '#10b981' }}
354 >
355 <span className="material-symbols-outlined">payments</span>
356 </button>
357 )} <button className="btn-icon" onClick={() => navigate(`/invoices/${invoice.invoice_id}`)} title="View">
358 <span className="material-symbols-outlined">visibility</span>
359 </button>
360 <button className="btn-icon" onClick={() => navigate(`/invoices/${invoice.invoice_id}/edit`)} title="Edit">
361 <span className="material-symbols-outlined">edit</span>
362 </button>
363
364 {/* Mark as Unpaid - Admin only, for test invoices */}
365 {isAdmin() && invoice.payment_status === 'paid' && (
366 <button
367 className="btn-icon"
368 title="Mark as Unpaid (for test cleanup)"
369 onClick={() => markAsUnpaid(invoice)}
370 style={{ color: '#f59e0b' }}
371 >
372 <span className="material-symbols-outlined">money_off</span>
373 </button>
374 )}
375
376 {/* Void invoice (not if already void or paid) */}
377 <button
378 className="btn-icon"
379 title={
380 invoice.payment_status === 'paid'
381 ? 'Cannot void paid invoices (use Mark as Unpaid first)'
382 : invoice.status === 'void'
383 ? 'Already voided'
384 : 'Void invoice'
385 }
386 onClick={() => voidInvoice(invoice)}
387 disabled={invoice.status === 'void' || invoice.payment_status === 'paid'}
388 style={{
389 opacity: (invoice.status === 'void' || invoice.payment_status === 'paid') ? 0.3 : 1,
390 cursor: (invoice.status === 'void' || invoice.payment_status === 'paid') ? 'not-allowed' : 'pointer'
391 }}
392 >
393 <span className="material-symbols-outlined">block</span>
394 </button>
395 {/* Delete invoice (only draft or void and not paid) - Admin only */}
396 {isAdmin() && (
397 <button
398 className="btn-icon"
399 title={
400 invoice.payment_status === 'paid'
401 ? 'Cannot delete paid invoices'
402 : (invoice.status !== 'draft' && invoice.status !== 'void')
403 ? `Cannot delete ${invoice.status} invoices (only draft or void)`
404 : 'Delete invoice (will restock products)'
405 }
406 onClick={() => deleteInvoice(invoice)}
407 disabled={!(invoice.status === 'draft' || invoice.status === 'void') || invoice.payment_status === 'paid'}
408 style={{
409 opacity: (!(invoice.status === 'draft' || invoice.status === 'void') || invoice.payment_status === 'paid') ? 0.3 : 1,
410 cursor: (!(invoice.status === 'draft' || invoice.status === 'void') || invoice.payment_status === 'paid') ? 'not-allowed' : 'pointer'
411 }}
412 >
413 <span className="material-symbols-outlined">delete</span>
414 </button>
415 )}
416 </td>
417 </tr>
418 ))}
419 </tbody>
420 </table>
421
422 <div className="pagination">
423 <button
424 className="btn"
425 onClick={() => setPage(p => Math.max(1, p - 1))}
426 disabled={page === 1}
427 >
428 <span className="material-symbols-outlined">navigate_before</span>
429 Previous
430 </button>
431 <span className="page-info">Page {page} of {totalPages}</span>
432 <button
433 className="btn"
434 onClick={() => setPage(p => Math.min(totalPages, p + 1))}
435 disabled={page === totalPages}
436 >
437 Next
438 <span className="material-symbols-outlined">navigate_next</span>
439 </button>
440 </div>
441 </>
442 )}
443 </div>
444 )}
445 </div>
446
447 {/* Payment Modal */}
448 {showPayModal && selectedInvoice && (
449 <PayInvoiceModal
450 invoice={selectedInvoice}
451 onClose={() => {
452 setShowPayModal(false);
453 setSelectedInvoice(null);
454 }}
455 onSuccess={() => {
456 refetch(); // Reload invoices to show updated payment status
457 }}
458 />
459 )}
460 </MainLayout>
461 );
462}
463
464export default Invoices;
465