edtech / apps /admin /src /pages /AnalyticsPage.tsx
CognxSafeTrack
feat(i18n): complete admin app internationalization across all pages
d80fec4
import { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell
} from 'recharts';
import {
TrendingUp, Users, MessageSquare, BrainCircuit,
ChevronRight, Award, Clock, Search, Loader2, Terminal
} from 'lucide-react';
import { useAuth } from '../lib/auth';
import { useTenant } from '../lib/tenant';
import { api } from '../lib/api';
import { logError } from '../lib/logger';
const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444'];
export default function AnalyticsPage() {
const { token } = useAuth();
const { t } = useTranslation();
const { selectedOrgId } = useTenant();
const [usage, setUsage] = useState<any>(null);
const [pedagogy, setPedagogy] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [sqlQuestion, setSqlQuestion] = useState('');
const [sqlLoading, setSqlLoading] = useState(false);
const [sqlResult, setSqlResult] = useState<{ rows: Record<string, unknown>[]; sql: string; count: number } | null>(null);
const [sqlError, setSqlError] = useState<string | null>(null);
const sqlInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!selectedOrgId || !token) return;
const fetchData = async () => {
setLoading(true);
try {
const [usageData, pedagogyData] = await Promise.all([
api.get('/v1/analytics/usage', token, selectedOrgId),
api.get('/v1/analytics/pedagogy', token, selectedOrgId)
]);
setUsage(usageData);
setPedagogy(pedagogyData);
} catch (err) {
logError('Analytics fetch error:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, [selectedOrgId, token]);
if (!selectedOrgId) {
return (
<div className="p-12 text-center text-gray-400">
<Building2 className="w-12 h-12 mx-auto mb-4 opacity-20" />
{t('dashboard.select_org_hint')}
</div>
);
}
if (loading) return <div className="p-12 animate-pulse text-gray-400">{t('common.loading')}</div>;
const handleSqlQuery = async (e: React.FormEvent) => {
e.preventDefault();
if (!sqlQuestion.trim() || !selectedOrgId || !token) return;
setSqlLoading(true);
setSqlResult(null);
setSqlError(null);
try {
const data = await api.post('/v1/analytics/query', { question: sqlQuestion, language: 'FR' }, token, selectedOrgId);
setSqlResult(data);
} catch (err: any) {
setSqlError(err?.message ?? t('analytics.sql_error'));
} finally {
setSqlLoading(false);
}
};
const EXAMPLE_QUESTIONS = [
t('analytics.sql_example_1'),
t('analytics.sql_example_2'),
t('analytics.sql_example_3'),
t('analytics.sql_example_4'),
];
const messageData = [
{ name: t('analytics.messages.inbound'), value: usage?.messages?.inbound || 0 },
{ name: t('analytics.messages.outbound'), value: usage?.messages?.outbound || 0 },
];
const completionData = [
{ name: t('analytics.completion.completed'), value: pedagogy?.completion?.completed || 0 },
{ name: t('analytics.completion.in_progress'), value: pedagogy?.completion?.active || 0 },
];
return (
<div className="p-8 max-w-7xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900">{t('dashboard.title')}</h1>
<p className="text-slate-500">{t('dashboard.subtitle')}</p>
</div>
<div className="bg-white px-4 py-2 rounded-2xl border border-slate-100 flex items-center gap-3 shadow-sm">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-xs font-bold text-slate-600 uppercase tracking-wider">Live System</span>
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
title={t('dashboard.stats.total_messages')}
value={usage?.messages?.total || 0}
icon={<MessageSquare className="w-5 h-5" />}
trend={null}
color="text-indigo-600"
bg="bg-indigo-50"
/>
<StatCard
title={t('dashboard.stats.active_users')}
value={usage?.users?.activeLast24h || 0}
icon={<Users className="w-5 h-5" />}
trend={null}
color="text-emerald-600"
bg="bg-emerald-50"
/>
<StatCard
title={t('dashboard.stats.completion_rate')}
value={`${Math.round(pedagogy?.completion?.rate || 0)}%`}
icon={<TrendingUp className="w-5 h-5" />}
trend={null}
color="text-amber-600"
bg="bg-amber-50"
/>
<StatCard
title={t('dashboard.stats.ai_cost')}
value={`$${(usage?.costs?.totalUsd ?? 0).toFixed(4)}`}
icon={<BrainCircuit className="w-5 h-5" />}
trend={`${((usage?.costs?.totalTokens ?? 0) / 1000).toFixed(1)}k tokens`}
color="text-purple-600"
bg="bg-purple-50"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Messages Chart */}
<div className="bg-white p-8 rounded-3xl border border-slate-100 shadow-sm">
<h3 className="text-lg font-bold text-slate-800 mb-6 flex items-center gap-2">
{t('analytics.messages.title')}
</h3>
<div className="h-64 min-h-[256px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={messageData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<YAxis axisLine={false} tickLine={false} tick={{fill: '#94a3b8', fontSize: 12}} />
<Tooltip
contentStyle={{borderRadius: '16px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'}}
/>
<Bar dataKey="value" fill="#6366f1" radius={[8, 8, 0, 0]} barSize={40} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Completion Pie */}
<div className="bg-white p-8 rounded-3xl border border-slate-100 shadow-sm">
<h3 className="text-lg font-bold text-slate-800 mb-6">{t('analytics.completion.title')}</h3>
<div className="h-64 min-h-[256px] w-full flex items-center">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={completionData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{completionData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="space-y-4 pr-8">
{completionData.map((d, i) => (
<div key={d.name} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{backgroundColor: COLORS[i]}} />
<span className="text-xs text-slate-500 font-medium">{d.name}: {d.value}</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* Detailed Pedagogy */}
<div className="bg-slate-900 text-white rounded-3xl p-10 overflow-hidden relative">
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-500/10 blur-3xl rounded-full -mr-20 -mt-20" />
<div className="relative z-10 grid grid-cols-1 md:grid-cols-3 gap-12">
<div>
<div className="flex items-center gap-3 text-indigo-300 mb-4 font-bold uppercase tracking-widest text-[10px]">
<Award className="w-4 h-4" /> {t('analytics.performance.title')}
</div>
<div className="text-4xl font-bold mb-1">{pedagogy?.performance?.averageScore?.toFixed(1) || 0}</div>
<div className="text-sm text-slate-400">{t('analytics.performance.avg_score')}</div>
</div>
<div>
<div className="flex items-center gap-3 text-indigo-300 mb-4 font-bold uppercase tracking-widest text-[10px]">
<Clock className="w-4 h-4" /> {t('analytics.engagement.title')}
</div>
<div className="text-4xl font-bold mb-1">{pedagogy?.performance?.averageProgressDays?.toFixed(1) || 0}</div>
<div className="text-sm text-slate-400">{t('analytics.engagement.avg_days')}</div>
</div>
<div className="flex items-end justify-end">
<button className="bg-white/10 hover:bg-white/20 transition px-6 py-3 rounded-xl text-sm font-bold flex items-center gap-2">
{t('analytics.export')} <ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* ── AI Cost Breakdown ─────────────────────────────────────────── */}
{usage?.costs?.byFeature?.length > 0 && (
<div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-8">
<div className="flex items-center gap-3 mb-6">
<div className="p-2.5 bg-purple-50 rounded-xl">
<BrainCircuit className="w-5 h-5 text-purple-600" />
</div>
<div>
<h2 className="font-bold text-slate-800">{t('analytics.ai_cost_title')}</h2>
<p className="text-xs text-slate-400">{t('analytics.ai_cost_subtitle')}</p>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-slate-400 uppercase tracking-wider border-b border-slate-100">
<th className="pb-3 font-semibold">{t('analytics.col_feature')}</th>
<th className="pb-3 font-semibold text-right">{t('analytics.col_calls')}</th>
<th className="pb-3 font-semibold text-right">{t('analytics.col_tokens_in')}</th>
<th className="pb-3 font-semibold text-right">{t('analytics.col_tokens_out')}</th>
<th className="pb-3 font-semibold text-right">{t('analytics.col_cost')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{(usage.costs.byFeature as Array<{ feature: string; calls: number; tokensIn: number; tokensOut: number; costUsd: number }>)
.sort((a, b) => b.costUsd - a.costUsd)
.map((row) => (
<tr key={row.feature} className="hover:bg-slate-50 transition">
<td className="py-3 font-medium text-slate-700">
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded-full text-[11px] font-mono">{row.feature}</span>
</td>
<td className="py-3 text-right text-slate-600">{row.calls.toLocaleString()}</td>
<td className="py-3 text-right text-slate-500">{row.tokensIn.toLocaleString()}</td>
<td className="py-3 text-right text-slate-500">{row.tokensOut.toLocaleString()}</td>
<td className="py-3 text-right font-bold text-slate-800">${row.costUsd.toFixed(4)}</td>
</tr>
))}
</tbody>
<tfoot className="border-t-2 border-slate-200">
<tr>
<td className="pt-3 font-bold text-slate-800" colSpan={4}>{t('analytics.total')}</td>
<td className="pt-3 text-right font-bold text-purple-700">${(usage.costs.totalUsd as number).toFixed(4)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{/* ── Text-to-SQL Search ─────────────────────────────────────────── */}
<div className="bg-white rounded-3xl border border-slate-100 shadow-sm p-8">
<div className="flex items-center gap-3 mb-6">
<div className="p-2.5 bg-violet-50 rounded-xl">
<Search className="w-5 h-5 text-violet-600" />
</div>
<div>
<h2 className="font-bold text-slate-800">{t('analytics.nl_search_title')}</h2>
<p className="text-xs text-slate-400">{t('analytics.nl_search_subtitle')}</p>
</div>
</div>
{/* Example questions */}
<div className="flex flex-wrap gap-2 mb-4">
{EXAMPLE_QUESTIONS.map(q => (
<button
key={q}
onClick={() => { setSqlQuestion(q); sqlInputRef.current?.focus(); }}
className="text-[11px] px-3 py-1.5 bg-slate-50 hover:bg-violet-50 hover:text-violet-700 text-slate-500 rounded-full border border-slate-200 hover:border-violet-200 transition"
>
{q}
</button>
))}
</div>
<form onSubmit={handleSqlQuery} className="flex gap-3">
<input
ref={sqlInputRef}
type="text"
value={sqlQuestion}
onChange={e => setSqlQuestion(e.target.value)}
placeholder={t('analytics.nl_search_placeholder')}
className="flex-1 px-4 py-3 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-violet-300 bg-slate-50"
/>
<button
type="submit"
disabled={sqlLoading || !sqlQuestion.trim()}
className="px-5 py-3 bg-violet-600 hover:bg-violet-700 disabled:opacity-40 text-white text-sm font-bold rounded-xl flex items-center gap-2 transition"
>
{sqlLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
{t('analytics.search_btn')}
</button>
</form>
{/* Error */}
{sqlError && (
<div className="mt-4 p-4 bg-red-50 border border-red-100 rounded-xl text-sm text-red-600">
{sqlError}
</div>
)}
{/* Results */}
{sqlResult && (
<div className="mt-5">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-slate-500">{sqlResult.count} résultat{sqlResult.count > 1 ? 's' : ''}</span>
<button
onClick={() => setSqlResult(r => r ? { ...r, _showSql: !((r as any)._showSql) } as any : r)}
className="flex items-center gap-1.5 text-[11px] text-slate-400 hover:text-slate-600 transition"
>
<Terminal className="w-3.5 h-3.5" /> {t('analytics.view_sql')}
</button>
</div>
{(sqlResult as any)._showSql && (
<pre className="mb-4 p-3 bg-slate-900 text-emerald-400 text-[11px] rounded-xl overflow-x-auto">
{sqlResult.sql}
</pre>
)}
{sqlResult.rows.length > 0 ? (
<div className="overflow-x-auto rounded-xl border border-slate-100">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs text-slate-500 uppercase">
<tr>
{Object.keys(sqlResult.rows[0]).map(col => (
<th key={col} className="px-4 py-3 text-left font-bold">{col}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{sqlResult.rows.map((row, i) => (
<tr key={i} className="hover:bg-slate-50/50">
{Object.values(row).map((val, j) => (
<td key={j} className="px-4 py-2.5 text-slate-700 text-xs">
{val === null ? <span className="text-slate-300"></span> : String(val)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-6 text-slate-400 text-sm">{t('analytics.no_results')}</div>
)}
</div>
)}
</div>
</div>
);
}
function StatCard({ title, value, icon, trend, color, bg }: any) {
return (
<div className="bg-white p-6 rounded-3xl border border-slate-100 shadow-sm hover:shadow-md transition group">
<div className="flex items-center justify-between mb-4">
<div className={`p-3 rounded-2xl ${bg} ${color} transition-colors`}>
{icon}
</div>
{trend != null && (
<div className="text-[10px] font-bold bg-slate-50 text-slate-400 px-2 py-1 rounded-lg uppercase">
{trend}
</div>
)}
</div>
<div>
<div className="text-sm text-slate-500 font-medium mb-1">{title}</div>
<div className="text-2xl font-bold text-slate-900">{value}</div>
</div>
</div>
);
}
function Building2(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect width="16" height="20" x="4" y="2" rx="2" ry="2" />
<path d="M9 22v-4h6v4" />
<path d="M8 6h.01" />
<path d="M16 6h.01" />
<path d="M12 6h.01" />
<path d="M12 10h.01" />
<path d="M12 14h.01" />
<path d="M16 10h.01" />
<path d="M16 14h.01" />
<path d="M8 10h.01" />
<path d="M8 14h.01" />
</svg>
);
}