diff --git a/components/quotes/ticket-detail-modal.tsx b/components/quotes/ticket-detail-modal.tsx index ecb5c45..a851681 100644 --- a/components/quotes/ticket-detail-modal.tsx +++ b/components/quotes/ticket-detail-modal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Dialog, DialogContent, @@ -27,38 +27,44 @@ import { ChevronRight, MessageSquare, Timer, + Bot, + UserCircle, } from 'lucide-react'; +// noteType values that are system/automated (workflow rules, monitoring, auto-close, etc.) +const SYSTEM_NOTE_TYPES = new Set([13, 91, 93, 94, 99, 101]); + interface TicketDetailModalProps { ticketNumber: string; open: boolean; onOpenChange: (open: boolean) => void; } +type TimelineItem = + | { kind: 'note'; ts: number; data: any } + | { kind: 'time'; ts: number; data: any }; + export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDetailModalProps) { const [ticket, setTicket] = useState(null); 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 [timelineLoading, setTimelineLoading] = useState(false); + const [timelineOpen, setTimelineOpen] = useState(false); + const [timelineFetched, setTimelineFetched] = useState(false); + const [showSystem, setShowSystem] = useState(false); const fetchTicketDetails = async () => { setLoading(true); setError(null); setTicket(null); setNotes([]); - setNotesFetched(false); - setTimeFetched(false); - setNotesOpen(false); - setTimeOpen(false); + setTimeEntries([]); + setTimelineFetched(false); + setTimelineOpen(false); + setShowSystem(false); try { const response = await fetch('/api/tickets'); if (response.ok) { @@ -86,46 +92,67 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe } }; - const fetchNotes = async (ticketId: number) => { - if (notesFetched) return; - setNotesLoading(true); + const fetchTimeline = async (ticketId: number) => { + if (timelineFetched) return; + setTimelineLoading(true); try { - const res = await fetch(`/api/tickets/${ticketId}/notes`); - if (res.ok) { - const data = await res.json(); - setNotes(data.notes || []); - } + const [notesRes, timeRes] = await Promise.all([ + fetch(`/api/tickets/${ticketId}/notes`), + fetch(`/api/tickets/${ticketId}/time-entries`), + ]); + if (notesRes.ok) setNotes((await notesRes.json()).notes || []); + if (timeRes.ok) setTimeEntries((await timeRes.json()).timeEntries || []); } catch (err) { - console.error('Error fetching notes:', err); + console.error('Error fetching timeline:', 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); + setTimelineLoading(false); + setTimelineFetched(true); } }; useEffect(() => { - if (open && ticketNumber) { - fetchTicketDetails(); - } + if (open && ticketNumber) fetchTicketDetails(); }, [open, ticketNumber]); + // Build merged, sorted timeline + const timeline = useMemo(() => { + const items: TimelineItem[] = [ + ...notes.map((n) => ({ + kind: 'note' as const, + ts: new Date(n.createDateTime || 0).getTime(), + data: n, + })), + ...timeEntries.map((e) => ({ + kind: 'time' as const, + ts: new Date(e.dateWorked || 0).getTime(), + data: e, + })), + ]; + return items.sort((a, b) => b.ts - a.ts); + }, [notes, timeEntries]); + + const visibleTimeline = useMemo( + () => + showSystem + ? timeline + : timeline.filter( + (item) => + item.kind === 'time' || + !SYSTEM_NOTE_TYPES.has(item.data.noteType) + ), + [timeline, showSystem] + ); + + const systemCount = useMemo( + () => + timeline.filter( + (item) => item.kind === 'note' && SYSTEM_NOTE_TYPES.has(item.data.noteType) + ).length, + [timeline] + ); + + const totalHours = timeEntries.reduce((sum, e) => sum + (e.hoursWorked || 0), 0); + const getStatusBadge = (status: number, label?: string | null) => { const resolved = label || `Status ${status}`; const lower = resolved.toLowerCase(); @@ -139,7 +166,7 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe const getPriorityBadge = (priority: number, label?: string | null) => { const resolved = label || `Priority ${priority}`; const lower = resolved.toLowerCase(); - const variant: "destructive" | "default" | "secondary" | "outline" = + const variant: 'destructive' | 'default' | 'secondary' | 'outline' = lower.includes('critical') || lower.includes('high') ? 'destructive' : lower.includes('medium') ? 'default' : lower.includes('low') ? 'secondary' : 'outline'; @@ -149,11 +176,8 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe const formatDate = (dateString?: string) => { if (!dateString) return 'N/A'; return new Date(dateString).toLocaleString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', }); }; @@ -162,8 +186,6 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe return `${hours.toFixed(2)}h`; }; - const totalHours = timeEntries.reduce((sum, e) => sum + (e.hoursWorked || 0), 0); - return ( @@ -190,7 +212,7 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe {ticket && !loading && (
- {/* Metadata row */} + {/* Metadata */}
{getStatusBadge(ticket.status, ticket.statusLabel)} {getPriorityBadge(ticket.priority, ticket.priorityLabel)} @@ -202,7 +224,7 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe )}
- {/* Dates row */} + {/* Dates */}
@@ -255,96 +277,124 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe - {/* Notes collapsible */} + {/* Timeline collapsible */} { - setNotesOpen(val); - if (val) fetchNotes(ticket.id); + setTimelineOpen(val); + if (val && ticket) fetchTimeline(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 && ( -
+ + + {timelineLoading && ( +
- Loading time entries… + Loading activity…
)} - {!timeLoading && timeEntries.length === 0 && timeFetched && ( -

No time entries found.

- )} - {timeEntries.map((entry) => ( -
-
- {entry.resourceName || 'Unknown'} -
- {formatHours(entry.hoursWorked)} - {formatDate(entry.dateWorked)} + + {!timelineLoading && timelineFetched && ( + <> + {/* System notes toggle */} + {systemCount > 0 && ( +
+
-
- {entry.summaryNotes && ( -

{entry.summaryNotes}

)} -
- ))} + + {visibleTimeline.length === 0 && ( +

No activity found.

+ )} + + {/* Timeline items */} +
+ {visibleTimeline.map((item, idx) => { + const isLast = idx === visibleTimeline.length - 1; + const isSystem = item.kind === 'note' && SYSTEM_NOTE_TYPES.has(item.data.noteType); + + if (item.kind === 'time') { + const e = item.data; + return ( +
+
+
+ +
+ {!isLast &&
} +
+
+
+ {e.resourceName || 'Unknown'} +
+ {formatHours(e.hoursWorked)} + {formatDate(e.dateWorked)} +
+
+ {e.summaryNotes && ( +

{e.summaryNotes}

+ )} +
+
+ ); + } + + // note + const n = item.data; + return ( +
+
+
+ {isSystem ? : } +
+ {!isLast &&
} +
+
+
+ {n.creatorName || (isSystem ? 'System' : 'Unknown')} + {formatDate(n.createDateTime)} +
+ {n.title &&

{n.title}

} + {n.description && ( +

{n.description}

+ )} +
+
+ ); + })} +
+ + )}