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';
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({
34 useEffect(() => { fetchProducts(); fetchTaxRate(); fetchCompanyInfo(); }, [selectedTenantId]);
35 useEffect(() => { if (id) fetchInvoice(); }, [id, selectedTenantId]);
37 const getTenantHeaders = async (baseHeaders = {}) => {
38 const headers = { ...baseHeaders };
39 if (selectedTenantId) {
40 const token = localStorage.getItem('token');
41 const tenantRes = await apiFetch('/tenants', {
43 'Authorization': `Bearer ${token}`,
44 'X-Tenant-Subdomain': 'admin'
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));
52 headers['X-Tenant-Subdomain'] = tenant.subdomain;
59 const fetchTaxRate = async () => {
61 const token = localStorage.getItem('token');
62 const headers = await getTenantHeaders({ Authorization: `Bearer ${token}` });
63 const res = await apiFetch('/settings/default_tax_rate', { headers });
65 const data = await res.json();
66 setTaxRate(parseFloat(data.setting_value) || 10);
69 console.error('Failed to fetch tax rate:', err);
73 const fetchCompanyInfo = async () => {
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 })
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: '' };
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 || ''
99 console.error('Failed to fetch company info:', err);
103 const fetchInvoice = async () => {
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();
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));
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) })));
123 // Fetch customer details
124 if (data.customer_id) {
125 const custRes = await apiFetch(`/customers/${data.customer_id}`, { headers });
127 const customer = await custRes.json();
128 setSelectedCustomer(customer);
129 setCustomerSearch(customer.name);
132 } catch (err) { console.error(err); }
135 const fetchProducts = async () => {
136 setLoadingProducts(true);
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); }
148 const searchCustomers = async (query) => {
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`,
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); }
162 if (customerSearch) {
163 const timer = setTimeout(() => searchCustomers(customerSearch), 300);
164 return () => clearTimeout(timer);
168 }, [customerSearch]);
170 const addItem = () => setItems(s => ([...s, {
180 const removeItem = (idx) => setItems(s => s.filter((_, i) => i !== idx));
182 const searchProducts = async (search, idx) => {
184 const token = localStorage.getItem('token');
185 const res = await apiFetch(`/products?search=${encodeURIComponent(search)}&limit=10`,
186 { headers: { Authorization: `Bearer ${token}` } }
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));
192 console.error('Search failed:', err);
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));
207 tax_amount: taxAmount,
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);
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);
223 const handleSave = async () => {
226 const token = localStorage.getItem('token');
227 const headers = await getTenantHeaders({
228 'Content-Type': 'application/json',
229 Authorization: `Bearer ${token}`
232 customer_id: customerId || null,
234 issued_date: issuedDate || null,
235 due_date: dueDate || null,
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),
246 tax_amount: it.tax_amount || 0
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) });
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);
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'));
264 await notifySuccess(id ? 'Invoice Updated' : 'Invoice Created', `Invoice #${invoice.invoice_id} has been ${id ? 'updated' : 'created'} successfully`);
266 navigate(`/invoices/${invoice.invoice_id}`);
268 // Error already notified above
269 } finally { setLoading(false); }
274 <div className="page-content">
275 <div className="page-header"><h2>{id ? 'Edit Invoice' : 'New Invoice'}</h2></div>
279 <div className="invoice-form-wrapper">
281 {/* TAX INVOICE Header */}
282 <div className="invoice-header">
284 {id && <div className="invoice-number">Invoice #{String(id).padStart(5, '0')}</div>}
287 {/* Business and Customer Details - Side by Side */}
288 <div className="invoice-details-grid">
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>
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
307 {/* To: Customer Details */}
308 <div className="invoice-to-section">
309 <h3 className="invoice-section-title">Bill To:</h3>
311 <div className="invoice-customer-search">
313 value={customerSearch}
314 onChange={e => setCustomerSearch(e.target.value)}
315 placeholder="Search customers..."
317 {customers.length > 0 && (
318 <div className="customer-dropdown">
319 {customers.map(c => (
322 className="customer-dropdown-item"
324 setCustomerId(c.customer_id);
325 setSelectedCustomer(c);
326 setCustomerSearch(c.name);
329 onMouseDown={(e) => e.preventDefault()}
331 <div style={{ fontWeight: 'bold' }}>{c.name}</div>
332 <div style={{ fontSize: '0.9em', color: '#666' }}>{c.email || 'No email'}</div>
339 {selectedCustomer && (
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>
347 {selectedCustomer.email && <div style={{ marginBottom: '4px' }}>Email: {selectedCustomer.email}</div>}
348 {selectedCustomer.phone && <div style={{ marginBottom: '4px' }}>Phone: {selectedCustomer.phone}</div>}
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' }} />
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' }} />
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>
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' }} />
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' }} />
385 <div style={{ marginBottom: '24px' }}>
386 <h3 style={{ marginBottom: '16px', fontSize: '1.2em', fontWeight: 'bold' }}>Line Items</h3>
387 {loadingProducts ? <div>Loading products...</div> : (
389 <table style={{ width: '100%', borderCollapse: 'collapse', border: '1px solid #ddd' }}>
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>
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' }}>
408 value={it.productSearch || products.find(p => p.product_id === it.product_id)?.name || ''}
410 const search = e.target.value;
411 updateItem(idx, { ...it, productSearch: search, product_id: null });
413 onFocus={() => updateItem(idx, { ...it, showProducts: true })}
416 updateItem(idx, { ...it, showProducts: false });
419 placeholder="Search products..."
420 style={{ width: '100%', padding: '6px', border: '1px solid #ccc', borderRadius: '4px' }}
422 {it.showProducts && it.searchResults && it.searchResults.length > 0 && (
424 position: 'absolute',
429 border: '1px solid #ccc',
434 boxShadow: '0 4px 8px rgba(0,0,0,0.1)'
436 {it.searchResults.map(p => (
439 style={{ padding: '8px', cursor: 'pointer', borderBottom: '1px solid #eee' }}
440 onMouseDown={(e) => {
443 product_id: p.product_id,
445 unit_price: Number(p.price_retail || p.price_ex_tax || 0),
446 quantity: it.quantity || 1,
447 productSearch: p.name,
452 <div style={{ fontWeight: 'bold' }}>{p.name}</div>
453 {p.supplier && <div style={{ fontSize: '0.8em', color: '#666' }}>{p.supplier}</div>}
460 <td style={{ padding: '8px', borderRight: '1px solid #ddd' }}>
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' }}
467 <td style={{ padding: '8px', borderRight: '1px solid #ddd' }}>
471 onChange={e => updateItem(idx, { quantity: Number(e.target.value) })}
472 style={{ width: '100%', padding: '6px', border: '1px solid #ccc', borderRadius: '4px', textAlign: 'center' }}
475 <td style={{ padding: '8px', borderRight: '1px solid #ddd' }}>
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' }}
484 <td style={{ padding: '8px', textAlign: 'right', fontWeight: '500', borderRight: '1px solid #ddd' }}>
485 ${(it.total || 0).toFixed(2)}
487 <td style={{ padding: '8px', textAlign: 'right', fontWeight: '500', borderRight: '1px solid #ddd' }}>
488 ${(it.tax_amount || 0).toFixed(2)}
490 <td style={{ padding: '8px', textAlign: 'center' }}>
493 onClick={() => removeItem(idx)}
495 style={{ padding: '4px 8px', fontSize: '0.9em' }}
505 <div style={{ marginTop: '12px' }}>
507 className="btn primary"
510 style={{ padding: '8px 16px' }}
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>
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>
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>
537 {/* Action Buttons */}
538 <div style={{ display: 'flex', gap: '12px', justifyContent: 'center', paddingTop: '24px', borderTop: '1px solid #ddd' }}>
540 className="btn primary"
543 style={{ padding: '12px 32px', fontSize: '1.1em', minWidth: '150px' }}
545 {loading ? 'Saving...' : (id ? 'Update Invoice' : 'Create Invoice')}
549 onClick={() => navigate('/invoices')}
550 style={{ padding: '12px 32px', fontSize: '1.1em', minWidth: '150px' }}