SPB Git

spb/uqo-chat

Public
17.7 KB · 214 lines tsx
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
Files · cd08d441f7c277e8fedd6f3d3be82f02b44553ba
Menu

Theme