EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
Customers.jsx
Go to the documentation of this file.
1import { apiFetch } from '../lib/api';
2import { useState, useEffect } from 'react';
3import { useNavigate } from 'react-router-dom';
4import MainLayout from '../components/Layout/MainLayout';
5// import removed: TenantSelector
6import '../styles/data-table.css';
7import './Tickets.css';
8import './Customers.css';
9import DownloadAgentModal from "../components/DownloadAgentModal";
10import CustomerMergeModal from "../components/CustomerMergeModal";
11import { isAdmin } from '../utils/auth';
12import { notifySuccess, notifyError } from '../utils/notifications';
13
14function Customers() {
15 // selectedTenantId should come from props/context, not localStorage
16 // Remove all localStorage usage for tenant context
17 // If needed, get tenantId from JWT in MainLayout and pass as prop
18 const token = localStorage.getItem('token');
19 let tenantId = '';
20 if (token) {
21 try {
22 const payload = token.split('.')[1];
23 const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
24 tenantId = decoded.tenant_id || decoded.tenantId || '';
25 } catch { }
26 }
27 const [showDownloadModal, setShowDownloadModal] = useState(false);
28 const [publicUrl, setPublicUrl] = useState('');
29 const navigate = useNavigate();
30 const [customers, setCustomers] = useState([]);
31 const [loading, setLoading] = useState(false);
32 const [error, setError] = useState('');
33 const [isMSP, setIsMSP] = useState(false);
34 const [tenantsMap, setTenantsMap] = useState({});
35 const [page, setPage] = useState(1);
36 const [totalPages, setTotalPages] = useState(1);
37 const [search, setSearch] = useState('');
38 const [showFilters, setShowFilters] = useState(false);
39 const [showDownload, setShowDownload] = useState(false);
40 const [selectedCustomer, setSelectedCustomer] = useState(null);
41 const [deleteConfirm, setDeleteConfirm] = useState(null);
42 const [showMergeModal, setShowMergeModal] = useState(false);
43
44 // Remove localStorage for filters, just use defaults
45 const [filters, setFilters] = useState({
46 status: 'all',
47 date_from: '',
48 date_to: ''
49 });
50 const perPage = 10;
51
52 // ...no localStorage for filters...
53
54 const fetchCustomers = async (fetchTenantId = tenantId) => {
55 setLoading(true);
56 try {
57 let url = '/customers';
58 const params = new URLSearchParams();
59 params.set('page', page);
60 params.set('limit', perPage);
61 if (search) params.set('search', search);
62 if (filters.status && filters.status !== 'all') params.set('status', filters.status);
63 if (filters.date_from) params.set('date_from', filters.date_from);
64 if (filters.date_to) params.set('date_to', filters.date_to);
65 const queryString = params.toString();
66 if (queryString) url += `?${queryString}`;
67
68 const headers = { 'Content-Type': 'application/json' };
69 const res = await apiFetch(url, { method: 'GET', headers });
70 if (!res.ok) throw new Error('Failed to load customers');
71
72 const data = await res.json();
73 setCustomers(data.customers);
74 setTotalPages(Math.ceil(data.total / perPage));
75 } catch (err) {
76 console.error(err);
77 setError(err.message);
78 } finally {
79 setLoading(false);
80 }
81 };
82 useEffect(() => {
83 fetchCustomers(tenantId);
84 }, [page, search, filters, tenantId]);
85
86 // Detect MSP and load tenants list to map tenant_id -> name
87 useEffect(() => {
88 (async () => {
89 try {
90 const res = await apiFetch('/tenants');
91 if (res.ok) {
92 setIsMSP(true);
93 const data = await res.json();
94 const map = {};
95 (data.tenants || data || []).forEach(t => { if (t.tenant_id) map[t.tenant_id] = t.name; });
96 setTenantsMap(map);
97 } else {
98 setIsMSP(false);
99 }
100 } catch (e) {
101 setIsMSP(false);
102 console.error('[Customers] tenants API error:', e);
103 }
104 })();
105 }, []);
106
107 const handleCreateClick = () => {
108 navigate('/customers/new');
109 };
110
111 const handleViewClick = (customerId) => {
112 navigate(`/customers/${customerId}`);
113 };
114
115 const handleEditClick = (customerId) => {
116 navigate(`/customers/${customerId}/edit`);
117 };
118
119 const handleFilterChange = (key, value) => {
120 setFilters(prev => ({ ...prev, [key]: value }));
121 setPage(1);
122 };
123
124 const handleClearFilters = () => {
125 const defaultFilters = { status: 'all', date_from: '', date_to: '' };
126 setFilters(defaultFilters);
127 setSearch('');
128 setPage(1);
129 };
130
131 const handleDeleteCustomer = async (customerId) => {
132 setLoading(true);
133 try {
134 const token = localStorage.getItem('token');
135 const res = await apiFetch(`/customers/${customerId}`, {
136 method: 'DELETE',
137 headers: { Authorization: `Bearer ${token}` }
138 });
139 if (!res.ok) {
140 const errorData = await res.json();
141 const errorMsg = errorData.error || 'Failed to delete customer';
142 await notifyError('Delete Failed', errorMsg);
143 throw new Error(errorMsg);
144 }
145 setDeleteConfirm(null);
146 await notifySuccess('Customer Deleted', 'Customer has been deleted successfully');
147 fetchCustomers(tenantId);
148 } catch (err) {
149 setError(err.message);
150 } finally {
151 setLoading(false);
152 }
153 };
154
155 const handleDeleteClick = (customer) => {
156 setDeleteConfirm(customer);
157 };
158
159 const hasActiveFilters = () => {
160 return filters.status !== 'all' || filters.date_from || filters.date_to || search;
161 };
162
163 return (
164 <MainLayout>
165
166
167
168 <div className="page-content">
169 <div className="page-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
170 <h2 style={{ margin: 0 }}>Customers</h2>
171 <div style={{ display: 'flex', gap: 8 }}>
172 <button className="btn primary" onClick={handleCreateClick}>
173 <span className="material-symbols-outlined">add</span>
174 Create Customer
175 </button>
176 {isAdmin() && (
177 <button className="btn" onClick={() => setShowMergeModal(true)}>
178 <span className="material-symbols-outlined">merge</span>
179 Merge Customers
180 </button>
181 )}
182 <button
183 className="btn primary"
184 onClick={() => {
185 setSelectedCustomer(null);
186 setShowDownload(true);
187 }}
188 title="Download Agent Installer"
189 style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
190 >
191 <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>
192 download
193 </span>
194 Download Agent
195 </button>
196 </div>
197 </div>
198 {/* ...existing code... */}
199
200 {/* Search and Filter Bar */}
201 <div className="toolbar">
202 <div className="toolbar-left">
203 <div className="search-box">
204 <span className="material-symbols-outlined">search</span>
205 <input
206 placeholder="Search customers by name, email, phone, city..."
207 value={search}
208 onChange={e => { setSearch(e.target.value); setPage(1); }}
209 />
210 </div>
211 </div>
212 <div className="toolbar-right">
213 <button
214 className={`btn ${showFilters ? 'active' : ''}`}
215 onClick={() => setShowFilters(!showFilters)}
216 title="Toggle filters"
217 >
218 <span className="material-symbols-outlined">tune</span>
219 Filters
220 {hasActiveFilters() && (
221 <span className="badge">{Object.values(filters).filter(v => v && v !== 'all').length + (search ? 1 : 0)}</span>
222 )}
223 </button>
224 {hasActiveFilters() && (
225 <button className="btn" onClick={handleClearFilters}>
226 <span className="material-symbols-outlined">clear_all</span>
227 Clear All
228 </button>
229 )}
230 </div>
231 </div>
232
233 {/* Filter Panel */}
234 {showFilters && (
235 <div className="filter-panel">
236 <h3>Filter Options</h3>
237 <div className="filter-grid">
238 <div className="filter-group">
239 <label>Status</label>
240 <select
241 value={filters.status}
242 onChange={e => handleFilterChange('status', e.target.value)}
243 >
244 <option value="all">All Statuses</option>
245 <option value="active">Active</option>
246 <option value="inactive">Inactive</option>
247 </select>
248 </div>
249
250 <div className="filter-group">
251 <label>Date From</label>
252 <input
253 type="date"
254 value={filters.date_from}
255 onChange={e => handleFilterChange('date_from', e.target.value)}
256 />
257 </div>
258
259 <div className="filter-group">
260 <label>Date To</label>
261 <input
262 type="date"
263 value={filters.date_to}
264 onChange={e => handleFilterChange('date_to', e.target.value)}
265 />
266 </div>
267 </div>
268
269 {hasActiveFilters() && (
270 <div className="active-filters">
271 <div className="active-filters-header">
272 <span className="filter-label">Active Filters:</span>
273 </div>
274 <div className="filter-tags">
275 {filters.status !== 'all' && (
276 <span className="filter-tag">
277 Status: {filters.status}
278 <button onClick={(e) => { e.stopPropagation(); handleFilterChange('status', 'all'); }}>×</button>
279 </span>
280 )}
281 {filters.date_from && (
282 <span className="filter-tag">
283 From: {filters.date_from}
284 <button onClick={(e) => { e.stopPropagation(); handleFilterChange('date_from', ''); }}>×</button>
285 </span>
286 )}
287 {filters.date_to && (
288 <span className="filter-tag">
289 To: {filters.date_to}
290 <button onClick={(e) => { e.stopPropagation(); handleFilterChange('date_to', ''); }}>×</button>
291 </span>
292 )}
293 {search && (
294 <span className="filter-tag">
295 Search: "{search}"
296 <button onClick={(e) => { e.stopPropagation(); setSearch(''); }}>×</button>
297 </span>
298 )}
299 </div>
300 </div>
301 )}
302 </div>
303 )}
304
305 {error && <div className="error-message">{error}</div>}
306
307 {loading ? (
308 <div className="loading">Loading customers...</div>
309 ) : (
310 <>
311 <div className="table-container">
312 <table className="data-table">
313 <thead>
314 <tr>
315 <th>ID</th>
316 {isMSP && <th>Tenant</th>}
317 <th>Company Name</th>
318 <th>Email</th>
319 <th>Contact Name</th>
320 <th>Status</th>
321 <th>Actions</th>
322 </tr>
323 </thead>
324 <tbody>
325 {customers.map(customer => (
326 <tr key={customer.customer_id}>
327 <td>
328 <a
329 href="#"
330 onClick={e => { e.preventDefault(); handleViewClick(customer.customer_id); }}
331 style={{ color: 'var(--primary)', textDecoration: 'none', cursor: 'pointer', fontWeight: 500 }}
332 onMouseEnter={e => e.target.style.textDecoration = 'underline'}
333 onMouseLeave={e => e.target.style.textDecoration = 'none'}
334 >
335 View
336 </a>
337 <span style={{ marginLeft: 6, color: '#888', fontSize: '0.95em' }}>#{customer.customer_id}</span>
338 </td>
339 {isMSP && (
340 <td title={customer.tenant_id ? `Tenant #${customer.tenant_id}` : ''}>
341 {customer.tenant_id === 1 ? 'Root Tenant' : (customer.tenant_name || tenantsMap[customer.tenant_id] || (customer.tenant_id ? `#${customer.tenant_id}` : '-'))}
342 </td>
343 )}
344 <td>
345 <a
346 href="#"
347 onClick={(e) => { e.preventDefault(); handleViewClick(customer.customer_id); }}
348 style={{ color: 'var(--primary)', textDecoration: 'none', cursor: 'pointer' }}
349 onMouseEnter={(e) => e.target.style.textDecoration = 'underline'}
350 onMouseLeave={(e) => e.target.style.textDecoration = 'none'}
351 >
352 {customer.name}
353 </a>
354 </td>
355 <td>{customer.email}</td>
356 <td>{customer.contact_name}</td>
357 <td>
358 <span className={`status-badge ${customer.status}`}>
359 {customer.status}
360 </span>
361 </td>
362 <td>
363 <button
364 className="btn"
365 onClick={() => handleEditClick(customer.customer_id)}
366 style={{ marginRight: '8px' }}
367 >
368 <span className="material-symbols-outlined">edit</span>
369 </button>
370 <button
371 className="btn btn-sm primary"
372 onClick={() => {
373 setSelectedCustomer(customer);
374 setShowDownload(true);
375 }}
376 title="Download Agent Installer"
377 style={{ display: 'inline-flex', alignItems: 'center', gap: 4, marginRight: '8px' }}
378 >
379 <span className="material-symbols-outlined" style={{ fontSize: '18px' }}>
380 download
381 </span>
382 Agent
383 </button>
384 {isAdmin() && (
385 <button
386 className="btn danger"
387 onClick={() => handleDeleteClick(customer)}
388 title="Delete Customer"
389 >
390 <span className="material-symbols-outlined">delete</span>
391 </button>
392 )}
393
394 </td>
395 </tr>
396 ))}
397 </tbody>
398 </table>
399 </div>
400
401 <div className="pagination">
402 <button className="btn" disabled={page === 1} onClick={() => setPage(p => Math.max(1, p - 1))}>
403 <span className="material-symbols-outlined">navigate_before</span>
404 </button>
405 <span>
406 Page {page} of {totalPages}
407 </span>
408 <button className="btn" disabled={page === totalPages} onClick={() => setPage(p => Math.min(totalPages, p + 1))}>
409 <span className="material-symbols-outlined">navigate_next</span>
410 </button>
411 </div>
412 </>
413 )}
414
415 {showDownload && selectedCustomer && (
416 <DownloadAgentModal
417 onClose={() => setShowDownload(false)}
418 customerId={selectedCustomer.customer_id}
419 customerName={selectedCustomer.name}
420 />
421 )}
422
423 {deleteConfirm && (
424 <div className="modal-overlay" onClick={() => setDeleteConfirm(null)}>
425 <div className="modal-content" onClick={e => e.stopPropagation()}>
426 <div className="modal-header">
427 <h3>Confirm Delete</h3>
428 <button
429 className="btn"
430 onClick={() => setDeleteConfirm(null)}
431 style={{ padding: '4px', minWidth: 'auto' }}
432 >
433 <span className="material-symbols-outlined">close</span>
434 </button>
435 </div>
436 <div className="modal-body">
437 <p>Are you sure you want to delete the customer:</p>
438 <p><strong>"{deleteConfirm.name}"</strong></p>
439 <p style={{ color: 'var(--error)', fontSize: '0.9em' }}>
440 This action cannot be undone. This will fail if the customer has existing tickets or invoices.
441 </p>
442 </div>
443 <div className="modal-footer">
444 <button
445 className="btn"
446 onClick={() => setDeleteConfirm(null)}
447 style={{ marginRight: '8px' }}
448 >
449 Cancel
450 </button>
451 <button
452 className="btn danger"
453 onClick={() => handleDeleteCustomer(deleteConfirm.customer_id)}
454 >
455 Delete
456 </button>
457 </div>
458 </div>
459 </div>
460 )}
461
462 {/* Customer Merge Modal */}
463 <CustomerMergeModal
464 isOpen={showMergeModal}
465 onClose={() => setShowMergeModal(false)}
466 onMergeComplete={() => fetchCustomers(tenantId)}
467 customers={customers}
468 />
469
470 </div>
471 </MainLayout>
472 );
473}
474
475export default Customers;