diff --git a/app/api/tickets/[id]/notes/route.ts b/app/api/tickets/[id]/notes/route.ts new file mode 100644 index 0000000..ec624f0 --- /dev/null +++ b/app/api/tickets/[id]/notes/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAutotaskClient } from '@/lib/services/autotask-factory'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const client = getAutotaskClient(); + + const notes = await client.queryEntity('TicketNotes', { + filter: [ + { op: 'eq', field: 'ticketID', value: parseInt(id) } + ], + }); + + // Enrich with resource names + const resourceCache: Record = {}; + const enriched = await Promise.all( + (notes as any[]).map(async (note) => { + let creatorName = null; + const resourceId = note.creatorResourceID; + if (resourceId) { + if (resourceCache[resourceId]) { + creatorName = resourceCache[resourceId]; + } else { + try { + const resource = await client.getEntityById('Resources', resourceId) as any; + creatorName = resource ? `${resource.firstName} ${resource.lastName}` : null; + if (creatorName) resourceCache[resourceId] = creatorName; + } catch { + // ignore + } + } + } + return { ...note, creatorName }; + }) + ); + + enriched.sort((a, b) => + new Date(b.createDateTime || 0).getTime() - new Date(a.createDateTime || 0).getTime() + ); + + return NextResponse.json({ notes: enriched }); + } catch (error) { + console.error('Error fetching ticket notes:', error); + return NextResponse.json( + { error: 'Failed to fetch ticket notes' }, + { status: 500 } + ); + } +} diff --git a/components/quotes/ticket-detail-modal.tsx b/components/quotes/ticket-detail-modal.tsx index 164b249..ecb5c45 100644 --- a/components/quotes/ticket-detail-modal.tsx +++ b/components/quotes/ticket-detail-modal.tsx @@ -7,6 +7,11 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; @@ -18,7 +23,10 @@ import { AlertCircle, CheckCircle, ExternalLink, - Loader2 + Loader2, + ChevronRight, + MessageSquare, + Timer, } from 'lucide-react'; interface TicketDetailModalProps { @@ -32,16 +40,30 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [notes, setNotes] = useState([]); + const [notesLoading, setNotesLoading] = useState(false); + const [notesOpen, setNotesOpen] = useState(false); + const [notesFetched, setNotesFetched] = useState(false); + + const [timeEntries, setTimeEntries] = useState([]); + const [timeLoading, setTimeLoading] = useState(false); + const [timeOpen, setTimeOpen] = useState(false); + const [timeFetched, setTimeFetched] = useState(false); + const fetchTicketDetails = async () => { setLoading(true); setError(null); setTicket(null); + setNotes([]); + setNotesFetched(false); + setTimeFetched(false); + setNotesOpen(false); + setTimeOpen(false); try { const response = await fetch('/api/tickets'); if (response.ok) { const data = await response.json(); const matchingTicket = data.tickets?.find((t: any) => t.ticketNumber === ticketNumber); - if (matchingTicket) { const detailResponse = await fetch(`/api/tickets/${matchingTicket.id}`); if (detailResponse.ok) { @@ -64,17 +86,46 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe } }; - // Fetch ticket when modal opens + const fetchNotes = async (ticketId: number) => { + if (notesFetched) return; + setNotesLoading(true); + try { + const res = await fetch(`/api/tickets/${ticketId}/notes`); + if (res.ok) { + const data = await res.json(); + setNotes(data.notes || []); + } + } catch (err) { + console.error('Error fetching notes:', err); + } finally { + setNotesLoading(false); + setNotesFetched(true); + } + }; + + const fetchTimeEntries = async (ticketId: number) => { + if (timeFetched) return; + setTimeLoading(true); + try { + const res = await fetch(`/api/tickets/${ticketId}/time-entries`); + if (res.ok) { + const data = await res.json(); + setTimeEntries(data.timeEntries || []); + } + } catch (err) { + console.error('Error fetching time entries:', err); + } finally { + setTimeLoading(false); + setTimeFetched(true); + } + }; + useEffect(() => { if (open && ticketNumber) { fetchTicketDetails(); } }, [open, ticketNumber]); - const handleOpenChange = (newOpen: boolean) => { - onOpenChange(newOpen); - }; - const getStatusBadge = (status: number, label?: string | null) => { const resolved = label || `Status ${status}`; const lower = resolved.toLowerCase(); @@ -106,9 +157,16 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe }); }; + const formatHours = (hours?: number) => { + if (!hours) return '0h'; + return `${hours.toFixed(2)}h`; + }; + + const totalHours = timeEntries.reduce((sum, e) => sum + (e.hoursWorked || 0), 0); + return ( - - + + @@ -195,6 +253,101 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe )} + + + {/* Notes collapsible */} + { + setNotesOpen(val); + if (val) fetchNotes(ticket.id); + }} + > + + + + + {notesLoading && ( +
+ + Loading notes… +
+ )} + {!notesLoading && notes.length === 0 && notesFetched && ( +

No notes found.

+ )} + {notes.map((note) => ( +
+
+ {note.creatorName || 'Unknown'} + {formatDate(note.createDateTime)} +
+ {note.title &&

{note.title}

} +

{note.description}

+
+ ))} +
+
+ + {/* Time entries collapsible */} + { + setTimeOpen(val); + if (val) fetchTimeEntries(ticket.id); + }} + > + + + + + {timeLoading && ( +
+ + Loading time entries… +
+ )} + {!timeLoading && timeEntries.length === 0 && timeFetched && ( +

No time entries found.

+ )} + {timeEntries.map((entry) => ( +
+
+ {entry.resourceName || 'Unknown'} +
+ {formatHours(entry.hoursWorked)} + {formatDate(entry.dateWorked)} +
+
+ {entry.summaryNotes && ( +

{entry.summaryNotes}

+ )} +
+ ))} +
+
+ {/* Actions */}