EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
TicketForm.jsx
Go to the documentation of this file.
1import { useState, useEffect, useRef } from 'react';
2import Quill from 'quill';
3import { useNavigate, useParams } from 'react-router-dom';
4import DOMPurify from 'dompurify';
5import { apiFetch } from '../lib/api';
6import { notifySuccess, notifyError } from '../utils/notifications';
7
8import MainLayout from '../components/Layout/MainLayout';
9import AIRewordButton from '../components/AIRewordButton';
10import './TicketForm.css';
11
12function TicketForm() {
13 const navigate = useNavigate();
14 const { id } = useParams();
15 const quillRef = useRef(null);
16 const [loading, setLoading] = useState(false);
17 const [error, setError] = useState('');
18 const [form, setForm] = useState({
19 title: '',
20 description: '',
21 priority: 'low',
22 status: 'open',
23 assigned_to: null,
24 customer_id: null
25 });
26
27 // Staff assignment state
28 const [assigneeQuery, setAssigneeQuery] = useState('');
29 const [assigneeSuggestions, setAssigneeSuggestions] = useState([]);
30 const [assigneeName, setAssigneeName] = useState('');
31 const [assigneeSelected, setAssigneeSelected] = useState(false);
32
33 // Customer selection state
34 const [customerQuery, setCustomerQuery] = useState('');
35 const [customerSuggestions, setCustomerSuggestions] = useState([]);
36 const [customerName, setCustomerName] = useState('');
37 const [customerSelected, setCustomerSelected] = useState(false);
38
39 // Check if user is root/MSP
40 const [isRootUser, setIsRootUser] = useState(false);
41 const [tenants, setTenants] = useState([]);
42 const [selectedTenantId, setSelectedTenantId] = useState('');
43
44 // Time tracking state
45 const [timeEntries, setTimeEntries] = useState([]);
46 const [newTimeEntry, setNewTimeEntry] = useState({
47 description: '',
48 duration_minutes: 0,
49 billable: true,
50 start_time: new Date().toISOString().slice(0, 16),
51 end_time: new Date().toISOString().slice(0, 16)
52 });
53
54 // Notes state
55 const [notes, setNotes] = useState([]);
56 const [newNote, setNewNote] = useState({
57 content: '',
58 is_private: false,
59 // Add time tracking fields
60 has_time: true,
61 start_time: '',
62 end_time: '',
63 billable: true,
64 duration_minutes: 0
65 });
66
67 // Timer state for quick time tracking
68 const [timerRunning, setTimerRunning] = useState(false);
69 const [timerStart, setTimerStart] = useState(null);
70 const [elapsedSeconds, setElapsedSeconds] = useState(0);
71 const [ticketCreatedAt, setTicketCreatedAt] = useState(null);
72 const [timeTouched, setTimeTouched] = useState(false);
73 // Unsaved prompt state
74 const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
75 const [pendingAction, setPendingAction] = useState(null); // 'save' | 'navigate' | null
76
77 // Helper: detect unsaved note/time or running timer
78 const hasUnsavedNoteOrTimer = () => {
79 const noteContent = (newNote.content || '').trim();
80 const hasNoteText = noteContent.length > 0;
81 const hasTimeData = newNote.has_time && (
82 // user explicitly entered duration
83 (newNote.duration_minutes && newNote.duration_minutes > 0) ||
84 // user interacted with time fields AND has both start & end
85 (timeTouched && newNote.start_time && newNote.end_time) ||
86 // running timer always counts as unsaved
87 !!timerRunning
88 );
89 return hasNoteText || hasTimeData;
90 };
91
92 // Warn on browser/tab close if there are unsaved notes or running timer (edit mode only)
93 useEffect(() => {
94 if (!id) return; // only on edit page
95 const handleBeforeUnload = (e) => {
96 if (hasUnsavedNoteOrTimer()) {
97 e.preventDefault();
98 e.returnValue = '';
99 return '';
100 }
101 return undefined;
102 };
103 window.addEventListener('beforeunload', handleBeforeUnload);
104 return () => window.removeEventListener('beforeunload', handleBeforeUnload);
105 }, [id, newNote, timerRunning]);
106
107 // Helper to format datetime-local value
108 const formatDateTimeLocal = (d) => {
109 const pad = (n) => String(n).padStart(2, '0');
110 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
111 };
112
113 // When enabling track time, prefill start & end to now for easy tweaking
114 useEffect(() => {
115 if (newNote.has_time) {
116 const now = new Date();
117 const val = formatDateTimeLocal(now);
118 setNewNote(n => ({ ...n, start_time: n.start_time || val, end_time: n.end_time || val }));
119 }
120 }, [newNote.has_time]);
121
122 // Tick elapsed timer when running
123 useEffect(() => {
124 if (!timerRunning) return;
125 const iv = setInterval(() => {
126 setElapsedSeconds(Math.floor((Date.now() - (timerStart?.getTime() || Date.now())) / 1000));
127 }, 1000);
128 return () => clearInterval(iv);
129 }, [timerRunning, timerStart]);
130
131 const handleStartTimer = () => {
132 const now = new Date();
133 setTimerStart(now);
134 setElapsedSeconds(0);
135 setTimerRunning(true);
136 setTimeTouched(true);
137 // ensure time tracking enabled and start time set
138 setNewNote(n => ({ ...n, has_time: true, start_time: formatDateTimeLocal(now), end_time: '' }));
139 };
140
141 const handleStopTimer = () => {
142 const now = new Date();
143 setTimerRunning(false);
144 const secs = Math.max(0, Math.floor((now.getTime() - (timerStart?.getTime() || now.getTime())) / 1000));
145 const mins = Math.max(1, Math.round(secs / 60));
146 setNewNote(n => ({ ...n, end_time: formatDateTimeLocal(now), duration_minutes: mins }));
147 };
148
149
150 useEffect(() => {
151 if (id) {
152 fetchTicketDetails();
153 }
154 // Check if user is root and fetch tenants
155 async function checkRootStatus() {
156 try {
157 const res = await apiFetch('/tenants', { method: 'GET', credentials: 'include' });
158 if (res.ok) {
159 setIsRootUser(true);
160 const data = await res.json();
161 setTenants(data.tenants || []);
162 } else {
163 setIsRootUser(false);
164 }
165 } catch (err) {
166 setIsRootUser(false);
167 }
168 }
169 checkRootStatus();
170 }, [id]);
171
172 const fetchTicketDetails = async () => {
173 try {
174 const res = await apiFetch(`/tickets/${id}`, { method: 'GET', credentials: 'include' });
175 if (!res.ok) throw new Error('Failed to load ticket');
176 const data = await res.json();
177
178 // Auto-select the tenant if the ticket belongs to a different tenant
179 if (data.ticket.tenant_id && !selectedTenantId) {
180 setSelectedTenantId(String(data.ticket.tenant_id));
181 }
182
183 setForm({
184 title: data.ticket.title,
185 description: data.ticket.description,
186 priority: data.ticket.priority,
187 status: data.ticket.status,
188 assigned_to: data.ticket.assigned_to,
189 customer_id: data.ticket.customer_id
190 });
191 setTicketCreatedAt(data.ticket.created_at || null);
192
193 if (data.ticket.assigned_to_name) {
194 setAssigneeQuery(data.ticket.assigned_to_name);
195 setAssigneeName(data.ticket.assigned_to_name);
196 setAssigneeSelected(true);
197 }
198
199 if (data.ticket.customer_name) {
200 setCustomerQuery(data.ticket.customer_name);
201 setCustomerName(data.ticket.customer_name);
202 setCustomerSelected(true);
203 }
204
205 if (quillRef.current) {
206 quillRef.current.root.innerHTML = data.ticket.description || '';
207 }
208
209 setNotes(data.notes || []);
210 setTimeEntries(data.timeEntries || []);
211 } catch (err) {
212 console.error(err);
213 setError('Failed to load ticket details');
214 }
215 };
216
217 // Reload only time entries without disturbing the main form
218 const loadTimeEntries = async () => {
219 if (!id) return;
220 try {
221 const res = await apiFetch(`/tickets/${id}`, { method: 'GET', credentials: 'include' });
222 if (!res.ok) return;
223 const data = await res.json();
224 setTimeEntries(data.timeEntries || []);
225 setNotes(data.notes || []);
226 } catch (err) {
227 // Non-fatal; keep existing time entries
228 console.error(err);
229 }
230 };
231
232 // initialize quill for ticket description
233 useEffect(() => {
234 if (!quillRef.current) {
235 const container = document.querySelector('#quill-editor-ticket');
236 if (container) {
237 quillRef.current = new Quill(container, { theme: 'snow' });
238 // Make read-only if editing existing ticket
239 if (id) {
240 quillRef.current.enable(false);
241 } else {
242 quillRef.current.on('text-change', () => {
243 setForm(f => ({ ...f, description: quillRef.current.root.innerHTML }));
244 });
245 }
246 }
247 }
248 }, [id]);
249
250 // debounced assignee search
251 useEffect(() => {
252 // Don't search if user has selected something
253 if (assigneeSelected) return;
254
255 const ac = setTimeout(async () => {
256 if (!assigneeQuery || !assigneeQuery.trim()) {
257 setAssigneeSuggestions([]);
258 return;
259 }
260 try {
261 const res = await apiFetch(`/users?search=${encodeURIComponent(assigneeQuery)}`, { method: 'GET', credentials: 'include' });
262 if (!res.ok) return;
263 const list = await res.json();
264 setAssigneeSuggestions(list);
265 } catch (err) { console.error(err); }
266 }, 300);
267 return () => clearTimeout(ac);
268 }, [assigneeQuery, assigneeSelected, isRootUser, selectedTenantId, tenants]);
269
270 useEffect(() => {
271 if (assigneeName) {
272 // find selected suggestion to set assigned_to
273 const found = assigneeSuggestions.find(s => s.name === assigneeName);
274 if (found) setForm(f => ({ ...f, assigned_to: found.user_id }));
275 }
276 }, [assigneeName, assigneeSuggestions]);
277
278 // Add note handler
279 const handleAddNote = async (e) => {
280 e.preventDefault();
281 if (!newNote.content) return;
282
283 // If time tracking is included, validate inputs (allow either start/end or duration)
284 if (newNote.has_time) {
285 const hasRange = newNote.start_time && newNote.end_time;
286 const hasDuration = newNote.duration_minutes && newNote.duration_minutes > 0;
287 if (!hasRange && !hasDuration) {
288 setError('Please provide a start/end time or enter duration (minutes).');
289 return;
290 }
291 }
292
293 try {
294 const notePayload = {
295 content: newNote.content,
296 is_private: newNote.is_private
297 };
298 if (newNote.has_time) {
299 notePayload.billable = newNote.billable;
300 if (newNote.duration_minutes && newNote.duration_minutes > 0) {
301 notePayload.duration_minutes = newNote.duration_minutes;
302 if (newNote.end_time) notePayload.end_time = newNote.end_time;
303 } else {
304 notePayload.start_time = newNote.start_time;
305 notePayload.end_time = newNote.end_time;
306 }
307 }
308 const res = await apiFetch(`/tickets/${id}/notes`, {
309 method: 'POST',
310 headers: { 'Content-Type': 'application/json' },
311 credentials: 'include',
312 body: JSON.stringify(notePayload)
313 });
314 if (!res.ok) throw new Error('Failed to add note');
315 const note = await res.json();
316 setNotes([note, ...notes]);
317 setNewNote({
318 content: '',
319 is_private: false,
320 has_time: true,
321 start_time: '',
322 end_time: '',
323 billable: true,
324 duration_minutes: 0
325 });
326 if (newNote.has_time) {
327 loadTimeEntries();
328 }
329 } catch (err) {
330 console.error(err);
331 setError('Failed to add note');
332 }
333 };
334
335 // Add time entry handler
336 const handleAddTime = async (e) => {
337 e.preventDefault();
338 if (!newTimeEntry.description || !newTimeEntry.start_time) return;
339 try {
340 const res = await apiFetch(`/tickets/${id}/time`, {
341 method: 'POST',
342 headers: { 'Content-Type': 'application/json' },
343 credentials: 'include',
344 body: JSON.stringify(newTimeEntry)
345 });
346 if (!res.ok) throw new Error('Failed to add time entry');
347 const entry = await res.json();
348 setTimeEntries([entry, ...timeEntries]);
349 setNewTimeEntry({
350 description: '',
351 duration_minutes: 0,
352 billable: true,
353 start_time: new Date().toISOString().slice(0, 16),
354 end_time: new Date().toISOString().slice(0, 16)
355 });
356 } catch (err) {
357 console.error(err);
358 setError('Failed to add time entry');
359 }
360 };
361
362 // Search for customers
363 useEffect(() => {
364 // Don't search if user has selected something
365 if (customerSelected) return;
366
367 const searchCustomers = async () => {
368 if (!customerQuery || !customerQuery.trim()) {
369 setCustomerSuggestions([]);
370 return;
371 }
372 try {
373 const res = await apiFetch(`/customers?search=${encodeURIComponent(customerQuery)}`, { method: 'GET', credentials: 'include' });
374 if (!res.ok) return;
375 const data = await res.json();
376 setCustomerSuggestions(data.customers || []);
377 } catch (err) { console.error(err); }
378 };
379 const timer = setTimeout(searchCustomers, 300);
380 return () => clearTimeout(timer);
381 }, [customerQuery, customerSelected, isRootUser, selectedTenantId, tenants]);
382
383 const performSave = async () => {
384 setLoading(true);
385 setError('');
386 try {
387 const res = await apiFetch(`/tickets${id ? `/${id}` : ''}`, {
388 method: id ? 'PUT' : 'POST',
389 headers: { 'Content-Type': 'application/json' },
390 credentials: 'include',
391 body: JSON.stringify(form)
392 });
393 if (!res.ok) {
394 const errorData = await res.json().catch(() => ({}));
395 const errorMsg = errorData.error || `Failed to ${id ? 'update' : 'create'} ticket`;
396 await notifyError(id ? 'Update Failed' : 'Create Failed', errorMsg);
397 throw new Error(errorMsg);
398 }
399 await notifySuccess(id ? 'Ticket Updated' : 'Ticket Created', `Ticket has been ${id ? 'updated' : 'created'} successfully`);
400 navigate('/tickets');
401 } catch (err) {
402 setError(err.message || 'Failed to save ticket');
403 } finally {
404 setLoading(false);
405 }
406 };
407
408 const handleSubmit = async (e) => {
409 e.preventDefault();
410
411 // For new tickets, require title and description
412 if (!id && (!form.title || !form.description)) {
413 setError('Please fill in title and description');
414 return;
415 }
416
417 // For both new and existing tickets, require customer
418 if (!form.customer_id) {
419 setError('Please select a customer from the dropdown');
420 return;
421 }
422
423 if (id && hasUnsavedNoteOrTimer()) {
424 setPendingAction('save');
425 setShowUnsavedPrompt(true);
426 return;
427 }
428
429 await performSave();
430 };
431
432 const handleNavigateBack = () => {
433 if (id && hasUnsavedNoteOrTimer()) {
434 setPendingAction('navigate');
435 setShowUnsavedPrompt(true);
436 return;
437 }
438 navigate('/tickets');
439 };
440
441 return (
442 <MainLayout>
443 <div className="page-content">
444 <div className="page-header">
445 <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
446 <h2>{id ? 'Edit Ticket' : 'Create Ticket'}</h2>
447 {id && <h3 className="ticket-subject" style={{ flex: 1, textAlign: 'center', margin: '0 2rem' }}>{form.title}</h3>}
448 {id && (
449 <button
450 type="button"
451 className="btn"
452 onClick={handleNavigateBack}
453 >
454 <span className="material-symbols-outlined">arrow_back</span> Back to Tickets
455 </button>
456 )}
457 </div>
458 </div>
459
460 {error && <div className="error-message">{error}</div>}
461
462 {showUnsavedPrompt && (
463 <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
464 <div style={{ background: '#fff', padding: '1.25rem', borderRadius: 8, maxWidth: 480, width: '90%', boxShadow: '0 10px 24px rgba(0,0,0,0.2)' }}>
465 <h3 style={{ marginTop: 0 }}>Unsaved note or timer</h3>
466 <p style={{ margin: '0.5rem 0 1rem' }}>Do you have any unsaved notes you need to add?</p>
467 <div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
468 <button className="btn" onClick={() => { // Proceed
469 setShowUnsavedPrompt(false);
470 const action = pendingAction;
471 setPendingAction(null);
472 if (action === 'save') {
473 performSave();
474 } else if (action === 'navigate') {
475 navigate('/tickets');
476 }
477 }}>Proceed</button>
478 <button className="btn primary" onClick={() => { // Go Back
479 setShowUnsavedPrompt(false);
480 setPendingAction(null);
481 }}>Go Back</button>
482 </div>
483 </div>
484 </div>
485 )}
486
487 {isRootUser && (
488 <div className="form-section" style={{ marginBottom: '1.5rem', backgroundColor: '#f8f9fa', padding: '1rem', borderRadius: '8px', border: '2px solid #dee2e6' }}>
489 <div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
490 <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: '#6c757d', fontWeight: 500 }}>
491 <span className="material-symbols-outlined" style={{ fontSize: '20px' }}>admin_panel_settings</span>
492 <span>Root Tenant Control:</span>
493 </div>
494 <div style={{ flex: '1', minWidth: '250px', maxWidth: '400px' }}>
495 <select
496 value={selectedTenantId}
497 onChange={(e) => {
498 setSelectedTenantId(e.target.value);
499 // Clear customer selection when tenant changes
500 setCustomerQuery('');
501 setCustomerName('');
502 setCustomerSelected(false);
503 setCustomerSuggestions([]);
504 setForm(f => ({ ...f, customer_id: null }));
505 // Clear assignee selection when tenant changes
506 setAssigneeQuery('');
507 setAssigneeName('');
508 setAssigneeSelected(false);
509 setAssigneeSuggestions([]);
510 setForm(f => ({ ...f, assigned_to: null }));
511 }}
512 style={{ width: '100%', padding: '0.5rem', borderRadius: '4px', border: '1px solid #ced4da' }}
513 >
514 <option value="">Root Tenant (Admin)</option>
515 {tenants.map(t => (
516 <option key={t.tenant_id} value={t.tenant_id}>
517 {t.name}
518 </option>
519 ))}
520 </select>
521 </div>
522 <small style={{ color: '#6c757d', flex: '1 1 100%', marginTop: '0.25rem' }}>
523 Creating ticket on behalf of: <strong>{selectedTenantId ? tenants.find(t => t.tenant_id === parseInt(selectedTenantId))?.name : 'Root Tenant (Admin)'}</strong>. Staff and customers will be searched within the selected tenant.
524 </small>
525 </div>
526 </div>
527 )}
528
529 <div className={`ticket-form-grid ${!id ? 'create-mode' : ''}`}>
530 <div className="ticket-main">
531 {/* Description field with AI reword button */}
532 <div className="form-section">
533 <label htmlFor="description">Description</label>
534 <div style={{ position: 'relative' }}>
535 <textarea
536 id="description"
537 value={form.description}
538 onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
539 placeholder="Describe the issue or request..."
540 rows={5}
541 style={{ width: '100%', paddingRight: 40 }}
542 />
543 <AIRewordButton
544 value={form.description}
545 onReword={reworded => setForm(f => ({ ...f, description: reworded }))}
546 disabled={!form.description}
547 style={{ position: 'absolute', right: 8, top: 8 }}
548 />
549 </div>
550 </div>
551 {/* End description field */}
552 {id && (
553 <div className="form-section">
554 <h3>Notes & Time Tracking</h3>
555 <form onSubmit={handleAddNote} className="note-form">
556
557 <div style={{ position: 'relative' }}>
558 <textarea
559 value={newNote.content}
560 onChange={e => setNewNote({ ...newNote, content: e.target.value })}
561 placeholder="Add a note..."
562 rows={3}
563 style={{ width: '100%', paddingRight: 40 }}
564 />
565 <AIRewordButton
566 value={newNote.content}
567 onReword={reworded => setNewNote({ ...newNote, content: reworded })}
568 disabled={!newNote.content}
569 style={{ position: 'absolute', right: 8, top: 8 }}
570 />
571 </div>
572
573 <div className="note-form-options">
574 <label>
575 <input
576 type="checkbox"
577 checked={newNote.is_private}
578 onChange={e => setNewNote({ ...newNote, is_private: e.target.checked })}
579 />
580 Private Note
581 </label>
582
583 <label>
584 <input
585 type="checkbox"
586 checked={newNote.has_time}
587 onChange={e => setNewNote({ ...newNote, has_time: e.target.checked })}
588 />
589 Track Time
590 </label>
591 </div>
592
593 {newNote.has_time && (
594 <div className="time-tracking-fields">
595 <div className="timer-bar">
596 <button
597 type="button"
598 className={`btn ${timerRunning ? 'danger' : 'primary'}`}
599 onClick={timerRunning ? handleStopTimer : handleStartTimer}
600 >
601 <span className="material-symbols-outlined">{timerRunning ? 'stop_circle' : 'play_circle'}</span>
602 {timerRunning ? 'Stop Timer' : 'Start Timer'}
603 </button>
604 <div className="timer-display">
605 <span className="material-symbols-outlined">timer</span>
606 {Math.floor(elapsedSeconds / 3600).toString().padStart(2, '0')}
607 :{Math.floor((elapsedSeconds % 3600) / 60).toString().padStart(2, '0')}
608 :{(elapsedSeconds % 60).toString().padStart(2, '0')}
609 </div>
610 </div>
611
612 <div className="form-row">
613 <div className="form-field">
614 <label>Start Time</label>
615 <input
616 type="datetime-local"
617 value={newNote.start_time}
618 onChange={e => { setTimeTouched(true); setNewNote({ ...newNote, start_time: e.target.value }); }}
619 placeholder="YYYY-MM-DDTHH:mm"
620 />
621 </div>
622 <div className="form-field">
623 <label>End Time</label>
624 <input
625 type="datetime-local"
626 value={newNote.end_time}
627 onChange={e => { setTimeTouched(true); setNewNote({ ...newNote, end_time: e.target.value }); }}
628 placeholder="YYYY-MM-DDTHH:mm"
629 />
630 </div>
631 <div className="form-field">
632 <label>Duration (minutes)</label>
633 <input
634 type="number"
635 min="0"
636 value={newNote.duration_minutes || 0}
637 onChange={e => { setTimeTouched(true); setNewNote({ ...newNote, duration_minutes: parseInt(e.target.value || '0', 10) }); }}
638 />
639 </div>
640 <div className="form-field">
641 <label>
642 <input
643 type="checkbox"
644 checked={newNote.billable}
645 onChange={e => setNewNote({ ...newNote, billable: e.target.checked })}
646 />
647 Billable
648 </label>
649 </div>
650 </div>
651 <div className="hint">Provide start/end or just enter the duration. If only duration is set, we'll use now as the end time.</div>
652 </div>
653 )}
654
655 <div className="note-form-actions">
656 <button type="submit" className="btn primary">Add Note</button>
657 </div>
658 </form>
659
660 <div className="notes-list">
661 {[
662 ...(id && form.description ? [{ type: 'description', date: ticketCreatedAt ? new Date(ticketCreatedAt) : new Date(0), content: form.description }] : []),
663 ...notes.map(n => ({ ...n, type: n.is_ai ? 'ai' : 'note', date: new Date(n.created_at) })),
664 ...timeEntries.map(t => ({ ...t, type: 'time', date: new Date(t.start_time) }))
665 ]
666 .sort((a, b) => b.date - a.date)
667 .map(item => {
668 if (item.type === 'note') {
669 return (
670 <div key={`note-${item.note_id}`} className={`note ${item.is_private ? 'private' : ''}`}>
671 <div className="note-header">
672 <span>{item.author_name}</span>
673 <span className="subtle">
674 {item.date.toLocaleString()}
675 {item.is_private && ' (Private)'}
676 </span>
677 </div>
678 <div className="note-content">{item.content}</div>
679 </div>
680 );
681 } else if (item.type === 'ai') {
682 return (
683 <div key={`ai-note-${item.note_id}`} className="note ai-note" style={{ borderLeft: '4px solid #4b8cff', background: '#f4f8ff' }}>
684 <div className="note-header">
685 <span style={{ color: '#4b8cff', fontWeight: 600 }}>AI Assistant</span>
686 <span className="subtle">{item.date.toLocaleString()}</span>
687 </div>
688 <div className="note-content">{item.content}</div>
689 </div>
690 );
691 } else if (item.type === 'time') {
692 return (
693 <div key={`time-${item.entry_id}`} className="time-entry">
694 <div className="time-entry-header">
695 <span>{item.user_name} - Time Entry</span>
696 <span className={item.billable ? 'billable' : ''}>
697 {item.duration_minutes} minutes
698 {item.billable && ' (Billable)'}
699 </span>
700 </div>
701 <div className="note-content">{item.description}</div>
702 <div className="subtle">
703 {item.date.toLocaleString()} - {new Date(item.end_time).toLocaleString()}
704 </div>
705 </div>
706 );
707 } else if (item.type === 'description') {
708 return (
709 <div key="ticket-description" className="note">
710 <div className="note-header">
711 <span>Initial Description</span>
712 {item.date && (
713 <span className="subtle">{item.date.toLocaleString()}</span>
714 )}
715 </div>
716 <div
717 className="note-content"
718 dangerouslySetInnerHTML={{
719 __html: DOMPurify.sanitize(item.content, {
720 ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'ul', 'ol', 'li', 'a', 'h1', 'h2', 'h3', 'h4', 'blockquote', 'code', 'pre'],
721 ALLOWED_ATTR: ['href', 'target', 'class'],
722 ALLOW_DATA_ATTR: false
723 })
724 }}
725 />
726 </div>
727 );
728 }
729 return null;
730 })}
731 </div>
732 </div>
733 )}
734 </div>
735
736 <div className="ticket-side">
737 <form onSubmit={handleSubmit}>
738 <div className="form-section">
739 <h3>Ticket Details</h3>
740 {!id && (
741 <input
742 placeholder="Title"
743 value={form.title}
744 onChange={(e) => setForm({ ...form, title: e.target.value })}
745 required
746 className="full-width"
747 />
748 )}
749
750 <div className="form-row">
751 <div className="form-field">
752 <label>Status</label>
753 <select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
754 <option value="open">Open</option>
755 <option value="in_progress">In Progress</option>
756 <option value="pending">Pending</option>
757 <option value="resolved">Resolved</option>
758 <option value="closed">Closed</option>
759 </select>
760 </div>
761 <div className="form-field">
762 <label>Priority</label>
763 <select value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value })}>
764 <option value="low">Low</option>
765 <option value="medium">Medium</option>
766 <option value="high">High</option>
767 </select>
768 </div>
769 </div>
770
771 <div className="form-field">
772 <label>Assigned To</label>
773 <div className="search-input">
774 <input
775 placeholder="Search staff by name"
776 value={assigneeQuery}
777 onChange={(e) => {
778 setAssigneeQuery(e.target.value);
779 setAssigneeName('');
780 setAssigneeSelected(false);
781 }}
782 />
783 {assigneeSuggestions.length > 0 && !assigneeSelected && (
784 <div className="suggestions-dropdown">
785 {assigneeSuggestions.map(s => (
786 <div
787 key={s.user_id}
788 className="suggestion-item"
789 onClick={() => {
790 setAssigneeName(s.name);
791 setAssigneeQuery(s.name);
792 setAssigneeSuggestions([]);
793 setAssigneeSelected(true);
794 setForm(f => ({ ...f, assigned_to: s.user_id }));
795 }}
796 >
797 {s.name} <span className="subtle">({s.email})</span>
798 </div>
799 ))}
800 </div>
801 )}
802 </div>
803 </div>
804
805 <div className="form-row">
806 <div className="form-field">
807 <label>Customer *</label>
808 <div className="search-input">
809 <input
810 placeholder="Search and select a customer"
811 value={customerQuery}
812 onChange={(e) => {
813 setCustomerQuery(e.target.value);
814 setCustomerName('');
815 setCustomerSelected(false);
816 setForm(f => ({ ...f, customer_id: null }));
817 }}
818 style={!customerSelected && customerQuery ? { borderColor: '#f0ad4e' } : {}}
819 />
820 {customerSuggestions.length > 0 && !customerSelected && (
821 <div className="suggestions-dropdown">
822 {customerSuggestions.map(c => (
823 <div
824 key={c.customer_id}
825 className="suggestion-item"
826 onClick={() => {
827 setCustomerName(c.name);
828 setCustomerQuery(c.name);
829 setCustomerSuggestions([]);
830 setCustomerSelected(true);
831 setForm(f => ({ ...f, customer_id: c.customer_id }));
832 }}
833 >
834 {c.name} <span className="subtle">({c.email || 'No email'})</span>
835 </div>
836 ))}
837 </div>
838 )}
839 {!customerSelected && customerQuery && (
840 <small style={{ display: 'block', marginTop: '4px', color: '#f0ad4e' }}>
841 Please select a customer from the dropdown
842 </small>
843 )}
844 </div>
845 </div>
846 </div>
847
848 {!id && (
849 <>
850 <label>Description</label>
851 <div id="quill-editor-ticket" style={{ minHeight: 160, marginBottom: 12 }} />
852 </>
853 )}
854 </div>
855
856 <div className="form-actions">
857 <button type="submit" disabled={loading} className="btn primary">
858 {loading ? 'Saving...' : (
859 <>
860 <span className="material-symbols-outlined">save</span>
861 {id ? 'Save Changes' : 'Create Ticket'}
862 </>
863 )}
864 </button>
865 <button type="button" className="btn" onClick={handleNavigateBack}>
866 <span className="material-symbols-outlined">close</span> Cancel
867 </button>
868 </div>
869 </form>
870 </div>
871 </div>
872 </div>
873 </MainLayout>
874 );
875}
876
877export default TicketForm;