1import { useState, useEffect } from 'react';
2import { useNavigate, useParams } from 'react-router-dom';
3import MainLayout from '../components/Layout/MainLayout';
4import '../styles/forms.css';
5import { apiFetch } from '../lib/api';
6import { notifySuccess, notifyError, notifyWarning } from '../utils/notifications';
7import { isAdmin } from '../utils/auth';
9const GST_RATE = 0.10; // 10% GST
11export default function InvoiceForm() {
12 const navigate = useNavigate();
13 const { id } = useParams();
16 const [customerId, setCustomerId] = useState('');
17 const [customerSearch, setCustomerSearch] = useState('');
18 const [customers, setCustomers] = useState([]);
19 const [currency, setCurrency] = useState('AUD');
20 const [issuedDate, setIssuedDate] = useState(new Date().toISOString().split('T')[0]);
21 const [dueDate, setDueDate] = useState('');
22 const [description, setDescription] = useState('');
23 const [status, setStatus] = useState('draft');
24 const [paymentStatus, setPaymentStatus] = useState('unpaid');
25 const [purchaseOrderNumber, setPurchaseOrderNumber] = useState('');
26 const [internalNotes, setInternalNotes] = useState('');
29 const [lineItems, setLineItems] = useState([]);
30 const [products, setProducts] = useState([]);
32 const [loading, setLoading] = useState(false);
33 const [loadingProducts, setLoadingProducts] = useState(true);
35 useEffect(() => { fetchProducts(); }, []);
36 useEffect(() => { if (id) fetchInvoice(); }, [id]);
38 const fetchInvoice = async () => {
40 const token = localStorage.getItem('token');
41 const res = await apiFetch(`/invoices/${id}`, {
42 headers: { Authorization: `Bearer ${token}` }
44 if (!res.ok) throw new Error('Failed to load invoice');
45 const data = await res.json();
47 setCustomerId(data.customer_id || '');
48 setCustomerSearch(data.customer_name || '');
49 setCurrency(data.currency || 'AUD');
50 setIssuedDate(data.issued_date?.split('T')[0] || '');
51 setDueDate(data.due_date?.split('T')[0] || '');
52 setDescription(data.description || '');
53 setStatus(data.status || 'draft');
54 setPaymentStatus(data.payment_status || 'unpaid');
55 setPurchaseOrderNumber(data.purchase_order_number || '');
56 setInternalNotes(data.internal_notes || '');
59 setLineItems((data.line_items || []).map(item => ({
60 line_item_id: item.line_item_id,
61 product_id: item.product_id,
62 description: item.description || '',
63 quantity: Number(item.quantity) || 1,
64 unit_price_ex: Number(item.unit_price_ex) || 0,
65 unit_price_inc: Number(item.unit_price_inc) || 0,
66 gst_amount: Number(item.gst_amount) || 0,
73 alert('Failed to load invoice: ' + err.message);
77 const fetchProducts = async () => {
78 setLoadingProducts(true);
80 const token = localStorage.getItem('token');
81 const res = await apiFetch('/products?limit=1000', {
82 headers: { Authorization: `Bearer ${token}` }
84 if (!res.ok) throw new Error('Failed to load products');
85 const data = await res.json();
86 setProducts(data.products || []);
90 setLoadingProducts(false);
94 const searchCustomers = async (query) => {
96 const token = localStorage.getItem('token');
97 const res = await apiFetch(
98 `/customers?search=${encodeURIComponent(query)}&limit=10`,
99 { headers: { Authorization: `Bearer ${token}` } }
101 if (!res.ok) throw new Error('Failed to search customers');
102 const data = await res.json();
103 setCustomers(data.customers || []);
110 if (customerSearch && !customerId) {
111 const timer = setTimeout(() => searchCustomers(customerSearch), 300);
112 return () => clearTimeout(timer);
113 } else if (!customerSearch) {
116 }, [customerSearch]);
118 const addLineItem = () => {
119 setLineItems(items => [...items, {
132 const removeLineItem = (idx) => {
133 setLineItems(items => items.filter((_, i) => i !== idx));
136 const searchProducts = async (search, idx) => {
138 const token = localStorage.getItem('token');
139 const res = await apiFetch(
140 `/products?search=${encodeURIComponent(search)}&limit=10`,
141 { headers: { Authorization: `Bearer ${token}` } }
143 if (!res.ok) throw new Error('Product search failed');
144 const data = await res.json();
145 setLineItems(items => items.map((it, i) =>
146 i === idx ? { ...it, searchResults: data.products || [] } : it
149 console.error('Search failed:', err);
153 const calculateLinePrices = (quantity, unitPriceEx, gstFree = false) => {
154 const qty = Number(quantity) || 0;
155 const priceEx = Number(unitPriceEx) || 0;
156 const gstAmount = gstFree ? 0 : priceEx * GST_RATE;
157 const unitPriceInc = priceEx + gstAmount;
158 const lineTotalEx = qty * priceEx;
159 const lineGst = gstFree ? 0 : lineTotalEx * GST_RATE;
160 const lineTotalInc = lineTotalEx + lineGst;
163 unit_price_inc: Number(unitPriceInc.toFixed(2)),
164 gst_amount: Number(gstAmount.toFixed(2)),
165 line_total_ex: Number(lineTotalEx.toFixed(2)),
166 line_gst: Number(lineGst.toFixed(2)),
167 line_total_inc: Number(lineTotalInc.toFixed(2))
171 const updateLineItem = (idx, patch) => {
172 setLineItems(items => items.map((it, i) => {
173 if (i !== idx) return it;
175 const updated = { ...it, ...patch };
177 // Recalculate prices if quantity or unit_price_ex changed
178 if (patch.quantity !== undefined || patch.unit_price_ex !== undefined) {
179 const prices = calculateLinePrices(
181 updated.unit_price_ex,
184 Object.assign(updated, prices);
190 // Trigger product search if needed
191 if (patch.productSearch !== undefined && patch.productSearch) {
192 setTimeout(() => searchProducts(patch.productSearch, idx), 300);
196 const calculateTotals = () => {
200 lineItems.forEach(item => {
201 const qty = Number(item.quantity) || 0;
202 const priceEx = Number(item.unit_price_ex) || 0;
203 const lineTotalEx = qty * priceEx;
204 const lineGst = item.gst_free ? 0 : lineTotalEx * GST_RATE;
206 subtotal += lineTotalEx;
210 const total = subtotal + gstTotal;
213 subtotal: subtotal.toFixed(2),
214 gstTotal: gstTotal.toFixed(2),
215 total: total.toFixed(2)
219 // Administrative functions (admin-only)
220 const markAsUnpaid = async () => {
221 if (!confirm(`Mark invoice #${id} as unpaid? This will allow you to void or delete it. Use only for test invoices.`)) return;
223 const token = localStorage.getItem('token');
224 const res = await apiFetch(`/invoices/${id}`, {
226 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
227 body: JSON.stringify({
228 customer_id: customerId,
230 issued_date: issuedDate,
234 payment_status: 'pending', // Force to pending
235 purchase_order_number: purchaseOrderNumber,
236 internal_notes: internalNotes,
237 line_items: lineItems
240 if (!res.ok) throw new Error(await res.text() || 'Failed to update invoice');
241 setPaymentStatus('pending');
242 notifySuccess(`Invoice #${id} marked as unpaid. You can now void or delete it.`);
244 console.error('[Mark Unpaid] Error:', err);
245 notifyError(err.message || 'Failed to mark invoice as unpaid');
249 const voidInvoice = async () => {
250 if (!confirm(`Void invoice #${id}? This will restock items and mark it as void.`)) return;
251 const reason = window.prompt('Enter a reason for voiding this invoice (optional):', '') || '';
253 const token = localStorage.getItem('token');
254 const res = await apiFetch(`/invoices/${id}/void`, {
256 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
257 body: JSON.stringify({ reason })
260 const txt = await res.text();
261 throw new Error(txt || 'Failed to void invoice');
263 notifySuccess('Invoice voided successfully');
264 navigate('/invoices');
266 notifyError(err.message || 'Failed to void invoice');
270 const deleteInvoice = async () => {
271 console.log('[Delete] Attempting to delete invoice');
272 const isDraftOrVoid = status === 'draft' || status === 'void';
273 const isPaid = paymentStatus === 'paid';
275 if (!isDraftOrVoid) {
276 notifyWarning(`Cannot delete invoice with status: ${status}. Only draft or void invoices can be deleted.`);
280 notifyError('Cannot delete a paid invoice. Use "Mark as Unpaid" first.');
283 if (!confirm(`Delete invoice #${id}? This will permanently remove it and restock any products.`)) {
284 console.log('[Delete] User cancelled deletion');
289 const token = localStorage.getItem('token');
290 const res = await apiFetch(`/invoices/${id}`, {
292 headers: { Authorization: `Bearer ${token}` }
295 const text = await res.text();
296 throw new Error(text || 'Failed to delete invoice');
298 console.log('[Delete] Invoice deleted successfully');
299 notifySuccess(`Invoice #${id} deleted successfully`);
300 navigate('/invoices');
302 console.error('[Delete] Error:', err);
303 notifyError(err.message || 'Failed to delete invoice');
307 const handleSave = async () => {
309 await notifyWarning('Customer Required', 'Please select a customer before saving');
315 const token = localStorage.getItem('token');
316 const totals = calculateTotals();
319 customer_id: customerId,
321 issued_date: issuedDate || null,
322 due_date: dueDate || null,
325 payment_status: paymentStatus,
326 purchase_order_number: purchaseOrderNumber || null,
327 internal_notes: internalNotes || null,
328 subtotal: Number(totals.subtotal),
329 tax_total: Number(totals.gstTotal),
330 total: Number(totals.total),
331 line_items: lineItems.map(item => ({
332 line_item_id: item.line_item_id,
333 product_id: item.product_id,
334 description: item.description,
335 quantity: Number(item.quantity),
336 unit_price_ex: Number(item.unit_price_ex),
337 unit_price_inc: Number(item.unit_price_inc),
338 gst_amount: Number(item.gst_amount)
342 const url = id ? `/invoices/${id}` : '/invoices';
343 const method = id ? 'PUT' : 'POST';
345 const res = await apiFetch(url, {
348 'Content-Type': 'application/json',
349 Authorization: `Bearer ${token}`
351 body: JSON.stringify(payload)
355 const txt = await res.text();
356 const errorMsg = txt || 'Save failed';
357 await notifyError(id ? 'Update Failed' : 'Create Failed', errorMsg);
358 throw new Error(errorMsg);
361 const result = await res.json();
362 const invoice = result.invoice || result;
364 await notifySuccess(id ? 'Invoice Updated' : 'Invoice Created', `Invoice #${invoice.invoice_id} has been ${id ? 'updated' : 'created'} successfully`);
365 navigate(`/invoices/${invoice.invoice_id}`);
367 // Error already notified above
373 const totals = calculateTotals();
377 <div className="page-content">
378 <div className="page-header">
379 <h2>{id ? 'Edit Invoice' : 'Create Invoice'}</h2>
382 <div className="form-container">
383 <div className="form-grid">
384 {/* Basic Information */}
385 <div className="form-section">
386 <h3>Invoice Details</h3>
388 <div className="form-row">
389 <div className="form-field">
390 <label>Customer<span className="required">*</span></label>
391 <div style={{ position: 'relative' }}>
394 value={customerSearch}
396 setCustomerSearch(e.target.value);
399 placeholder="Search customers..."
402 {customers.length > 0 && (
404 position: 'absolute',
408 background: 'var(--surface)',
409 border: '1px solid var(--border)',
414 boxShadow: '0 4px 8px rgba(0,0,0,0.1)'
416 {customers.map(c => (
422 borderBottom: '1px solid var(--border)'
425 setCustomerId(c.customer_id);
426 setCustomerSearch(c.name);
430 <strong>{c.name}</strong>
431 {c.email && <div style={{ fontSize: '0.9em', color: 'var(--text-muted)' }}>{c.email}</div>}
439 <div className="form-field">
440 <label>Purchase Order Number</label>
443 value={purchaseOrderNumber}
444 onChange={e => setPurchaseOrderNumber(e.target.value)}
445 placeholder="PO-12345"
450 <div className="form-row">
451 <div className="form-field">
452 <label>Issued Date</label>
456 onChange={e => setIssuedDate(e.target.value)}
460 <div className="form-field">
461 <label>Due Date</label>
465 onChange={e => setDueDate(e.target.value)}
469 <div className="form-field">
470 <label>Currency</label>
471 <select value={currency} onChange={e => setCurrency(e.target.value)}>
472 <option value="AUD">AUD</option>
473 <option value="USD">USD</option>
474 <option value="EUR">EUR</option>
475 <option value="GBP">GBP</option>
480 <div className="form-row">
481 <div className="form-field">
482 <label>Status</label>
483 <select value={status} onChange={e => setStatus(e.target.value)}>
484 <option value="draft">Draft</option>
485 <option value="sent">Sent</option>
486 <option value="approved">Approved</option>
487 <option value="cancelled">Cancelled</option>
491 <div className="form-field">
492 <label>Payment Status</label>
493 <select value={paymentStatus} onChange={e => setPaymentStatus(e.target.value)}>
494 <option value="unpaid">Unpaid</option>
495 <option value="partial">Partially Paid</option>
496 <option value="paid">Paid</option>
497 <option value="overdue">Overdue</option>
502 <div className="form-field">
503 <label>Description</label>
506 onChange={e => setDescription(e.target.value)}
508 placeholder="Brief description of the invoice..."
512 <div className="form-field">
513 <label>Internal Notes</label>
515 value={internalNotes}
516 onChange={e => setInternalNotes(e.target.value)}
518 placeholder="Notes for internal use only (not visible to customer)..."
520 <span className="field-hint">These notes are for internal use and won't appear on the invoice</span>
525 <div className="form-section">
529 <div>Loading products...</div>
534 gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr auto',
536 marginBottom: '12px',
539 color: 'var(--text-muted)'
541 <div>Product/Description</div>
543 <div>Unit Price Ex</div>
544 <div>Unit Price Inc</div>
549 {lineItems.map((item, idx) => {
550 const linePrices = calculateLinePrices(item.quantity, item.unit_price_ex, item.gst_free);
553 <div key={idx} style={{
554 marginBottom: '16px',
556 background: 'var(--bg-secondary)',
558 border: '1px solid var(--border)'
562 gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr auto',
566 <div style={{ position: 'relative' }}>
569 value={item.productSearch || products.find(p => p.product_id === item.product_id)?.name || ''}
571 const search = e.target.value;
572 updateLineItem(idx, { productSearch: search, product_id: null });
574 onFocus={() => updateLineItem(idx, { showProducts: true })}
575 onBlur={() => setTimeout(() => updateLineItem(idx, { showProducts: false }), 200)}
576 placeholder="Search products..."
577 style={{ marginBottom: '8px' }}
580 {item.showProducts && item.searchResults?.length > 0 && (
582 position: 'absolute',
586 background: 'var(--surface)',
587 border: '1px solid var(--border)',
592 boxShadow: '0 4px 8px rgba(0,0,0,0.1)'
594 {item.searchResults.map(p => (
600 borderBottom: '1px solid var(--border)'
604 updateLineItem(idx, {
605 product_id: p.product_id,
607 unit_price_ex: Number(p.price_ex_tax || 0),
608 gst_free: p.gst_free || false,
609 productSearch: p.name,
614 <strong>{p.name}</strong>
615 {p.sku && <div style={{ fontSize: '0.85em', color: 'var(--text-muted)' }}>SKU: {p.sku}</div>}
616 <div style={{ fontSize: '0.85em', color: 'var(--text-muted)' }}>
617 ${Number(p.price_ex_tax || 0).toFixed(2)} ex GST
625 value={item.description}
626 onChange={e => updateLineItem(idx, { description: e.target.value })}
627 placeholder="Description..."
634 value={item.quantity}
635 onChange={e => updateLineItem(idx, { quantity: Number(e.target.value) })}
642 value={item.unit_price_ex}
643 onChange={e => updateLineItem(idx, { unit_price_ex: Number(e.target.value) })}
648 <div style={{ padding: '10px 0', color: 'var(--text-muted)' }}>
649 ${linePrices.unit_price_inc.toFixed(2)}
652 <div style={{ padding: '10px 0', color: 'var(--text-muted)' }}>
653 ${linePrices.gst_amount.toFixed(2)}
659 onClick={() => removeLineItem(idx)}
660 style={{ padding: '8px', minHeight: 'auto' }}
662 <span className="material-symbols-outlined">delete</span>
669 borderTop: '1px solid var(--border)',
671 color: 'var(--text-muted)'
673 Line Total: ${linePrices.line_total_ex.toFixed(2)} + ${linePrices.line_gst.toFixed(2)} GST =
674 <strong style={{ marginLeft: '4px', color: 'var(--text)' }}>
675 ${linePrices.line_total_inc.toFixed(2)}
685 onClick={addLineItem}
686 style={{ marginTop: '8px' }}
688 <span className="material-symbols-outlined">add</span>
695 {/* Totals Summary */}
696 <div className="form-section">
700 flexDirection: 'column',
704 <div style={{ display: 'flex', justifyContent: 'space-between' }}>
705 <span>Subtotal (Ex GST):</span>
706 <strong>${totals.subtotal}</strong>
708 <div style={{ display: 'flex', justifyContent: 'space-between' }}>
709 <span>GST Total:</span>
710 <strong>${totals.gstTotal}</strong>
714 justifyContent: 'space-between',
716 borderTop: '2px solid var(--border)',
718 color: 'var(--primary)'
720 <span>Total (Inc GST):</span>
721 <strong>{currency} ${totals.total}</strong>
727 <div className="form-actions">
734 {loading ? 'Saving...' : (
736 <span className="material-symbols-outlined">save</span>
737 {id ? 'Update Invoice' : 'Create Invoice'}
744 onClick={() => navigate('/invoices')}
746 <span className="material-symbols-outlined">close</span>
750 {/* Admin actions - only show when editing existing invoice */}
751 {id && isAdmin() && (
752 <div style={{ marginLeft: 'auto', display: 'flex', gap: '10px' }}>
753 {/* Mark as Unpaid - only for paid invoices */}
754 {paymentStatus === 'paid' && (
758 onClick={markAsUnpaid}
759 title="Mark as Unpaid (for test cleanup)"
760 style={{ color: '#f59e0b', fontSize: '24px', padding: '8px' }}
762 <span className="material-symbols-outlined">money_off</span>
770 onClick={voidInvoice}
771 disabled={status === 'void' || paymentStatus === 'paid'}
775 : paymentStatus === 'paid'
776 ? 'Cannot void paid invoices (use Mark as Unpaid first)'
780 opacity: (status === 'void' || paymentStatus === 'paid') ? 0.3 : 1,
781 cursor: (status === 'void' || paymentStatus === 'paid') ? 'not-allowed' : 'pointer',
786 <span className="material-symbols-outlined">block</span>
789 {/* Delete button */}
793 onClick={deleteInvoice}
794 disabled={!(status === 'draft' || status === 'void') || paymentStatus === 'paid'}
796 paymentStatus === 'paid'
797 ? 'Cannot delete paid invoices'
798 : !(status === 'draft' || status === 'void')
799 ? 'Cannot delete issued invoices (only draft or void)'
800 : 'Delete invoice (will restock products)'
803 opacity: (!(status === 'draft' || status === 'void') || paymentStatus === 'paid') ? 0.3 : 1,
804 cursor: (!(status === 'draft' || status === 'void') || paymentStatus === 'paid') ? 'not-allowed' : 'pointer',
809 <span className="material-symbols-outlined">delete</span>