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';
11export default function ContractForm() {
12 const navigate = useNavigate();
13 const { id } = useParams();
14 const [selectedTenantId, setSelectedTenantId] = useState('');
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');
25 const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
26 const [endDate, setEndDate] = useState('');
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);
36 const [maxDevices, setMaxDevices] = useState(0);
37 const [maxContacts, setMaxContacts] = useState(0);
38 const [maxProducts, setMaxProducts] = useState(0);
41 const [laborRate, setLaborRate] = useState(0);
42 const [currency, setCurrency] = useState('AUD');
45 const [lineItems, setLineItems] = useState([]);
46 const [products, setProducts] = useState([]);
49 const [notes, setNotes] = useState('');
51 const [loading, setLoading] = useState(false);
52 const [loadingProducts, setLoadingProducts] = useState(true);
53 const [error, setError] = useState('');
55 useEffect(() => { fetchProducts(); }, [selectedTenantId]);
56 useEffect(() => { if (id) fetchContract(); }, [id, selectedTenantId]);
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;
68 const fetchContract = async () => {
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));
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) {
96 const dates = JSON.parse(data.custom_billing_dates);
97 setCustomBillingDates(dates || []);
99 console.error('Error parsing custom billing dates:', e);
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,
116 setError('Failed to load contract: ' + err.message);
120 const fetchProducts = async () => {
121 setLoadingProducts(true);
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 || []);
130 setLoadingProducts(false);
134 const searchCustomers = async (query) => {
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 || []);
146 if (customerSearch && !customerId) {
147 const timer = setTimeout(() => searchCustomers(customerSearch), 300);
148 return () => clearTimeout(timer);
149 } else if (!customerSearch) {
152 }, [customerSearch]);
154 const addLineItem = () => {
155 setLineItems(items => [...items, {
167 const removeLineItem = (idx) => {
168 setLineItems(items => items.filter((_, i) => i !== idx));
171 const searchProducts = async (search, idx) => {
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
180 console.error('Search failed:', err);
184 const updateLineItem = (idx, patch) => {
185 setLineItems(items => items.map((it, i) => {
186 if (i !== idx) return it;
187 return { ...it, ...patch };
190 if (patch.productSearch !== undefined && patch.productSearch) {
191 setTimeout(() => searchProducts(patch.productSearch, idx), 300);
195 const addCustomBillingDate = () => {
196 if (newBillingDate && !customBillingDates.includes(newBillingDate)) {
197 setCustomBillingDates([...customBillingDates, newBillingDate].sort());
198 setNewBillingDate('');
202 const removeCustomBillingDate = (date) => {
203 setCustomBillingDates(customBillingDates.filter(d => d !== date));
206 const calculateTotal = () => {
207 return lineItems.reduce((sum, item) => {
208 return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
212 const handleSave = async () => {
214 setError('Please select a customer');
218 setError('Please enter a contract title');
222 setError('Please enter a start date');
229 customer_id: customerId,
230 name: title, // Ensure backend receives contract name
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,
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
255 const url = id ? `/contracts/${id}` : '/contracts';
256 const method = id ? 'PUT' : 'POST';
257 const res = await apiFetch(url, {
259 headers: buildHeaders({ 'Content-Type': 'application/json' }),
260 body: JSON.stringify(payload)
263 const text = await res.text();
264 throw new Error(text || 'Save failed');
266 const result = await res.json();
267 const contract = result.contract || result;
268 navigate(`/contracts/${contract.contract_id}`);
270 setError('Save failed: ' + (err.message || err));
278 <div className="page-content">
279 <div className="page-header">
280 <h2>{id ? 'Edit Contract' : 'New Contract'}</h2>
283 {error && <div className="error-message">{error}</div>}
286 <div className="form-container">
287 <div className="form-grid">
288 {/* Basic Information */}
289 <div className="form-section">
290 <h3>Contract Details</h3>
292 <div className="form-row">
293 <div className="form-field">
294 <label>Customer<span className="required">*</span></label>
295 <div style={{ position: 'relative' }}>
298 value={customerSearch}
300 setCustomerSearch(e.target.value);
303 placeholder="Search customers..."
306 {customers.length > 0 && (
308 position: 'absolute',
312 background: 'var(--surface)',
313 border: '1px solid var(--border)',
318 boxShadow: '0 4px 8px rgba(0,0,0,0.1)'
320 {customers.map(c => (
326 borderBottom: '1px solid var(--border)'
329 setCustomerId(c.customer_id);
330 setCustomerSearch(c.name);
334 <strong>{c.name}</strong>
335 {c.email && <div style={{ fontSize: '0.9em', color: 'var(--text-muted)' }}>{c.email}</div>}
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>
354 <div className="form-field">
355 <label>Contract Title<span className="required">*</span></label>
359 onChange={e => setTitle(e.target.value)}
360 placeholder="e.g., Managed IT Services - 10 Devices"
365 <div className="form-field">
366 <label>Description</label>
369 onChange={e => setDescription(e.target.value)}
371 placeholder="Brief description of the contract..."
375 <div className="form-row">
376 <div className="form-field">
377 <label>Start Date<span className="required">*</span></label>
381 onChange={e => setStartDate(e.target.value)}
386 <div className="form-field">
387 <label>End Date</label>
391 onChange={e => setEndDate(e.target.value)}
393 <span className="field-hint">Leave blank for ongoing contract</span>
398 {/* Billing Configuration */}
399 <div className="form-section">
400 <h3>Billing Configuration</h3>
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>
412 {billingInterval === 'monthly' && (
413 <div className="form-field">
414 <label>Billing Day of Month</label>
420 onChange={e => setBillingDay(Number(e.target.value))}
422 <span className="field-hint">Day of month to generate invoice (1-31)</span>
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>
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' }}>
444 value={newBillingDate}
445 onChange={e => setNewBillingDate(e.target.value)}
451 onClick={addCustomBillingDate}
453 <span className="material-symbols-outlined">add</span>
459 {customBillingDates.length > 0 && (
466 background: 'var(--bg-secondary)',
469 {customBillingDates.map(date => (
470 <span key={date} style={{
472 background: 'var(--primary)',
475 display: 'inline-flex',
476 alignItems: 'center',
479 {new Date(date).toLocaleDateString()}
482 onClick={() => removeCustomBillingDate(date)}
498 <span className="field-hint">Invoice will be generated on each of these dates</span>
502 <div className="form-field">
503 <label style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
506 checked={autoInvoiceEnabled}
507 onChange={e => setAutoInvoiceEnabled(e.target.checked)}
509 Enable Automatic Invoice Generation
511 <span className="field-hint">Invoices will be automatically created on billing dates</span>
515 {/* Support Limits */}
516 <div className="form-section">
517 <h3>Support Limits</h3>
519 <div className="form-row">
520 <div className="form-field">
521 <label>Max Devices</label>
526 onChange={e => setMaxDevices(e.target.value)}
528 <span className="field-hint">Maximum number of devices/endpoints covered</span>
531 <div className="form-field">
532 <label>Max Contacts</label>
537 onChange={e => setMaxContacts(e.target.value)}
539 <span className="field-hint">Maximum number of customer contacts supported</span>
542 <div className="form-field">
543 <label>Max Products</label>
548 onChange={e => setMaxProducts(e.target.value)}
550 <span className="field-hint">Maximum number of products/licenses included</span>
554 <div className="form-field">
555 <label>Labor Rate (per hour)</label>
561 onChange={e => setLaborRate(e.target.value)}
563 <span className="field-hint">Hourly rate for additional services</span>
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>
573 <div>Loading products...</div>
578 gridTemplateColumns: '2fr 1fr 1fr 1fr auto',
580 marginBottom: '12px',
584 color: 'var(--text-muted)'
586 <div>Product/Description</div>
588 <div>Unit Price</div>
593 {lineItems.map((item, idx) => {
594 const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
597 <div key={idx} style={{
598 marginBottom: '16px',
600 background: 'var(--bg-secondary)',
602 border: '1px solid var(--border)'
606 gridTemplateColumns: '2fr 1fr 1fr 1fr auto',
610 <div style={{ position: 'relative' }}>
613 value={item.productSearch || products.find(p => p.product_id === item.product_id)?.name || ''}
615 const search = e.target.value;
616 updateLineItem(idx, { productSearch: search, product_id: null });
618 onFocus={() => updateLineItem(idx, { showProducts: true })}
619 onBlur={() => setTimeout(() => updateLineItem(idx, { showProducts: false }), 200)}
620 placeholder="Search products..."
621 style={{ marginBottom: '8px' }}
624 {item.showProducts && item.searchResults?.length > 0 && (
626 position: 'absolute',
630 background: 'var(--surface)',
631 border: '1px solid var(--border)',
636 boxShadow: '0 4px 8px rgba(0,0,0,0.1)'
638 {item.searchResults.map(p => (
644 borderBottom: '1px solid var(--border)'
648 updateLineItem(idx, {
649 product_id: p.product_id,
651 unit_price: Number(p.price_ex_tax || 0),
652 productSearch: p.name,
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)}
668 value={item.description}
669 onChange={e => updateLineItem(idx, { description: e.target.value })}
670 placeholder="Description..."
677 value={item.quantity}
678 onChange={e => updateLineItem(idx, { quantity: Number(e.target.value) })}
685 value={item.unit_price}
686 onChange={e => updateLineItem(idx, { unit_price: Number(e.target.value) })}
691 <div style={{ padding: '10px 0' }}>
692 <label style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
695 checked={item.recurring}
696 onChange={e => updateLineItem(idx, { recurring: e.target.checked })}
698 <span style={{ fontSize: '0.9em' }}>Recurring</span>
705 onClick={() => removeLineItem(idx)}
706 style={{ padding: '8px', minHeight: 'auto' }}
708 <span className="material-symbols-outlined">delete</span>
715 borderTop: '1px solid var(--border)',
717 color: 'var(--text-muted)'
719 Line Total: <strong style={{ color: 'var(--text)' }}>${lineTotal.toFixed(2)}</strong>
728 onClick={addLineItem}
729 style={{ marginTop: '8px' }}
731 <span className="material-symbols-outlined">add</span>
740 background: 'var(--bg-secondary)',
744 <strong>Monthly Recurring Total: {currency} ${calculateTotal()}</strong>
749 <div className="form-section">
751 <div className="form-field">
752 <label>Contract Notes</label>
755 onChange={e => setNotes(e.target.value)}
757 placeholder="Any additional notes about this contract..."
763 <div className="form-actions">
770 {loading ? 'Saving...' : (
772 <span className="material-symbols-outlined">save</span>
773 {id ? 'Update Contract' : 'Create Contract'}
780 onClick={() => navigate('/contracts')}
782 <span className="material-symbols-outlined">close</span>