SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
30.0 KB · 333 lines tsx
Raw Blame History
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 &lt;courriel@uqo.ca&gt;</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