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:
parent
e8462ef301
commit
6eee14f8af
171 changed files with 32671 additions and 621 deletions
416
components/analytics/AnalysisPanel.tsx
Normal file
416
components/analytics/AnalysisPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
533
components/analytics/ScoreCard.tsx
Normal file
533
components/analytics/ScoreCard.tsx
Normal 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" />;
|
||||
}
|
||||
335
components/analytics/TimelineView.tsx
Normal file
335
components/analytics/TimelineView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue