feat: ticket modal collapsible notes and time entries

- New GET /api/tickets/[id]/notes: fetches TicketNotes from Autotask,
  enriches with creator resource names, sorted newest first
- Modal: collapsible Notes section (lazy fetch on expand, shows count badge)
- Modal: collapsible Time Entries section (lazy fetch, count + total hours in header)
- Both sections fetch once and cache for the modal session
This commit is contained in:
lorentz 2026-03-23 10:27:31 -04:00
parent 0a318fb9d6
commit b72fe2c70e
2 changed files with 215 additions and 9 deletions

View file

@ -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<string | null>(null);
const [notes, setNotes] = useState<any[]>([]);
const [notesLoading, setNotesLoading] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesFetched, setNotesFetched] = useState(false);
const [timeEntries, setTimeEntries] = useState<any[]>([]);
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader className="pb-0">
<DialogTitle className="flex items-center gap-2 text-base">
<TicketIcon className="h-4 w-4 shrink-0" />
@ -195,6 +253,101 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
</div>
)}
<Separator />
{/* Notes collapsible */}
<Collapsible
open={notesOpen}
onOpenChange={(val) => {
setNotesOpen(val);
if (val) fetchNotes(ticket.id);
}}
>
<CollapsibleTrigger asChild>
<button className="flex w-full items-center justify-between py-1 text-sm font-medium hover:text-foreground text-muted-foreground transition-colors group">
<span className="flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
Notes
{notesFetched && (
<Badge variant="secondary" className="text-xs px-1.5 py-0">{notes.length}</Badge>
)}
</span>
<ChevronRight className={`h-4 w-4 transition-transform duration-200 ${notesOpen ? 'rotate-90' : ''}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
{notesLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="h-3 w-3 animate-spin" />
Loading notes
</div>
)}
{!notesLoading && notes.length === 0 && notesFetched && (
<p className="text-sm text-muted-foreground py-1">No notes found.</p>
)}
{notes.map((note) => (
<div key={note.id} className="rounded-lg border bg-muted/40 p-3 text-sm space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium text-xs">{note.creatorName || 'Unknown'}</span>
<span className="text-xs text-muted-foreground">{formatDate(note.createDateTime)}</span>
</div>
{note.title && <p className="font-medium text-xs text-muted-foreground">{note.title}</p>}
<p className="whitespace-pre-wrap text-sm">{note.description}</p>
</div>
))}
</CollapsibleContent>
</Collapsible>
{/* Time entries collapsible */}
<Collapsible
open={timeOpen}
onOpenChange={(val) => {
setTimeOpen(val);
if (val) fetchTimeEntries(ticket.id);
}}
>
<CollapsibleTrigger asChild>
<button className="flex w-full items-center justify-between py-1 text-sm font-medium hover:text-foreground text-muted-foreground transition-colors group">
<span className="flex items-center gap-2">
<Timer className="h-4 w-4" />
Time Entries
{timeFetched && (
<>
<Badge variant="secondary" className="text-xs px-1.5 py-0">{timeEntries.length}</Badge>
<span className="text-xs text-muted-foreground">{formatHours(totalHours)} total</span>
</>
)}
</span>
<ChevronRight className={`h-4 w-4 transition-transform duration-200 ${timeOpen ? 'rotate-90' : ''}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
{timeLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="h-3 w-3 animate-spin" />
Loading time entries
</div>
)}
{!timeLoading && timeEntries.length === 0 && timeFetched && (
<p className="text-sm text-muted-foreground py-1">No time entries found.</p>
)}
{timeEntries.map((entry) => (
<div key={entry.id} className="rounded-lg border bg-muted/40 p-3 text-sm space-y-1">
<div className="flex items-center justify-between">
<span className="font-medium text-xs">{entry.resourceName || 'Unknown'}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs px-1.5 py-0">{formatHours(entry.hoursWorked)}</Badge>
<span className="text-xs text-muted-foreground">{formatDate(entry.dateWorked)}</span>
</div>
</div>
{entry.summaryNotes && (
<p className="whitespace-pre-wrap text-xs text-muted-foreground">{entry.summaryNotes}</p>
)}
</div>
))}
</CollapsibleContent>
</Collapsible>
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>