EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
Tickets.jsx
Go to the documentation of this file.
1import { useState, useEffect } from 'react';
2import { useNavigate } from 'react-router-dom';
3import MainLayout from '../components/Layout/MainLayout';
4// import removed: TenantSelector
5import '../styles/data-table.css';
6import './Tickets.css';
7import { apiFetch } from '../lib/api';
8import { isAdmin } from '../utils/auth';
9import { notifySuccess, notifyError } from '../utils/notifications';
10
11
12function Tickets() {
13 const token = localStorage.getItem('token');
14 let tenantId = '';
15 if (token) {
16 try {
17 const payload = token.split('.')[1];
18 const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
19 tenantId = decoded.tenant_id || decoded.tenantId || '';
20 } catch { }
21 }
22 const navigate = useNavigate();
23 const [tickets, setTickets] = useState([]);
24 const [loading, setLoading] = useState(false);
25 const [error, setError] = useState('');
26 const [isMSP, setIsMSP] = useState(false);
27 const [tenantsMap, setTenantsMap] = useState({});
28 const [page, setPage] = useState(1);
29 const [totalPages, setTotalPages] = useState(1);
30 const [search, setSearch] = useState('');
31 const [showFilters, setShowFilters] = useState(false);
32 const [deleteConfirm, setDeleteConfirm] = useState(null);
33 const perPage = 10;
34
35 // Use React state for filters only (no persistence)
36 const [filters, setFilters] = useState({
37 status: 'all',
38 priority: 'all',
39 sla_filter: 'all',
40 date_from: '',
41 date_to: ''
42 });
43
44
45 const fetchTickets = async () => {
46 setLoading(true);
47 try {
48 const urlParams = new URLSearchParams();
49 urlParams.set('page', page);
50 urlParams.set('limit', perPage);
51
52 if (search) urlParams.set('search', search);
53 if (filters.status && filters.status !== 'all') urlParams.set('status', filters.status);
54 if (filters.priority && filters.priority !== 'all') urlParams.set('priority', filters.priority);
55 if (filters.sla_filter && filters.sla_filter !== 'all') urlParams.set('sla_filter', filters.sla_filter);
56 if (filters.date_from) urlParams.set('date_from', filters.date_from);
57 if (filters.date_to) urlParams.set('date_to', filters.date_to);
58
59 // No need to handle tenant_id — apiFetch injects it automatically
60 // But if you *want* to include it explicitly, you can:
61 // if (selectedTenantId) urlParams.set('tenant_id', selectedTenantId);
62
63 const res = await apiFetch(`/tickets?${urlParams.toString()}`, {
64 method: 'GET',
65 });
66
67 if (!res.ok) throw new Error('Failed to load tickets');
68
69 const data = await res.json();
70 setTickets(data.tickets);
71 setTotalPages(Math.ceil(data.total / perPage));
72 } catch (err) {
73 console.error(err);
74 setError(err.message);
75 } finally {
76 setLoading(false);
77 }
78 };
79
80 useEffect(() => {
81 // Clear tickets immediately to avoid showing stale data
82 setTickets([]);
83 fetchTickets();
84 }, [page, search, filters, tenantId]);
85
86 // Removed selectedTenantId effect; only use tenantId from JWT
87
88 // Detect MSP and load tenants for mapping
89 useEffect(() => {
90 (async () => {
91 try {
92 const res = await apiFetch('/tenants', { method: 'GET', credentials: 'include' });
93 if (res.ok) {
94 setIsMSP(true);
95 const data = await res.json();
96 const map = {};
97 (data.tenants || data || []).forEach(t => { if (t.tenant_id) map[t.tenant_id] = t.name; });
98 setTenantsMap(map);
99 } else {
100 setIsMSP(false);
101 }
102 } catch (e) {
103 setIsMSP(false);
104 }
105 })();
106 }, []);
107
108 const handleCreateClick = () => {
109 navigate('/tickets/new');
110 };
111
112 const handleEditClick = (ticketId) => {
113 navigate(`/tickets/${ticketId}/edit`);
114 };
115
116 const handleFilterChange = (key, value) => {
117 setFilters(prev => ({ ...prev, [key]: value }));
118 setPage(1); // Reset to first page when filters change
119 };
120
121 const handleClearFilters = () => {
122 const defaultFilters = {
123 status: 'all',
124 priority: 'all',
125 sla_filter: 'all',
126 date_from: '',
127 date_to: ''
128 };
129 setFilters(defaultFilters);
130 setSearch('');
131 setPage(1);
132 };
133
134 const handleDeleteTicket = async (ticketId) => {
135 setLoading(true);
136 try {
137 const token = localStorage.getItem('token');
138 const res = await apiFetch(`/tickets/${ticketId}`, {
139 method: 'DELETE',
140 headers: { Authorization: `Bearer ${token}` }
141 });
142 if (!res.ok) {
143 const errorData = await res.json();
144 const errorMsg = errorData.error || 'Failed to delete ticket';
145 await notifyError('Delete Failed', errorMsg);
146 throw new Error(errorMsg);
147 }
148 setDeleteConfirm(null);
149 await notifySuccess('Ticket Deleted', 'Ticket has been deleted successfully');
150 fetchTickets();
151 } catch (err) {
152 setError(err.message);
153 } finally {
154 setLoading(false);
155 }
156 };
157
158 const handleDeleteClick = (ticket) => {
159 setDeleteConfirm(ticket);
160 };
161
162 const hasActiveFilters = () => {
163 return filters.status !== 'all' ||
164 filters.priority !== 'all' ||
165 filters.sla_filter !== 'all' ||
166 filters.date_from ||
167 filters.date_to ||
168 search;
169 };
170
171 return (
172 <MainLayout>
173
174 <div className="page-content">
175 <div className="page-header">
176 <div className="header-content">
177 <h2>Tickets</h2>
178 <button className="btn primary" onClick={handleCreateClick}>
179 <span className="material-symbols-outlined">add</span>
180 Create Ticket
181 </button>
182 </div>
183 </div>
184
185 {/* Search and Filter Bar */}
186 <div className="toolbar">
187 <div className="toolbar-left">
188 <div className="search-box">
189 <span className="material-symbols-outlined">search</span>
190 <input
191 id="ticket-search"
192 name="ticket-search"
193 placeholder="Search tickets by title or description..."
194 value={search}
195 onChange={e => { setSearch(e.target.value); setPage(1); }}
196 />
197 </div>
198 </div>
199 <div className="toolbar-right">
200 <button
201 className={`btn ${showFilters ? 'active' : ''}`}
202 onClick={() => setShowFilters(!showFilters)}
203 title="Toggle filters"
204 >
205 <span className="material-symbols-outlined">tune</span>
206 Filters
207 {hasActiveFilters() && (
208 <span className="badge">{Object.values(filters).filter(v => v && v !== 'all').length + (search ? 1 : 0)}</span>
209 )}
210 </button>
211 {hasActiveFilters() && (
212 <button className="btn" onClick={handleClearFilters}>
213 <span className="material-symbols-outlined">clear_all</span>
214 Clear All
215 </button>
216 )}
217 </div>
218 </div>
219
220 {/* Filter Panel */}
221 {showFilters && (
222 <div className="filter-panel">
223 <h3>Filter Options</h3>
224 <div className="filter-grid">
225 <div className="filter-group">
226 <label>Status</label>
227 <select
228 value={filters.status}
229 onChange={e => handleFilterChange('status', e.target.value)}
230 >
231 <option value="all">All Statuses</option>
232 <option value="open">Open</option>
233 <option value="in-progress">In Progress</option>
234 <option value="pending">Pending</option>
235 <option value="resolved">Resolved</option>
236 <option value="closed">Closed</option>
237 </select>
238 </div>
239
240 <div className="filter-group">
241 <label>Priority</label>
242 <select
243 value={filters.priority}
244 onChange={e => handleFilterChange('priority', e.target.value)}
245 >
246 <option value="all">All Priorities</option>
247 <option value="low">Low</option>
248 <option value="medium">Medium</option>
249 <option value="high">High</option>
250 <option value="urgent">Urgent</option>
251 </select>
252 </div>
253
254 <div className="filter-group">
255 <label>SLA Status</label>
256 <select
257 value={filters.sla_filter}
258 onChange={e => handleFilterChange('sla_filter', e.target.value)}
259 >
260 <option value="all">All Tickets</option>
261 <option value="overdue">Overdue (SLA breach)</option>
262 <option value="due_today">Active Today</option>
263 <option value="due_this_week">This Week</option>
264 </select>
265 </div>
266
267 <div className="filter-group">
268 <label>Date From</label>
269 <input
270 id="ticket-date-from"
271 name="ticket-date-from"
272 type="date"
273 value={filters.date_from}
274 onChange={e => handleFilterChange('date_from', e.target.value)}
275 />
276 </div>
277
278 <div className="filter-group">
279 <label>Date To</label>
280 <input
281 id="ticket-date-to"
282 name="ticket-date-to"
283 type="date"
284 value={filters.date_to}
285 onChange={e => handleFilterChange('date_to', e.target.value)}
286 />
287 </div>
288 </div>
289
290 {hasActiveFilters() && (
291 <div className="active-filters">
292 <div className="active-filters-header">
293 <span className="filter-label">Active Filters:</span>
294 </div>
295 <div className="filter-tags">
296 {filters.status !== 'all' && (
297 <span className="filter-tag">
298 Status: {filters.status}
299 <button onClick={() => handleFilterChange('status', 'all')}>×</button>
300 </span>
301 )}
302 {filters.priority !== 'all' && (
303 <span className="filter-tag">
304 Priority: {filters.priority}
305 <button onClick={() => handleFilterChange('priority', 'all')}>×</button>
306 </span>
307 )}
308 {filters.sla_filter !== 'all' && (
309 <span className="filter-tag">
310 SLA: {filters.sla_filter.replace('_', ' ')}
311 <button onClick={() => handleFilterChange('sla_filter', 'all')}>×</button>
312 </span>
313 )}
314 {filters.date_from && (
315 <span className="filter-tag">
316 From: {filters.date_from}
317 <button onClick={() => handleFilterChange('date_from', '')}>×</button>
318 </span>
319 )}
320 {filters.date_to && (
321 <span className="filter-tag">
322 To: {filters.date_to}
323 <button onClick={() => handleFilterChange('date_to', '')}>×</button>
324 </span>
325 )}
326 {search && (
327 <span className="filter-tag">
328 Search: "{search}"
329 <button onClick={() => setSearch('')}>×</button>
330 </span>
331 )}
332 </div>
333 </div>
334 )}
335 </div>
336 )}
337
338 {error && <div className="error-message">{error}</div>}
339
340 {loading ? (
341 <div className="loading">Loading tickets...</div>
342 ) : (
343 <>
344 <div className="table-container">
345 <table className="data-table">
346 <thead>
347 <tr>
348 <th>ID</th>
349 <th>Tenant</th>
350 <th>Title</th>
351 <th>Customer</th>
352 <th>Status</th>
353 <th>Priority</th>
354 <th>SLA</th>
355 <th>Created</th>
356 <th>Actions</th>
357 </tr>
358 </thead>
359 <tbody>
360 {tickets.map(ticket => (
361 <tr key={ticket.ticket_id}>
362 <td>
363 <a
364 href={`/tickets/${ticket.ticket_id}/edit`}
365 style={{ textDecoration: 'underline', color: 'inherit', cursor: 'pointer' }}
366 title={`Edit ticket #${ticket.ticket_id}`}
367 onClick={e => {
368 e.preventDefault();
369 navigate(`/tickets/${ticket.ticket_id}/edit`);
370 }}
371 >
372 #{ticket.ticket_id}
373 </a>
374 </td>
375 <td title={ticket.tenant_id ? `Tenant #${ticket.tenant_id}` : ''}>
376 {ticket.tenant_id === 1 ? 'Root Tenant' : (ticket.tenant_name || tenantsMap[ticket.tenant_id] || (ticket.tenant_id ? `#${ticket.tenant_id}` : '-'))}
377 </td>
378 <td>{ticket.title}</td>
379 <td className="col-customer" title={ticket.customer_name || (ticket.customer_id ? `Customer #${ticket.customer_id}` : '')}>
380 {ticket.customer_name && ticket.customer_name.trim().length > 0
381 ? ticket.customer_name
382 : (ticket.customer_id ? `#${ticket.customer_id}` : '-')}
383 </td>
384 <td>
385 <span className={`status-badge ${ticket.status}`}>
386 {ticket.status}
387 </span>
388 </td>
389 <td>
390 <span className={`priority-badge ${ticket.priority}`}>
391 {ticket.priority}
392 </span>
393 </td>
394 <td>
395 {ticket.sla_status === 'closed' ? (
396 <span className="sla-badge closed">Closed</span>
397 ) : (
398 <span className={`sla-badge ${ticket.sla_status}`}>
399 {(() => {
400 const secs = Number(ticket.sla_remaining_seconds || 0);
401 const sign = secs < 0 ? -1 : 1;
402 const abs = Math.abs(secs);
403 const h = Math.floor(abs / 3600);
404 const m = Math.floor((abs % 3600) / 60);
405 if (ticket.sla_status === 'overdue') {
406 return `Overdue by ${h}h ${m}m`;
407 }
408 if (ticket.sla_status === 'due_soon') {
409 return `Due soon: ${h}h ${m}m`;
410 }
411 return `Due in ${h}h ${m}m`;
412 })()}
413 </span>
414 )}
415 </td>
416 <td>{new Date(ticket.created_at).toLocaleDateString()}</td>
417 <td>
418 <button
419 className="btn"
420 onClick={() => handleEditClick(ticket.ticket_id)}
421 style={{ marginRight: '8px' }}
422 >
423 <span className="material-symbols-outlined">edit</span>
424 </button>
425 {isAdmin() && (
426 <button
427 className="btn danger"
428 onClick={() => handleDeleteClick(ticket)}
429 title="Delete Ticket"
430 >
431 <span className="material-symbols-outlined">delete</span>
432 </button>
433 )}
434 </td>
435 </tr>
436 ))}
437 </tbody>
438 </table>
439 </div>
440
441 <div className="pagination">
442 <button className="btn" disabled={page === 1} onClick={() => setPage(p => p - 1)}>
443 <span className="material-symbols-outlined">navigate_before</span>
444 </button>
445 <span>
446 Page {page} of {totalPages}
447 </span>
448 <button className="btn" disabled={page === totalPages} onClick={() => setPage(p => p + 1)}>
449 <span className="material-symbols-outlined">navigate_next</span>
450 </button>
451 </div>
452 </>
453 )}
454
455 {deleteConfirm && (
456 <div className="modal-overlay" onClick={() => setDeleteConfirm(null)}>
457 <div className="modal-content" onClick={e => e.stopPropagation()}>
458 <div className="modal-header">
459 <h3>Confirm Delete</h3>
460 <button
461 className="btn"
462 onClick={() => setDeleteConfirm(null)}
463 style={{ padding: '4px', minWidth: 'auto' }}
464 >
465 <span className="material-symbols-outlined">close</span>
466 </button>
467 </div>
468 <div className="modal-body">
469 <p>Are you sure you want to delete the ticket:</p>
470 <p><strong>"#{deleteConfirm.ticket_id}: {deleteConfirm.title}"</strong></p>
471 <p style={{ color: 'var(--error)', fontSize: '0.9em' }}>
472 This action cannot be undone. Only open or draft tickets can be deleted.
473 </p>
474 </div>
475 <div className="modal-footer">
476 <button
477 className="btn"
478 onClick={() => setDeleteConfirm(null)}
479 style={{ marginRight: '8px' }}
480 >
481 Cancel
482 </button>
483 <button
484 className="btn danger"
485 onClick={() => handleDeleteTicket(deleteConfirm.ticket_id)}
486 >
487 Delete
488 </button>
489 </div>
490 </div>
491 </div>
492 )}
493 </div>
494 </MainLayout>
495 );
496}
497
498export default Tickets;