EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
ContractFormNew.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 '../styles/forms.css';
6import { apiFetch } from '../lib/api';
7
8
9const GST_RATE = 0.10;
10
11export default function ContractForm() {
12 const navigate = useNavigate();
13 const { id } = useParams();
14 const [selectedTenantId, setSelectedTenantId] = useState('');
15
16 // Contract details
17 const [customerId, setCustomerId] = useState('');
18 const [customerSearch, setCustomerSearch] = useState('');
19 const [customers, setCustomers] = useState([]);
20 const [title, setTitle] = useState('');
21 const [description, setDescription] = useState('');
22 const [status, setStatus] = useState('active');
23
24 // Dates
25 const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
26 const [endDate, setEndDate] = useState('');
27
28 // Billing configuration
29 const [billingInterval, setBillingInterval] = useState('monthly');
30 const [billingDay, setBillingDay] = useState(1);
31 const [customBillingDates, setCustomBillingDates] = useState([]);
32 const [newBillingDate, setNewBillingDate] = useState('');
33 const [autoInvoiceEnabled, setAutoInvoiceEnabled] = useState(false);
34
35 // Support limits
36 const [maxDevices, setMaxDevices] = useState(0);
37 const [maxContacts, setMaxContacts] = useState(0);
38 const [maxProducts, setMaxProducts] = useState(0);
39
40 // Pricing
41 const [laborRate, setLaborRate] = useState(0);
42 const [currency, setCurrency] = useState('AUD');
43
44 // Line items
45 const [lineItems, setLineItems] = useState([]);
46 const [products, setProducts] = useState([]);
47
48 // Notes
49 const [notes, setNotes] = useState('');
50
51 const [loading, setLoading] = useState(false);
52 const [loadingProducts, setLoadingProducts] = useState(true);
53 const [error, setError] = useState('');
54
55 useEffect(() => { fetchProducts(); }, [selectedTenantId]);
56 useEffect(() => { if (id) fetchContract(); }, [id, selectedTenantId]);
57
58 // Always include Authorization and tenant header if needed
59 const buildHeaders = (extra = {}) => {
60 const token = localStorage.getItem('token');
61 const headers = { ...extra, Authorization: `Bearer ${token}` };
62 if (selectedTenantId) {
63 headers['X-Tenant-Id'] = selectedTenantId;
64 }
65 return headers;
66 };
67
68 const fetchContract = async () => {
69 try {
70 const res = await apiFetch(`/contracts/${id}`, { headers: buildHeaders() });
71 if (!res.ok) throw new Error('Failed to load contract');
72 const data = await res.json();
73 // Auto-select the tenant if the contract belongs to a different tenant
74 if (data.tenant_id && !selectedTenantId) {
75 setSelectedTenantId(String(data.tenant_id));
76 }
77 setCustomerId(data.customer_id || '');
78 setCustomerSearch(data.customer_name || '');
79 setTitle(data.title || '');
80 setDescription(data.description || '');
81 setStatus(data.status || 'active');
82 setStartDate(data.start_date?.split('T')[0] || '');
83 setEndDate(data.end_date?.split('T')[0] || '');
84 setBillingInterval(data.billing_interval || 'monthly');
85 setBillingDay(data.billing_day || 1);
86 setAutoInvoiceEnabled(data.auto_invoice_enabled || false);
87 setMaxDevices(data.max_devices || 0);
88 setMaxContacts(data.max_contacts || 0);
89 setMaxProducts(data.max_products || 0);
90 setLaborRate(data.labor_rate || 0);
91 setCurrency(data.currency || 'AUD');
92 setNotes(data.notes || '');
93 // Parse custom billing dates
94 if (data.custom_billing_dates) {
95 try {
96 const dates = JSON.parse(data.custom_billing_dates);
97 setCustomBillingDates(dates || []);
98 } catch (e) {
99 console.error('Error parsing custom billing dates:', e);
100 }
101 }
102 // Load line items
103 setLineItems((data.line_items || []).map(item => ({
104 line_item_id: item.line_item_id,
105 product_id: item.product_id,
106 description: item.description || '',
107 quantity: Number(item.quantity) || 1,
108 unit_price: Number(item.unit_price) || 0,
109 recurring: item.recurring !== false,
110 productSearch: '',
111 showProducts: false,
112 searchResults: []
113 })));
114 } catch (err) {
115 console.error(err);
116 setError('Failed to load contract: ' + err.message);
117 }
118 };
119
120 const fetchProducts = async () => {
121 setLoadingProducts(true);
122 try {
123 const res = await apiFetch('/products?limit=1000', { headers: buildHeaders() });
124 if (!res.ok) throw new Error('Failed to load products');
125 const data = await res.json();
126 setProducts(data.products || []);
127 } catch (err) {
128 console.error(err);
129 } finally {
130 setLoadingProducts(false);
131 }
132 };
133
134 const searchCustomers = async (query) => {
135 try {
136 const res = await apiFetch(`/customers?search=${encodeURIComponent(query)}&limit=10`, { headers: buildHeaders() });
137 if (!res.ok) throw new Error('Failed to search customers');
138 const data = await res.json();
139 setCustomers(data.customers || []);
140 } catch (err) {
141 console.error(err);
142 }
143 };
144
145 useEffect(() => {
146 if (customerSearch && !customerId) {
147 const timer = setTimeout(() => searchCustomers(customerSearch), 300);
148 return () => clearTimeout(timer);
149 } else if (!customerSearch) {
150 setCustomers([]);
151 }
152 }, [customerSearch]);
153
154 const addLineItem = () => {
155 setLineItems(items => [...items, {
156 product_id: null,
157 description: '',
158 quantity: 1,
159 unit_price: 0,
160 recurring: true,
161 productSearch: '',
162 showProducts: false,
163 searchResults: []
164 }]);
165 };
166
167 const removeLineItem = (idx) => {
168 setLineItems(items => items.filter((_, i) => i !== idx));
169 };
170
171 const searchProducts = async (search, idx) => {
172 try {
173 const res = await apiFetch(`/products?search=${encodeURIComponent(search)}&limit=10`, { headers: buildHeaders() });
174 if (!res.ok) throw new Error('Product search failed');
175 const data = await res.json();
176 setLineItems(items => items.map((it, i) =>
177 i === idx ? { ...it, searchResults: data.products || [] } : it
178 ));
179 } catch (err) {
180 console.error('Search failed:', err);
181 }
182 };
183
184 const updateLineItem = (idx, patch) => {
185 setLineItems(items => items.map((it, i) => {
186 if (i !== idx) return it;
187 return { ...it, ...patch };
188 }));
189
190 if (patch.productSearch !== undefined && patch.productSearch) {
191 setTimeout(() => searchProducts(patch.productSearch, idx), 300);
192 }
193 };
194
195 const addCustomBillingDate = () => {
196 if (newBillingDate && !customBillingDates.includes(newBillingDate)) {
197 setCustomBillingDates([...customBillingDates, newBillingDate].sort());
198 setNewBillingDate('');
199 }
200 };
201
202 const removeCustomBillingDate = (date) => {
203 setCustomBillingDates(customBillingDates.filter(d => d !== date));
204 };
205
206 const calculateTotal = () => {
207 return lineItems.reduce((sum, item) => {
208 return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
209 }, 0).toFixed(2);
210 };
211
212 const handleSave = async () => {
213 if (!customerId) {
214 setError('Please select a customer');
215 return;
216 }
217 if (!title.trim()) {
218 setError('Please enter a contract title');
219 return;
220 }
221 if (!startDate) {
222 setError('Please enter a start date');
223 return;
224 }
225 setLoading(true);
226 setError('');
227 try {
228 const payload = {
229 customer_id: customerId,
230 name: title, // Ensure backend receives contract name
231 title,
232 description,
233 status,
234 start_date: startDate,
235 end_date: endDate || null,
236 billing_interval: billingInterval,
237 billing_day: billingInterval === 'monthly' ? billingDay : null,
238 custom_billing_dates: billingInterval === 'custom' ? JSON.stringify(customBillingDates) : null,
239 auto_invoice_enabled: autoInvoiceEnabled,
240 max_devices: Number(maxDevices) || 0,
241 max_contacts: Number(maxContacts) || 0,
242 max_products: Number(maxProducts) || 0,
243 labor_rate: Number(laborRate) || 0,
244 currency,
245 notes,
246 line_items: lineItems.map(item => ({
247 line_item_id: item.line_item_id,
248 product_id: item.product_id,
249 description: item.description,
250 quantity: Number(item.quantity),
251 unit_price: Number(item.unit_price),
252 recurring: item.recurring
253 }))
254 };
255 const url = id ? `/contracts/${id}` : '/contracts';
256 const method = id ? 'PUT' : 'POST';
257 const res = await apiFetch(url, {
258 method,
259 headers: buildHeaders({ 'Content-Type': 'application/json' }),
260 body: JSON.stringify(payload)
261 });
262 if (!res.ok) {
263 const text = await res.text();
264 throw new Error(text || 'Save failed');
265 }
266 const result = await res.json();
267 const contract = result.contract || result;
268 navigate(`/contracts/${contract.contract_id}`);
269 } catch (err) {
270 setError('Save failed: ' + (err.message || err));
271 } finally {
272 setLoading(false);
273 }
274 };
275
276 return (
277 <MainLayout>
278 <div className="page-content">
279 <div className="page-header">
280 <h2>{id ? 'Edit Contract' : 'New Contract'}</h2>
281 </div>
282
283 {error && <div className="error-message">{error}</div>}
284
285
286 <div className="form-container">
287 <div className="form-grid">
288 {/* Basic Information */}
289 <div className="form-section">
290 <h3>Contract Details</h3>
291
292 <div className="form-row">
293 <div className="form-field">
294 <label>Customer<span className="required">*</span></label>
295 <div style={{ position: 'relative' }}>
296 <input
297 type="text"
298 value={customerSearch}
299 onChange={e => {
300 setCustomerSearch(e.target.value);
301 setCustomerId('');
302 }}
303 placeholder="Search customers..."
304 required
305 />
306 {customers.length > 0 && (
307 <div style={{
308 position: 'absolute',
309 top: '100%',
310 left: 0,
311 right: 0,
312 background: 'var(--surface)',
313 border: '1px solid var(--border)',
314 borderRadius: '4px',
315 zIndex: 1000,
316 maxHeight: '200px',
317 overflowY: 'auto',
318 boxShadow: '0 4px 8px rgba(0,0,0,0.1)'
319 }}>
320 {customers.map(c => (
321 <div
322 key={c.customer_id}
323 style={{
324 padding: '12px',
325 cursor: 'pointer',
326 borderBottom: '1px solid var(--border)'
327 }}
328 onClick={() => {
329 setCustomerId(c.customer_id);
330 setCustomerSearch(c.name);
331 setCustomers([]);
332 }}
333 >
334 <strong>{c.name}</strong>
335 {c.email && <div style={{ fontSize: '0.9em', color: 'var(--text-muted)' }}>{c.email}</div>}
336 </div>
337 ))}
338 </div>
339 )}
340 </div>
341 </div>
342
343 <div className="form-field">
344 <label>Status</label>
345 <select value={status} onChange={e => setStatus(e.target.value)}>
346 <option value="active">Active</option>
347 <option value="inactive">Inactive</option>
348 <option value="expired">Expired</option>
349 <option value="cancelled">Cancelled</option>
350 </select>
351 </div>
352 </div>
353
354 <div className="form-field">
355 <label>Contract Title<span className="required">*</span></label>
356 <input
357 type="text"
358 value={title}
359 onChange={e => setTitle(e.target.value)}
360 placeholder="e.g., Managed IT Services - 10 Devices"
361 required
362 />
363 </div>
364
365 <div className="form-field">
366 <label>Description</label>
367 <textarea
368 value={description}
369 onChange={e => setDescription(e.target.value)}
370 rows={3}
371 placeholder="Brief description of the contract..."
372 />
373 </div>
374
375 <div className="form-row">
376 <div className="form-field">
377 <label>Start Date<span className="required">*</span></label>
378 <input
379 type="date"
380 value={startDate}
381 onChange={e => setStartDate(e.target.value)}
382 required
383 />
384 </div>
385
386 <div className="form-field">
387 <label>End Date</label>
388 <input
389 type="date"
390 value={endDate}
391 onChange={e => setEndDate(e.target.value)}
392 />
393 <span className="field-hint">Leave blank for ongoing contract</span>
394 </div>
395 </div>
396 </div>
397
398 {/* Billing Configuration */}
399 <div className="form-section">
400 <h3>Billing Configuration</h3>
401
402 <div className="form-row">
403 <div className="form-field">
404 <label>Billing Interval</label>
405 <select value={billingInterval} onChange={e => setBillingInterval(e.target.value)}>
406 <option value="monthly">Monthly</option>
407 <option value="yearly">Yearly</option>
408 <option value="custom">Custom (Specific Dates)</option>
409 </select>
410 </div>
411
412 {billingInterval === 'monthly' && (
413 <div className="form-field">
414 <label>Billing Day of Month</label>
415 <input
416 type="number"
417 min="1"
418 max="31"
419 value={billingDay}
420 onChange={e => setBillingDay(Number(e.target.value))}
421 />
422 <span className="field-hint">Day of month to generate invoice (1-31)</span>
423 </div>
424 )}
425
426 <div className="form-field">
427 <label>Currency</label>
428 <select value={currency} onChange={e => setCurrency(e.target.value)}>
429 <option value="AUD">AUD</option>
430 <option value="USD">USD</option>
431 <option value="EUR">EUR</option>
432 <option value="GBP">GBP</option>
433 </select>
434 </div>
435 </div>
436
437 {billingInterval === 'custom' && (
438 <div className="form-field">
439 <label>Custom Billing Dates</label>
440 <div style={{ marginBottom: '8px' }}>
441 <div style={{ display: 'flex', gap: '8px' }}>
442 <input
443 type="date"
444 value={newBillingDate}
445 onChange={e => setNewBillingDate(e.target.value)}
446 style={{ flex: 1 }}
447 />
448 <button
449 type="button"
450 className="btn"
451 onClick={addCustomBillingDate}
452 >
453 <span className="material-symbols-outlined">add</span>
454 Add Date
455 </button>
456 </div>
457 </div>
458
459 {customBillingDates.length > 0 && (
460 <div style={{
461 display: 'flex',
462 flexWrap: 'wrap',
463 gap: '8px',
464 marginTop: '12px',
465 padding: '12px',
466 background: 'var(--bg-secondary)',
467 borderRadius: '4px'
468 }}>
469 {customBillingDates.map(date => (
470 <span key={date} style={{
471 padding: '6px 12px',
472 background: 'var(--primary)',
473 color: 'white',
474 borderRadius: '4px',
475 display: 'inline-flex',
476 alignItems: 'center',
477 gap: '8px'
478 }}>
479 {new Date(date).toLocaleDateString()}
480 <button
481 type="button"
482 onClick={() => removeCustomBillingDate(date)}
483 style={{
484 background: 'none',
485 border: 'none',
486 color: 'white',
487 cursor: 'pointer',
488 padding: 0,
489 fontSize: '1.2em'
490 }}
491 >
492 ×
493 </button>
494 </span>
495 ))}
496 </div>
497 )}
498 <span className="field-hint">Invoice will be generated on each of these dates</span>
499 </div>
500 )}
501
502 <div className="form-field">
503 <label style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
504 <input
505 type="checkbox"
506 checked={autoInvoiceEnabled}
507 onChange={e => setAutoInvoiceEnabled(e.target.checked)}
508 />
509 Enable Automatic Invoice Generation
510 </label>
511 <span className="field-hint">Invoices will be automatically created on billing dates</span>
512 </div>
513 </div>
514
515 {/* Support Limits */}
516 <div className="form-section">
517 <h3>Support Limits</h3>
518
519 <div className="form-row">
520 <div className="form-field">
521 <label>Max Devices</label>
522 <input
523 type="number"
524 min="0"
525 value={maxDevices}
526 onChange={e => setMaxDevices(e.target.value)}
527 />
528 <span className="field-hint">Maximum number of devices/endpoints covered</span>
529 </div>
530
531 <div className="form-field">
532 <label>Max Contacts</label>
533 <input
534 type="number"
535 min="0"
536 value={maxContacts}
537 onChange={e => setMaxContacts(e.target.value)}
538 />
539 <span className="field-hint">Maximum number of customer contacts supported</span>
540 </div>
541
542 <div className="form-field">
543 <label>Max Products</label>
544 <input
545 type="number"
546 min="0"
547 value={maxProducts}
548 onChange={e => setMaxProducts(e.target.value)}
549 />
550 <span className="field-hint">Maximum number of products/licenses included</span>
551 </div>
552 </div>
553
554 <div className="form-field">
555 <label>Labor Rate (per hour)</label>
556 <input
557 type="number"
558 step="0.01"
559 min="0"
560 value={laborRate}
561 onChange={e => setLaborRate(e.target.value)}
562 />
563 <span className="field-hint">Hourly rate for additional services</span>
564 </div>
565 </div>
566
567 {/* Line Items */}
568 <div className="form-section">
569 <h3>Line Items (Recurring Charges)</h3>
570 <span className="field-hint">These items will be included in each invoice</span>
571
572 {loadingProducts ? (
573 <div>Loading products...</div>
574 ) : (
575 <>
576 <div style={{
577 display: 'grid',
578 gridTemplateColumns: '2fr 1fr 1fr 1fr auto',
579 gap: '12px',
580 marginBottom: '12px',
581 marginTop: '16px',
582 fontWeight: 'bold',
583 fontSize: '0.9em',
584 color: 'var(--text-muted)'
585 }}>
586 <div>Product/Description</div>
587 <div>Qty</div>
588 <div>Unit Price</div>
589 <div>Recurring</div>
590 <div></div>
591 </div>
592
593 {lineItems.map((item, idx) => {
594 const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
595
596 return (
597 <div key={idx} style={{
598 marginBottom: '16px',
599 padding: '12px',
600 background: 'var(--bg-secondary)',
601 borderRadius: '6px',
602 border: '1px solid var(--border)'
603 }}>
604 <div style={{
605 display: 'grid',
606 gridTemplateColumns: '2fr 1fr 1fr 1fr auto',
607 gap: '12px',
608 alignItems: 'start'
609 }}>
610 <div style={{ position: 'relative' }}>
611 <input
612 type="text"
613 value={item.productSearch || products.find(p => p.product_id === item.product_id)?.name || ''}
614 onChange={e => {
615 const search = e.target.value;
616 updateLineItem(idx, { productSearch: search, product_id: null });
617 }}
618 onFocus={() => updateLineItem(idx, { showProducts: true })}
619 onBlur={() => setTimeout(() => updateLineItem(idx, { showProducts: false }), 200)}
620 placeholder="Search products..."
621 style={{ marginBottom: '8px' }}
622 />
623
624 {item.showProducts && item.searchResults?.length > 0 && (
625 <div style={{
626 position: 'absolute',
627 top: '100%',
628 left: 0,
629 right: 0,
630 background: 'var(--surface)',
631 border: '1px solid var(--border)',
632 borderRadius: '4px',
633 zIndex: 1000,
634 maxHeight: '200px',
635 overflowY: 'auto',
636 boxShadow: '0 4px 8px rgba(0,0,0,0.1)'
637 }}>
638 {item.searchResults.map(p => (
639 <div
640 key={p.product_id}
641 style={{
642 padding: '8px',
643 cursor: 'pointer',
644 borderBottom: '1px solid var(--border)'
645 }}
646 onMouseDown={e => {
647 e.preventDefault();
648 updateLineItem(idx, {
649 product_id: p.product_id,
650 description: p.name,
651 unit_price: Number(p.price_ex_tax || 0),
652 productSearch: p.name,
653 showProducts: false
654 });
655 }}
656 >
657 <strong>{p.name}</strong>
658 {p.sku && <div style={{ fontSize: '0.85em', color: 'var(--text-muted)' }}>SKU: {p.sku}</div>}
659 <div style={{ fontSize: '0.85em', color: 'var(--text-muted)' }}>
660 ${Number(p.price_ex_tax || 0).toFixed(2)}
661 </div>
662 </div>
663 ))}
664 </div>
665 )}
666
667 <textarea
668 value={item.description}
669 onChange={e => updateLineItem(idx, { description: e.target.value })}
670 placeholder="Description..."
671 rows={2}
672 />
673 </div>
674
675 <input
676 type="number"
677 value={item.quantity}
678 onChange={e => updateLineItem(idx, { quantity: Number(e.target.value) })}
679 step="0.01"
680 min="0"
681 />
682
683 <input
684 type="number"
685 value={item.unit_price}
686 onChange={e => updateLineItem(idx, { unit_price: Number(e.target.value) })}
687 step="0.01"
688 min="0"
689 />
690
691 <div style={{ padding: '10px 0' }}>
692 <label style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
693 <input
694 type="checkbox"
695 checked={item.recurring}
696 onChange={e => updateLineItem(idx, { recurring: e.target.checked })}
697 />
698 <span style={{ fontSize: '0.9em' }}>Recurring</span>
699 </label>
700 </div>
701
702 <button
703 type="button"
704 className="btn"
705 onClick={() => removeLineItem(idx)}
706 style={{ padding: '8px', minHeight: 'auto' }}
707 >
708 <span className="material-symbols-outlined">delete</span>
709 </button>
710 </div>
711
712 <div style={{
713 marginTop: '8px',
714 paddingTop: '8px',
715 borderTop: '1px solid var(--border)',
716 fontSize: '0.9em',
717 color: 'var(--text-muted)'
718 }}>
719 Line Total: <strong style={{ color: 'var(--text)' }}>${lineTotal.toFixed(2)}</strong>
720 </div>
721 </div>
722 );
723 })}
724
725 <button
726 type="button"
727 className="btn"
728 onClick={addLineItem}
729 style={{ marginTop: '8px' }}
730 >
731 <span className="material-symbols-outlined">add</span>
732 Add Line Item
733 </button>
734 </>
735 )}
736
737 <div style={{
738 marginTop: '16px',
739 padding: '16px',
740 background: 'var(--bg-secondary)',
741 borderRadius: '6px',
742 fontSize: '1.1em'
743 }}>
744 <strong>Monthly Recurring Total: {currency} ${calculateTotal()}</strong>
745 </div>
746 </div>
747
748 {/* Notes */}
749 <div className="form-section">
750 <h3>Notes</h3>
751 <div className="form-field">
752 <label>Contract Notes</label>
753 <textarea
754 value={notes}
755 onChange={e => setNotes(e.target.value)}
756 rows={4}
757 placeholder="Any additional notes about this contract..."
758 />
759 </div>
760 </div>
761 </div>
762
763 <div className="form-actions">
764 <button
765 type="button"
766 className="btn"
767 onClick={handleSave}
768 disabled={loading}
769 >
770 {loading ? 'Saving...' : (
771 <>
772 <span className="material-symbols-outlined">save</span>
773 {id ? 'Update Contract' : 'Create Contract'}
774 </>
775 )}
776 </button>
777 <button
778 type="button"
779 className="btn"
780 onClick={() => navigate('/contracts')}
781 >
782 <span className="material-symbols-outlined">close</span>
783 Cancel
784 </button>
785 </div>
786 </div>
787 </div>
788 </MainLayout>
789 );
790}