edtech / apps /admin /src /App.tsx
CognxSafeTrack
feat(admin): build Ground Truth Training Lab to calculate WER and capture dictionary improvements
83c2a9a
import { BrowserRouter as Router, Routes, Route, Link, Navigate, useNavigate, useParams } from 'react-router-dom';
import { useEffect, useState, createContext, useContext } from 'react';
import { Users, PlayCircle, CheckCircle, Lightbulb, Download, BookOpen, Plus, Edit2, Trash2, ChevronRight, X, Save, BarChart2, DollarSign, ArrowLeft, Mic, Activity } from 'lucide-react';
import LiveFeed from './pages/LiveFeed';
import TrainingLab from './pages/TrainingLab';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const SESSION_KEY = 'edtech_admin_key';
export const AuthContext = createContext<{ apiKey: string | null; login: (k: string) => void; logout: () => void; }>({ apiKey: null, login: () => { }, logout: () => { } });
function AuthProvider({ children }: { children: React.ReactNode }) {
const [apiKey, setApiKey] = useState<string | null>(() => sessionStorage.getItem(SESSION_KEY));
const login = (k: string) => { sessionStorage.setItem(SESSION_KEY, k); setApiKey(k); };
const logout = () => { sessionStorage.removeItem(SESSION_KEY); setApiKey(null); };
return <AuthContext.Provider value={{ apiKey, login, logout }}>{children}</AuthContext.Provider>;
}
export const useAuth = () => useContext(AuthContext);
const ah = (k: string) => ({ 'Authorization': `Bearer ${k}`, 'Content-Type': 'application/json' });
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { apiKey } = useAuth();
if (!apiKey) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function LoginPage() {
const { login, apiKey } = useAuth();
const navigate = useNavigate();
const [key, setKey] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => { if (apiKey) navigate('/', { replace: true }); }, [apiKey, navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); setError(''); setLoading(true);
try {
const res = await fetch(`${API_URL}/v1/admin/stats`, { headers: { 'Authorization': `Bearer ${key}` } });
if (res.ok) { login(key); navigate('/', { replace: true }); }
else setError('Clé API invalide.');
} catch { setError('Impossible de joindre le serveur.'); } finally { setLoading(false); }
};
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm">
<div className="text-center mb-6"><div className="text-3xl mb-2">🔐</div>
<h1 className="text-2xl font-bold text-slate-800">Admin Access</h1>
<p className="text-sm text-slate-500 mt-1">Entrez votre ADMIN_API_KEY</p></div>
<form onSubmit={handleSubmit} className="space-y-4">
<input id="apiKey" type="password" required placeholder="sk-admin-..." value={key}
onChange={e => setKey(e.target.value)}
className="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-slate-400" />
{error && <p className="text-red-500 text-sm">{error}</p>}
<button type="submit" disabled={loading}
className="w-full bg-slate-900 hover:bg-slate-700 text-white py-3 rounded-xl font-bold text-sm transition disabled:opacity-50">
{loading ? 'Vérification...' : 'Se connecter'}
</button>
</form>
</div>
</div>
);
}
function Dashboard() {
const { apiKey, logout } = useAuth();
const [stats, setStats] = useState<any>(null);
const [enrollments, setEnrollments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const h = { 'Authorization': `Bearer ${apiKey}` };
const [sRes, eRes] = await Promise.all([
fetch(`${API_URL}/v1/admin/stats`, { headers: h }),
fetch(`${API_URL}/v1/admin/enrollments`, { headers: h })
]);
if (sRes.status === 401) { logout(); return; }
setStats(await sRes.json());
setEnrollments(await eRes.json());
} finally { setLoading(false); }
})();
}, [apiKey, logout]);
const exportCSV = () => {
if (!enrollments.length) return alert('Aucune inscription.');
const rows = enrollments.map((e: any) => [e.id, e.user?.phone, e.track?.title, e.status, e.currentDay, e.startedAt]);
const csv = [['ID', 'Phone', 'Track', 'Status', 'Day', 'Started'].join(','), ...rows.map(r => r.join(','))].join('\n');
const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
a.download = `enrollments_${new Date().toISOString().slice(0, 10)}.csv`; a.click();
};
if (loading) return <div className="p-8 text-slate-400">Chargement...</div>;
const statCards = [
{ icon: <Users className="w-6 h-6 text-slate-400" />, label: 'Utilisateurs', value: stats?.totalUsers || 0, color: 'text-slate-900' },
{ icon: <PlayCircle className="w-6 h-6 text-blue-400" />, label: 'Actifs', value: stats?.activeEnrollments || 0, color: 'text-blue-600' },
{ icon: <CheckCircle className="w-6 h-6 text-green-400" />, label: 'Complétés', value: stats?.completedEnrollments || 0, color: 'text-green-600' },
{ icon: <Lightbulb className="w-6 h-6 text-purple-400" />, label: 'Parcours', value: stats?.totalTracks || 0, color: 'text-purple-600' },
{ icon: <DollarSign className="w-6 h-6 text-emerald-400" />, label: 'Revenus', value: `${(stats?.totalRevenue || 0).toLocaleString()} XOF`, color: 'text-emerald-600' },
];
return (
<div className="p-8">
<h1 className="text-3xl font-bold mb-8 text-slate-800">Dashboard</h1>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
{statCards.map((s, i) => (
<div key={i} className="bg-white p-5 rounded-xl shadow-sm border border-slate-100 flex flex-col items-center gap-2">
{s.icon}<p className="text-xs font-semibold text-slate-500 uppercase tracking-wider">{s.label}</p>
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
</div>
))}
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
<h2 className="text-lg font-semibold text-slate-800">Inscriptions récentes</h2>
<button onClick={exportCSV} className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
<Download className="w-4 h-4" /><span>Export CSV</span>
</button>
</div>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs text-slate-500 uppercase">
<tr>{['Téléphone', 'Parcours', 'Statut', 'Jour', 'Date'].map(h => <th key={h} className="px-6 py-3 text-left">{h}</th>)}</tr>
</thead>
<tbody>
{enrollments.map((e: any) => (
<tr key={e.id} className="border-t border-slate-50 hover:bg-slate-50/50">
<td className="px-6 py-4 font-medium text-slate-900">{e.user?.phone || '—'}</td>
<td className="px-6 py-4">{e.track?.title || '—'}</td>
<td className="px-6 py-4"><span className={`px-2 py-1 rounded-full text-xs font-medium ${e.status === 'ACTIVE' ? 'bg-blue-100 text-blue-800' : e.status === 'COMPLETED' ? 'bg-green-100 text-green-800' : 'bg-slate-100 text-slate-800'}`}>{e.status}</span></td>
<td className="px-6 py-4">Jour {e.currentDay}</td>
<td className="px-6 py-4 text-slate-500">{new Date(e.startedAt).toLocaleDateString('fr-FR')}</td>
</tr>
))}
{!enrollments.length && <tr><td colSpan={5} className="px-6 py-8 text-center text-slate-400">Aucune inscription</td></tr>}
</tbody>
</table>
</div>
</div>
);
}
function TrackList() {
const { apiKey } = useAuth(); const navigate = useNavigate();
const [tracks, setTracks] = useState<any[]>([]); const [loading, setLoading] = useState(true);
const load = async () => { const r = await fetch(`${API_URL}/v1/admin/tracks`, { headers: ah(apiKey!) }); setTracks(await r.json()); setLoading(false); };
useEffect(() => { load(); }, []);
const del = async (id: string) => { if (!confirm('Supprimer ce parcours ?')) return; await fetch(`${API_URL}/v1/admin/tracks/${id}`, { method: 'DELETE', headers: ah(apiKey!) }); load(); };
if (loading) return <div className="p-8 text-slate-400">Chargement...</div>;
return (
<div className="p-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-slate-800">Parcours</h1>
<button onClick={() => navigate('/content/new')} className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-slate-700">
<Plus className="w-4 h-4" /> Nouveau parcours
</button>
</div>
<div className="grid gap-4">
{tracks.map((t: any) => (
<div key={t.id} className="bg-white rounded-xl border border-slate-100 p-5 flex items-center justify-between shadow-sm hover:shadow-md transition">
<div className="flex items-center gap-4">
<div className="bg-purple-100 p-3 rounded-xl"><BookOpen className="w-5 h-5 text-purple-600" /></div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-bold text-slate-800">{t.title}</h3>
{t.isPremium && <span className="bg-amber-100 text-amber-700 text-xs px-2 py-0.5 rounded-full font-medium">Premium</span>}
<span className="bg-slate-100 text-slate-600 text-xs px-2 py-0.5 rounded-full">{t.language}</span>
</div>
<p className="text-sm text-slate-500 mt-0.5">{t._count?.days || 0} jours · {t._count?.enrollments || 0} inscrits · {t.duration}j</p>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={() => navigate(`/content/${t.id}`)} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => navigate(`/content/${t.id}/days`)} className="flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900 px-3 py-2 rounded-lg hover:bg-slate-50">Jours <ChevronRight className="w-4 h-4" /></button>
<button onClick={() => del(t.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4" /></button>
</div>
</div>
))}
{!tracks.length && <div className="text-center py-16 text-slate-400"><BookOpen className="w-12 h-12 mx-auto mb-3 opacity-30" /><p>Aucun parcours. Créez-en un !</p></div>}
</div>
</div>
);
}
function TrackForm() {
const { apiKey } = useAuth(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate();
const isNew = id === 'new';
const [form, setForm] = useState({ title: '', description: '', duration: 7, language: 'FR', isPremium: false, priceAmount: 0, stripePriceId: '' });
const [saving, setSaving] = useState(false);
useEffect(() => { if (!isNew) fetch(`${API_URL}/v1/admin/tracks/${id}`, { headers: ah(apiKey!) }).then(r => r.json()).then(t => setForm({ title: t.title, description: t.description || '', duration: t.duration, language: t.language, isPremium: t.isPremium, priceAmount: t.priceAmount || 0, stripePriceId: t.stripePriceId || '' })); }, [id]);
const inp = "w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-slate-300";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true);
const url = isNew ? `${API_URL}/v1/admin/tracks` : `${API_URL}/v1/admin/tracks/${id}`;
await fetch(url, { method: isNew ? 'POST' : 'PUT', headers: ah(apiKey!), body: JSON.stringify({ ...form, priceAmount: form.priceAmount || undefined, stripePriceId: form.stripePriceId || undefined }) });
navigate('/content');
};
return (
<div className="p-8 max-w-xl">
<div className="flex items-center gap-3 mb-6">
<button onClick={() => navigate('/content')} className="p-2 hover:bg-slate-100 rounded-lg"><ArrowLeft className="w-4 h-4" /></button>
<h1 className="text-2xl font-bold text-slate-800">{isNew ? 'Nouveau parcours' : 'Modifier le parcours'}</h1>
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-2xl border border-slate-100 p-6 space-y-4 shadow-sm">
<div><label className="text-sm font-medium text-slate-700 mb-1 block">Titre *</label>
<input required className={inp} value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
<div><label className="text-sm font-medium text-slate-700 mb-1 block">Description</label>
<textarea className={inp} rows={3} value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></div>
<div className="grid grid-cols-2 gap-4">
<div><label className="text-sm font-medium text-slate-700 mb-1 block">Durée (jours)</label>
<input type="number" min={1} required className={inp} value={form.duration} onChange={e => setForm(f => ({ ...f, duration: parseInt(e.target.value) }))} /></div>
<div><label className="text-sm font-medium text-slate-700 mb-1 block">Langue</label>
<select className={inp} value={form.language} onChange={e => setForm(f => ({ ...f, language: e.target.value }))}>
<option value="FR">Français</option><option value="WOLOF">Wolof</option>
</select></div>
</div>
<label className="flex items-center gap-3 p-3 bg-amber-50 rounded-xl cursor-pointer">
<input type="checkbox" checked={form.isPremium} onChange={e => setForm(f => ({ ...f, isPremium: e.target.checked }))} className="w-4 h-4" />
<span className="text-sm font-medium text-amber-800">Formation Premium (payante)</span>
</label>
{form.isPremium && <div className="grid grid-cols-2 gap-4">
<div><label className="text-sm font-medium text-slate-700 mb-1 block">Prix (XOF)</label>
<input type="number" className={inp} value={form.priceAmount} onChange={e => setForm(f => ({ ...f, priceAmount: parseInt(e.target.value) }))} /></div>
<div><label className="text-sm font-medium text-slate-700 mb-1 block">Stripe Price ID</label>
<input className={inp} value={form.stripePriceId} onChange={e => setForm(f => ({ ...f, stripePriceId: e.target.value }))} /></div>
</div>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => navigate('/content')} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">Annuler</button>
<button type="submit" disabled={saving} className="flex-1 bg-slate-900 text-white py-2.5 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-700 disabled:opacity-50">
<Save className="w-4 h-4" />{saving ? 'Enregistrement...' : 'Enregistrer'}
</button>
</div>
</form>
</div>
);
}
function TrackDays() {
const { apiKey } = useAuth(); const { trackId } = useParams<{ trackId: string }>(); const navigate = useNavigate();
const [days, setDays] = useState<any[]>([]); const [track, setTrack] = useState<any>(null); const [editing, setEditing] = useState<any>(null); const [saving, setSaving] = useState(false);
const load = async () => { const [tR, dR] = await Promise.all([fetch(`${API_URL}/v1/admin/tracks/${trackId}`, { headers: ah(apiKey!) }), fetch(`${API_URL}/v1/admin/tracks/${trackId}/days`, { headers: ah(apiKey!) })]); setTrack(await tR.json()); setDays(await dR.json()); };
useEffect(() => { load(); }, []);
const emptyDay = { dayNumber: (days.length || 0) + 1, title: '', lessonText: '', audioUrl: '', exerciseType: 'TEXT', exercisePrompt: '', validationKeyword: '' };
const saveDay = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true);
const url = editing.id ? `${API_URL}/v1/admin/tracks/${trackId}/days/${editing.id}` : `${API_URL}/v1/admin/tracks/${trackId}/days`;
await fetch(url, { method: editing.id ? 'PUT' : 'POST', headers: ah(apiKey!), body: JSON.stringify(editing) });
setEditing(null); load(); setSaving(false);
};
const del = async (dayId: string) => { if (!confirm('Supprimer ce jour?')) return; await fetch(`${API_URL}/v1/admin/tracks/${trackId}/days/${dayId}`, { method: 'DELETE', headers: ah(apiKey!) }); load(); };
const inp = "w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-slate-300";
return (
<div className="p-8">
<div className="flex items-center gap-3 mb-6">
<button onClick={() => navigate('/content')} className="p-2 hover:bg-slate-100 rounded-lg"><ArrowLeft className="w-4 h-4" /></button>
<div><h1 className="text-2xl font-bold text-slate-800">{track?.title}</h1>
<p className="text-sm text-slate-500">{days.length} jours configurés</p></div>
<button onClick={() => setEditing(emptyDay)} className="ml-auto flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-slate-700">
<Plus className="w-4 h-4" /> Ajouter un jour
</button>
</div>
{editing && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-5 border-b">
<h2 className="font-bold text-slate-800">{editing.id ? `Modifier Jour ${editing.dayNumber}` : 'Nouveau jour'}</h2>
<button onClick={() => setEditing(null)}><X className="w-5 h-5 text-slate-400" /></button>
</div>
<form onSubmit={saveDay} className="p-5 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div><label className="text-xs font-medium text-slate-600 mb-1 block">Numéro du jour</label>
<input type="number" min={1} required className={inp} value={editing.dayNumber} onChange={e => setEditing((d: any) => ({ ...d, dayNumber: parseInt(e.target.value) }))} /></div>
<div><label className="text-xs font-medium text-slate-600 mb-1 block">Titre</label>
<input className={inp} value={editing.title || ''} onChange={e => setEditing((d: any) => ({ ...d, title: e.target.value }))} /></div>
</div>
<div><label className="text-xs font-medium text-slate-600 mb-1 block">Texte de la leçon</label>
<textarea className={inp} rows={5} value={editing.lessonText || ''} onChange={e => setEditing((d: any) => ({ ...d, lessonText: e.target.value }))} placeholder="Contenu pédagogique..." /></div>
<div><label className="text-xs font-medium text-slate-600 mb-1 block">URL Audio (optionnel)</label>
<input className={inp} value={editing.audioUrl || ''} onChange={e => setEditing((d: any) => ({ ...d, audioUrl: e.target.value }))} placeholder="https://..." /></div>
<div className="grid grid-cols-2 gap-3">
<div><label className="text-xs font-medium text-slate-600 mb-1 block">Type exercice</label>
<select className={inp} value={editing.exerciseType} onChange={e => setEditing((d: any) => ({ ...d, exerciseType: e.target.value }))}>
<option value="TEXT">Texte libre</option><option value="AUDIO">Audio</option><option value="BUTTON">Boutons</option>
</select></div>
<div><label className="text-xs font-medium text-slate-600 mb-1 block">Mot-clé validation</label>
<input className={inp} value={editing.validationKeyword || ''} onChange={e => setEditing((d: any) => ({ ...d, validationKeyword: e.target.value }))} /></div>
</div>
<div><label className="text-xs font-medium text-slate-600 mb-1 block">Prompt exercice</label>
<textarea className={inp} rows={2} value={editing.exercisePrompt || ''} onChange={e => setEditing((d: any) => ({ ...d, exercisePrompt: e.target.value }))} placeholder="Question posée à l'étudiant..." /></div>
<div className="flex gap-3">
<button type="button" onClick={() => setEditing(null)} className="flex-1 border border-slate-200 text-slate-600 py-2.5 rounded-xl text-sm hover:bg-slate-50">Annuler</button>
<button type="submit" disabled={saving} className="flex-1 bg-slate-900 text-white py-2.5 rounded-xl text-sm font-bold flex items-center justify-center gap-2 hover:bg-slate-700 disabled:opacity-50">
<Save className="w-4 h-4" />{saving ? 'Enregistrement...' : 'Enregistrer'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="grid gap-3">
{days.map((d: any) => (
<div key={d.id} className="bg-white rounded-xl border border-slate-100 p-4 flex items-start justify-between shadow-sm">
<div className="flex gap-4">
<div className="bg-slate-900 text-white w-9 h-9 rounded-lg flex items-center justify-center text-sm font-bold shrink-0">{d.dayNumber}</div>
<div>
<p className="font-medium text-slate-800">{d.title || `Jour ${d.dayNumber}`}</p>
<p className="text-xs text-slate-500 mt-0.5 line-clamp-1">{d.lessonText?.substring(0, 100) || 'Pas de texte'}</p>
<div className="flex gap-2 mt-1.5">
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full">{d.exerciseType}</span>
{d.audioUrl && <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">🎵 Audio</span>}
{d.exercisePrompt && <span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">🎯 Exercice</span>}
</div>
</div>
</div>
<div className="flex gap-1 shrink-0 ml-4">
<button onClick={() => setEditing(d)} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => del(d.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4" /></button>
</div>
</div>
))}
{!days.length && <div className="text-center py-12 text-slate-400 bg-white rounded-xl border border-dashed border-slate-200"><p>Aucun jour. Ajoutez le contenu pédagogique !</p></div>}
</div>
</div>
);
}
function UserList() {
const { apiKey } = useAuth();
const [users, setUsers] = useState<any[]>([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true);
const [selectedUser, setSelectedUser] = useState<any>(null); const [messages, setMessages] = useState<any[]>([]); const [loadingMsg, setLoadingMsg] = useState(false);
useEffect(() => { fetch(`${API_URL}/v1/admin/users`, { headers: ah(apiKey!) }).then(r => r.json()).then(d => { setUsers(d.users || d); setTotal(d.total || 0); setLoading(false); }); }, []);
const viewMessages = async (userId: string) => {
setLoadingMsg(true); setSelectedUser({ id: userId });
try {
const res = await fetch(`${API_URL}/v1/admin/users/${userId}/messages`, { headers: ah(apiKey!) });
const data = await res.json();
setSelectedUser(data.user);
setMessages(data.messages || []);
} catch (e) {
alert("Erreur lors du chargement des messages.");
} finally {
setLoadingMsg(false);
}
};
if (loading) return <div className="p-8 text-slate-400">Chargement...</div>;
return (
<div className="p-8">
<h1 className="text-3xl font-bold mb-6 text-slate-800">Utilisateurs <span className="text-lg font-normal text-slate-400">({total})</span></h1>
<div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs text-slate-500 uppercase">
<tr>{['Téléphone', 'Nom', 'Langue', 'Secteur', 'Inscrip.', 'Réponses', 'Date', 'Actions'].map(h => <th key={h} className="px-5 py-3 text-left">{h}</th>)}</tr>
</thead>
<tbody>
{users.map((u: any) => (
<tr key={u.id} className="border-t border-slate-50 hover:bg-slate-50/50">
<td className="px-5 py-3 font-medium">{u.phone}</td>
<td className="px-5 py-3 text-slate-600">{u.name || '—'}</td>
<td className="px-5 py-3"><span className="bg-blue-100 text-blue-700 text-xs px-2 py-0.5 rounded-full">{u.language}</span></td>
<td className="px-5 py-3 text-slate-500 text-xs">{u.activity || '—'}</td>
<td className="px-5 py-3 text-center">{u._count?.enrollments || 0}</td>
<td className="px-5 py-3 text-center">{u._count?.responses || 0}</td>
<td className="px-5 py-3 text-slate-400 text-xs">{new Date(u.createdAt).toLocaleDateString('fr-FR')}</td>
<td className="px-5 py-3 text-right">
<button onClick={() => viewMessages(u.id)} className="text-xs bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1.5 rounded-lg font-medium transition-colors">Conversation</button>
</td>
</tr>
))}
{!users.length && <tr><td colSpan={8} className="px-5 py-8 text-center text-slate-400">Aucun utilisateur</td></tr>}
</tbody>
</table>
</div>
{selectedUser && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-slate-50 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<div className="bg-white px-6 py-4 flex items-center justify-between border-b border-slate-200">
<div>
<h3 className="font-bold text-slate-800">{selectedUser.name || 'Chat Utilisateur'}</h3>
<p className="text-xs text-slate-500">{selectedUser.phone}</p>
</div>
<button onClick={() => { setSelectedUser(null); setMessages([]); }} className="p-2 hover:bg-slate-100 rounded-full"><X className="w-5 h-5 text-slate-500" /></button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4 bg-[#e5ddd5]">
{loadingMsg ? (
<div className="text-center text-slate-500 py-10">Chargement de l'historique...</div>
) : messages.length === 0 ? (
<div className="text-center text-slate-500 py-10 bg-white/50 rounded-xl">Aucun message pour cet utilisateur.</div>
) : (
messages.map((m: any) => {
const isBot = m.direction === 'OUTBOUND';
return (
<div key={m.id} className={`flex ${isBot ? 'justify-start' : 'justify-end'}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 shadow-sm text-sm ${isBot ? 'bg-white text-slate-800 rounded-tl-none' : 'bg-[#dcf8c6] text-slate-900 rounded-tr-none'}`}>
{m.mediaUrl && (
<div className="mb-2">
{m.mediaUrl.endsWith('.mp3') || m.mediaUrl.endsWith('.ogg') || m.mediaUrl.endsWith('.webm') ?
<audio src={m.mediaUrl} controls className="h-10 max-w-full" /> :
<a href={m.mediaUrl} target="_blank" rel="noreferrer" className="text-blue-600 underline">Voir Media</a>
}
</div>
)}
{m.content && <p className="whitespace-pre-wrap">{m.content}</p>}
<p className={`text-[10px] mt-1 text-right ${isBot ? 'text-slate-400' : 'text-slate-500'}`}>
{new Date(m.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
);
})
)}
</div>
</div>
</div>
)}
</div>
);
}
function Settings() {
return (
<div className="p-8 max-w-xl">
<h1 className="text-3xl font-bold mb-6 text-slate-800">Configuration</h1>
<div className="bg-white rounded-2xl border border-slate-100 p-6 shadow-sm space-y-3">
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl">
<div><p className="font-medium text-slate-800">API URL</p><p className="text-sm text-slate-500 font-mono">{API_URL}</p></div>
</div>
<p className="text-sm font-medium text-slate-600">Variables Railway requises :</p>
{['WHATSAPP_VERIFY_TOKEN', 'WHATSAPP_APP_SECRET', 'WHATSAPP_ACCESS_TOKEN', 'OPENAI_API_KEY', 'DATABASE_URL', 'REDIS_URL', 'API_URL', 'ADMIN_API_KEY'].map(v => (
<div key={v} className="flex items-center gap-3 p-3 border border-slate-100 rounded-xl">
<span className="font-mono text-xs text-slate-700">{v}</span>
</div>
))}
</div>
</div>
);
}
function AppShell() {
const { logout } = useAuth();
const navItems = [
{ to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
{ to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" /> },
{ to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" /> },
{ to: '/training', label: 'Training Lab', icon: <Activity className="w-4 h-4 text-purple-400" /> },
{ to: '/users', label: 'Utilisateurs', icon: <Users className="w-4 h-4" /> },
{ to: '/settings', label: 'Paramètres', icon: <Lightbulb className="w-4 h-4" /> },
];
return (
<div className="min-h-screen bg-gray-50 flex">
<aside className="w-56 bg-slate-900 text-white p-5 flex flex-col shrink-0">
<div className="text-lg font-bold mb-8 flex items-center gap-2"><span className="text-2xl">🎓</span>EdTech Admin</div>
<nav className="space-y-1 flex-1">
{navItems.map(n => (
<Link key={n.to} to={n.to} className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800 transition">
{n.icon}{n.label}
</Link>
))}
</nav>
<button onClick={logout} className="text-xs text-slate-500 hover:text-white transition px-3 py-2 text-left">🔓 Se déconnecter</button>
</aside>
<main className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/content" element={<TrackList />} />
<Route path="/content/new" element={<TrackForm />} />
<Route path="/content/:id" element={<TrackForm />} />
<Route path="/content/:trackId/days" element={<TrackDays />} />
<Route path="/live-feed" element={<LiveFeed />} />
<Route path="/training" element={<TrainingLab />} />
<Route path="/users" element={<UserList />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</main>
</div>
);
}
function App() {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/*" element={<ProtectedRoute><AppShell /></ProtectedRoute>} />
</Routes>
</Router>
</AuthProvider>
);
}
export default App;