feat: unified Activity Timeline with system note toggle
- Merge notes + time entries into single chronological timeline - Time entries: blue clock icon, hours badge, resource name, summary - Human notes: green user icon, creator name, title + body - System notes (noteType 13/91/93/94/99/101): bot icon, dimmed, hidden by default - 'Show/Hide system notes' toggle with count badge appears only when system notes exist - Both data sources fetched in parallel on first expand, cached for session
This commit is contained in:
parent
b72fe2c70e
commit
cb4552a3eb
1 changed files with 176 additions and 126 deletions
|
|
@ -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<any>(null);
|
||||
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 [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<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();
|
||||
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
|
|
@ -190,7 +212,7 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
|
|||
|
||||
{ticket && !loading && (
|
||||
<div className="space-y-3">
|
||||
{/* Metadata row */}
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{getStatusBadge(ticket.status, ticket.statusLabel)}
|
||||
{getPriorityBadge(ticket.priority, ticket.priorityLabel)}
|
||||
|
|
@ -202,7 +224,7 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Dates row */}
|
||||
{/* 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" />
|
||||
|
|
@ -255,96 +277,124 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* Notes collapsible */}
|
||||
{/* Timeline collapsible */}
|
||||
<Collapsible
|
||||
open={notesOpen}
|
||||
open={timelineOpen}
|
||||
onOpenChange={(val) => {
|
||||
setNotesOpen(val);
|
||||
if (val) fetchNotes(ticket.id);
|
||||
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 group">
|
||||
<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">
|
||||
<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 && (
|
||||
<Clock className="h-4 w-4" />
|
||||
Activity Timeline
|
||||
{timelineFetched && (
|
||||
<>
|
||||
<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>
|
||||
<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 ${timeOpen ? 'rotate-90' : ''}`} />
|
||||
<ChevronRight className={`h-4 w-4 transition-transform duration-200 ${timelineOpen ? '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">
|
||||
|
||||
<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 time entries…
|
||||
Loading activity…
|
||||
</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>
|
||||
|
||||
{!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>
|
||||
</div>
|
||||
{entry.summaryNotes && (
|
||||
<p className="whitespace-pre-wrap text-xs text-muted-foreground">{entry.summaryNotes}</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue