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