- DetailModal: thread tz through resolveLabel(...) module helper + default export's 3 inline date/time calls. - IntegrationStatusTabs: thread tz through fmtDate helper + VeeamTab sub-component prop. - SyncScheduler: thread tz into closure-scoped formatDate helper. - audit-log-table, user-table, user-sessions, active-sessions: inline toLocale calls in component body. - analysis-view: useUserTimezone in AnalysisView; thread tz into 4 toLocaleString calls. - resolution-trend, volume-trend (recharts): module-scope fmtDate(iso) → fmtDate(iso, tz); useUserTimezone in named export; thread tz into axis tickFormatter + tooltip labelFormatter. - ticket-detail-modal: thread tz into formatDate arrow inside TicketDetailModal. - TimelineView: useUserTimezone; thread tz into 4 toLocale*String calls (hour/day/month/event-time formatters). - ScoreCard: useUserTimezone in AggregateScoreCard; thread tz into the date-range latest call. - addigy-tab: useUserTimezone in AddigyTab; thread tz into 2 inline calls. - activity-sparkline: module-scope fmtHour(iso) → fmtHour(iso, tz); useUserTimezone in ActivitySparkline; update 3 callsites in title/aria. - compliance-detail-table: thread tz from ComplianceDetailTable into ContractCoverageModal sub-component (2 inline date calls). - company-backup-detail: module-scope formatDate(d) → formatDate(d, tz); useUserTimezone in CompanyBackupDetail; update 3 callsites. Migrates 31 of 81 audit leak callsites.
508 lines
20 KiB
TypeScript
508 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogClose,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import {
|
|
Ticket as TicketIcon,
|
|
User,
|
|
Calendar,
|
|
Clock,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
ExternalLink,
|
|
Loader2,
|
|
ChevronRight,
|
|
MessageSquare,
|
|
Timer,
|
|
Bot,
|
|
UserCircle,
|
|
Mail,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
|
|
|
// noteType values that are system/automated (workflow rules, monitoring, auto-close, etc.)
|
|
const SYSTEM_NOTE_TYPES = new Set([13, 91, 93, 94, 99, 101]);
|
|
// publish=2 means Internal Users Only in Autotask
|
|
const INTERNAL_PUBLISH = 2;
|
|
// noteType=2 is Service Desk Notification (email communication)
|
|
const EMAIL_NOTE_TYPE = 2;
|
|
|
|
const URL_REGEX = /https?:\/\/[^\s<>"'\[\]()]+/g;
|
|
|
|
function renderTextWithLinks(text: string): React.ReactNode[] {
|
|
const parts: React.ReactNode[] = [];
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
URL_REGEX.lastIndex = 0;
|
|
while ((match = URL_REGEX.exec(text)) !== null) {
|
|
if (match.index > lastIndex) {
|
|
// Strip orphaned bracket that wraps the URL e.g. "link [https://..."
|
|
const before = text.slice(lastIndex, match.index).replace(/\s*\[$/, ' ');
|
|
if (before) parts.push(before);
|
|
}
|
|
const url = match[0].replace(/[.,;:!?]+$/, '');
|
|
const label = 'link';
|
|
parts.push(
|
|
<a
|
|
key={match.index}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-blue-500 hover:underline"
|
|
>
|
|
{label}
|
|
</a>
|
|
);
|
|
lastIndex = match.index + match[0].length;
|
|
// Consume trailing bracket/paren that closed around the URL
|
|
if (lastIndex < text.length && /^[\])]/.test(text[lastIndex])) lastIndex++;
|
|
}
|
|
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
|
return parts;
|
|
}
|
|
|
|
function LinkedText({ text, className }: { text: string; className?: string }) {
|
|
const lines = text.split('\n');
|
|
return (
|
|
<span className={className}>
|
|
{lines.map((line, i) => (
|
|
<span key={i}>
|
|
{renderTextWithLinks(line)}
|
|
{i < lines.length - 1 && <br />}
|
|
</span>
|
|
))}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 tz = useUserTimezone();
|
|
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 [showInternal, setShowInternal] = useState(false);
|
|
|
|
const fetchTicketDetails = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setTicket(null);
|
|
setNotes([]);
|
|
setTimeEntries([]);
|
|
setTimelineFetched(false);
|
|
setTimelineOpen(false);
|
|
setShowSystem(false);
|
|
setShowInternal(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(
|
|
() =>
|
|
timeline.filter((item) => {
|
|
if (item.kind === 'time') return true;
|
|
const n = item.data;
|
|
if (!showSystem && SYSTEM_NOTE_TYPES.has(n.noteType)) return false;
|
|
if (!showInternal && n.publish === INTERNAL_PUBLISH) return false;
|
|
return true;
|
|
}),
|
|
[timeline, showSystem, showInternal]
|
|
);
|
|
|
|
const systemCount = useMemo(
|
|
() =>
|
|
timeline.filter(
|
|
(item) => item.kind === 'note' && SYSTEM_NOTE_TYPES.has(item.data.noteType)
|
|
).length,
|
|
[timeline]
|
|
);
|
|
|
|
const internalCount = useMemo(
|
|
() =>
|
|
timeline.filter(
|
|
(item) => item.kind === 'note' && item.data.publish === INTERNAL_PUBLISH && !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', timeZone: tz,
|
|
});
|
|
};
|
|
|
|
const formatHours = (hours?: number) => {
|
|
if (!hours) return '0h';
|
|
return `${hours.toFixed(2)}h`;
|
|
};
|
|
|
|
const autotaskUrl = ticket
|
|
? `https://ww1.autotask.net/Mvc/ServiceDesk/TicketDetail.mvc?workspace=False&ids%5B0%5D=${ticket.id}&ticketId=${ticket.id}`
|
|
: null;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className="max-w-4xl w-[90vw] h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
|
|
>
|
|
{/* Sticky header */}
|
|
<div className="flex items-center gap-2 px-4 py-3 border-b shrink-0 min-w-0">
|
|
<TicketIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className="font-mono text-sm font-semibold text-blue-600 dark:text-blue-400 shrink-0">{ticketNumber}</span>
|
|
<span className="text-sm text-muted-foreground truncate flex-1 min-w-0">
|
|
{ticket?.title ?? (loading ? 'Loading…' : '')}
|
|
</span>
|
|
<div className="flex items-center gap-1 shrink-0 ml-2">
|
|
{autotaskUrl && (
|
|
<a
|
|
href={autotaskUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title="Open in Autotask"
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-sm opacity-70 hover:opacity-100 transition-opacity"
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
</a>
|
|
)}
|
|
<DialogClose className="inline-flex h-7 w-7 items-center justify-center rounded-sm opacity-70 hover:opacity-100 transition-opacity">
|
|
<X className="h-4 w-4" />
|
|
<span className="sr-only">Close</span>
|
|
</DialogClose>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scrollable body */}
|
|
<div className="flex-1 overflow-y-auto overflow-x-hidden min-w-0 p-4">
|
|
|
|
{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 min-w-0 overflow-hidden">
|
|
{/* 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 text-sm [overflow-wrap:anywhere]">
|
|
<LinkedText text={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 text-sm [overflow-wrap:anywhere]">
|
|
<LinkedText text={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 && (
|
|
<>
|
|
{/* Toggles for system / internal notes */}
|
|
{(systemCount > 0 || internalCount > 0) && (
|
|
<div className="flex items-center justify-end gap-3 mb-2">
|
|
{internalCount > 0 && (
|
|
<button
|
|
onClick={() => setShowInternal((v) => !v)}
|
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<UserCircle className="h-3 w-3" />
|
|
{showInternal ? 'Hide' : 'Show'} internal notes
|
|
<Badge variant="outline" className="text-xs px-1.5 py-0">{internalCount}</Badge>
|
|
</button>
|
|
)}
|
|
{systemCount > 0 && (
|
|
<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 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 [overflow-wrap:anywhere]">
|
|
<LinkedText text={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'
|
|
: n.noteType === EMAIL_NOTE_TYPE
|
|
? 'bg-violet-100 dark:bg-violet-900/40 text-violet-600 dark:text-violet-400'
|
|
: 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-600 dark:text-emerald-400'
|
|
}`}>
|
|
{isSystem ? <Bot className="h-3 w-3" /> : n.noteType === EMAIL_NOTE_TYPE ? <Mail 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 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 [overflow-wrap:anywhere]">
|
|
<LinkedText text={n.description} />
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|