- 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.
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Calendar, Clock, Users, ChevronDown, ChevronRight, Activity, AlertCircle, CheckCircle } from 'lucide-react';
|
|
import { TimelineEvent, TimelineView as TimelineViewType } from '@/lib/types/analytics';
|
|
import { cn } from '@/lib/utils';
|
|
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
|
|
|
interface TimelineViewProps {
|
|
events: TimelineEvent[];
|
|
timeRange: 'hour' | 'day' | 'week' | 'month';
|
|
onTimeRangeChange: (range: 'hour' | 'day' | 'week' | 'month') => void;
|
|
loading?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export function TimelineView({
|
|
events,
|
|
timeRange,
|
|
onTimeRangeChange,
|
|
loading = false,
|
|
className
|
|
}: TimelineViewProps) {
|
|
const tz = useUserTimezone();
|
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
|
const [selectedEvent, setSelectedEvent] = useState<TimelineEvent | null>(null);
|
|
|
|
// Group events by time period based on timeRange
|
|
const groupedEvents = useMemo(() => {
|
|
const groups = new Map<string, TimelineEvent[]>();
|
|
|
|
events.forEach(event => {
|
|
const eventDate = new Date(event.timestamp);
|
|
let groupKey: string;
|
|
|
|
switch (timeRange) {
|
|
case 'hour':
|
|
groupKey = eventDate.toISOString().substring(0, 13); // YYYY-MM-DDTHH
|
|
break;
|
|
case 'day':
|
|
groupKey = eventDate.toISOString().substring(0, 10); // YYYY-MM-DD
|
|
break;
|
|
case 'week':
|
|
const weekStart = new Date(eventDate);
|
|
weekStart.setDate(eventDate.getDate() - eventDate.getDay());
|
|
groupKey = `Week of ${weekStart.toISOString().substring(0, 10)}`;
|
|
break;
|
|
case 'month':
|
|
groupKey = eventDate.toISOString().substring(0, 7); // YYYY-MM
|
|
break;
|
|
default:
|
|
groupKey = eventDate.toISOString().substring(0, 10);
|
|
}
|
|
|
|
if (!groups.has(groupKey)) {
|
|
groups.set(groupKey, []);
|
|
}
|
|
groups.get(groupKey)!.push(event);
|
|
});
|
|
|
|
// Sort events within each group by timestamp
|
|
groups.forEach(group => {
|
|
group.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
});
|
|
|
|
return groups;
|
|
}, [events, timeRange]);
|
|
|
|
// Calculate summary statistics
|
|
const summary = useMemo(() => {
|
|
const humanActivities = events.filter(e => e.isHumanActivity).length;
|
|
const systemActivities = events.filter(e => !e.isHumanActivity).length;
|
|
const totalHours = events.reduce((sum, e) => sum + (e.duration || 0), 0);
|
|
const averageScore = events.length > 0
|
|
? events.reduce((sum, e) => sum + (e.score || 0), 0) / events.length
|
|
: 0;
|
|
|
|
return {
|
|
totalEvents: events.length,
|
|
humanActivities,
|
|
systemActivities,
|
|
totalHours,
|
|
averageScore,
|
|
};
|
|
}, [events]);
|
|
|
|
const toggleSection = (sectionKey: string) => {
|
|
const newExpanded = new Set(expandedSections);
|
|
if (newExpanded.has(sectionKey)) {
|
|
newExpanded.delete(sectionKey);
|
|
} else {
|
|
newExpanded.add(sectionKey);
|
|
}
|
|
setExpandedSections(newExpanded);
|
|
};
|
|
|
|
const getEventIcon = (event: TimelineEvent) => {
|
|
switch (event.type) {
|
|
case 'key_moment':
|
|
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
|
case 'milestone':
|
|
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
|
case 'time_entry':
|
|
default:
|
|
if (event.isHumanActivity) {
|
|
return <Users className="h-4 w-4 text-blue-500" />;
|
|
} else {
|
|
return <Activity className="h-4 w-4 text-gray-500" />;
|
|
}
|
|
}
|
|
};
|
|
|
|
const getImportanceColor = (importance: string) => {
|
|
switch (importance) {
|
|
case 'critical':
|
|
return 'bg-red-100 text-red-800 border-red-200';
|
|
case 'high':
|
|
return 'bg-orange-100 text-orange-800 border-orange-200';
|
|
case 'medium':
|
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
|
case 'low':
|
|
default:
|
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
|
}
|
|
};
|
|
|
|
const formatGroupTitle = (groupKey: string) => {
|
|
switch (timeRange) {
|
|
case 'hour':
|
|
return new Date(groupKey + ':00:00').toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
hour12: true,
|
|
timeZone: tz,
|
|
});
|
|
case 'day':
|
|
return new Date(groupKey).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
timeZone: tz,
|
|
});
|
|
case 'week':
|
|
return groupKey;
|
|
case 'month':
|
|
return new Date(groupKey + '-01').toLocaleDateString('en-US', {
|
|
month: 'long',
|
|
year: 'numeric',
|
|
timeZone: tz,
|
|
});
|
|
default:
|
|
return groupKey;
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card className={className}>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Clock className="h-5 w-5" />
|
|
Timeline View
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card className={className}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Clock className="h-5 w-5" />
|
|
Timeline View
|
|
</CardTitle>
|
|
|
|
<Select value={timeRange} onValueChange={onTimeRangeChange}>
|
|
<SelectTrigger className="w-32">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="hour">Hour</SelectItem>
|
|
<SelectItem value="day">Day</SelectItem>
|
|
<SelectItem value="week">Week</SelectItem>
|
|
<SelectItem value="month">Month</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Summary Statistics */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mt-4">
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-blue-600">{summary.totalEvents}</div>
|
|
<div className="text-sm text-gray-600">Total Events</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-green-600">{summary.humanActivities}</div>
|
|
<div className="text-sm text-gray-600">Human Activities</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-gray-600">{summary.systemActivities}</div>
|
|
<div className="text-sm text-gray-600">System Activities</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-purple-600">{Number(summary.totalHours).toFixed(1)}</div>
|
|
<div className="text-sm text-gray-600">Total Hours</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-orange-600">
|
|
{(summary.averageScore * 100).toFixed(0)}%
|
|
</div>
|
|
<div className="text-sm text-gray-600">Avg Score</div>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-4">
|
|
{groupedEvents.size === 0 ? (
|
|
<div className="text-center py-8 text-gray-500">
|
|
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
<p>No events found for the selected time range</p>
|
|
</div>
|
|
) : (
|
|
Array.from(groupedEvents.entries())
|
|
.sort(([a], [b]) => b.localeCompare(a)) // Sort by date descending
|
|
.map(([groupKey, groupEvents]) => (
|
|
<Collapsible
|
|
key={groupKey}
|
|
open={expandedSections.has(groupKey)}
|
|
onOpenChange={() => toggleSection(groupKey)}
|
|
>
|
|
<CollapsibleTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full justify-between p-4 h-auto hover:bg-gray-50"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{expandedSections.has(groupKey) ? (
|
|
<ChevronDown className="h-4 w-4" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4" />
|
|
)}
|
|
<span className="font-medium">{formatGroupTitle(groupKey)}</span>
|
|
<Badge variant="secondary">{groupEvents.length} events</Badge>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
|
<span>
|
|
{groupEvents.filter(e => e.isHumanActivity).length} human
|
|
</span>
|
|
<span>•</span>
|
|
<span>
|
|
{groupEvents.reduce((sum, e) => sum + (Number(e.duration) || 0), 0).toFixed(1)}h
|
|
</span>
|
|
</div>
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
|
|
<CollapsibleContent className="space-y-2 px-4 pb-4">
|
|
{groupEvents.map((event) => (
|
|
<div
|
|
key={event.id}
|
|
className={cn(
|
|
"flex items-start gap-3 p-3 rounded-lg border transition-colors cursor-pointer",
|
|
selectedEvent?.id === event.id
|
|
? "border-blue-300 bg-blue-50"
|
|
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50",
|
|
!event.isHumanActivity && "opacity-60"
|
|
)}
|
|
onClick={() => setSelectedEvent(event)}
|
|
>
|
|
<div className="mt-0.5">
|
|
{getEventIcon(event)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h4 className="font-medium truncate">{event.title}</h4>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn("text-xs", getImportanceColor(event.importance))}
|
|
>
|
|
{event.importance}
|
|
</Badge>
|
|
</div>
|
|
|
|
{event.description && (
|
|
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
|
|
{event.description}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-center gap-4 text-xs text-gray-500">
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{new Date(event.timestamp).toLocaleTimeString(undefined, { timeZone: tz })}
|
|
</span>
|
|
|
|
{event.duration && (
|
|
<span className="flex items-center gap-1">
|
|
<Activity className="h-3 w-3" />
|
|
{event.duration}h
|
|
</span>
|
|
)}
|
|
|
|
{event.score && (
|
|
<span className="flex items-center gap-1">
|
|
<div className="w-8 h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-green-500"
|
|
style={{ width: `${event.score * 100}%` }}
|
|
/>
|
|
</div>
|
|
{(event.score * 100).toFixed(0)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
))
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|