EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
InvoiceDetail.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';
4import { apiFetch } from '../lib/api';
5import { isAdmin } from '../utils/auth';
6import { notifySuccess, notifyError } from '../utils/notifications';
7import PayInvoiceModal from '../components/PayInvoiceModal';
8
9export default function InvoiceDetail() {
10 const { id } = useParams();
11 const navigate = useNavigate();
12 const [invoice, setInvoice] = useState(null);
13 const [loading, setLoading] = useState(true);
14 const [error, setError] = useState(null);
15 const [customer, setCustomer] = useState(null);
16 const [showPayModal, setShowPayModal] = useState(false);
17 const [pdfBlob, setPdfBlob] = useState(null);
18 const [pdfLoading, setPdfLoading] = useState(true);
19 const [companyInfo, setCompanyInfo] = useState({
20 name: '',
21 abn: '',
22 address: '',
23 phone: '',
24 email: ''
25 });
26
27 const voidInvoice = async () => {
28 if (!invoice) return;
29 if (!confirm(`Void invoice #${invoice.invoice_id}? This will restock items and mark it as void.`)) return;
30 const reason = window.prompt('Enter a reason for voiding this invoice (optional):', '') || '';
31 try {
32 const token = localStorage.getItem('token');
33 const res = await apiFetch(`/invoices/${invoice.invoice_id}/void`, {
34 method: 'POST',
35 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
36 body: JSON.stringify({ reason })
37 });
38 if (!res.ok) {
39 const txt = await res.text();
40 const errorMsg = txt || 'Failed to void invoice';
41 await notifyError('Void Failed', errorMsg);
42 throw new Error(errorMsg);
43 }
44 await fetchInvoice();
45 await notifySuccess('Invoice Voided', `Invoice #${invoice.invoice_id} has been voided successfully`);
46 } catch (err) {
47 // Error already notified above
48 }
49 };
50
51 const deleteInvoice = async () => {
52 if (!invoice) return;
53 if (!confirm(`Delete invoice #${invoice.invoice_id}? This will permanently remove it.`)) return;
54 try {
55 const token = localStorage.getItem('token');
56 const res = await apiFetch(`/invoices/${invoice.invoice_id}`, {
57 method: 'DELETE',
58 headers: { Authorization: `Bearer ${token}` }
59 });
60 if (!res.ok) {
61 const txt = await res.text();
62 const errorMsg = txt || 'Failed to delete invoice';
63 await notifyError('Delete Failed', errorMsg);
64 throw new Error(errorMsg);
65 }
66 await notifySuccess('Invoice Deleted', `Invoice #${invoice.invoice_id} has been deleted successfully`);
67 navigate('/invoices');
68 } catch (err) {
69 // Error already notified above
70 }
71 };
72
73 useEffect(() => {
74 fetchInvoice();
75 fetchCompanyInfo();
76 fetchPdfBlob();
77 }, [id]);
78
79 // Cleanup blob URL on unmount
80 useEffect(() => {
81 return () => {
82 if (pdfBlob && typeof pdfBlob === 'string' && pdfBlob.startsWith('blob:')) {
83 URL.revokeObjectURL(pdfBlob);
84 }
85 };
86 }, [pdfBlob]);
87
88 const fetchPdfBlob = async () => {
89 setPdfLoading(true);
90 try {
91 // Revoke old blob URL if it exists
92 if (pdfBlob && typeof pdfBlob === 'string' && pdfBlob.startsWith('blob:')) {
93 URL.revokeObjectURL(pdfBlob);
94 }
95
96 const token = localStorage.getItem('token');
97 const baseURL = import.meta.env.VITE_API_BASE_URL || 'https://rmm-psa-backend-t9f7k.ondigitalocean.app/api';
98 const response = await fetch(`${baseURL}/invoices/${id}/pdf?t=${Date.now()}`, {
99 headers: {
100 'Authorization': `Bearer ${token}`
101 },
102 credentials: 'include' // Include cookies
103 });
104
105 if (!response.ok) {
106 throw new Error('Failed to fetch PDF');
107 }
108
109 const blob = await response.blob();
110 const blobUrl = URL.createObjectURL(blob);
111 setPdfBlob(blobUrl);
112 } catch (err) {
113 console.error('Error fetching PDF:', err);
114 await notifyError('PDF Error', 'Failed to load invoice PDF');
115 } finally {
116 setPdfLoading(false);
117 }
118 };
119
120 const fetchCompanyInfo = async () => {
121 try {
122 const token = localStorage.getItem('token');
123 const [settingsRes, abnRes, addressRes, phoneRes, emailRes] = await Promise.all([
124 apiFetch('/settings', { headers: { Authorization: `Bearer ${token}` } }),
125 apiFetch('/settings/company_abn', { headers: { Authorization: `Bearer ${token}` } }),
126 apiFetch('/settings/company_address', { headers: { Authorization: `Bearer ${token}` } }),
127 apiFetch('/settings/company_phone', { headers: { Authorization: `Bearer ${token}` } }),
128 apiFetch('/settings/company_email', { headers: { Authorization: `Bearer ${token}` } })
129 ]);
130
131 const settings = await settingsRes.json();
132 const abn = abnRes.ok ? await abnRes.json() : { setting_value: '' };
133 const address = addressRes.ok ? await addressRes.json() : { setting_value: '' };
134 const phone = phoneRes.ok ? await phoneRes.json() : { setting_value: '' };
135 const email = emailRes.ok ? await emailRes.json() : { setting_value: '' };
136
137 setCompanyInfo({
138 name: settings?.general?.companyName || 'Your Company',
139 abn: abn.setting_value || '',
140 address: address.setting_value || '',
141 phone: phone.setting_value || '',
142 email: email.setting_value || ''
143 });
144 } catch (err) {
145 console.error('Failed to fetch company info:', err);
146 }
147 };
148
149 const fetchInvoice = async () => {
150 setLoading(true);
151 try {
152 const token = localStorage.getItem('token');
153 const res = await apiFetch(`/invoices/${id}`, { headers: { Authorization: `Bearer ${token}` } });
154 if (!res.ok) throw new Error('Failed to load invoice');
155 const data = await res.json();
156 setInvoice(data);
157
158 // Fetch customer details
159 if (data.customer_id) {
160 const custRes = await apiFetch(`/customers/${data.customer_id}`, { headers: { Authorization: `Bearer ${token}` } });
161 if (custRes.ok) {
162 const customerData = await custRes.json();
163 // Backend returns { customer: {...} } so extract the customer object
164 setCustomer(customerData.customer || customerData);
165 }
166 }
167 } catch (err) { setError(err.message || 'Error'); }
168 finally { setLoading(false); }
169 };
170
171 if (loading) return (<MainLayout><div className="page-content">Loading...</div></MainLayout>);
172 if (error) return (<MainLayout><div className="page-content">Error: {error}</div></MainLayout>);
173 if (!invoice) return (<MainLayout><div className="page-content">Invoice not found</div></MainLayout>);
174
175 return (
176 <MainLayout>
177 <div className="page-content" style={{ maxWidth: '1200px', margin: '0 auto' }}>
178
179 {/* Action Buttons at Top */}
180 <div style={{ marginBottom: '20px', display: 'flex', gap: '12px', justifyContent: 'space-between', alignItems: 'center' }}>
181 <div>
182 <h2 style={{ margin: 0 }}>Invoice #{String(invoice.invoice_id).padStart(5, '0')}</h2>
183 <span style={{
184 display: 'inline-block',
185 marginTop: '8px',
186 padding: '4px 12px',
187 borderRadius: '12px',
188 fontSize: '0.9em',
189 fontWeight: '600',
190 backgroundColor: invoice.payment_status === 'paid' ? '#d4edda' : invoice.status === 'void' ? '#e2e3e5' : '#fff3cd',
191 color: invoice.payment_status === 'paid' ? '#155724' : invoice.status === 'void' ? '#383d41' : '#856404'
192 }}>
193 {invoice.payment_status === 'paid' ? 'PAID' : invoice.status?.toUpperCase() || 'DRAFT'}
194 </span>
195 </div>
196
197 <div style={{ display: 'flex', gap: '12px' }}>
198 {/* Pay Now button - only show for unpaid invoices */}
199 {invoice.payment_status !== 'paid' && invoice.status !== 'void' && (
200 <button
201 className="btn"
202 onClick={() => setShowPayModal(true)}
203 style={{ background: '#10b981', color: 'white' }}
204 >
205 <span className="material-symbols-outlined" style={{ fontSize: '18px', marginRight: '4px' }}>payments</span>
206 Pay Now
207 </button>
208 )}
209
210 <button className="btn primary" onClick={() => navigate(`/invoices/${invoice.invoice_id}/edit`)}>
211 <span className="material-symbols-outlined" style={{ fontSize: '18px', marginRight: '4px' }}>edit</span>
212 Edit
213 </button>
214
215 <button
216 className="btn"
217 onClick={voidInvoice}
218 disabled={invoice.status === 'void' || invoice.payment_status === 'paid'}
219 >
220 <span className="material-symbols-outlined" style={{ fontSize: '18px', marginRight: '4px' }}>block</span>
221 Void
222 </button>
223
224 {isAdmin() && (
225 <button
226 className="btn"
227 onClick={deleteInvoice}
228 disabled={!(invoice.status === 'draft' || invoice.status === 'void') || invoice.payment_status === 'paid'}
229 >
230 <span className="material-symbols-outlined" style={{ fontSize: '18px', marginRight: '4px' }}>delete</span>
231 Delete
232 </button>
233 )}
234
235 {/* Download PDF */}
236 <a
237 href={pdfBlob || '#'}
238 download={`invoice-${String(invoice.invoice_id).padStart(5, '0')}.pdf`}
239 className="btn"
240 onClick={(e) => {
241 if (!pdfBlob) {
242 e.preventDefault();
243 notifyError('PDF Not Ready', 'PDF is still loading');
244 }
245 }}
246 >
247 <span className="material-symbols-outlined" style={{ fontSize: '18px', marginRight: '4px' }}>download</span>
248 Download PDF
249 </a>
250
251 <button className="btn" onClick={() => navigate('/invoices')}>
252 <span className="material-symbols-outlined" style={{ fontSize: '18px', marginRight: '4px' }}>arrow_back</span>
253 Back
254 </button>
255 </div>
256 </div>
257
258 {/* PDF Viewer */}
259 <div style={{
260 background: 'white',
261 borderRadius: '8px',
262 boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
263 overflow: 'hidden',
264 minHeight: '800px',
265 display: 'flex',
266 flexDirection: 'column'
267 }}>
268 {pdfLoading ? (
269 <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '400px' }}>
270 <p>Loading PDF...</p>
271 </div>
272 ) : pdfBlob ? (
273 <iframe
274 src={pdfBlob}
275 style={{
276 width: '100%',
277 height: '800px',
278 border: 'none'
279 }}
280 title="Invoice PDF"
281 />
282 ) : (
283 <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '400px' }}>
284 <p>Failed to load PDF</p>
285 </div>
286 )}
287 </div>
288 </div>
289
290 {/* Payment Modal */}
291 {showPayModal && invoice && (
292 <PayInvoiceModal
293 invoice={invoice}
294 onClose={() => setShowPayModal(false)}
295 onSuccess={() => {
296 fetchInvoice(); // Reload invoice to show updated payment status
297 }}
298 />
299 )}
300 </MainLayout>
301 );
302}