Add comprehensive admin features and multi-system integration

- Add admin dashboard with sync controls and data browser
- Implement RMM, Auvik, and Addigy organization mappings
- Add chunked ticket sync with progress tracking
- Implement entity sync service with rate limiting
- Add analytics engine and performance optimizer
- Create data browser for all PSA entities
- Add navigation components and UI improvements
- Implement background processing and sync services
- Add comprehensive documentation and migration scripts
- Update configuration items with multi-system support
- Enhance contact management and purchase history
- Add issue type assignment and LLM analyzer
- Improve error handling and logging utilities
This commit is contained in:
root 2025-11-19 14:18:16 -05:00
parent e8462ef301
commit 6eee14f8af
171 changed files with 32671 additions and 621 deletions

View file

@ -0,0 +1,416 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
Brain,
Lightbulb,
TrendingUp,
AlertTriangle,
CheckCircle,
Info,
RefreshCw,
Download,
Filter,
Calendar,
Target,
Activity
} from 'lucide-react';
import { AnalyticsInsight, LLMAnalysisResponse } from '@/lib/types/analytics';
import { cn } from '@/lib/utils';
interface AnalysisPanelProps {
insights: AnalyticsInsight[];
llmAnalysis?: LLMAnalysisResponse;
loading?: boolean;
onRefresh?: () => void;
onExport?: () => void;
className?: string;
}
export function AnalysisPanel({
insights,
llmAnalysis,
loading = false,
onRefresh,
onExport,
className
}: AnalysisPanelProps) {
const [activeTab, setActiveTab] = useState('insights');
const [filter, setFilter] = useState<'all' | 'warnings' | 'recommendations' | 'success'>('all');
// Filter insights based on selected filter
const filteredInsights = insights.filter(insight => {
switch (filter) {
case 'warnings':
return insight.type === 'warning' || insight.type === 'error';
case 'recommendations':
return insight.actionable === true && insight.recommendation;
case 'success':
return insight.type === 'success';
default:
return true;
}
});
// Group insights by category
const insightsByCategory = filteredInsights.reduce((groups, insight) => {
if (!groups[insight.category]) {
groups[insight.category] = [];
}
groups[insight.category].push(insight);
return groups;
}, {} as Record<string, AnalyticsInsight[]>);
const getInsightIcon = (type: string) => {
switch (type) {
case 'success':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'warning':
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
case 'error':
return <AlertTriangle className="h-4 w-4 text-red-500" />;
case 'info':
default:
return <Info className="h-4 w-4 text-blue-500" />;
}
};
const getInsightColor = (type: string) => {
switch (type) {
case 'success':
return 'border-green-200 bg-green-50';
case 'warning':
return 'border-yellow-200 bg-yellow-50';
case 'error':
return 'border-red-200 bg-red-50';
case 'info':
default:
return 'border-blue-200 bg-blue-50';
}
};
const getSeverityColor = (severity?: string) => {
switch (severity) {
case 'high':
return 'bg-red-100 text-red-800';
case 'medium':
return 'bg-yellow-100 text-yellow-800';
case 'low':
default:
return 'bg-gray-100 text-gray-800';
}
};
const getCategoryIcon = (category: string) => {
switch (category) {
case 'activity':
return <Activity className="h-4 w-4 text-blue-500" />;
case 'content':
return <Target className="h-4 w-4 text-green-500" />;
case 'timeliness':
return <Calendar className="h-4 w-4 text-orange-500" />;
case 'patterns':
return <TrendingUp className="h-4 w-4 text-purple-500" />;
case 'recommendations':
return <Lightbulb className="h-4 w-4 text-yellow-500" />;
default:
return <Info className="h-4 w-4 text-gray-500" />;
}
};
if (loading) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Brain className="h-5 w-5" />
AI Analysis
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="h-8 w-8 animate-spin text-blue-600" />
<p className="text-sm text-gray-600">Analyzing time entries...</p>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Brain className="h-5 w-5" />
AI Analysis & Insights
</CardTitle>
<div className="flex items-center gap-2">
{onRefresh && (
<Button variant="outline" size="sm" onClick={onRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
)}
{onExport && (
<Button variant="outline" size="sm" onClick={onExport}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
)}
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{insights.length}</div>
<div className="text-sm text-gray-600">Total Insights</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{insights.filter(i => i.type === 'success').length}
</div>
<div className="text-sm text-gray-600">Positive</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-600">
{insights.filter(i => i.type === 'warning').length}
</div>
<div className="text-sm text-gray-600">Warnings</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">
{insights.filter(i => i.type === 'error').length}
</div>
<div className="text-sm text-gray-600">Issues</div>
</div>
</div>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<div className="flex items-center justify-between mb-4">
<TabsList>
<TabsTrigger value="insights">Insights</TabsTrigger>
<TabsTrigger value="patterns">Patterns</TabsTrigger>
<TabsTrigger value="recommendations">Recommendations</TabsTrigger>
{llmAnalysis && <TabsTrigger value="llm">AI Analysis</TabsTrigger>}
</TabsList>
{/* Filter */}
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-500" />
<select
value={filter}
onChange={(e) => setFilter(e.target.value as any)}
className="text-sm border rounded px-2 py-1"
>
<option value="all">All</option>
<option value="warnings">Warnings</option>
<option value="recommendations">Recommendations</option>
<option value="success">Success</option>
</select>
</div>
</div>
<TabsContent value="insights" className="space-y-4">
{filteredInsights.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Info className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No insights found for the selected filter</p>
</div>
) : (
<ScrollArea className="h-96">
<div className="space-y-4">
{Object.entries(insightsByCategory).map(([category, categoryInsights]) => (
<div key={category} className="space-y-3">
<div className="flex items-center gap-2">
{getCategoryIcon(category)}
<h3 className="font-medium capitalize">{category}</h3>
<Badge variant="secondary">{categoryInsights.length}</Badge>
</div>
<div className="space-y-2 pl-6">
{categoryInsights.map((insight, index) => (
<div
key={index}
className={cn(
"p-4 rounded-lg border",
getInsightColor(insight.type)
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{getInsightIcon(insight.type)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-medium">{insight.title}</h4>
{insight.severity && (
<Badge variant="outline" className={getSeverityColor(insight.severity)}>
{insight.severity}
</Badge>
)}
{insight.actionable && (
<Badge variant="outline" className="bg-blue-100 text-blue-800">
Actionable
</Badge>
)}
</div>
<p className="text-sm text-gray-700 mb-2">
{insight.description}
</p>
{insight.recommendation && (
<div className="bg-white bg-opacity-50 p-3 rounded border border-gray-200">
<p className="text-sm font-medium text-gray-800 mb-1">
Recommendation:
</p>
<p className="text-sm text-gray-700">
{insight.recommendation}
</p>
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</ScrollArea>
)}
</TabsContent>
<TabsContent value="patterns" className="space-y-4">
{llmAnalysis?.patterns && llmAnalysis.patterns.length > 0 ? (
<ScrollArea className="h-96">
<div className="space-y-3">
{llmAnalysis.patterns.map((pattern, index) => (
<div key={index} className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{pattern.type}</h4>
<div className="flex items-center gap-2">
<Badge variant="outline">
{pattern.frequency} occurrences
</Badge>
<Badge
variant="outline"
className={getSeverityColor(pattern.impact)}
>
{pattern.impact} impact
</Badge>
</div>
</div>
<p className="text-sm text-gray-700">{pattern.description}</p>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="text-center py-8 text-gray-500">
<TrendingUp className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No patterns detected yet</p>
<p className="text-sm">AI analysis will identify recurring work patterns</p>
</div>
)}
</TabsContent>
<TabsContent value="recommendations" className="space-y-4">
{llmAnalysis?.recommendations && llmAnalysis.recommendations.length > 0 ? (
<ScrollArea className="h-96">
<div className="space-y-3">
{llmAnalysis.recommendations.map((rec, index) => (
<div key={index} className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{rec.category}</h4>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={getSeverityColor(rec.priority)}
>
{rec.priority} priority
</Badge>
</div>
</div>
<p className="text-sm text-gray-700 mb-2">{rec.action}</p>
<div className="text-xs text-gray-600 bg-gray-50 p-2 rounded">
<strong>Expected Impact:</strong> {rec.expectedImpact}
</div>
</div>
))}
</div>
</ScrollArea>
) : (
<div className="text-center py-8 text-gray-500">
<Lightbulb className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No recommendations available yet</p>
<p className="text-sm">AI will provide actionable recommendations based on analysis</p>
</div>
)}
</TabsContent>
{llmAnalysis && (
<TabsContent value="llm" className="space-y-4">
<div className="space-y-6">
{/* Summary */}
<div className="p-4 bg-gray-50 rounded-lg">
<h4 className="font-medium mb-3">AI Summary</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-600">Overall Quality</div>
<div className="text-2xl font-bold text-blue-600">
{Math.round(llmAnalysis.summary.overallQuality * 100)}%
</div>
</div>
<div>
<div className="text-sm text-gray-600">Productivity Level</div>
<div className="text-2xl font-bold text-green-600">
{Math.round(llmAnalysis.summary.productivityLevel * 100)}%
</div>
</div>
</div>
{llmAnalysis.summary.keyFindings.length > 0 && (
<div className="mt-4">
<h5 className="font-medium text-sm mb-2">Key Findings</h5>
<ul className="text-sm text-gray-700 space-y-1">
{llmAnalysis.summary.keyFindings.map((finding, index) => (
<li key={index} className="flex items-start gap-2">
<span className="text-blue-500 mt-1"></span>
{finding}
</li>
))}
</ul>
</div>
)}
</div>
{/* Processing Info */}
<div className="text-xs text-gray-500 border-t pt-4">
<div className="flex justify-between">
<span>Processing time: {llmAnalysis.processingTime}ms</span>
<span>Tokens used: {llmAnalysis.tokensUsed}</span>
</div>
</div>
</div>
</TabsContent>
)}
</Tabs>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,533 @@
'use client';
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Activity,
FileText,
Clock,
TrendingUp,
TrendingDown,
Minus,
Info,
CheckCircle,
AlertTriangle,
XCircle
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
ActivityScore,
ContentScore,
TimelinessScore,
TimeEntryAnalysis,
AggregateAnalysis
} from '@/lib/types/analytics';
interface ScoreCardProps {
title: string;
score: number;
description?: string;
trend?: 'up' | 'down' | 'neutral';
trendValue?: number;
icon?: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function ScoreCard({
title,
score,
description,
trend,
trendValue,
icon,
size = 'md',
className
}: ScoreCardProps) {
const percentage = Math.round(score * 100);
const scoreColor = getScoreColor(score);
const scoreLabel = getScoreLabel(score);
const sizeClasses = {
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
const titleSizeClasses = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
};
const scoreSizeClasses = {
sm: 'text-2xl',
md: 'text-3xl',
lg: 'text-4xl',
};
return (
<Card className={cn(sizeClasses[size], className)}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className={cn("flex items-center gap-2", titleSizeClasses[size])}>
{icon}
{title}
</CardTitle>
{trend && (
<div className="flex items-center gap-1">
{trend === 'up' && <TrendingUp className="h-4 w-4 text-green-500" />}
{trend === 'down' && <TrendingDown className="h-4 w-4 text-red-500" />}
{trend === 'neutral' && <Minus className="h-4 w-4 text-gray-500" />}
{(trendValue !== undefined) && (
<span className={cn(
"text-sm font-medium",
trendValue > 0 ? "text-green-600" : trendValue < 0 ? "text-red-600" : "text-gray-600"
)}>
{trendValue > 0 ? '+' : ''}{trendValue}%
</span>
)}
</div>
)}
</div>
{description && (
<p className="text-sm text-gray-600">{description}</p>
)}
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Score Display */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn(scoreSizeClasses[size], "font-bold", scoreColor.text)}>
{percentage}%
</span>
<Badge variant="outline" className={scoreColor.badge}>
{scoreLabel}
</Badge>
</div>
<div className="flex items-center gap-1">
{getScoreIcon(score)}
<span className="text-sm text-gray-500">{scoreLabel}</span>
</div>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<Progress
value={percentage}
className="h-2"
/>
<div className="flex justify-between text-xs text-gray-500">
<span>Poor</span>
<span>Average</span>
<span>Excellent</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
interface ActivityScoreCardProps {
title: string;
score: ActivityScore;
icon?: React.ReactNode;
className?: string;
}
export function ActivityScoreCard({ title, score, icon, className }: ActivityScoreCardProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{icon || <Activity className="h-5 w-5 text-blue-500" />}
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Overall Score */}
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">
{Math.round(score.score * 100)}%
</div>
<div className="text-sm text-gray-600">Activity Score</div>
</div>
{/* Breakdown */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Score Breakdown</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Completeness</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.completeness * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.completeness * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Consistency</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.consistency * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.consistency * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Duration</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.duration * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.duration * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Categorization</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.categorization * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.categorization * 100)}%
</span>
</div>
</div>
</div>
</div>
{/* Positive Factors */}
{score.factors.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-sm flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Positive Factors
</h4>
<div className="flex flex-wrap gap-1">
{score.factors.map((factor: string, index: number) => (
<Badge key={index} variant="secondary" className="text-xs">
{factor}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface ContentScoreCardProps {
title: string;
score: ContentScore;
icon?: React.ReactNode;
className?: string;
}
export function ContentScoreCard({ title, score, icon, className }: ContentScoreCardProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{icon || <FileText className="h-5 w-5 text-green-500" />}
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Overall Score */}
<div className="text-center">
<div className="text-3xl font-bold text-green-600">
{Math.round(score.score * 100)}%
</div>
<div className="text-sm text-gray-600">Content Score</div>
</div>
{/* Breakdown */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Score Breakdown</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Notes Quality</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.notesQuality * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.notesQuality * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Title Clarity</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.titleClarity * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.titleClarity * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Internal Notes</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.internalNotes * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.internalNotes * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Technical Detail</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.technicalDetail * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.technicalDetail * 100)}%
</span>
</div>
</div>
</div>
</div>
{/* Positive Factors */}
{score.factors.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-sm flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Positive Factors
</h4>
<div className="flex flex-wrap gap-1">
{score.factors.map((factor: string, index: number) => (
<Badge key={index} variant="secondary" className="text-xs">
{factor}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface TimelinessScoreCardProps {
title: string;
score: TimelinessScore;
icon?: React.ReactNode;
className?: string;
}
export function TimelinessScoreCard({ title, score, icon, className }: TimelinessScoreCardProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{icon || <Clock className="h-5 w-5 text-orange-500" />}
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Overall Score */}
<div className="text-center">
<div className="text-3xl font-bold text-orange-600">
{Math.round(score.score * 100)}%
</div>
<div className="text-sm text-gray-600">Timeliness Score</div>
</div>
{/* Breakdown */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Score Breakdown</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Entry Delay</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.entryDelay * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.entryDelay * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Business Hours</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.businessHours * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.businessHours * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Regularity</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.regularity * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.regularity * 100)}%
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Approval Time</span>
<div className="flex items-center gap-2">
<Progress value={score.breakdown.approvalTimeliness * 100} className="w-20 h-2" />
<span className="text-sm font-medium w-10 text-right">
{Math.round(score.breakdown.approvalTimeliness * 100)}%
</span>
</div>
</div>
</div>
</div>
{/* Positive Factors */}
{score.factors.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-sm flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Positive Factors
</h4>
<div className="flex flex-wrap gap-1">
{score.factors.map((factor: string, index: number) => (
<Badge key={index} variant="secondary" className="text-xs">
{factor}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
interface AggregateScoreCardProps {
analysis: AggregateAnalysis;
className?: string;
}
export function AggregateScoreCard({ analysis, className }: AggregateScoreCardProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-purple-500" />
Overall Performance
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Overall Score */}
<div className="text-center">
<div className="text-4xl font-bold text-purple-600">
{Math.round(analysis.scores.overall * 100)}%
</div>
<div className="text-sm text-gray-600">Overall Score</div>
</div>
{/* Individual Scores */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{Math.round(analysis.scores.activity * 100)}%
</div>
<div className="text-sm text-gray-600">Activity</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{Math.round(analysis.scores.content * 100)}%
</div>
<div className="text-sm text-gray-600">Content</div>
</div>
<div className="text-center p-3 bg-orange-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
{Math.round(analysis.scores.timeliness * 100)}%
</div>
<div className="text-sm text-gray-600">Timeliness</div>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Total Entries:</span>
<span className="font-medium">{analysis.totalEntries}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Total Hours:</span>
<span className="font-medium">{Number(analysis.totalHours).toFixed(1)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Avg Hours/Entry:</span>
<span className="font-medium">{Number(analysis.averageHoursPerEntry).toFixed(1)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Date Range:</span>
<span className="font-medium">
{analysis.dateRange.latest.toLocaleDateString()}
</span>
</div>
</div>
</CardContent>
</Card>
);
}
// Helper functions
function getScoreColor(score: number) {
if (score >= 0.8) {
return {
text: 'text-green-600',
badge: 'bg-green-100 text-green-800 border-green-200',
progress: 'bg-green-500',
};
}
if (score >= 0.6) {
return {
text: 'text-yellow-600',
badge: 'bg-yellow-100 text-yellow-800 border-yellow-200',
progress: 'bg-yellow-500',
};
}
return {
text: 'text-red-600',
badge: 'bg-red-100 text-red-800 border-red-200',
progress: 'bg-red-500',
};
}
function getScoreLabel(score: number) {
if (score >= 0.8) return 'Excellent';
if (score >= 0.6) return 'Good';
if (score >= 0.4) return 'Average';
return 'Poor';
}
function getScoreIcon(score: number) {
if (score >= 0.8) {
return <CheckCircle className="h-4 w-4 text-green-500" />;
}
if (score >= 0.6) {
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
}
return <XCircle className="h-4 w-4 text-red-500" />;
}

View file

@ -0,0 +1,335 @@
'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';
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 [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,
});
case 'day':
return new Date(groupKey).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
});
case 'week':
return groupKey;
case 'month':
return new Date(groupKey + '-01').toLocaleDateString('en-US', {
month: 'long',
year: 'numeric',
});
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()}
</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>
);
}