- Updated StatCard to use CSS variables instead of hardcoded colors - Applied typography classes (caption) - Added hover shadow transition Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
import { useSelector } from 'react-redux';
|
|
import { RootState } from '@/store/store';
|
|
import {
|
|
BarChart3,
|
|
Users,
|
|
FileText,
|
|
TrendingUp,
|
|
Clock,
|
|
AlertTriangle,
|
|
CheckCircle2,
|
|
Loader2,
|
|
Cpu,
|
|
Zap,
|
|
} from 'lucide-react';
|
|
import { Card } from '@/components/ui/card';
|
|
import { getHeader } from '@/app/(presentation-generator)/services/api/header';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface OverviewData {
|
|
total_presentations: number;
|
|
this_month: number;
|
|
this_week: number;
|
|
active_users: number;
|
|
approval_rate: number;
|
|
}
|
|
|
|
interface UsageData {
|
|
daily: { date: string; count: number }[];
|
|
top_users: { user_id: string; count: number }[];
|
|
}
|
|
|
|
interface QualityData {
|
|
status_distribution: Record<string, number>;
|
|
presentations_with_comments: number;
|
|
}
|
|
|
|
interface PerformanceData {
|
|
job_status_distribution: Record<string, number>;
|
|
avg_generation_time_seconds: number | null;
|
|
error_rate: number;
|
|
total_jobs: number;
|
|
}
|
|
|
|
interface AIUsageData {
|
|
total_calls: number;
|
|
total_input_tokens: number;
|
|
total_output_tokens: number;
|
|
total_tokens: number;
|
|
by_provider: { provider: string; calls: number; tokens: number }[];
|
|
by_model: { model: string; calls: number; tokens: number }[];
|
|
daily: { date: string; calls: number; tokens: number }[];
|
|
}
|
|
|
|
async function fetchAnalytics(endpoint: string, clientId?: string) {
|
|
const params = clientId ? `?client_id=${clientId}` : '';
|
|
const response = await fetch(`/api/v1/admin/analytics/${endpoint}${params}`, {
|
|
headers: getHeader(),
|
|
});
|
|
if (!response.ok) throw new Error(`Failed to fetch ${endpoint}`);
|
|
return response.json();
|
|
}
|
|
|
|
function StatCard({
|
|
title,
|
|
value,
|
|
icon: Icon,
|
|
subtitle,
|
|
color = 'text-[hsl(var(--primary))]',
|
|
}: {
|
|
title: string;
|
|
value: string | number;
|
|
icon: React.ElementType;
|
|
subtitle?: string;
|
|
color?: string;
|
|
}) {
|
|
return (
|
|
<Card className="p-5 hover:shadow-md transition-shadow">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<p className="caption">{title}</p>
|
|
<p className="text-2xl font-bold mt-1">{value}</p>
|
|
{subtitle && <p className="caption mt-1">{subtitle}</p>}
|
|
</div>
|
|
<div className={cn('p-3 rounded-lg bg-[hsl(var(--primary-light))]', color)}>
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function MiniBarChart({ data }: { data: { date: string; count: number }[] }) {
|
|
if (!data.length) return <p className="text-gray-400 text-sm text-center py-8">No data</p>;
|
|
const max = Math.max(...data.map((d) => d.count), 1);
|
|
const last14 = data.slice(-14);
|
|
|
|
return (
|
|
<div className="flex items-end gap-1 h-32">
|
|
{last14.map((d, i) => (
|
|
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
|
<div
|
|
className="w-full bg-[#5146E5] rounded-t"
|
|
style={{ height: `${(d.count / max) * 100}%`, minHeight: d.count > 0 ? 4 : 0 }}
|
|
/>
|
|
<span className="text-[9px] text-gray-400 truncate w-full text-center">
|
|
{d.date.slice(5)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusBar({ distribution }: { distribution: Record<string, number> }) {
|
|
const total = Object.values(distribution).reduce((a, b) => a + b, 0);
|
|
if (total === 0) return <p className="text-gray-400 text-sm text-center py-4">No data</p>;
|
|
|
|
const colors: Record<string, string> = {
|
|
draft: 'bg-yellow-400',
|
|
in_review: 'bg-blue-400',
|
|
approved: 'bg-green-400',
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex rounded-full overflow-hidden h-4 mb-3">
|
|
{Object.entries(distribution).map(([status, count]) => (
|
|
<div
|
|
key={status}
|
|
className={cn('transition-all', colors[status] || 'bg-gray-300')}
|
|
style={{ width: `${(count / total) * 100}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-4 text-xs">
|
|
{Object.entries(distribution).map(([status, count]) => (
|
|
<div key={status} className="flex items-center gap-1.5">
|
|
<div className={cn('w-2.5 h-2.5 rounded-full', colors[status] || 'bg-gray-300')} />
|
|
<span className="capitalize">{status.replace('_', ' ')}</span>
|
|
<span className="text-gray-400">({count})</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatTokens(n: number): string {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
return String(n);
|
|
}
|
|
|
|
export default function AnalyticsPage() {
|
|
const user = useSelector((state: RootState) => state.auth.user);
|
|
const [overview, setOverview] = useState<OverviewData | null>(null);
|
|
const [usage, setUsage] = useState<UsageData | null>(null);
|
|
const [quality, setQuality] = useState<QualityData | null>(null);
|
|
const [performance, setPerformance] = useState<PerformanceData | null>(null);
|
|
const [aiUsage, setAiUsage] = useState<AIUsageData | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
// Auto-filter by user's clientId
|
|
const clientId = user?.clientId || undefined;
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
loadAll();
|
|
}, [user?.clientId]);
|
|
|
|
const loadAll = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const [o, u, q, p, ai] = await Promise.all([
|
|
fetchAnalytics('overview', clientId),
|
|
fetchAnalytics('usage', clientId),
|
|
fetchAnalytics('quality', clientId),
|
|
fetchAnalytics('performance', clientId),
|
|
fetchAnalytics('ai-usage', clientId).catch(() => null),
|
|
]);
|
|
setOverview(o);
|
|
setUsage(u);
|
|
setQuality(q);
|
|
setPerformance(p);
|
|
setAiUsage(ai);
|
|
} catch (e) {
|
|
console.error('Analytics load error:', e);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading && !overview) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-semibold">Analytics</h1>
|
|
</div>
|
|
|
|
{/* Overview Cards */}
|
|
{overview && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
|
<StatCard
|
|
title="Total Presentations"
|
|
value={overview.total_presentations}
|
|
icon={FileText}
|
|
/>
|
|
<StatCard
|
|
title="This Month"
|
|
value={overview.this_month}
|
|
icon={TrendingUp}
|
|
color="text-green-600"
|
|
/>
|
|
<StatCard
|
|
title="This Week"
|
|
value={overview.this_week}
|
|
icon={BarChart3}
|
|
color="text-blue-600"
|
|
/>
|
|
<StatCard
|
|
title="Active Users"
|
|
value={overview.active_users}
|
|
icon={Users}
|
|
subtitle="Last 30 days"
|
|
/>
|
|
<StatCard
|
|
title="Approval Rate"
|
|
value={`${overview.approval_rate}%`}
|
|
icon={CheckCircle2}
|
|
color="text-green-600"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Charts Row */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<Card className="p-5">
|
|
<h3 className="text-sm font-semibold mb-4">Presentations per Day</h3>
|
|
{usage ? <MiniBarChart data={usage.daily} /> : <p className="text-gray-400">Loading...</p>}
|
|
</Card>
|
|
|
|
<Card className="p-5">
|
|
<h3 className="text-sm font-semibold mb-4">Status Distribution</h3>
|
|
{quality ? (
|
|
<StatusBar distribution={quality.status_distribution} />
|
|
) : (
|
|
<p className="text-gray-400">Loading...</p>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Performance Row */}
|
|
{performance && (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<StatCard
|
|
title="Avg Generation Time"
|
|
value={
|
|
performance.avg_generation_time_seconds
|
|
? `${Math.round(performance.avg_generation_time_seconds)}s`
|
|
: 'N/A'
|
|
}
|
|
icon={Clock}
|
|
color="text-orange-600"
|
|
/>
|
|
<StatCard
|
|
title="Total Jobs"
|
|
value={performance.total_jobs}
|
|
icon={BarChart3}
|
|
/>
|
|
<StatCard
|
|
title="Error Rate"
|
|
value={`${performance.error_rate}%`}
|
|
icon={AlertTriangle}
|
|
color={performance.error_rate > 10 ? 'text-red-600' : 'text-green-600'}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* AI Usage Section */}
|
|
{aiUsage && (
|
|
<>
|
|
<h2 className="text-lg font-semibold mt-2">AI Model Usage</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<StatCard
|
|
title="Total AI Calls"
|
|
value={aiUsage.total_calls}
|
|
icon={Cpu}
|
|
color="text-purple-600"
|
|
/>
|
|
<StatCard
|
|
title="Input Tokens"
|
|
value={formatTokens(aiUsage.total_input_tokens)}
|
|
icon={Zap}
|
|
color="text-blue-600"
|
|
/>
|
|
<StatCard
|
|
title="Output Tokens"
|
|
value={formatTokens(aiUsage.total_output_tokens)}
|
|
icon={Zap}
|
|
color="text-green-600"
|
|
/>
|
|
</div>
|
|
|
|
{aiUsage.by_provider.length > 0 && (
|
|
<Card className="p-5">
|
|
<h3 className="text-sm font-semibold mb-4">Usage by Provider</h3>
|
|
<div className="space-y-2">
|
|
{aiUsage.by_provider.map((p) => {
|
|
const maxCalls = Math.max(...aiUsage.by_provider.map((x) => x.calls), 1);
|
|
return (
|
|
<div key={p.provider} className="flex items-center gap-3">
|
|
<span className="text-sm font-medium w-24 capitalize">{p.provider}</span>
|
|
<div className="flex-1">
|
|
<div
|
|
className="h-6 bg-purple-100 rounded flex items-center px-2"
|
|
style={{ width: `${(p.calls / maxCalls) * 100}%`, minWidth: 60 }}
|
|
>
|
|
<span className="text-xs font-medium">{p.calls} calls</span>
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-gray-400 w-20 text-right">
|
|
{formatTokens(p.tokens)} tokens
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Top Users */}
|
|
{usage && usage.top_users.length > 0 && (
|
|
<Card className="p-5">
|
|
<h3 className="text-sm font-semibold mb-4">Top Users (Last 30 Days)</h3>
|
|
<div className="space-y-2">
|
|
{usage.top_users.map((u, i) => {
|
|
const maxCount = usage.top_users[0].count || 1;
|
|
return (
|
|
<div key={u.user_id} className="flex items-center gap-3">
|
|
<span className="text-xs text-gray-400 w-5">{i + 1}.</span>
|
|
<div className="flex-1">
|
|
<div
|
|
className="h-6 bg-[#5146E5]/10 rounded flex items-center px-2"
|
|
style={{ width: `${(u.count / maxCount) * 100}%`, minWidth: 60 }}
|
|
>
|
|
<span className="text-xs font-medium truncate">
|
|
{u.user_id.slice(0, 8)}...
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<span className="text-sm font-semibold text-gray-700">{u.count}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|