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:
parent
0a318fb9d6
commit
b72fe2c70e
2 changed files with 215 additions and 9 deletions
53
app/api/tickets/[id]/notes/route.ts
Normal file
53
app/api/tickets/[id]/notes/route.ts
Normal file
|
|
@ -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<number, string> = {};
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue