EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
CustomerDetail.jsx
Go to the documentation of this file.
1import { useEffect, useState } from 'react';
2import { useParams, useNavigate } from 'react-router-dom';
3import MainLayout from '../components/Layout/MainLayout';
4import './Customers.css';
5import DownloadAgentModal from "../components/DownloadAgentModal";
6import { apiFetch } from '../lib/api';
7import { notifySuccess, notifyError } from '../utils/notifications';
8
9function CustomerDetail() {
10 // Helper to decode JWT and extract tenantId
11 function getTenantIdFromStorageOrJWT() {
12 let tid = localStorage.getItem('selectedTenantId');
13 if (tid) return tid.trim().replace(/['"]/g, '');
14 const token = localStorage.getItem('token');
15 if (token) {
16 try {
17 const payload = token.split('.')[1];
18 const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
19 tid = decoded.tenantId || decoded.tenant_id || null;
20 if (tid) {
21 const sanitizedTid = String(tid).trim().replace(/['"]/g, '');
22 localStorage.setItem('selectedTenantId', sanitizedTid);
23 return sanitizedTid;
24 }
25 return tid;
26 } catch (e) {
27 return null;
28 }
29 }
30 return null;
31 }
32 // Download Agent modal state for this customer
33
34 const [showDownload, setShowDownload] = useState(false);
35
36 const { id } = useParams();
37 const navigate = useNavigate();
38 const [data, setData] = useState(null);
39 const [loading, setLoading] = useState(false);
40 const [error, setError] = useState('');
41
42 // Contact management state
43 const [showContactForm, setShowContactForm] = useState(false);
44 const [editingContact, setEditingContact] = useState(null);
45 const [contactForm, setContactForm] = useState({
46 name: '',
47 role: '',
48 email: '',
49 phone: '',
50 is_primary: false,
51 notes: ''
52 });
53
54 // Portal user management state
55 const [portalUsers, setPortalUsers] = useState([]);
56 const [showPortalUserForm, setShowPortalUserForm] = useState(false);
57 const [editingPortalUser, setEditingPortalUser] = useState(null);
58 const [portalUserForm, setPortalUserForm] = useState({
59 email: '',
60 password: '',
61 first_name: '',
62 last_name: '',
63 phone: '',
64 is_primary: false
65 });
66 const [resetPasswordUserId, setResetPasswordUserId] = useState(null);
67 const [newPassword, setNewPassword] = useState('');
68
69 useEffect(() => {
70 fetchDetail();
71 fetchPortalUsers();
72 }, [id]);
73
74 async function fetchPortalUsers() {
75 try {
76 const token = localStorage.getItem('token');
77 const res = await apiFetch(`/customer-users?customer_id=${id}`, {
78 headers: { Authorization: `Bearer ${token}` }
79 });
80 if (!res.ok) throw new Error('Failed to load portal users');
81 const json = await res.json();
82 setPortalUsers(json.users || []);
83 } catch (err) {
84 console.error('Error fetching portal users:', err);
85 }
86 }
87
88 async function handleSavePortalUser(e) {
89 e.preventDefault();
90 try {
91 const token = localStorage.getItem('token');
92
93 // Validation
94 if (!portalUserForm.email) {
95 await notifyError('Validation Error', 'Email is required');
96 return;
97 }
98
99 if (!editingPortalUser && !portalUserForm.password) {
100 await notifyError('Validation Error', 'Password is required for new users');
101 return;
102 }
103
104 const body = {
105 customer_id: parseInt(id),
106 ...portalUserForm
107 };
108
109 const url = editingPortalUser
110 ? `/customer-users/${editingPortalUser.customer_user_id}`
111 : `/customer-users`;
112 const method = editingPortalUser ? 'PUT' : 'POST';
113
114 const res = await apiFetch(url, {
115 method,
116 headers: {
117 'Content-Type': 'application/json',
118 Authorization: `Bearer ${token}`
119 },
120 body: JSON.stringify(body)
121 });
122
123 if (!res.ok) {
124 const error = await res.json();
125 throw new Error(error.error || 'Failed to save portal user');
126 }
127
128 // Reset form and refresh
129 setShowPortalUserForm(false);
130 setEditingPortalUser(null);
131 setPortalUserForm({ email: '', password: '', first_name: '', last_name: '', phone: '', is_primary: false });
132 await notifySuccess(
133 editingPortalUser ? 'User Updated' : 'User Created',
134 `Portal user has been ${editingPortalUser ? 'updated' : 'created'} successfully`
135 );
136 fetchPortalUsers();
137 } catch (err) {
138 console.error(err);
139 await notifyError('Save Failed', err.message || 'Failed to save portal user');
140 }
141 }
142
143 async function handleResetPassword() {
144 if (!newPassword) {
145 await notifyError('Validation Error', 'Please enter a new password');
146 return;
147 }
148
149 try {
150 const token = localStorage.getItem('token');
151 const res = await apiFetch(`/customer-users/${resetPasswordUserId}/reset-password`, {
152 method: 'POST',
153 headers: {
154 'Content-Type': 'application/json',
155 Authorization: `Bearer ${token}`
156 },
157 body: JSON.stringify({ new_password: newPassword })
158 });
159
160 if (!res.ok) {
161 const error = await res.json();
162 throw new Error(error.error || 'Failed to reset password');
163 }
164
165 await notifySuccess('Password Reset', 'Password has been reset successfully');
166 setResetPasswordUserId(null);
167 setNewPassword('');
168 } catch (err) {
169 console.error(err);
170 await notifyError('Reset Failed', err.message || 'Failed to reset password');
171 }
172 }
173
174 async function handleTogglePortalUserStatus(userId, currentStatus) {
175 const newStatus = !currentStatus;
176 const action = newStatus ? 'activate' : 'deactivate';
177
178 if (!confirm(`Are you sure you want to ${action} this portal user?`)) return;
179
180 try {
181 const token = localStorage.getItem('token');
182 const res = await apiFetch(`/customer-users/${userId}/status`, {
183 method: 'PUT',
184 headers: {
185 'Content-Type': 'application/json',
186 Authorization: `Bearer ${token}`
187 },
188 body: JSON.stringify({ is_active: newStatus })
189 });
190
191 if (!res.ok) {
192 const error = await res.json();
193 throw new Error(error.error || 'Failed to update status');
194 }
195
196 await notifySuccess('Status Updated', `User has been ${action}d successfully`);
197 fetchPortalUsers();
198 } catch (err) {
199 console.error(err);
200 await notifyError('Update Failed', err.message || 'Failed to update status');
201 }
202 }
203
204 async function handleDeletePortalUser(userId) {
205 if (!confirm('Permanently delete this portal user? This cannot be undone.')) return;
206
207 try {
208 const token = localStorage.getItem('token');
209 const res = await apiFetch(`/customer-users/${userId}`, {
210 method: 'DELETE',
211 headers: { Authorization: `Bearer ${token}` }
212 });
213
214 if (!res.ok) {
215 const error = await res.json();
216 throw new Error(error.error || 'Failed to delete user');
217 }
218
219 await notifySuccess('User Deleted', 'Portal user has been deleted successfully');
220 fetchPortalUsers();
221 } catch (err) {
222 console.error(err);
223 await notifyError('Delete Failed', err.message || 'Failed to delete user');
224 }
225 }
226
227 function startEditPortalUser(user) {
228 setEditingPortalUser(user);
229 setPortalUserForm({
230 email: user.email || '',
231 password: '', // Don't populate password for edit
232 first_name: user.first_name || '',
233 last_name: user.last_name || '',
234 phone: user.phone || '',
235 is_primary: user.is_primary || false
236 });
237 setShowPortalUserForm(true);
238 }
239
240 function cancelPortalUserForm() {
241 setShowPortalUserForm(false);
242 setEditingPortalUser(null);
243 setPortalUserForm({ email: '', password: '', first_name: '', last_name: '', phone: '', is_primary: false });
244 }
245
246 useEffect(() => {
247 fetchDetail();
248 }, [id]);
249
250 async function fetchDetail() {
251 setLoading(true);
252 setError('');
253 try {
254 const token = localStorage.getItem('token');
255 const url = `/customers/${id}`;
256 const res = await apiFetch(url, { headers: { Authorization: `Bearer ${token}` } });
257 if (!res.ok) throw new Error('Failed to load customer');
258 const json = await res.json();
259 setData(json);
260 } catch (err) {
261 console.error(err);
262 setError(err.message || 'Load error');
263 } finally {
264 setLoading(false);
265 }
266 }
267
268 async function handleSaveContact(e) {
269 e.preventDefault();
270 try {
271 const token = localStorage.getItem('token');
272 const url = editingContact
273 ? `/customers/${id}/contacts/${editingContact.contact_id}`
274 : `/customers/${id}/contacts`;
275 const method = editingContact ? 'PUT' : 'POST';
276 const res = await apiFetch(url, {
277 method,
278 headers: {
279 'Content-Type': 'application/json',
280 Authorization: `Bearer ${token}`
281 },
282 body: JSON.stringify(contactForm)
283 });
284 if (!res.ok) throw new Error('Failed to save contact');
285 // Reset form and refresh data
286 setShowContactForm(false);
287 setEditingContact(null);
288 setContactForm({ name: '', role: '', email: '', phone: '', is_primary: false, notes: '' });
289 await notifySuccess(editingContact ? 'Contact Updated' : 'Contact Created', `Contact has been ${editingContact ? 'updated' : 'created'} successfully`);
290 fetchDetail();
291 } catch (err) {
292 console.error(err);
293 await notifyError('Save Failed', err.message || 'Failed to save contact');
294 }
295 }
296
297 async function handleDeleteContact(contactId) {
298 if (!confirm('Delete this contact?')) return;
299 try {
300 const token = localStorage.getItem('token');
301 const url = `/customers/${id}/contacts/${contactId}`;
302 const res = await apiFetch(url, {
303 method: 'DELETE',
304 headers: { Authorization: `Bearer ${token}` }
305 });
306 if (!res.ok) throw new Error('Failed to delete contact');
307 fetchDetail();
308 } catch (err) {
309 console.error(err);
310 alert(err.message || 'Failed to delete contact');
311 }
312 }
313
314 function startEditContact(contact) {
315 setEditingContact(contact);
316 setContactForm({
317 name: contact.name || '',
318 role: contact.role || '',
319 email: contact.email || '',
320 phone: contact.phone || '',
321 is_primary: contact.is_primary || false,
322 notes: contact.notes || ''
323 });
324 setShowContactForm(true);
325 }
326
327 function cancelContactForm() {
328 setShowContactForm(false);
329 setEditingContact(null);
330 setContactForm({ name: '', role: '', email: '', phone: '', is_primary: false, notes: '' });
331 }
332
333 if (loading) return (
334 <MainLayout>
335 <div className="page-content"><div className="loading">Loading customer...</div></div>
336 </MainLayout>
337 );
338
339 if (error) return (
340 <MainLayout>
341 <div className="page-content"><div className="error-message">{error}</div></div>
342 </MainLayout>
343 );
344
345 if (!data) return null;
346
347 const { customer, tickets = [], invoices = [], agents = [], contacts = [], contracts = [], domains = [], office365_subscriptions = [], hosting_apps = [] } = data;
348
349 return (
350 <MainLayout>
351 <div className="page-content">
352 <div className="page-header">
353 <h2>{customer.name}</h2>
354 <div>
355 <button className="btn" onClick={() => navigate(`/customers/${id}/edit`)}>
356 <span className="material-symbols-outlined">edit</span> Edit Customer
357 </button>
358 </div>
359 </div>
360
361 <section className="dashboard-grid">
362 {/* Customer Information Card */}
363 <div className="dashboard-card">
364 <h3>Customer Information</h3>
365 <div style={{ display: 'grid', gap: '12px' }}>
366 <div>
367 <strong>Status:</strong>{' '}
368 <span className={`status-badge status-${customer.status?.toLowerCase() || 'active'}`}>
369 {customer.status || 'Active'}
370 </span>
371 </div>
372 {customer.abn && (
373 <div><strong>ABN:</strong> {customer.abn}</div>
374 )}
375 <div><strong>Primary Contact:</strong> {customer.contact_name || 'N/A'}</div>
376 <div><strong>Email:</strong> {customer.email || 'N/A'}</div>
377 {customer.billing_email && (
378 <div><strong>Billing Email:</strong> {customer.billing_email}</div>
379 )}
380 <div><strong>Phone:</strong> {customer.phone || 'N/A'}</div>
381 <div style={{ marginTop: '8px' }}>
382 <strong>Address:</strong>
383 <div style={{ marginLeft: '8px', color: 'var(--text-muted)' }}>
384 {customer.address && <div>{customer.address}</div>}
385 {(customer.city || customer.state || customer.postal_code) && (
386 <div>{[customer.city, customer.state, customer.postal_code].filter(Boolean).join(', ')}</div>
387 )}
388 {customer.country && <div>{customer.country}</div>}
389 </div>
390 </div>
391 {customer.notes && (
392 <div style={{ marginTop: '8px' }}>
393 <strong>Notes:</strong>
394 <div style={{ marginLeft: '8px', color: 'var(--text-muted)', fontStyle: 'italic' }}>
395 {customer.notes}
396 </div>
397 </div>
398 )}
399 </div>
400 </div>
401
402 {/* Contacts Card */}
403 <div className="dashboard-card">
404 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
405 <h3 style={{ margin: 0 }}>Contacts</h3>
406 <button
407 className="btn btn-sm"
408 onClick={() => setShowContactForm(true)}
409 style={{ padding: '4px 12px', fontSize: '0.9em' }}
410 >
411 <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>add</span> Add Contact
412 </button>
413 </div>
414
415 {showContactForm && (
416 <form onSubmit={handleSaveContact} style={{
417 marginBottom: '16px',
418 padding: '12px',
419 border: '1px solid var(--border)',
420 borderRadius: '4px',
421 backgroundColor: 'var(--bg-secondary)'
422 }}>
423 <h4 style={{ marginTop: 0 }}>{editingContact ? 'Edit Contact' : 'New Contact'}</h4>
424 <div style={{ display: 'grid', gap: '8px' }}>
425 <input
426 type="text"
427 placeholder="Name *"
428 value={contactForm.name}
429 onChange={e => setContactForm({ ...contactForm, name: e.target.value })}
430 required
431 style={{ padding: '8px' }}
432 />
433 <input
434 type="text"
435 placeholder="Role (e.g., IT Manager)"
436 value={contactForm.role}
437 onChange={e => setContactForm({ ...contactForm, role: e.target.value })}
438 style={{ padding: '8px' }}
439 />
440 <input
441 type="email"
442 placeholder="Email"
443 value={contactForm.email}
444 onChange={e => setContactForm({ ...contactForm, email: e.target.value })}
445 style={{ padding: '8px' }}
446 />
447 <input
448 type="tel"
449 placeholder="Phone"
450 value={contactForm.phone}
451 onChange={e => setContactForm({ ...contactForm, phone: e.target.value })}
452 style={{ padding: '8px' }}
453 />
454 <label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
455 <input
456 type="checkbox"
457 checked={contactForm.is_primary}
458 onChange={e => setContactForm({ ...contactForm, is_primary: e.target.checked })}
459 />
460 Set as Primary Contact
461 </label>
462 <textarea
463 placeholder="Notes"
464 value={contactForm.notes}
465 onChange={e => setContactForm({ ...contactForm, notes: e.target.value })}
466 rows="2"
467 style={{ padding: '8px' }}
468 />
469 <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
470 <button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
471 {editingContact ? 'Update' : 'Create'}
472 </button>
473 <button type="button" className="btn" onClick={cancelContactForm} style={{ flex: 1 }}>
474 Cancel
475 </button>
476 </div>
477 </div>
478 </form>
479 )}
480
481 {contacts.length === 0 ? (
482 <div style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No contacts added</div>
483 ) : (
484 <div style={{ display: 'grid', gap: '12px' }}>
485 {contacts.map(contact => (
486 <div key={contact.contact_id} style={{
487 padding: '12px',
488 border: '1px solid var(--border)',
489 borderRadius: '4px',
490 backgroundColor: contact.is_primary ? 'var(--bg-hover)' : 'transparent'
491 }}>
492 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
493 <div style={{ flex: 1 }}>
494 <div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
495 {contact.name}
496 {contact.is_primary && (
497 <span style={{
498 marginLeft: '8px',
499 fontSize: '0.7em',
500 padding: '2px 6px',
501 borderRadius: '3px',
502 backgroundColor: 'var(--primary)',
503 color: 'white'
504 }}>PRIMARY</span>
505 )}
506 </div>
507 {contact.role && (
508 <div style={{ fontSize: '0.9em', color: 'var(--text-muted)', marginBottom: '4px' }}>
509 {contact.role}
510 </div>
511 )}
512 {contact.email && (
513 <div style={{ fontSize: '0.9em' }}>📧 {contact.email}</div>
514 )}
515 {contact.phone && (
516 <div style={{ fontSize: '0.9em' }}>📞 {contact.phone}</div>
517 )}
518 {contact.notes && (
519 <div style={{ fontSize: '0.85em', color: 'var(--text-muted)', marginTop: '4px', fontStyle: 'italic' }}>
520 {contact.notes}
521 </div>
522 )}
523 </div>
524 <div style={{ display: 'flex', gap: '4px' }}>
525 <button
526 className="btn btn-sm"
527 onClick={() => startEditContact(contact)}
528 style={{ padding: '4px 8px' }}
529 >
530 <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>edit</span>
531 </button>
532 <button
533 className="btn btn-sm"
534 onClick={() => handleDeleteContact(contact.contact_id)}
535 style={{ padding: '4px 8px', color: 'var(--danger)' }}
536 >
537 <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>delete</span>
538 </button>
539 </div>
540 </div>
541 </div>
542 ))}
543 </div>
544 )}
545 </div>
546
547 {/* Portal Users Card */}
548 <div className="dashboard-card">
549 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
550 <h3 style={{ margin: 0 }}>Portal Users ({portalUsers.length})</h3>
551 <button
552 className="btn btn-sm"
553 onClick={() => setShowPortalUserForm(true)}
554 style={{ padding: '4px 12px', fontSize: '0.9em' }}
555 >
556 <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>add</span> Add Portal User
557 </button>
558 </div>
559
560 {showPortalUserForm && (
561 <form onSubmit={handleSavePortalUser} style={{
562 marginBottom: '16px',
563 padding: '12px',
564 border: '1px solid var(--border)',
565 borderRadius: '4px',
566 backgroundColor: 'var(--bg-secondary)'
567 }}>
568 <h4 style={{ margin: '0 0 12px 0' }}>{editingPortalUser ? 'Edit Portal User' : 'Create Portal User'}</h4>
569 <div style={{ display: 'grid', gap: '8px' }}>
570 <input
571 type="email"
572 placeholder="Email (login username)"
573 value={portalUserForm.email}
574 onChange={e => setPortalUserForm({ ...portalUserForm, email: e.target.value })}
575 style={{ padding: '8px' }}
576 required
577 />
578 {!editingPortalUser && (
579 <input
580 type="password"
581 placeholder="Password (required for new users)"
582 value={portalUserForm.password}
583 onChange={e => setPortalUserForm({ ...portalUserForm, password: e.target.value })}
584 style={{ padding: '8px' }}
585 required
586 />
587 )}
588 <input
589 type="text"
590 placeholder="First Name"
591 value={portalUserForm.first_name}
592 onChange={e => setPortalUserForm({ ...portalUserForm, first_name: e.target.value })}
593 style={{ padding: '8px' }}
594 />
595 <input
596 type="text"
597 placeholder="Last Name"
598 value={portalUserForm.last_name}
599 onChange={e => setPortalUserForm({ ...portalUserForm, last_name: e.target.value })}
600 style={{ padding: '8px' }}
601 />
602 <input
603 type="tel"
604 placeholder="Phone"
605 value={portalUserForm.phone}
606 onChange={e => setPortalUserForm({ ...portalUserForm, phone: e.target.value })}
607 style={{ padding: '8px' }}
608 />
609 <label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
610 <input
611 type="checkbox"
612 checked={portalUserForm.is_primary}
613 onChange={e => setPortalUserForm({ ...portalUserForm, is_primary: e.target.checked })}
614 />
615 Set as Primary Contact
616 </label>
617 {editingPortalUser && (
618 <div style={{ fontSize: '0.85em', color: 'var(--text-muted)', fontStyle: 'italic' }}>
619 To reset password, save changes and use the Reset Password button on the user card.
620 </div>
621 )}
622 <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
623 <button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
624 {editingPortalUser ? 'Update' : 'Create'}
625 </button>
626 <button type="button" className="btn" onClick={cancelPortalUserForm} style={{ flex: 1 }}>
627 Cancel
628 </button>
629 </div>
630 </div>
631 </form>
632 )}
633
634 {portalUsers.length === 0 ? (
635 <div style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>
636 No portal users created. Portal users can log in to view and manage their services, tickets, and invoices.
637 </div>
638 ) : (
639 <div style={{ display: 'grid', gap: '12px' }}>
640 {portalUsers.map(user => (
641 <div key={user.customer_user_id} style={{
642 padding: '12px',
643 border: '1px solid var(--border)',
644 borderRadius: '4px',
645 backgroundColor: user.is_primary ? 'var(--bg-hover)' : 'transparent',
646 opacity: user.is_active ? 1 : 0.6
647 }}>
648 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
649 <div style={{ flex: 1 }}>
650 <div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
651 {user.first_name || user.last_name
652 ? `${user.first_name || ''} ${user.last_name || ''}`.trim()
653 : user.email}
654 {user.is_primary && (
655 <span style={{
656 marginLeft: '8px',
657 fontSize: '0.7em',
658 padding: '2px 6px',
659 borderRadius: '3px',
660 backgroundColor: 'var(--primary)',
661 color: 'white'
662 }}>PRIMARY</span>
663 )}
664 {!user.is_active && (
665 <span style={{
666 marginLeft: '8px',
667 fontSize: '0.7em',
668 padding: '2px 6px',
669 borderRadius: '3px',
670 backgroundColor: 'var(--danger)',
671 color: 'white'
672 }}>DISABLED</span>
673 )}
674 </div>
675 <div style={{ fontSize: '0.9em' }}>📧 {user.email}</div>
676 {user.phone && (
677 <div style={{ fontSize: '0.9em' }}>📞 {user.phone}</div>
678 )}
679 {user.last_login && (
680 <div style={{ fontSize: '0.85em', color: 'var(--text-muted)', marginTop: '4px' }}>
681 Last login: {new Date(user.last_login).toLocaleString()}
682 </div>
683 )}
684 <div style={{ fontSize: '0.85em', color: 'var(--text-muted)' }}>
685 Created: {new Date(user.created_at).toLocaleDateString()}
686 </div>
687 </div>
688 <div style={{ display: 'flex', gap: '4px', flexDirection: 'column' }}>
689 <div style={{ display: 'flex', gap: '4px' }}>
690 <button
691 className="btn btn-sm"
692 onClick={() => startEditPortalUser(user)}
693 style={{ padding: '4px 8px' }}
694 title="Edit user"
695 >
696 <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>edit</span>
697 </button>
698 <button
699 className="btn btn-sm"
700 onClick={() => {
701 setResetPasswordUserId(user.customer_user_id);
702 setNewPassword('');
703 }}
704 style={{ padding: '4px 8px' }}
705 title="Reset password"
706 >
707 <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>lock_reset</span>
708 </button>
709 </div>
710 <div style={{ display: 'flex', gap: '4px' }}>
711 <button
712 className="btn btn-sm"
713 onClick={() => handleTogglePortalUserStatus(user.customer_user_id, user.is_active)}
714 style={{
715 padding: '4px 8px',
716 backgroundColor: user.is_active ? 'var(--warning)' : 'var(--success)',
717 color: 'white'
718 }}
719 title={user.is_active ? 'Disable user' : 'Enable user'}
720 >
721 <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>
722 {user.is_active ? 'block' : 'check_circle'}
723 </span>
724 </button>
725 <button
726 className="btn btn-sm"
727 onClick={() => handleDeletePortalUser(user.customer_user_id)}
728 style={{ padding: '4px 8px', color: 'var(--danger)' }}
729 title="Delete user permanently"
730 >
731 <span className="material-symbols-outlined" style={{ fontSize: '16px' }}>delete</span>
732 </button>
733 </div>
734 </div>
735 </div>
736
737 {/* Password Reset Form */}
738 {resetPasswordUserId === user.customer_user_id && (
739 <div style={{
740 marginTop: '12px',
741 paddingTop: '12px',
742 borderTop: '1px solid var(--border)'
743 }}>
744 <div style={{ fontWeight: 'bold', marginBottom: '8px' }}>Reset Password</div>
745 <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
746 <input
747 type="password"
748 placeholder="New password"
749 value={newPassword}
750 onChange={e => setNewPassword(e.target.value)}
751 style={{ flex: 1, padding: '6px' }}
752 />
753 <button
754 className="btn btn-sm btn-primary"
755 onClick={handleResetPassword}
756 style={{ padding: '6px 12px' }}
757 >
758 Reset
759 </button>
760 <button
761 className="btn btn-sm"
762 onClick={() => {
763 setResetPasswordUserId(null);
764 setNewPassword('');
765 }}
766 style={{ padding: '6px 12px' }}
767 >
768 Cancel
769 </button>
770 </div>
771 </div>
772 )}
773 </div>
774 ))}
775 </div>
776 )}
777 </div>
778
779 {/* Contracts Card */}
780 <div className="dashboard-card">
781 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
782 <h3 style={{ margin: 0 }}>Contracts ({contracts.length})</h3>
783 <button
784 className="btn btn-sm"
785 onClick={() => navigate('/contracts/new')}
786 style={{ padding: '4px 12px', fontSize: '0.9em' }}
787 >
788 <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>add</span> New
789 </button>
790 </div>
791 {contracts.length === 0 ? (
792 <div style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No contracts</div>
793 ) : (
794 <div style={{ display: 'grid', gap: '8px' }}>
795 {contracts.map(contract => (
796 <div
797 key={contract.contract_id}
798 onClick={() => navigate(`/contracts/${contract.contract_id}`)}
799 style={{
800 padding: '10px',
801 border: '1px solid var(--border)',
802 borderRadius: '4px',
803 cursor: 'pointer',
804 transition: 'all 0.2s'
805 }}
806 onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'}
807 onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
808 >
809 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
810 <div style={{ flex: 1 }}>
811 <div style={{ fontWeight: 'bold' }}>#{contract.contract_id} - {contract.contract_name || 'Untitled'}</div>
812 <div style={{ fontSize: '0.9em', color: 'var(--text-muted)' }}>
813 {contract.billing_interval} billing • ${parseFloat(contract.total_amount || 0).toFixed(2)} {contract.currency}
814 </div>
815 </div>
816 <span className={`status-badge status-${contract.status?.toLowerCase() || 'active'}`}>
817 {contract.status}
818 </span>
819 </div>
820 </div>
821 ))}
822 </div>
823 )}
824 </div>
825
826 {/* Services Card */}
827 <div className="dashboard-card">
828 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
829 <h3 style={{ margin: 0 }}>Services</h3>
830 <button
831 className="btn btn-sm"
832 onClick={() => navigate('/services')}
833 style={{ padding: '4px 12px', fontSize: '0.9em' }}
834 >
835 <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>open_in_new</span> View All
836 </button>
837 </div>
838 <div style={{ display: 'grid', gap: '8px' }}>
839 <div
840 onClick={() => navigate('/services')}
841 style={{
842 padding: '10px',
843 border: '1px solid var(--border)',
844 borderRadius: '4px',
845 cursor: 'pointer',
846 transition: 'all 0.2s',
847 display: 'flex',
848 justifyContent: 'space-between',
849 alignItems: 'center'
850 }}
851 onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'}
852 onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
853 >
854 <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
855 <span className="material-symbols-outlined" style={{ color: 'var(--primary)', fontSize: '20px' }}>language</span>
856 <span>Domains</span>
857 </div>
858 <span className="status-badge">{domains.length}</span>
859 </div>
860 <div
861 onClick={() => navigate('/services')}
862 style={{
863 padding: '10px',
864 border: '1px solid var(--border)',
865 borderRadius: '4px',
866 cursor: 'pointer',
867 transition: 'all 0.2s',
868 display: 'flex',
869 justifyContent: 'space-between',
870 alignItems: 'center'
871 }}
872 onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'}
873 onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
874 >
875 <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
876 <span className="material-symbols-outlined" style={{ color: 'var(--primary)', fontSize: '20px' }}>cloud</span>
877 <span>Office 365 Licenses</span>
878 </div>
879 <span className="status-badge">{office365_subscriptions.length}</span>
880 </div>
881 <div
882 onClick={() => navigate('/services')}
883 style={{
884 padding: '10px',
885 border: '1px solid var(--border)',
886 borderRadius: '4px',
887 cursor: 'pointer',
888 transition: 'all 0.2s',
889 display: 'flex',
890 justifyContent: 'space-between',
891 alignItems: 'center'
892 }}
893 onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'}
894 onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
895 >
896 <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
897 <span className="material-symbols-outlined" style={{ color: 'var(--primary)', fontSize: '20px' }}>dns</span>
898 <span>Web Hosting Apps</span>
899 </div>
900 <span className="status-badge">{hosting_apps.length}</span>
901 </div>
902 </div>
903 </div>
904
905 {/* Tickets Card */}
906 <div className="dashboard-card">
907 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
908 <h3 style={{ margin: 0 }}>Tickets ({tickets.length})</h3>
909 <button
910 className="btn btn-sm"
911 onClick={() => navigate('/tickets/new')}
912 style={{ padding: '4px 12px', fontSize: '0.9em' }}
913 >
914 <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>add</span> New
915 </button>
916 </div>
917 {tickets.length === 0 ? (
918 <div style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No tickets</div>
919 ) : (
920 <div style={{ display: 'grid', gap: '8px' }}>
921 {tickets.slice(0, 5).map(t => (
922 <div
923 key={t.ticket_id}
924 onClick={() => navigate(`/tickets/${t.ticket_id}`)}
925 style={{
926 padding: '10px',
927 border: '1px solid var(--border)',
928 borderRadius: '4px',
929 cursor: 'pointer',
930 transition: 'all 0.2s'
931 }}
932 onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'}
933 onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
934 >
935 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
936 <div style={{ flex: 1 }}>
937 <div style={{ fontWeight: 'bold' }}>#{t.ticket_id} - {t.title}</div>
938 <div style={{ fontSize: '0.9em', color: 'var(--text-muted)' }}>
939 Priority: {t.priority} • Assigned: {t.assigned_to || 'Unassigned'}
940 </div>
941 </div>
942 <span className={`status-badge status-${t.status?.toLowerCase().replace(' ', '-') || 'open'}`}>
943 {t.status}
944 </span>
945 </div>
946 </div>
947 ))}
948 {tickets.length > 5 && (
949 <button
950 className="btn btn-sm"
951 onClick={() => navigate('/tickets')}
952 style={{ marginTop: '8px' }}
953 >
954 View All {tickets.length} Tickets
955 </button>
956 )}
957 </div>
958 )}
959 </div>
960
961 {/* Invoices Card */}
962 <div className="dashboard-card">
963 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
964 <h3 style={{ margin: 0 }}>Invoices ({invoices.length})</h3>
965 <button
966 className="btn btn-sm"
967 onClick={() => navigate('/invoices/new')}
968 style={{ padding: '4px 12px', fontSize: '0.9em' }}
969 >
970 <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>add</span> New
971 </button>
972 </div>
973 {invoices.length === 0 ? (
974 <div style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No invoices</div>
975 ) : (
976 <div style={{ display: 'grid', gap: '8px' }}>
977 {invoices.slice(0, 5).map(inv => (
978 <div
979 key={inv.invoice_id}
980 onClick={() => navigate(`/invoices/${inv.invoice_id}`)}
981 style={{
982 padding: '10px',
983 border: '1px solid var(--border)',
984 borderRadius: '4px',
985 cursor: 'pointer',
986 transition: 'all 0.2s'
987 }}
988 onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'}
989 onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
990 >
991 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
992 <div style={{ flex: 1 }}>
993 <div style={{ fontWeight: 'bold' }}>Invoice #{inv.invoice_id}</div>
994 <div style={{ fontSize: '0.9em', color: 'var(--text-muted)' }}>
995 ${parseFloat(inv.total || inv.amount || 0).toFixed(2)} {inv.currency}
996 </div>
997 <div style={{ fontSize: '0.85em', color: 'var(--text-muted)' }}>
998 Due: {inv.due_date ? new Date(inv.due_date).toLocaleDateString() : 'N/A'}
999 </div>
1000 </div>
1001 <span className={`status-badge status-${inv.status?.toLowerCase() || 'pending'}`}>
1002 {inv.status}
1003 </span>
1004 </div>
1005 </div>
1006 ))}
1007 {invoices.length > 5 && (
1008 <button
1009 className="btn btn-sm"
1010 onClick={() => navigate('/invoices')}
1011 style={{ marginTop: '8px' }}
1012 >
1013 View All {invoices.length} Invoices
1014 </button>
1015 )}
1016 </div>
1017 )}
1018 </div>
1019
1020 {/* Agents Card */}
1021 {/* Agents Card */}
1022 <div className="dashboard-card">
1023 <div className="dashboard-card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
1024 <h3 style={{ margin: 0 }}>Agents ({agents.length})</h3>
1025 <button
1026 className="btn btn-sm primary"
1027 onClick={() => setShowDownload(true)}
1028 title="Download Agent Installer"
1029 style={{ padding: '4px 12px', fontSize: '0.9em' }}
1030 >
1031 <span className="material-symbols-outlined" style={{ fontSize: '18px', marginRight: 4 }}>
1032 download
1033 </span>
1034 Download Agent
1035 </button>
1036 </div>
1037
1038 {agents.length === 0 ? (
1039 <div style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No agents registered</div>
1040 ) : (
1041 <div style={{ display: 'grid', gap: '8px' }}>
1042 {agents.map(a => (
1043 <div
1044 key={a.agent_id}
1045 onClick={() => navigate(`/agents/${a.agent_id}`)}
1046 style={{
1047 padding: '10px',
1048 border: '1px solid var(--border)',
1049 borderRadius: '4px',
1050 cursor: 'pointer',
1051 transition: 'all 0.2s'
1052 }}
1053 onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--bg-hover)'}
1054 onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
1055 >
1056 <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
1057 <div style={{ flex: 1 }}>
1058 <div style={{ fontWeight: 'bold' }}>{a.name || a.hostname || 'Unknown'}</div>
1059 <div style={{ fontSize: '0.9em', color: 'var(--text-muted)' }}>
1060 {a.os_info || 'N/A'}
1061 </div>
1062 <div style={{ fontSize: '0.85em', color: 'var(--text-muted)' }}>
1063 Last seen: {a.last_seen ? new Date(a.last_seen).toLocaleString() : 'Never'}
1064 </div>
1065 </div>
1066 <span className={`status-badge status-${a.status?.toLowerCase() || 'offline'}`}>
1067 {a.status}
1068 </span>
1069 </div>
1070 </div>
1071 ))}
1072 </div>
1073 )}
1074
1075 {/* Download Agent Modal (new unified version) */}
1076 {showDownload && (
1077 <DownloadAgentModal
1078 isOpen={showDownload}
1079 onClose={() => setShowDownload(false)}
1080 tenantId={getTenantIdFromStorageOrJWT() || customer?.tenant_id}
1081 customerId={customer?.id}
1082 />
1083 )}
1084 </div>
1085
1086 </section>
1087 </div>
1088 </MainLayout>
1089 );
1090}
1091
1092export default CustomerDetail;