EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
InvoiceForm.jsx
Go to the documentation of this file.
1import { useState, useEffect } from 'react';
2import { useNavigate, useParams } from 'react-router-dom';
3import MainLayout from '../components/Layout/MainLayout';
4// import removed: TenantSelector
5import './InvoiceForm.css';
6import { apiFetch } from '../lib/api';
7import { notifySuccess, notifyError, notifyWarning } from '../utils/notifications';
8
9export default function InvoiceForm() {
10 const navigate = useNavigate();
11 const { id } = useParams();
12 const [selectedTenantId, setSelectedTenantId] = useState('');
13 const [items, setItems] = useState([]);
14 const [products, setProducts] = useState([]);
15 const [customerId, setCustomerId] = useState('');
16 const [customerSearch, setCustomerSearch] = useState('');
17 const [customers, setCustomers] = useState([]);
18 const [currency, setCurrency] = useState('AUD');
19 const [issuedDate, setIssuedDate] = useState('');
20 const [dueDate, setDueDate] = useState('');
21 const [description, setDescription] = useState('');
22 const [loading, setLoading] = useState(false);
23 const [loadingProducts, setLoadingProducts] = useState(true);
24 const [taxRate, setTaxRate] = useState(10); // Default GST rate for Australia
25 const [selectedCustomer, setSelectedCustomer] = useState(null);
26 const [companyInfo, setCompanyInfo] = useState({
27 name: '',
28 abn: '',
29 address: '',
30 phone: '',
31 email: ''
32 });
33
34 useEffect(() => { fetchProducts(); fetchTaxRate(); fetchCompanyInfo(); }, [selectedTenantId]);
35 useEffect(() => { if (id) fetchInvoice(); }, [id, selectedTenantId]);
36
37 const getTenantHeaders = async (baseHeaders = {}) => {
38 const headers = { ...baseHeaders };
39 if (selectedTenantId) {
40 const token = localStorage.getItem('token');
41 const tenantRes = await apiFetch('/tenants', {
42 headers: {
43 'Authorization': `Bearer ${token}`,
44 'X-Tenant-Subdomain': 'admin'
45 }
46 });
47 if (tenantRes.ok) {
48 const data = await tenantRes.json();
49 const tenants = Array.isArray(data) ? data : (data.tenants || []);
50 const tenant = tenants.find(t => t.tenant_id === parseInt(selectedTenantId));
51 if (tenant) {
52 headers['X-Tenant-Subdomain'] = tenant.subdomain;
53 }
54 }
55 }
56 return headers;
57 };
58
59 const fetchTaxRate = async () => {
60 try {
61 const token = localStorage.getItem('token');
62 const headers = await getTenantHeaders({ Authorization: `Bearer ${token}` });
63 const res = await apiFetch('/settings/default_tax_rate', { headers });
64 if (res.ok) {
65 const data = await res.json();
66 setTaxRate(parseFloat(data.setting_value) || 10);
67 }
68 } catch (err) {
69 console.error('Failed to fetch tax rate:', err);
70 }
71 };
72
73 const fetchCompanyInfo = async () => {
74 try {
75 const token = localStorage.getItem('token');
76 const headers = await getTenantHeaders({ Authorization: `Bearer ${token}` });
77 const [settingsRes, abnRes, addressRes, phoneRes, emailRes] = await Promise.all([
78 apiFetch('/settings', { headers }),
79 apiFetch('/settings/company_abn', { headers }),
80 apiFetch('/settings/company_address', { headers }),
81 apiFetch('/settings/company_phone', { headers }),
82 apiFetch('/settings/company_email', { headers })
83 ]);
84
85 const settings = await settingsRes.json();
86 const abn = abnRes.ok ? await abnRes.json() : { setting_value: '' };
87 const address = addressRes.ok ? await addressRes.json() : { setting_value: '' };
88 const phone = phoneRes.ok ? await phoneRes.json() : { setting_value: '' };
89 const email = emailRes.ok ? await emailRes.json() : { setting_value: '' };
90
91 setCompanyInfo({
92 name: settings?.general?.companyName || 'Your Company',
93 abn: abn.setting_value || '',
94 address: address.setting_value || '',
95 phone: phone.setting_value || '',
96 email: email.setting_value || ''
97 });
98 } catch (err) {
99 console.error('Failed to fetch company info:', err);
100 }
101 };
102
103 const fetchInvoice = async () => {
104 try {
105 const token = localStorage.getItem('token');
106 const headers = await getTenantHeaders({ Authorization: `Bearer ${token}` });
107 const res = await apiFetch(`/invoices/${id}`, { headers });
108 if (!res.ok) throw new Error('Failed to load invoice');
109 const data = await res.json();
110
111 // Auto-select the tenant if the invoice belongs to a different tenant
112 if (data.tenant_id && !selectedTenantId) {
113 setSelectedTenantId(String(data.tenant_id));
114 }
115
116 setCustomerId(data.customer_id || '');
117 setCurrency(data.currency || 'USD');
118 setIssuedDate(data.issued_date || '');
119 setDueDate(data.due_date || '');
120 setDescription(data.description || '');
121 setItems((data.items || []).map(it => ({ product_id: it.product_id, description: it.description || '', quantity: Number(it.quantity), unit_price: Number(it.unit_price), total: Number(it.total), tax_amount: Number(it.tax_amount || 0), tax_rate: Number(it.tax_rate || taxRate) })));
122
123 // Fetch customer details
124 if (data.customer_id) {
125 const custRes = await apiFetch(`/customers/${data.customer_id}`, { headers });
126 if (custRes.ok) {
127 const customer = await custRes.json();
128 setSelectedCustomer(customer);
129 setCustomerSearch(customer.name);
130 }
131 }
132 } catch (err) { console.error(err); }
133 };
134
135 const fetchProducts = async () => {
136 setLoadingProducts(true);
137 try {
138 const token = localStorage.getItem('token');
139 const headers = await getTenantHeaders({ Authorization: `Bearer ${token}` });
140 const res = await apiFetch('/products?limit=100', { headers });
141 if (!res.ok) throw new Error('Failed to load products');
142 const data = await res.json();
143 setProducts(data.products || []);
144 } catch (err) { console.error(err); }
145 finally { setLoadingProducts(false); }
146 };
147
148 const searchCustomers = async (query) => {
149 try {
150 const token = localStorage.getItem('token');
151 const headers = await getTenantHeaders({ Authorization: `Bearer ${token}` });
152 const res = await apiFetch(`/customers?search=${encodeURIComponent(query)}&limit=10`,
153 { headers }
154 );
155 if (!res.ok) throw new Error('Failed to search customers');
156 const data = await res.json();
157 setCustomers(data.customers || []);
158 } catch (err) { console.error(err); }
159 };
160
161 useEffect(() => {
162 if (customerSearch) {
163 const timer = setTimeout(() => searchCustomers(customerSearch), 300);
164 return () => clearTimeout(timer);
165 } else {
166 setCustomers([]);
167 }
168 }, [customerSearch]);
169
170 const addItem = () => setItems(s => ([...s, {
171 product_id: null,
172 description: '',
173 quantity: 1,
174 unit_price: 0,
175 total: 0,
176 productSearch: '',
177 showProducts: false,
178 searchResults: []
179 }]));
180 const removeItem = (idx) => setItems(s => s.filter((_, i) => i !== idx));
181
182 const searchProducts = async (search, idx) => {
183 try {
184 const token = localStorage.getItem('token');
185 const res = await apiFetch(`/products?search=${encodeURIComponent(search)}&limit=10`,
186 { headers: { Authorization: `Bearer ${token}` } }
187 );
188 if (!res.ok) throw new Error('Product search failed');
189 const data = await res.json();
190 setItems(s => s.map((it, i) => i === idx ? { ...it, searchResults: data.products || [] } : it));
191 } catch (err) {
192 console.error('Search failed:', err);
193 }
194 };
195
196 const updateItem = (idx, patch) => {
197 setItems(s => s.map((it, i) => {
198 if (i !== idx) return it;
199 const updated = { ...it, ...patch };
200 const qty = patch.quantity ?? it.quantity;
201 const price = patch.unit_price ?? it.unit_price;
202 const subtotal = Number((qty * price).toFixed(2));
203 const taxAmount = Number((subtotal * (taxRate / 100)).toFixed(2));
204 return {
205 ...updated,
206 total: subtotal,
207 tax_amount: taxAmount,
208 tax_rate: taxRate
209 };
210 }));
211
212 // If productSearch changed, trigger search after a delay
213 if (patch.productSearch !== undefined && patch.productSearch) {
214 const timer = setTimeout(() => searchProducts(patch.productSearch, idx), 300);
215 return () => clearTimeout(timer);
216 }
217 };
218
219 const computeSubtotal = () => items.reduce((acc, it) => acc + Number(it.total || 0), 0);
220 const computeTaxTotal = () => items.reduce((acc, it) => acc + Number(it.tax_amount || 0), 0);
221 const computeTotal = () => (computeSubtotal() + computeTaxTotal()).toFixed(2);
222
223 const handleSave = async () => {
224 setLoading(true);
225 try {
226 const token = localStorage.getItem('token');
227 const headers = await getTenantHeaders({
228 'Content-Type': 'application/json',
229 Authorization: `Bearer ${token}`
230 });
231 const payload = {
232 customer_id: customerId || null,
233 currency,
234 issued_date: issuedDate || null,
235 due_date: dueDate || null,
236 description,
237 subtotal: computeSubtotal(),
238 tax_total: computeTaxTotal(),
239 total: parseFloat(computeTotal()),
240 items: items.map(it => ({
241 product_id: it.product_id,
242 description: it.description,
243 quantity: Number(it.quantity),
244 unit_price: Number(it.unit_price),
245 tax_rate: taxRate,
246 tax_amount: it.tax_amount || 0
247 }))
248 };
249 const url = id ? `/invoices/${id}` : '/invoices';
250 const method = id ? 'PUT' : 'POST';
251 const res = await apiFetch(url, { method, headers, body: JSON.stringify(payload) });
252 if (!res.ok) {
253 const txt = await res.text();
254 const errorMsg = txt || 'Save failed';
255 await notifyError(id ? 'Update Failed' : 'Create Failed', errorMsg);
256 throw new Error(errorMsg);
257 }
258 const created = await res.json();
259 // server returns either invoice or { invoice, warnings }
260 const invoice = created.invoice || created;
261 if (created.warnings && created.warnings.length) {
262 await notifyWarning('Invoice Saved with Warnings', created.warnings.join('\n'));
263 } else {
264 await notifySuccess(id ? 'Invoice Updated' : 'Invoice Created', `Invoice #${invoice.invoice_id} has been ${id ? 'updated' : 'created'} successfully`);
265 }
266 navigate(`/invoices/${invoice.invoice_id}`);
267 } catch (err) {
268 // Error already notified above
269 } finally { setLoading(false); }
270 };
271
272 return (
273 <MainLayout>
274 <div className="page-content">
275 <div className="page-header"><h2>{id ? 'Edit Invoice' : 'New Invoice'}</h2></div>
276
277
278
279 <div className="invoice-form-wrapper">
280
281 {/* TAX INVOICE Header */}
282 <div className="invoice-header">
283 <h1>TAX INVOICE</h1>
284 {id && <div className="invoice-number">Invoice #{String(id).padStart(5, '0')}</div>}
285 </div>
286
287 {/* Business and Customer Details - Side by Side */}
288 <div className="invoice-details-grid">
289
290 {/* From: Business Details */}
291 <div className="invoice-from-section">
292 <h3 className="invoice-section-title">From:</h3>
293 <div className="invoice-company-name">{companyInfo.name}</div>
294 {companyInfo.abn && (
295 <div className="invoice-detail-line"><strong>ABN:</strong> {companyInfo.abn}</div>
296 )}
297 {companyInfo.address && <div className="invoice-detail-line">{companyInfo.address}</div>}
298 {companyInfo.phone && <div className="invoice-detail-line">Phone: {companyInfo.phone}</div>}
299 {companyInfo.email && <div className="invoice-detail-line">Email: {companyInfo.email}</div>}
300 {(!companyInfo.abn || !companyInfo.address) && (
301 <div className="invoice-warning">
302 ⚠️ Please configure company details in Settings
303 </div>
304 )}
305 </div>
306
307 {/* To: Customer Details */}
308 <div className="invoice-to-section">
309 <h3 className="invoice-section-title">Bill To:</h3>
310
311 <div className="invoice-customer-search">
312 <input
313 value={customerSearch}
314 onChange={e => setCustomerSearch(e.target.value)}
315 placeholder="Search customers..."
316 />
317 {customers.length > 0 && (
318 <div className="customer-dropdown">
319 {customers.map(c => (
320 <div
321 key={c.customer_id}
322 className="customer-dropdown-item"
323 onClick={() => {
324 setCustomerId(c.customer_id);
325 setSelectedCustomer(c);
326 setCustomerSearch(c.name);
327 setCustomers([]);
328 }}
329 onMouseDown={(e) => e.preventDefault()}
330 >
331 <div style={{ fontWeight: 'bold' }}>{c.name}</div>
332 <div style={{ fontSize: '0.9em', color: '#666' }}>{c.email || 'No email'}</div>
333 </div>
334 ))}
335 </div>
336 )}
337 </div>
338
339 {selectedCustomer && (
340 <>
341 <div style={{ fontSize: '1.1em', fontWeight: 'bold', marginBottom: '8px' }}>{selectedCustomer.name}</div>
342 {selectedCustomer.abn && <div style={{ marginBottom: '4px' }}><strong>ABN:</strong> {selectedCustomer.abn}</div>}
343 {selectedCustomer.address && <div style={{ marginBottom: '4px' }}>{selectedCustomer.address}</div>}
344 {selectedCustomer.city && selectedCustomer.state && (
345 <div style={{ marginBottom: '4px' }}>{selectedCustomer.city}, {selectedCustomer.state} {selectedCustomer.postal_code}</div>
346 )}
347 {selectedCustomer.email && <div style={{ marginBottom: '4px' }}>Email: {selectedCustomer.email}</div>}
348 {selectedCustomer.phone && <div style={{ marginBottom: '4px' }}>Phone: {selectedCustomer.phone}</div>}
349 </>
350 )}
351 </div>
352 </div>
353
354 {/* Invoice Details */}
355 <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '32px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
356 <div className="form-group">
357 <label style={{ fontWeight: 'bold', marginBottom: '4px', display: 'block' }}>Invoice Date</label>
358 <input type="date" value={issuedDate} onChange={e => setIssuedDate(e.target.value)} style={{ width: '100%', padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} />
359 </div>
360 <div className="form-group">
361 <label style={{ fontWeight: 'bold', marginBottom: '4px', display: 'block' }}>Due Date</label>
362 <input type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} style={{ width: '100%', padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} />
363 </div>
364 <div className="form-group">
365 <label style={{ fontWeight: 'bold', marginBottom: '4px', display: 'block' }}>Currency</label>
366 <select value={currency} onChange={e => setCurrency(e.target.value)} style={{ width: '100%', padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }}>
367 <option value="AUD">AUD</option>
368 <option value="USD">USD</option>
369 <option value="EUR">EUR</option>
370 <option value="GBP">GBP</option>
371 </select>
372 </div>
373 <div className="form-group">
374 <label style={{ fontWeight: 'bold', marginBottom: '4px', display: 'block' }}>GST Rate</label>
375 <input type="text" value={`${taxRate}%`} disabled style={{ width: '100%', padding: '8px', border: '1px solid #ccc', borderRadius: '4px', backgroundColor: '#e9ecef' }} />
376 </div>
377 </div>
378
379 <div className="form-group" style={{ marginBottom: '24px' }}>
380 <label style={{ fontWeight: 'bold', marginBottom: '4px', display: 'block' }}>Description / Notes</label>
381 <input value={description} onChange={e => setDescription(e.target.value)} placeholder="Add invoice description or notes..." style={{ width: '100%', padding: '8px', border: '1px solid #ccc', borderRadius: '4px' }} />
382 </div>
383
384 {/* Line Items */}
385 <div style={{ marginBottom: '24px' }}>
386 <h3 style={{ marginBottom: '16px', fontSize: '1.2em', fontWeight: 'bold' }}>Line Items</h3>
387 {loadingProducts ? <div>Loading products...</div> : (
388 <div>
389 <table style={{ width: '100%', borderCollapse: 'collapse', border: '1px solid #ddd' }}>
390 <thead>
391 <tr style={{ backgroundColor: '#333', color: 'white' }}>
392 <th style={{ padding: '12px', textAlign: 'left', borderRight: '1px solid #555' }}>Product</th>
393 <th style={{ padding: '12px', textAlign: 'left', borderRight: '1px solid #555' }}>Description</th>
394 <th style={{ padding: '12px', textAlign: 'center', width: '100px', borderRight: '1px solid #555' }}>Qty</th>
395 <th style={{ padding: '12px', textAlign: 'right', width: '120px', borderRight: '1px solid #555' }}>Unit Price<br />(ex GST)</th>
396 <th style={{ padding: '12px', textAlign: 'right', width: '120px', borderRight: '1px solid #555' }}>Subtotal</th>
397 <th style={{ padding: '12px', textAlign: 'right', width: '100px', borderRight: '1px solid #555' }}>GST<br />({taxRate}%)</th>
398 <th style={{ padding: '12px', textAlign: 'center', width: '80px' }}>Action</th>
399 </tr>
400 </thead>
401 <tbody>
402 {items.map((it, idx) => (
403 <tr key={idx} style={{ borderBottom: '1px solid #ddd', backgroundColor: idx % 2 === 0 ? '#fff' : '#f9f9f9' }}>
404 <td style={{ padding: '8px', borderRight: '1px solid #ddd' }}>
405 <div style={{ position: 'relative' }}>
406 <input
407 type="text"
408 value={it.productSearch || products.find(p => p.product_id === it.product_id)?.name || ''}
409 onChange={e => {
410 const search = e.target.value;
411 updateItem(idx, { ...it, productSearch: search, product_id: null });
412 }}
413 onFocus={() => updateItem(idx, { ...it, showProducts: true })}
414 onBlur={() => {
415 setTimeout(() => {
416 updateItem(idx, { ...it, showProducts: false });
417 }, 200);
418 }}
419 placeholder="Search products..."
420 style={{ width: '100%', padding: '6px', border: '1px solid #ccc', borderRadius: '4px' }}
421 />
422 {it.showProducts && it.searchResults && it.searchResults.length > 0 && (
423 <div style={{
424 position: 'absolute',
425 top: '100%',
426 left: 0,
427 right: 0,
428 background: 'white',
429 border: '1px solid #ccc',
430 borderRadius: '4px',
431 zIndex: 1000,
432 maxHeight: '200px',
433 overflowY: 'auto',
434 boxShadow: '0 4px 8px rgba(0,0,0,0.1)'
435 }}>
436 {it.searchResults.map(p => (
437 <div
438 key={p.product_id}
439 style={{ padding: '8px', cursor: 'pointer', borderBottom: '1px solid #eee' }}
440 onMouseDown={(e) => {
441 e.preventDefault();
442 updateItem(idx, {
443 product_id: p.product_id,
444 description: p.name,
445 unit_price: Number(p.price_retail || p.price_ex_tax || 0),
446 quantity: it.quantity || 1,
447 productSearch: p.name,
448 showProducts: false
449 });
450 }}
451 >
452 <div style={{ fontWeight: 'bold' }}>{p.name}</div>
453 {p.supplier && <div style={{ fontSize: '0.8em', color: '#666' }}>{p.supplier}</div>}
454 </div>
455 ))}
456 </div>
457 )}
458 </div>
459 </td>
460 <td style={{ padding: '8px', borderRight: '1px solid #ddd' }}>
461 <input
462 value={it.description}
463 onChange={e => updateItem(idx, { description: e.target.value })}
464 style={{ width: '100%', padding: '6px', border: '1px solid #ccc', borderRadius: '4px' }}
465 />
466 </td>
467 <td style={{ padding: '8px', borderRight: '1px solid #ddd' }}>
468 <input
469 type="number"
470 value={it.quantity}
471 onChange={e => updateItem(idx, { quantity: Number(e.target.value) })}
472 style={{ width: '100%', padding: '6px', border: '1px solid #ccc', borderRadius: '4px', textAlign: 'center' }}
473 />
474 </td>
475 <td style={{ padding: '8px', borderRight: '1px solid #ddd' }}>
476 <input
477 type="number"
478 step="0.01"
479 value={it.unit_price}
480 onChange={e => updateItem(idx, { unit_price: Number(e.target.value) })}
481 style={{ width: '100%', padding: '6px', border: '1px solid #ccc', borderRadius: '4px', textAlign: 'right' }}
482 />
483 </td>
484 <td style={{ padding: '8px', textAlign: 'right', fontWeight: '500', borderRight: '1px solid #ddd' }}>
485 ${(it.total || 0).toFixed(2)}
486 </td>
487 <td style={{ padding: '8px', textAlign: 'right', fontWeight: '500', borderRight: '1px solid #ddd' }}>
488 ${(it.tax_amount || 0).toFixed(2)}
489 </td>
490 <td style={{ padding: '8px', textAlign: 'center' }}>
491 <button
492 className="btn"
493 onClick={() => removeItem(idx)}
494 type="button"
495 style={{ padding: '4px 8px', fontSize: '0.9em' }}
496 >
497
498 </button>
499 </td>
500 </tr>
501 ))}
502 </tbody>
503 </table>
504
505 <div style={{ marginTop: '12px' }}>
506 <button
507 className="btn primary"
508 onClick={addItem}
509 type="button"
510 style={{ padding: '8px 16px' }}
511 >
512 + Add Line Item
513 </button>
514 </div>
515 </div>
516 )}
517 </div>
518
519 {/* Totals Summary */}
520 <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '32px' }}>
521 <div style={{ width: '400px', border: '2px solid #333', borderRadius: '4px', overflow: 'hidden' }}>
522 <div style={{ padding: '12px 16px', borderBottom: '1px solid #ddd', display: 'flex', justifyContent: 'space-between', fontSize: '1.1em' }}>
523 <span>Subtotal (ex GST):</span>
524 <span style={{ fontWeight: 600 }}>${computeSubtotal().toFixed(2)}</span>
525 </div>
526 <div style={{ padding: '12px 16px', borderBottom: '2px solid #333', display: 'flex', justifyContent: 'space-between', fontSize: '1.1em' }}>
527 <span>GST ({taxRate}%):</span>
528 <span style={{ fontWeight: 600 }}>${computeTaxTotal().toFixed(2)}</span>
529 </div>
530 <div style={{ padding: '16px', backgroundColor: '#f5f5f5', display: 'flex', justifyContent: 'space-between', fontSize: '1.4em' }}>
531 <span style={{ fontWeight: 700 }}>TOTAL (inc GST):</span>
532 <span style={{ fontWeight: 700, color: '#2c5282' }}>{currency} ${computeTotal()}</span>
533 </div>
534 </div>
535 </div>
536
537 {/* Action Buttons */}
538 <div style={{ display: 'flex', gap: '12px', justifyContent: 'center', paddingTop: '24px', borderTop: '1px solid #ddd' }}>
539 <button
540 className="btn primary"
541 onClick={handleSave}
542 disabled={loading}
543 style={{ padding: '12px 32px', fontSize: '1.1em', minWidth: '150px' }}
544 >
545 {loading ? 'Saving...' : (id ? 'Update Invoice' : 'Create Invoice')}
546 </button>
547 <button
548 className="btn"
549 onClick={() => navigate('/invoices')}
550 style={{ padding: '12px 32px', fontSize: '1.1em', minWidth: '150px' }}
551 >
552 Cancel
553 </button>
554 </div>
555 </div>
556 </div>
557 </MainLayout>
558 );
559}