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';
8import MainLayout from '../components/Layout/MainLayout';
9import AIRewordButton from '../components/AIRewordButton';
10import './TicketForm.css';
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({
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);
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);
39 // Check if user is root/MSP
40 const [isRootUser, setIsRootUser] = useState(false);
41 const [tenants, setTenants] = useState([]);
42 const [selectedTenantId, setSelectedTenantId] = useState('');
44 // Time tracking state
45 const [timeEntries, setTimeEntries] = useState([]);
46 const [newTimeEntry, setNewTimeEntry] = useState({
50 start_time: new Date().toISOString().slice(0, 16),
51 end_time: new Date().toISOString().slice(0, 16)
55 const [notes, setNotes] = useState([]);
56 const [newNote, setNewNote] = useState({
59 // Add time tracking fields
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
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
89 return hasNoteText || hasTimeData;
92 // Warn on browser/tab close if there are unsaved notes or running timer (edit mode only)
94 if (!id) return; // only on edit page
95 const handleBeforeUnload = (e) => {
96 if (hasUnsavedNoteOrTimer()) {
103 window.addEventListener('beforeunload', handleBeforeUnload);
104 return () => window.removeEventListener('beforeunload', handleBeforeUnload);
105 }, [id, newNote, timerRunning]);
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())}`;
113 // When enabling track time, prefill start & end to now for easy tweaking
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 }));
120 }, [newNote.has_time]);
122 // Tick elapsed timer when running
124 if (!timerRunning) return;
125 const iv = setInterval(() => {
126 setElapsedSeconds(Math.floor((Date.now() - (timerStart?.getTime() || Date.now())) / 1000));
128 return () => clearInterval(iv);
129 }, [timerRunning, timerStart]);
131 const handleStartTimer = () => {
132 const now = new Date();
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: '' }));
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 }));
152 fetchTicketDetails();
154 // Check if user is root and fetch tenants
155 async function checkRootStatus() {
157 const res = await apiFetch('/tenants', { method: 'GET', credentials: 'include' });
160 const data = await res.json();
161 setTenants(data.tenants || []);
163 setIsRootUser(false);
166 setIsRootUser(false);
172 const fetchTicketDetails = async () => {
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();
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));
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
191 setTicketCreatedAt(data.ticket.created_at || null);
193 if (data.ticket.assigned_to_name) {
194 setAssigneeQuery(data.ticket.assigned_to_name);
195 setAssigneeName(data.ticket.assigned_to_name);
196 setAssigneeSelected(true);
199 if (data.ticket.customer_name) {
200 setCustomerQuery(data.ticket.customer_name);
201 setCustomerName(data.ticket.customer_name);
202 setCustomerSelected(true);
205 if (quillRef.current) {
206 quillRef.current.root.innerHTML = data.ticket.description || '';
209 setNotes(data.notes || []);
210 setTimeEntries(data.timeEntries || []);
213 setError('Failed to load ticket details');
217 // Reload only time entries without disturbing the main form
218 const loadTimeEntries = async () => {
221 const res = await apiFetch(`/tickets/${id}`, { method: 'GET', credentials: 'include' });
223 const data = await res.json();
224 setTimeEntries(data.timeEntries || []);
225 setNotes(data.notes || []);
227 // Non-fatal; keep existing time entries
232 // initialize quill for ticket description
234 if (!quillRef.current) {
235 const container = document.querySelector('#quill-editor-ticket');
237 quillRef.current = new Quill(container, { theme: 'snow' });
238 // Make read-only if editing existing ticket
240 quillRef.current.enable(false);
242 quillRef.current.on('text-change', () => {
243 setForm(f => ({ ...f, description: quillRef.current.root.innerHTML }));
250 // debounced assignee search
252 // Don't search if user has selected something
253 if (assigneeSelected) return;
255 const ac = setTimeout(async () => {
256 if (!assigneeQuery || !assigneeQuery.trim()) {
257 setAssigneeSuggestions([]);
261 const res = await apiFetch(`/users?search=${encodeURIComponent(assigneeQuery)}`, { method: 'GET', credentials: 'include' });
263 const list = await res.json();
264 setAssigneeSuggestions(list);
265 } catch (err) { console.error(err); }
267 return () => clearTimeout(ac);
268 }, [assigneeQuery, assigneeSelected, isRootUser, selectedTenantId, tenants]);
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 }));
276 }, [assigneeName, assigneeSuggestions]);
279 const handleAddNote = async (e) => {
281 if (!newNote.content) return;
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).');
294 const notePayload = {
295 content: newNote.content,
296 is_private: newNote.is_private
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;
304 notePayload.start_time = newNote.start_time;
305 notePayload.end_time = newNote.end_time;
308 const res = await apiFetch(`/tickets/${id}/notes`, {
310 headers: { 'Content-Type': 'application/json' },
311 credentials: 'include',
312 body: JSON.stringify(notePayload)
314 if (!res.ok) throw new Error('Failed to add note');
315 const note = await res.json();
316 setNotes([note, ...notes]);
326 if (newNote.has_time) {
331 setError('Failed to add note');
335 // Add time entry handler
336 const handleAddTime = async (e) => {
338 if (!newTimeEntry.description || !newTimeEntry.start_time) return;
340 const res = await apiFetch(`/tickets/${id}/time`, {
342 headers: { 'Content-Type': 'application/json' },
343 credentials: 'include',
344 body: JSON.stringify(newTimeEntry)
346 if (!res.ok) throw new Error('Failed to add time entry');
347 const entry = await res.json();
348 setTimeEntries([entry, ...timeEntries]);
353 start_time: new Date().toISOString().slice(0, 16),
354 end_time: new Date().toISOString().slice(0, 16)
358 setError('Failed to add time entry');
362 // Search for customers
364 // Don't search if user has selected something
365 if (customerSelected) return;
367 const searchCustomers = async () => {
368 if (!customerQuery || !customerQuery.trim()) {
369 setCustomerSuggestions([]);
373 const res = await apiFetch(`/customers?search=${encodeURIComponent(customerQuery)}`, { method: 'GET', credentials: 'include' });
375 const data = await res.json();
376 setCustomerSuggestions(data.customers || []);
377 } catch (err) { console.error(err); }
379 const timer = setTimeout(searchCustomers, 300);
380 return () => clearTimeout(timer);
381 }, [customerQuery, customerSelected, isRootUser, selectedTenantId, tenants]);
383 const performSave = async () => {
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)
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);
399 await notifySuccess(id ? 'Ticket Updated' : 'Ticket Created', `Ticket has been ${id ? 'updated' : 'created'} successfully`);
400 navigate('/tickets');
402 setError(err.message || 'Failed to save ticket');
408 const handleSubmit = async (e) => {
411 // For new tickets, require title and description
412 if (!id && (!form.title || !form.description)) {
413 setError('Please fill in title and description');
417 // For both new and existing tickets, require customer
418 if (!form.customer_id) {
419 setError('Please select a customer from the dropdown');
423 if (id && hasUnsavedNoteOrTimer()) {
424 setPendingAction('save');
425 setShowUnsavedPrompt(true);
432 const handleNavigateBack = () => {
433 if (id && hasUnsavedNoteOrTimer()) {
434 setPendingAction('navigate');
435 setShowUnsavedPrompt(true);
438 navigate('/tickets');
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>}
452 onClick={handleNavigateBack}
454 <span className="material-symbols-outlined">arrow_back</span> Back to Tickets
460 {error && <div className="error-message">{error}</div>}
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') {
474 } else if (action === 'navigate') {
475 navigate('/tickets');
478 <button className="btn primary" onClick={() => { // Go Back
479 setShowUnsavedPrompt(false);
480 setPendingAction(null);
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>
494 <div style={{ flex: '1', minWidth: '250px', maxWidth: '400px' }}>
496 value={selectedTenantId}
498 setSelectedTenantId(e.target.value);
499 // Clear customer selection when tenant changes
500 setCustomerQuery('');
502 setCustomerSelected(false);
503 setCustomerSuggestions([]);
504 setForm(f => ({ ...f, customer_id: null }));
505 // Clear assignee selection when tenant changes
506 setAssigneeQuery('');
508 setAssigneeSelected(false);
509 setAssigneeSuggestions([]);
510 setForm(f => ({ ...f, assigned_to: null }));
512 style={{ width: '100%', padding: '0.5rem', borderRadius: '4px', border: '1px solid #ced4da' }}
514 <option value="">Root Tenant (Admin)</option>
516 <option key={t.tenant_id} value={t.tenant_id}>
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.
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' }}>
537 value={form.description}
538 onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
539 placeholder="Describe the issue or request..."
541 style={{ width: '100%', paddingRight: 40 }}
544 value={form.description}
545 onReword={reworded => setForm(f => ({ ...f, description: reworded }))}
546 disabled={!form.description}
547 style={{ position: 'absolute', right: 8, top: 8 }}
551 {/* End description field */}
553 <div className="form-section">
554 <h3>Notes & Time Tracking</h3>
555 <form onSubmit={handleAddNote} className="note-form">
557 <div style={{ position: 'relative' }}>
559 value={newNote.content}
560 onChange={e => setNewNote({ ...newNote, content: e.target.value })}
561 placeholder="Add a note..."
563 style={{ width: '100%', paddingRight: 40 }}
566 value={newNote.content}
567 onReword={reworded => setNewNote({ ...newNote, content: reworded })}
568 disabled={!newNote.content}
569 style={{ position: 'absolute', right: 8, top: 8 }}
573 <div className="note-form-options">
577 checked={newNote.is_private}
578 onChange={e => setNewNote({ ...newNote, is_private: e.target.checked })}
586 checked={newNote.has_time}
587 onChange={e => setNewNote({ ...newNote, has_time: e.target.checked })}
593 {newNote.has_time && (
594 <div className="time-tracking-fields">
595 <div className="timer-bar">
598 className={`btn ${timerRunning ? 'danger' : 'primary'}`}
599 onClick={timerRunning ? handleStopTimer : handleStartTimer}
601 <span className="material-symbols-outlined">{timerRunning ? 'stop_circle' : 'play_circle'}</span>
602 {timerRunning ? 'Stop Timer' : 'Start Timer'}
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')}
612 <div className="form-row">
613 <div className="form-field">
614 <label>Start Time</label>
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"
622 <div className="form-field">
623 <label>End Time</label>
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"
631 <div className="form-field">
632 <label>Duration (minutes)</label>
636 value={newNote.duration_minutes || 0}
637 onChange={e => { setTimeTouched(true); setNewNote({ ...newNote, duration_minutes: parseInt(e.target.value || '0', 10) }); }}
640 <div className="form-field">
644 checked={newNote.billable}
645 onChange={e => setNewNote({ ...newNote, billable: e.target.checked })}
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>
655 <div className="note-form-actions">
656 <button type="submit" className="btn primary">Add Note</button>
660 <div className="notes-list">
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) }))
666 .sort((a, b) => b.date - a.date)
668 if (item.type === 'note') {
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)'}
678 <div className="note-content">{item.content}</div>
681 } else if (item.type === 'ai') {
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>
688 <div className="note-content">{item.content}</div>
691 } else if (item.type === 'time') {
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)'}
701 <div className="note-content">{item.description}</div>
702 <div className="subtle">
703 {item.date.toLocaleString()} - {new Date(item.end_time).toLocaleString()}
707 } else if (item.type === 'description') {
709 <div key="ticket-description" className="note">
710 <div className="note-header">
711 <span>Initial Description</span>
713 <span className="subtle">{item.date.toLocaleString()}</span>
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
736 <div className="ticket-side">
737 <form onSubmit={handleSubmit}>
738 <div className="form-section">
739 <h3>Ticket Details</h3>
744 onChange={(e) => setForm({ ...form, title: e.target.value })}
746 className="full-width"
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>
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>
771 <div className="form-field">
772 <label>Assigned To</label>
773 <div className="search-input">
775 placeholder="Search staff by name"
776 value={assigneeQuery}
778 setAssigneeQuery(e.target.value);
780 setAssigneeSelected(false);
783 {assigneeSuggestions.length > 0 && !assigneeSelected && (
784 <div className="suggestions-dropdown">
785 {assigneeSuggestions.map(s => (
788 className="suggestion-item"
790 setAssigneeName(s.name);
791 setAssigneeQuery(s.name);
792 setAssigneeSuggestions([]);
793 setAssigneeSelected(true);
794 setForm(f => ({ ...f, assigned_to: s.user_id }));
797 {s.name} <span className="subtle">({s.email})</span>
805 <div className="form-row">
806 <div className="form-field">
807 <label>Customer *</label>
808 <div className="search-input">
810 placeholder="Search and select a customer"
811 value={customerQuery}
813 setCustomerQuery(e.target.value);
815 setCustomerSelected(false);
816 setForm(f => ({ ...f, customer_id: null }));
818 style={!customerSelected && customerQuery ? { borderColor: '#f0ad4e' } : {}}
820 {customerSuggestions.length > 0 && !customerSelected && (
821 <div className="suggestions-dropdown">
822 {customerSuggestions.map(c => (
825 className="suggestion-item"
827 setCustomerName(c.name);
828 setCustomerQuery(c.name);
829 setCustomerSuggestions([]);
830 setCustomerSelected(true);
831 setForm(f => ({ ...f, customer_id: c.customer_id }));
834 {c.name} <span className="subtle">({c.email || 'No email'})</span>
839 {!customerSelected && customerQuery && (
840 <small style={{ display: 'block', marginTop: '4px', color: '#f0ad4e' }}>
841 Please select a customer from the dropdown
850 <label>Description</label>
851 <div id="quill-editor-ticket" style={{ minHeight: 160, marginBottom: 12 }} />
856 <div className="form-actions">
857 <button type="submit" disabled={loading} className="btn primary">
858 {loading ? 'Saving...' : (
860 <span className="material-symbols-outlined">save</span>
861 {id ? 'Save Changes' : 'Create Ticket'}
865 <button type="button" className="btn" onClick={handleNavigateBack}>
866 <span className="material-symbols-outlined">close</span> Cancel
877export default TicketForm;