wulf-pulse/components/quotes/ticket-detail-modal.tsx
lorentz 5dfd68affd perf: switch ticket modal from live API to Postgres
- New GET /api/tickets/by-number/[ticketNumber]: single query with resources
  join, returns ticket + picklist labels (status/priority cached per deploy)
- /api/tickets/[id]/notes: Postgres ticket_notes + resources join (was N+1 API calls)
- /api/tickets/[id]/time-entries: Postgres time_entries + resources join (was N+1 API calls)
- Modal: replaces 2-step fetch (all tickets → by id) with single by-number lookup
2026-03-23 11:09:29 -04:00

412 lines
16 KiB
TypeScript

'use client';
import { useState, useEffect, useMemo } from 'react';
import {
Dialog,
DialogContent,
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';
import {
Ticket as TicketIcon,
User,
Calendar,
Clock,
AlertCircle,
CheckCircle,
ExternalLink,
Loader2,
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<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notes, setNotes] = useState<any[]>([]);
const [timeEntries, setTimeEntries] = useState<any[]>([]);
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([]);
setTimeEntries([]);
setTimelineFetched(false);
setTimelineOpen(false);
setShowSystem(false);
try {
const response = await fetch(`/api/tickets/by-number/${encodeURIComponent(ticketNumber)}`);
if (response.ok) {
const data = await response.json();
setTicket(data.ticket);
} else if (response.status === 404) {
setError(`Ticket ${ticketNumber} not found`);
} else {
setError('Failed to fetch ticket details');
}
} catch (err) {
setError('Failed to fetch ticket');
console.error('Error fetching ticket:', err);
} finally {
setLoading(false);
}
};
const fetchTimeline = async (ticketId: number) => {
if (timelineFetched) return;
setTimelineLoading(true);
try {
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 timeline:', err);
} finally {
setTimelineLoading(false);
setTimelineFetched(true);
}
};
useEffect(() => {
if (open && ticketNumber) fetchTicketDetails();
}, [open, ticketNumber]);
// Build merged, sorted timeline
const timeline = useMemo<TimelineItem[]>(() => {
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();
const variant =
lower.includes('complete') || lower.includes('closed') || lower.includes('resolved') ? 'secondary' :
lower.includes('progress') || lower.includes('waiting') ? 'default' :
lower.includes('new') ? 'default' : 'outline';
return <Badge variant={variant}>{resolved}</Badge>;
};
const getPriorityBadge = (priority: number, label?: string | null) => {
const resolved = label || `Priority ${priority}`;
const lower = resolved.toLowerCase();
const variant: 'destructive' | 'default' | 'secondary' | 'outline' =
lower.includes('critical') || lower.includes('high') ? 'destructive' :
lower.includes('medium') ? 'default' :
lower.includes('low') ? 'secondary' : 'outline';
return <Badge variant={variant}>{resolved}</Badge>;
};
const formatDate = (dateString?: string) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
};
const formatHours = (hours?: number) => {
if (!hours) return '0h';
return `${hours.toFixed(2)}h`;
};
return (
<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" />
<span className="font-mono text-blue-600 dark:text-blue-400">{ticketNumber}</span>
{ticket && <span className="text-muted-foreground font-normal truncate">{ticket.title}</span>}
</DialogTitle>
</DialogHeader>
{loading && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<div className="flex items-center gap-2 p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{ticket && !loading && (
<div className="space-y-3">
{/* Metadata */}
<div className="flex flex-wrap items-center gap-2 text-sm">
{getStatusBadge(ticket.status, ticket.statusLabel)}
{getPriorityBadge(ticket.priority, ticket.priorityLabel)}
{ticket.assignedResourceName && (
<span className="flex items-center gap-1 text-muted-foreground">
<User className="h-3 w-3" />
{ticket.assignedResourceName}
</span>
)}
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Calendar className="h-3 w-3 shrink-0" />
<span>Created</span>
<span className="text-foreground font-medium">{formatDate(ticket.createDate)}</span>
</div>
{ticket.dueDateTime && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="h-3 w-3 shrink-0" />
<span>Due</span>
<span className="text-foreground font-medium">{formatDate(ticket.dueDateTime)}</span>
</div>
)}
{ticket.resolvedDateTime && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<CheckCircle className="h-3 w-3 shrink-0" />
<span>Resolved</span>
<span className="text-foreground font-medium">{formatDate(ticket.resolvedDateTime)}</span>
</div>
)}
{ticket.purchaseOrderNumber && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<span>PO</span>
<span className="text-foreground font-medium">{ticket.purchaseOrderNumber}</span>
</div>
)}
</div>
<Separator />
{/* Description */}
{ticket.description && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Description</p>
<div className="p-3 bg-muted rounded-lg whitespace-pre-wrap text-sm">
{ticket.description}
</div>
</div>
)}
{/* Resolution */}
{ticket.resolution && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Resolution</p>
<div className="p-3 bg-muted rounded-lg whitespace-pre-wrap text-sm">
{ticket.resolution}
</div>
</div>
)}
<Separator />
{/* Timeline collapsible */}
<Collapsible
open={timelineOpen}
onOpenChange={(val) => {
setTimelineOpen(val);
if (val && ticket) fetchTimeline(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">
<span className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Activity Timeline
{timelineFetched && (
<>
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{visibleTimeline.length}
</Badge>
{totalHours > 0 && (
<span className="text-xs text-muted-foreground">{formatHours(totalHours)} logged</span>
)}
</>
)}
</span>
<ChevronRight className={`h-4 w-4 transition-transform duration-200 ${timelineOpen ? 'rotate-90' : ''}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
{timelineLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-3">
<Loader2 className="h-3 w-3 animate-spin" />
Loading activity
</div>
)}
{!timelineLoading && timelineFetched && (
<>
{/* System notes toggle */}
{systemCount > 0 && (
<div className="flex items-center justify-end mb-2">
<button
onClick={() => setShowSystem((v) => !v)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bot className="h-3 w-3" />
{showSystem ? 'Hide' : 'Show'} system notes
<Badge variant="outline" className="text-xs px-1.5 py-0">{systemCount}</Badge>
</button>
</div>
)}
{visibleTimeline.length === 0 && (
<p className="text-sm text-muted-foreground py-1">No activity found.</p>
)}
{/* Timeline items */}
<div className="relative space-y-0">
{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 (
<div key={`time-${e.id}`} className="flex gap-3">
<div className="flex flex-col items-center">
<div className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-400">
<Timer className="h-3 w-3" />
</div>
{!isLast && <div className="w-px flex-1 bg-border my-1" />}
</div>
<div className={`pb-4 min-w-0 flex-1 ${isLast ? '' : ''}`}>
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="text-xs font-medium">{e.resourceName || 'Unknown'}</span>
<div className="flex items-center gap-1.5 shrink-0">
<Badge variant="outline" className="text-xs px-1.5 py-0">{formatHours(e.hoursWorked)}</Badge>
<span className="text-xs text-muted-foreground">{formatDate(e.dateWorked)}</span>
</div>
</div>
{e.summaryNotes && (
<p className="text-xs text-muted-foreground whitespace-pre-wrap">{e.summaryNotes}</p>
)}
</div>
</div>
);
}
// note
const n = item.data;
return (
<div key={`note-${n.id}`} className="flex gap-3">
<div className="flex flex-col items-center">
<div className={`mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full ${
isSystem
? 'bg-muted text-muted-foreground'
: 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-600 dark:text-emerald-400'
}`}>
{isSystem ? <Bot className="h-3 w-3" /> : <UserCircle className="h-3 w-3" />}
</div>
{!isLast && <div className="w-px flex-1 bg-border my-1" />}
</div>
<div className={`pb-4 min-w-0 flex-1 ${isSystem ? 'opacity-60' : ''}`}>
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="text-xs font-medium">{n.creatorName || (isSystem ? 'System' : 'Unknown')}</span>
<span className="text-xs text-muted-foreground shrink-0">{formatDate(n.createDateTime)}</span>
</div>
{n.title && <p className="text-xs font-medium text-muted-foreground mb-0.5">{n.title}</p>}
{n.description && (
<p className="text-xs whitespace-pre-wrap">{n.description}</p>
)}
</div>
</div>
);
})}
</div>
</>
)}
</CollapsibleContent>
</Collapsible>
{/* Actions */}
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button
size="sm"
onClick={() => {
window.open(`https://wam.autotask.net/Mvc/ServiceDesk/TicketDetail.mvc?ticketId=${ticket.id}`, '_blank');
}}
>
<ExternalLink className="h-4 w-4 mr-2" />
Open in Autotask
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}