ppt-tool/frontend/app/admin/analytics/page.tsx
Vadym Samoilenko c5da677986 Apply design system to analytics page
- 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>
2026-02-27 18:34:56 +00:00

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>
);
}