Python 64.6%
TypeScript 33.7%
CSS 0.8%
1import { useState, type FormEvent } from 'react';2import { fmtDate } from '@/lib/format';3import { Link } from 'react-router-dom';4import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';5import { ArrowLeft, Upload, Trash2, Eye, EyeOff, RefreshCw, UserPlus, Copy, Check, Shield, ShieldOff, Lock, Mail, Send } from 'lucide-react';6import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';7import { api } from '@/lib/api';8import { Button } from '@/components/ui/button';9import { Spinner } from '@/components/ui/spinner';10import { uqo } from '@/theme/uqo';1112interface Dashboard {13 days: number; messages_per_day: { day: string; count: number }[]; active_students: number; total_users: number; conversations: number;14 peak_hours: { hour: number; count: number }[]; top_topics: { topic: string; course: string; count: number }[];15 question_samples: { question: string; topic: string; course: string; at: string }[]; feedback: Record<string, number>;16 tool_usage: { tool: string; count: number }[]; budget: { budget_usd: number; spent_usd: number; ratio: number }; costs_by_course: { course: string; cost_usd: number }[];17}18interface CourseSettings { code: string; title: string; extra_system_prompt: string; announcement: string; deadlines: { label: string; date: string }[]; suggestions: string[]; settings: { tools_enabled?: string[] | null; model_primary?: string | null; budget_usd?: number } }19interface Settings { courses: CourseSettings[]; tools: string[]; models: Record<string, string>; budget_usd: number }20interface Content { documents: { id: string; course: string; filename: string; title: string; visibility: string; n_chunks: number; ingested_at: string }[]; index_size: number; jobs: Record<string, { status: string; filename: string; chunks?: number; error?: string }> }2122const TABS = ['Activité', 'Étudiants', 'Contenu', 'Réglages'] as const;2324export function ProfessorPage() {25 const [tab, setTab] = useState<(typeof TABS)[number]>('Activité');26 return (27 <div className="min-h-[100dvh] bg-neutral-surface">28 <header className="bg-uqo-gradient text-white">29 <div className="mx-auto max-w-[1100px] px-4 pt-[calc(12px+var(--safe-top))] pb-3 flex items-center gap-3">30 <Link to="/" className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white/10" aria-label="Retour"><ArrowLeft size={20} /></Link>31 <div><h1 className="text-lg font-bold">Tableau de bord professeur</h1><p className="text-xs text-white/80">IMM1003 · IMM1033 — données anonymisées</p></div>32 </div>33 <nav className="mx-auto max-w-[1100px] px-4 flex gap-1 overflow-x-auto">34 {TABS.map((t) => <button key={t} onClick={() => setTab(t)} className={`h-11 px-4 text-sm font-medium border-b-2 ${tab === t ? 'border-uqo-green text-white' : 'border-transparent text-white/70'}`}>{t}</button>)}35 </nav>36 </header>37 <main className="mx-auto max-w-[1100px] px-4 py-5 pb-[calc(24px+var(--safe-bottom))]">38 {tab === 'Activité' && <Activity />}39 {tab === 'Étudiants' && <StudentsTab />}40 {tab === 'Contenu' && <ContentTab />}41 {tab === 'Réglages' && <SettingsTab />}42 </main>43 </div>44 );45}4647function Card({ title, children, className = '' }: { title: string; children: React.ReactNode; className?: string }) {48 return <section className={`rounded-2xl bg-white shadow-card p-4 ${className}`}><h2 className="text-sm font-semibold text-uqo-blue-dark mb-3">{title}</h2>{children}</section>;49}5051function Stat({ label, value }: { label: string; value: string | number }) {52 return <div className="rounded-2xl bg-white shadow-card p-4"><div className="text-xs text-neutral-muted">{label}</div><div className="text-2xl font-bold text-uqo-blue-dark tabular-nums">{value}</div></div>;53}5455function Activity() {56 const { data, isLoading } = useQuery({ queryKey: ['dashboard'], queryFn: () => api<Dashboard>('/professor/dashboard?days=30') });57 if (isLoading || !data) return <div className="flex justify-center py-10"><Spinner /></div>;58 return (59 <div className="space-y-4">60 <div className="grid grid-cols-2 md:grid-cols-4 gap-3">61 <Stat label="Étudiants actifs (30 j)" value={data.active_students} />62 <Stat label="Comptes" value={data.total_users} />63 <Stat label="Conversations (30 j)" value={data.conversations} />64 <Stat label="Coût du mois" value={`${data.budget.spent_usd.toFixed(2)} $ / ${data.budget.budget_usd} $`} />65 </div>66 <div className="grid md:grid-cols-2 gap-4">67 <Card title="Messages par jour">68 <div className="h-56"><ResponsiveContainer><BarChart data={data.messages_per_day}><CartesianGrid vertical={false} stroke="#EEF2F6" /><XAxis dataKey="day" tickFormatter={(d) => d.slice(5)} fontSize={11} /><YAxis allowDecimals={false} fontSize={11} width={28} /><Tooltip /><Bar dataKey="count" fill={uqo.blue} radius={[4, 4, 0, 0]} /></BarChart></ResponsiveContainer></div>69 </Card>70 <Card title="Heures de pointe">71 <div className="h-56"><ResponsiveContainer><BarChart data={[...data.peak_hours].sort((a, b) => a.hour - b.hour)}><CartesianGrid vertical={false} stroke="#EEF2F6" /><XAxis dataKey="hour" fontSize={11} tickFormatter={(h) => `${h}h`} /><YAxis allowDecimals={false} fontSize={11} width={28} /><Tooltip /><Bar dataKey="count" fill={uqo.green} radius={[4, 4, 0, 0]} /></BarChart></ResponsiveContainer></div>72 </Card>73 <Card title="Top des notions demandées">74 {data.top_topics.length === 0 && <p className="text-sm text-neutral-muted">Pas encore de données.</p>}75 <ol className="space-y-1.5">76 {data.top_topics.map((t, i) => (77 <li key={t.topic + t.course} className="flex items-center gap-2 text-sm"><span className="w-5 text-neutral-muted">{i + 1}.</span><span className="flex-1 truncate">{t.topic}</span><span className="text-[11px] rounded bg-uqo-blue-light px-1.5 py-0.5 text-uqo-blue-dark">{t.course}</span><span className="tabular-nums font-medium">{t.count}</span></li>78 ))}79 </ol>80 </Card>81 <Card title="Qualité et outils">82 <div className="flex gap-4 text-sm mb-3"><span>👍 {data.feedback.up || 0}</span><span>👎 {data.feedback.down || 0}</span></div>83 <ul className="text-sm space-y-1">{data.tool_usage.map((t) => <li key={t.tool} className="flex justify-between"><span className="font-mono text-xs">{t.tool}</span><span className="tabular-nums">{t.count}</span></li>)}</ul>84 </Card>85 </div>86 <Card title="Questions anonymisées et reformulées (échantillon)">87 <ul className="divide-y divide-neutral-line/70">88 {data.question_samples.map((q, i) => <li key={i} className="py-2 text-sm flex gap-3"><span className="text-[11px] rounded bg-neutral-surface px-1.5 py-0.5 text-neutral-muted shrink-0 h-fit">{q.course} · {q.topic || '—'}</span><span>{q.question}</span></li>)}89 {data.question_samples.length === 0 && <li className="text-sm text-neutral-muted">Aucune question pour l'instant.</li>}90 </ul>91 </Card>92 </div>93 );94}9596function ContentTab() {97 const qc = useQueryClient();98 const { data, refetch } = useQuery({ queryKey: ['content'], queryFn: () => api<Content>('/professor/content'), refetchInterval: 5000 });99 const [course, setCourse] = useState('IMM1033');100 const [visibility, setVisibility] = useState('students');101 const [title, setTitle] = useState('');102 const [file, setFile] = useState<File | null>(null);103 const upload = useMutation({104 mutationFn: async () => {105 if (!file) return;106 const fd = new FormData();107 fd.append('file', file); fd.append('course', course); fd.append('visibility', visibility); fd.append('title', title);108 await api('/professor/content', { method: 'POST', body: fd });109 },110 onSuccess: () => { setFile(null); setTitle(''); qc.invalidateQueries({ queryKey: ['content'] }); },111 });112 const submit = (e: FormEvent) => { e.preventDefault(); upload.mutate(); };113 return (114 <div className="space-y-4">115 <Card title={`Matériel indexé (${data?.index_size ?? '…'} passages)`}>116 <form onSubmit={submit} className="grid gap-2 sm:grid-cols-[1fr_auto_auto_auto] items-end">117 <label className="text-sm"><span className="block text-xs text-neutral-muted">Fichier (pdf, docx, pptx, md, txt)</span><input type="file" accept=".pdf,.docx,.pptx,.md,.txt,.tex,.html" onChange={(e) => setFile(e.target.files?.[0] || null)} className="block w-full text-sm" /></label>118 <label className="text-sm"><span className="block text-xs text-neutral-muted">Cours</span><select value={course} onChange={(e) => setCourse(e.target.value)} className="h-11 rounded-xl border border-neutral-line px-2 bg-white"><option>IMM1003</option><option>IMM1033</option></select></label>119 <label className="text-sm"><span className="block text-xs text-neutral-muted">Visibilité</span><select value={visibility} onChange={(e) => setVisibility(e.target.value)} className="h-11 rounded-xl border border-neutral-line px-2 bg-white"><option value="students">Étudiants</option><option value="professor_only">Professeur seulement</option></select></label>120 <Button type="submit" disabled={!file || upload.isPending}><Upload size={16} /> Ingérer</Button>121 <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Titre / module (optionnel)" className="h-11 rounded-xl border border-neutral-line px-3 text-sm sm:col-span-4" />122 </form>123 {upload.isError && <p className="text-sm text-semantic-error mt-2">{(upload.error as Error).message}</p>}124 {data && Object.keys(data.jobs).length > 0 && (125 <ul className="mt-3 text-xs text-neutral-muted space-y-0.5">{Object.entries(data.jobs).map(([id, j]) => <li key={id}>{j.filename} — {j.status}{j.chunks ? ` (${j.chunks} passages)` : ''}{j.error ? ` : ${j.error}` : ''}</li>)}</ul>126 )}127 </Card>128 <Card title="Documents">129 <div className="flex justify-end mb-2"><Button size="sm" variant="ghost" onClick={() => refetch()}><RefreshCw size={14} /> Actualiser</Button></div>130 <div className="overflow-x-auto">131 <table className="w-full text-sm">132 <thead><tr className="text-left text-xs text-neutral-muted"><th className="py-1">Cours</th><th>Document</th><th>Passages</th><th>Visibilité</th><th></th></tr></thead>133 <tbody>134 {data?.documents.map((d) => (135 <tr key={d.id} className="border-t border-neutral-line/70">136 <td className="py-2 pr-2 font-medium">{d.course}</td>137 <td className="py-2 pr-2"><div className="truncate max-w-[320px]">{d.title || d.filename}</div><div className="text-[11px] text-neutral-muted">{d.filename}</div></td>138 <td className="py-2 pr-2 tabular-nums">{d.n_chunks}</td>139 <td className="py-2 pr-2">140 <button onClick={() => api(`/professor/content/${d.id}`, { method: 'PATCH', body: JSON.stringify({ visibility: d.visibility === 'students' ? 'professor_only' : 'students' }) }).then(() => refetch())} className="inline-flex items-center gap-1 text-xs rounded-lg border border-neutral-line px-2 h-9">141 {d.visibility === 'students' ? <><Eye size={13} /> étudiants</> : <><EyeOff size={13} /> prof seulement</>}142 </button>143 </td>144 <td className="py-2 text-right"><button onClick={() => confirm(`Retirer « ${d.filename} » de l'index ?`) && api(`/professor/content/${d.id}`, { method: 'DELETE' }).then(() => refetch())} className="h-9 w-9 inline-flex items-center justify-center rounded-lg text-semantic-error hover:bg-red-50" aria-label="Supprimer"><Trash2 size={15} /></button></td>145 </tr>146 ))}147 </tbody>148 </table>149 </div>150 </Card>151 </div>152 );153}154155function SettingsTab() {156 const qc = useQueryClient();157 const { data } = useQuery({ queryKey: ['prof-settings'], queryFn: () => api<Settings>('/professor/settings') });158 const [promote, setPromote] = useState('');159 if (!data) return <div className="flex justify-center py-10"><Spinner /></div>;160 return (161 <div className="space-y-4">162 {data.courses.map((c) => <CourseForm key={c.code} c={c} tools={data.tools} onSaved={() => qc.invalidateQueries({ queryKey: ['prof-settings'] })} defaultModel={data.models.primary} />)}163 <Card title="Modèles (configuration)">164 <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">{Object.entries(data.models).map(([k, v]) => <><dt key={k + 'k'} className="text-neutral-muted">{k}</dt><dd key={k + 'v'} className="font-mono text-xs">{v}</dd></>)}</dl>165 <p className="text-xs text-neutral-muted mt-2">Budget mensuel global : {data.budget_usd} $ US. Au-delà de 100 %, le tuteur bascule automatiquement sur le modèle économique.</p>166 </Card>167 <Card title="Promouvoir un compte au rôle professeur">168 <form onSubmit={(e) => { e.preventDefault(); api('/professor/promote', { method: 'POST', body: JSON.stringify({ email: promote }) }).then(() => { alert('Compte promu.'); setPromote(''); }).catch((er) => alert(er.message)); }} className="flex gap-2">169 <input type="email" value={promote} onChange={(e) => setPromote(e.target.value)} placeholder="collegue@uqo.ca" className="h-11 flex-1 rounded-xl border border-neutral-line px-3 text-sm" />170 <Button type="submit" variant="secondary">Promouvoir</Button>171 </form>172 </Card>173 </div>174 );175}176177function CourseForm({ c, tools, onSaved, defaultModel }: { c: CourseSettings; tools: string[]; onSaved: () => void; defaultModel: string }) {178 const [prompt, setPrompt] = useState(c.extra_system_prompt || '');179 const [announcement, setAnnouncement] = useState(c.announcement || '');180 const [deadlines, setDeadlines] = useState((c.deadlines || []).map((d) => `${d.label} | ${d.date}`).join('\n'));181 const [suggestions, setSuggestions] = useState((c.suggestions || []).join('\n'));182 const [enabled, setEnabled] = useState<string[]>(c.settings?.tools_enabled || tools);183 const [model, setModel] = useState(c.settings?.model_primary || '');184 const [budget, setBudget] = useState(String(c.settings?.budget_usd ?? ''));185 const save = useMutation({186 mutationFn: () => api(`/professor/settings/${c.code}`, {187 method: 'PATCH',188 body: JSON.stringify({189 extra_system_prompt: prompt, announcement,190 deadlines: deadlines.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => { const [label, date] = l.split('|').map((s) => s.trim()); return { label, date: date || '' }; }),191 suggestions: suggestions.split('\n').map((s) => s.trim()).filter(Boolean),192 settings: { tools_enabled: enabled.length === tools.length ? null : enabled, model_primary: model || null, budget_usd: budget ? Number(budget) : undefined },193 }),194 }),195 onSuccess: onSaved,196 });197 return (198 <Card title={`${c.code} — ${c.title}`}>199 <div className="grid gap-3 md:grid-cols-2">200 <label className="text-sm md:col-span-2"><span className="block text-xs text-neutral-muted">Annonce épinglée (visible par tous les étudiants)</span><input value={announcement} onChange={(e) => setAnnouncement(e.target.value)} className="mt-1 h-11 w-full rounded-xl border border-neutral-line px-3" placeholder="Ex. : Rappel — TP2 à remettre à la séance 9." /></label>201 <label className="text-sm md:col-span-2"><span className="block text-xs text-neutral-muted">Consignes additionnelles au tuteur (prompt)</span><textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={4} className="mt-1 w-full rounded-xl border border-neutral-line px-3 py-2" placeholder="Ex. : Insiste sur la distinction âge effectif / âge chronologique ; utilise le MEFQ comme référence de coûts." /></label>202 <label className="text-sm"><span className="block text-xs text-neutral-muted">Échéances (une par ligne : libellé | date)</span><textarea value={deadlines} onChange={(e) => setDeadlines(e.target.value)} rows={4} className="mt-1 w-full rounded-xl border border-neutral-line px-3 py-2 font-mono text-xs" placeholder="TP1 — Évaluation d'un terrain | 2026-10-06" /></label>203 <label className="text-sm"><span className="block text-xs text-neutral-muted">Suggestions de départ (une par ligne)</span><textarea value={suggestions} onChange={(e) => setSuggestions(e.target.value)} rows={4} className="mt-1 w-full rounded-xl border border-neutral-line px-3 py-2 text-xs" /></label>204 <div className="text-sm"><span className="block text-xs text-neutral-muted mb-1">Outils activés</span>205 <div className="flex flex-wrap gap-1.5">{tools.map((t) => <button key={t} type="button" onClick={() => setEnabled((e) => (e.includes(t) ? e.filter((x) => x !== t) : [...e, t]))} className={`h-9 px-2.5 rounded-lg text-xs font-mono border ${enabled.includes(t) ? 'bg-uqo-blue text-white border-uqo-blue' : 'border-neutral-line text-neutral-muted'}`}>{t}</button>)}</div>206 </div>207 <div className="grid gap-2">208 <label className="text-sm"><span className="block text-xs text-neutral-muted">Modèle primaire (vide = {defaultModel})</span><input value={model} onChange={(e) => setModel(e.target.value)} className="mt-1 h-11 w-full rounded-xl border border-neutral-line px-3 font-mono text-xs" placeholder="anthropic/claude-sonnet-4.6" /></label>209 <label className="text-sm"><span className="block text-xs text-neutral-muted">Plafond mensuel pour ce cours ($ US, optionnel)</span><input value={budget} onChange={(e) => setBudget(e.target.value)} inputMode="decimal" className="mt-1 h-11 w-full rounded-xl border border-neutral-line px-3" /></label>210 </div>211 </div>212 <div className="mt-3 flex items-center gap-3"><Button onClick={() => save.mutate()} disabled={save.isPending}>Enregistrer</Button>{save.isSuccess && <span className="text-sm text-uqo-green">Enregistré ✓</span>}{save.isError && <span className="text-sm text-semantic-error">{(save.error as Error).message}</span>}</div>213 </Card>214 );215}216217218interface StudentRow { id: string; email: string; role: string; display_name: string | null; created_at: string; last_seen_at: string | null; consent_at: string | null; messages: number; has_password?: boolean; invited_at?: string | null }219interface StudentsResp { users: StudentRow[]; mail: boolean; mail_from: string; invite_ttl_days: number; pending: number }220interface CreateResp { created: string[]; existing: string[]; invalid: string[]; invited: string[]; invite_failed: string[]; mail?: boolean }221222function StudentsTab() {223 const qc = useQueryClient();224 const { data } = useQuery({ queryKey: ['students'], queryFn: () => api<StudentsResp>('/professor/students') });225 const [bulk, setBulk] = useState('');226 const [sendInvites, setSendInvites] = useState(true);227 const [result, setResult] = useState<CreateResp | null>(null);228 const [links, setLinks] = useState<Record<string, string>>({});229 const [copied, setCopied] = useState<string | null>(null);230 const [filter, setFilter] = useState('');231 const [inviteAllMsg, setInviteAllMsg] = useState<string | null>(null);232 const refresh = () => qc.invalidateQueries({ queryKey: ['students'] });233 const create = useMutation({234 mutationFn: () => api<CreateResp>('/professor/students', { method: 'POST', body: JSON.stringify({ emails: bulk, send_invitations: sendInvites }) }),235 onSuccess: (r) => { setResult(r); setBulk(''); refresh(); },236 });237 const inviteAll = useMutation({238 mutationFn: (onlyNever: boolean) => api<{ sent: string[]; failed: string[]; total: number }>('/professor/students/invite-all', { method: 'POST', body: JSON.stringify({ only_never_invited: onlyNever }) }),239 onSuccess: (r) => { setInviteAllMsg(`${r.sent.length} invitation(s) envoyée(s)${r.failed.length ? `, ${r.failed.length} échec(s) : ${r.failed.join(', ')}` : ''}.`); refresh(); },240 onError: (e) => setInviteAllMsg((e as Error).message),241 });242 const invite = async (u: StudentRow) => {243 try {244 const r = await api<{ link: string; sent: boolean; expires_days: number }>(`/professor/students/${u.id}/invite`, { method: 'POST' });245 setLinks((l) => ({ ...l, [u.id]: r.link }));246 refresh();247 if (r.sent) alert(`Invitation envoyée à ${u.email} (lien valide ${r.expires_days} jours). Le lien est aussi affiché sous le courriel pour le copier.`);248 else alert('Courriel non envoyé (envoi non configuré ou refusé). Le lien est affiché sous le courriel : copie-le et transmets-le.');249 } catch (e) { alert((e as Error).message); }250 };251 const copy = async (key: string, text: string) => {252 await navigator.clipboard.writeText(text);253 setCopied(key);254 setTimeout(() => setCopied(null), 1200);255 };256 const users = (data?.users || []).filter((u) => !filter || u.email.includes(filter.toLowerCase()) || (u.display_name || '').toLowerCase().includes(filter.toLowerCase()));257 const neverInvited = (data?.users || []).filter((u) => !u.has_password && !u.invited_at).length;258 return (259 <div className="space-y-4">260 <div className="grid md:grid-cols-2 gap-4">261 <Card title="Ajouter des étudiant·es">262 <p className="text-xs text-neutral-muted mb-2">Un courriel par ligne (ou séparés par des virgules). Format accepté : <code>Prénom Nom <courriel@uqo.ca></code>. Chaque personne reçoit un courriel « Bienvenue sur UQO-Chat » avec un lien pour choisir son mot de passe (valide {data?.invite_ttl_days ?? 14} jours). Seules les adresses de cette liste peuvent se connecter.</p>263 <textarea value={bulk} onChange={(e) => setBulk(e.target.value)} rows={6} placeholder={'prenom.nom@uqo.ca\nMarie Tremblay <tremblay.marie@uqo.ca>'} className="w-full rounded-xl border border-neutral-line px-3 py-2 text-sm font-mono" />264 <label className="mt-2 flex items-center gap-2 text-sm"><input type="checkbox" checked={sendInvites} onChange={(e) => setSendInvites(e.target.checked)} className="h-4 w-4 accent-uqo-blue" disabled={!data?.mail} /> Envoyer l'invitation par courriel maintenant{!data?.mail && <span className="text-xs text-semantic-error">(envoi non configuré)</span>}</label>265 <div className="mt-2 flex items-center gap-3">266 <Button onClick={() => create.mutate()} disabled={!bulk.trim() || create.isPending}>{create.isPending ? <Spinner className="text-white" /> : <UserPlus size={16} />} Ajouter{sendInvites && data?.mail ? ' et inviter' : ''}</Button>267 {create.isError && <span className="text-sm text-semantic-error">{(create.error as Error).message}</span>}268 </div>269 {result && (270 <div className="mt-3 text-sm space-y-1">271 <div className="text-uqo-green">✓ {result.created.length} compte(s) créé(s){result.invited.length > 0 && ` · ${result.invited.length} invitation(s) envoyée(s)`}</div>272 {result.invite_failed.length > 0 && <div className="text-semantic-error">Courriel non envoyé à : {result.invite_failed.join(', ')} (icône enveloppe pour réessayer ou copier le lien)</div>}273 {result.existing.length > 0 && <div className="text-neutral-muted">{result.existing.length} existai(en)t déjà : {result.existing.join(', ')}</div>}274 {result.invalid.length > 0 && <div className="text-semantic-error">Invalides : {result.invalid.join(', ')}</div>}275 </div>276 )}277 </Card>278 <Card title="Invitations et connexion">279 <div className="flex items-start gap-3 rounded-xl bg-neutral-surface p-3 text-sm">280 <Mail size={18} className="text-uqo-blue shrink-0 mt-0.5" />281 <div>282 <div className="font-medium">{data?.mail ? 'Envoi de courriel actif (Resend)' : 'Envoi de courriel non configuré'}</div>283 <div className="text-xs text-neutral-muted">{data?.mail ? <>Expéditeur : {data.mail_from}. Les étudiant·es choisissent leur mot de passe via le lien reçu, puis se connectent avec courriel + mot de passe. « Mot de passe oublié » renvoie un lien (valide 60 min).</> : 'Ajoute RESEND_API_KEY au manifeste de déploiement ; en attendant, copie les liens (icône enveloppe).'}</div>284 </div>285 </div>286 <div className="mt-3 grid grid-cols-2 gap-2 text-center">287 <div className="rounded-xl border border-neutral-line p-3"><div className="text-2xl font-bold text-uqo-blue-dark tabular-nums">{data ? data.users.length - data.pending : '…'}</div><div className="text-xs text-neutral-muted">comptes activés</div></div>288 <div className="rounded-xl border border-neutral-line p-3"><div className="text-2xl font-bold text-amber-600 tabular-nums">{data?.pending ?? '…'}</div><div className="text-xs text-neutral-muted">en attente de mot de passe</div></div>289 </div>290 <div className="mt-3 flex flex-wrap gap-2">291 <Button variant="secondary" size="sm" disabled={!data?.mail || neverInvited === 0 || inviteAll.isPending} onClick={() => inviteAll.mutate(true)}><Send size={14} /> Inviter les {neverInvited} jamais invité·es</Button>292 <Button variant="ghost" size="sm" disabled={!data?.mail || !data?.pending || inviteAll.isPending} onClick={() => confirm(`Renvoyer l'invitation aux ${data?.pending} compte(s) sans mot de passe ?`) && inviteAll.mutate(false)}><RefreshCw size={14} /> Relancer tous les comptes en attente</Button>293 </div>294 {inviteAllMsg && <p className="mt-2 text-sm text-neutral-muted">{inviteAllMsg}</p>}295 <p className="text-xs text-neutral-muted mt-3">Page de connexion : <b>{window.location.origin}/connexion</b></p>296 </Card>297 </div>298 <Card title={`Comptes (${data?.users.length ?? '…'})`}>299 <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filtrer…" className="mb-3 h-11 w-full sm:w-72 rounded-xl border border-neutral-line px-3 text-sm" />300 <div className="overflow-x-auto">301 <table className="w-full text-sm">302 <thead><tr className="text-left text-xs text-neutral-muted"><th className="py-1">Courriel</th><th>Nom</th><th>Rôle</th><th>Statut</th><th>Dernière visite</th><th className="text-right">Messages</th><th></th></tr></thead>303 <tbody>304 {users.map((u) => (305 <tr key={u.id} className="border-t border-neutral-line/70 align-top">306 <td className="py-2 pr-2"><div className="font-medium">{u.email}</div>{links[u.id] && (307 <div className="mt-1 flex items-center gap-1 text-xs"><input readOnly value={links[u.id]} className="h-8 flex-1 min-w-[220px] rounded-lg border border-neutral-line px-2 font-mono text-[11px]" onFocus={(e) => e.target.select()} /><button onClick={() => copy(u.id, links[u.id])} className="h-8 w-8 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface" aria-label="Copier le lien">{copied === u.id ? <Check size={14} className="text-uqo-green" /> : <Copy size={14} />}</button></div>308 )}</td>309 <td className="py-2 pr-2">{u.display_name || '—'}</td>310 <td className="py-2 pr-2"><span className={`text-[11px] rounded px-1.5 py-0.5 ${u.role === 'student' ? 'bg-neutral-surface' : 'bg-uqo-blue-light text-uqo-blue-dark'}`}>{u.role === 'student' ? 'étudiant·e' : u.role === 'professor' ? 'professeur' : 'admin'}</span></td>311 <td className="py-2 pr-2 whitespace-nowrap">{u.has_password ? <span className="inline-flex items-center gap-1 text-[11px] rounded px-1.5 py-0.5 bg-green-50 text-uqo-green"><Check size={12} /> activé</span> : u.invited_at ? <span className="inline-flex items-center gap-1 text-[11px] rounded px-1.5 py-0.5 bg-amber-50 text-amber-700" title={`Invitation envoyée ${fmtDate(u.invited_at)}`}><Mail size={12} /> invité·e {fmtDate(u.invited_at)}</span> : <span className="text-[11px] rounded px-1.5 py-0.5 bg-neutral-surface text-neutral-muted">pas encore invité·e</span>}</td>312 <td className="py-2 pr-2 text-neutral-muted whitespace-nowrap">{u.last_seen_at ? fmtDate(u.last_seen_at) : 'jamais'}</td>313 <td className="py-2 pr-2 text-right tabular-nums">{u.messages}</td>314 <td className="py-2 text-right whitespace-nowrap">315 <button onClick={() => invite(u)} title={u.has_password ? 'Envoyer un lien de réinitialisation du mot de passe' : 'Envoyer / renvoyer l\'invitation (choisir le mot de passe)'} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-uqo-blue" aria-label="Envoyer l'invitation"><Send size={15} /></button>316 <button onClick={() => { const pw = prompt(`Définir manuellement un mot de passe pour ${u.email} (≥ 8 caractères) :`); if (pw) api(`/professor/students/${u.id}/password`, { method: 'PUT', body: JSON.stringify({ password: pw }) }).then(() => { alert('Mot de passe défini.'); refresh(); }).catch((e) => alert(e.message)); }} title="Définir un mot de passe manuellement (dépannage)" className={`h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface ${u.has_password ? 'text-uqo-green' : 'text-neutral-muted'}`} aria-label="Mot de passe"><Lock size={15} /></button>317 {u.role !== 'admin' && (318 <button onClick={() => api(`/professor/students/${u.id}`, { method: 'PATCH', body: JSON.stringify({ role: u.role === 'student' ? 'professor' : 'student' }) }).then(refresh).catch((e) => alert(e.message))} title={u.role === 'student' ? 'Promouvoir professeur' : 'Rétrograder étudiant'} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-neutral-muted" aria-label="Changer le rôle">{u.role === 'student' ? <Shield size={15} /> : <ShieldOff size={15} />}</button>319 )}320 {u.role !== 'admin' && (321 <button onClick={() => confirm(`Supprimer le compte ${u.email} et toutes ses données ?`) && api(`/professor/students/${u.id}`, { method: 'DELETE' }).then(refresh).catch((e) => alert(e.message))} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-red-50 text-semantic-error" aria-label="Supprimer"><Trash2 size={15} /></button>322 )}323 </td>324 </tr>325 ))}326 </tbody>327 </table>328 </div>329 </Card>330 </div>331 );332}333