1import { useState, type FormEvent } from 'react';2import { Link } from 'react-router-dom';3import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';4import { ArrowLeft, Upload, Trash2, Eye, EyeOff, RefreshCw } from 'lucide-react';5import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';6import { api } from '@/lib/api';7import { Button } from '@/components/ui/button';8import { Spinner } from '@/components/ui/spinner';9import { uqo } from '@/theme/uqo';1011interface Dashboard {12 days: number; messages_per_day: { day: string; count: number }[]; active_students: number; total_users: number; conversations: number;13 peak_hours: { hour: number; count: number }[]; top_topics: { topic: string; course: string; count: number }[];14 question_samples: { question: string; topic: string; course: string; at: string }[]; feedback: Record<string, number>;15 tool_usage: { tool: string; count: number }[]; budget: { budget_usd: number; spent_usd: number; ratio: number }; costs_by_course: { course: string; cost_usd: number }[];16}17interface 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 } }18interface Settings { courses: CourseSettings[]; tools: string[]; models: Record<string, string>; budget_usd: number }19interface 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 }> }2021const TABS = ['Activité', 'Contenu', 'Réglages'] as const;2223export function ProfessorPage() {24 const [tab, setTab] = useState<(typeof TABS)[number]>('Activité');25 return (26 <div className="min-h-[100dvh] bg-neutral-surface">27 <header className="bg-uqo-blue text-white">28 <div className="mx-auto max-w-[1100px] px-4 pt-[calc(12px+var(--safe-top))] pb-3 flex items-center gap-3">29 <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>30 <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>31 </div>32 <nav className="mx-auto max-w-[1100px] px-4 flex gap-1 overflow-x-auto">33 {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>)}34 </nav>35 </header>36 <main className="mx-auto max-w-[1100px] px-4 py-5 pb-[calc(24px+var(--safe-bottom))]">37 {tab === 'Activité' && <Activity />}38 {tab === 'Contenu' && <ContentTab />}39 {tab === 'Réglages' && <SettingsTab />}40 </main>41 </div>42 );43}4445function Card({ title, children, className = '' }: { title: string; children: React.ReactNode; className?: string }) {46 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>;47}4849function Stat({ label, value }: { label: string; value: string | number }) {50 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>;51}5253function Activity() {54 const { data, isLoading } = useQuery({ queryKey: ['dashboard'], queryFn: () => api<Dashboard>('/professor/dashboard?days=30') });55 if (isLoading || !data) return <div className="flex justify-center py-10"><Spinner /></div>;56 return (57 <div className="space-y-4">58 <div className="grid grid-cols-2 md:grid-cols-4 gap-3">59 <Stat label="Étudiants actifs (30 j)" value={data.active_students} />60 <Stat label="Comptes" value={data.total_users} />61 <Stat label="Conversations (30 j)" value={data.conversations} />62 <Stat label="Coût du mois" value={`${data.budget.spent_usd.toFixed(2)} $ / ${data.budget.budget_usd} $`} />63 </div>64 <div className="grid md:grid-cols-2 gap-4">65 <Card title="Messages par jour">66 <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>67 </Card>68 <Card title="Heures de pointe">69 <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>70 </Card>71 <Card title="Top des notions demandées">72 {data.top_topics.length === 0 && <p className="text-sm text-neutral-muted">Pas encore de données.</p>}73 <ol className="space-y-1.5">74 {data.top_topics.map((t, i) => (75 <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>76 ))}77 </ol>78 </Card>79 <Card title="Qualité et outils">80 <div className="flex gap-4 text-sm mb-3"><span>👍 {data.feedback.up || 0}</span><span>👎 {data.feedback.down || 0}</span></div>81 <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>82 </Card>83 </div>84 <Card title="Questions anonymisées et reformulées (échantillon)">85 <ul className="divide-y divide-neutral-line/70">86 {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>)}87 {data.question_samples.length === 0 && <li className="text-sm text-neutral-muted">Aucune question pour l'instant.</li>}88 </ul>89 </Card>90 </div>91 );92}9394function ContentTab() {95 const qc = useQueryClient();96 const { data, refetch } = useQuery({ queryKey: ['content'], queryFn: () => api<Content>('/professor/content'), refetchInterval: 5000 });97 const [course, setCourse] = useState('IMM1033');98 const [visibility, setVisibility] = useState('students');99 const [title, setTitle] = useState('');100 const [file, setFile] = useState<File | null>(null);101 const upload = useMutation({102 mutationFn: async () => {103 if (!file) return;104 const fd = new FormData();105 fd.append('file', file); fd.append('course', course); fd.append('visibility', visibility); fd.append('title', title);106 await api('/professor/content', { method: 'POST', body: fd });107 },108 onSuccess: () => { setFile(null); setTitle(''); qc.invalidateQueries({ queryKey: ['content'] }); },109 });110 const submit = (e: FormEvent) => { e.preventDefault(); upload.mutate(); };111 return (112 <div className="space-y-4">113 <Card title={`Matériel indexé (${data?.index_size ?? '…'} passages)`}>114 <form onSubmit={submit} className="grid gap-2 sm:grid-cols-[1fr_auto_auto_auto] items-end">115 <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>116 <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>117 <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>118 <Button type="submit" disabled={!file || upload.isPending}><Upload size={16} /> Ingérer</Button>119 <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" />120 </form>121 {upload.isError && <p className="text-sm text-semantic-error mt-2">{(upload.error as Error).message}</p>}122 {data && Object.keys(data.jobs).length > 0 && (123 <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>124 )}125 </Card>126 <Card title="Documents">127 <div className="flex justify-end mb-2"><Button size="sm" variant="ghost" onClick={() => refetch()}><RefreshCw size={14} /> Actualiser</Button></div>128 <div className="overflow-x-auto">129 <table className="w-full text-sm">130 <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>131 <tbody>132 {data?.documents.map((d) => (133 <tr key={d.id} className="border-t border-neutral-line/70">134 <td className="py-2 pr-2 font-medium">{d.course}</td>135 <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>136 <td className="py-2 pr-2 tabular-nums">{d.n_chunks}</td>137 <td className="py-2 pr-2">138 <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">139 {d.visibility === 'students' ? <><Eye size={13} /> étudiants</> : <><EyeOff size={13} /> prof seulement</>}140 </button>141 </td>142 <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>143 </tr>144 ))}145 </tbody>146 </table>147 </div>148 </Card>149 </div>150 );151}152153function SettingsTab() {154 const qc = useQueryClient();155 const { data } = useQuery({ queryKey: ['prof-settings'], queryFn: () => api<Settings>('/professor/settings') });156 const [promote, setPromote] = useState('');157 if (!data) return <div className="flex justify-center py-10"><Spinner /></div>;158 return (159 <div className="space-y-4">160 {data.courses.map((c) => <CourseForm key={c.code} c={c} tools={data.tools} onSaved={() => qc.invalidateQueries({ queryKey: ['prof-settings'] })} defaultModel={data.models.primary} />)}161 <Card title="Modèles (configuration)">162 <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>163 <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>164 </Card>165 <Card title="Promouvoir un compte au rôle professeur">166 <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">167 <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" />168 <Button type="submit" variant="secondary">Promouvoir</Button>169 </form>170 </Card>171 </div>172 );173}174175function CourseForm({ c, tools, onSaved, defaultModel }: { c: CourseSettings; tools: string[]; onSaved: () => void; defaultModel: string }) {176 const [prompt, setPrompt] = useState(c.extra_system_prompt || '');177 const [announcement, setAnnouncement] = useState(c.announcement || '');178 const [deadlines, setDeadlines] = useState((c.deadlines || []).map((d) => `${d.label} | ${d.date}`).join('\n'));179 const [suggestions, setSuggestions] = useState((c.suggestions || []).join('\n'));180 const [enabled, setEnabled] = useState<string[]>(c.settings?.tools_enabled || tools);181 const [model, setModel] = useState(c.settings?.model_primary || '');182 const [budget, setBudget] = useState(String(c.settings?.budget_usd ?? ''));183 const save = useMutation({184 mutationFn: () => api(`/professor/settings/${c.code}`, {185 method: 'PATCH',186 body: JSON.stringify({187 extra_system_prompt: prompt, announcement,188 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 || '' }; }),189 suggestions: suggestions.split('\n').map((s) => s.trim()).filter(Boolean),190 settings: { tools_enabled: enabled.length === tools.length ? null : enabled, model_primary: model || null, budget_usd: budget ? Number(budget) : undefined },191 }),192 }),193 onSuccess: onSaved,194 });195 return (196 <Card title={`${c.code} — ${c.title}`}>197 <div className="grid gap-3 md:grid-cols-2">198 <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>199 <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>200 <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>201 <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>202 <div className="text-sm"><span className="block text-xs text-neutral-muted mb-1">Outils activés</span>203 <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>204 </div>205 <div className="grid gap-2">206 <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>207 <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>208 </div>209 </div>210 <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>211 </Card>212 );213}214