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