TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Paramètres : budgets d'usage, politique d'intégrité académique, recherche3// croisée inter-cours et partage de conversations.4import { useEffect, useState } from "react";5import { Save } from "lucide-react";6import { PageHeader } from "@/components/app-shell";7import { Button, Card, Input, Label, Skeleton, Spinner } from "@/components/ui";8import { ErrorBanner, SectionTitle, SuccessFlash, Toggle, fmtUSD, useFetchJson } from "./shared";910type Budgets = {11 dailyPerUserUSD: number;12 monthlyPerUserUSD: number;13 monthlyGlobalUSD: number;14 dailyRequestsPerUser: number;15};1617type Settings = {18 budgets: Budgets;19 defaultBudgets: Budgets;20 integrityPolicy: { enabled: boolean; examLockdown: boolean };21 crossCourseEnabled: boolean;22 sharingEnabled: boolean;23};2425const BUDGET_FIELDS: { key: keyof Budgets; label: string; description: string; step: string; isInt?: boolean }[] = [26 {27 key: "dailyPerUserUSD",28 label: "Budget quotidien par étudiant·e ($)",29 description: "Dépense maximale d'appels de modèles par personne et par jour. Au-delà, seule l'attente ou un modèle gratuit reste possible.",30 step: "0.1",31 },32 {33 key: "monthlyPerUserUSD",34 label: "Budget mensuel par étudiant·e ($)",35 description: "Plafond mensuel individuel — protège contre un usage intensif prolongé.",36 step: "1",37 },38 {39 key: "monthlyGlobalUSD",40 label: "Budget global mensuel ($)",41 description: "Plafond de dépense pour toute la plateforme. Une fois atteint, plus aucun appel n'est effectué pour personne.",42 step: "10",43 },44 {45 key: "dailyRequestsPerUser",46 label: "Requêtes par jour par étudiant·e",47 description: "Nombre maximal de requêtes quotidiennes par personne, indépendamment du coût.",48 step: "10",49 isInt: true,50 },51];5253export function AdminSettings() {54 const { data, error, loading, reload } = useFetchJson<Settings>("/api/admin/settings");5556 const [budgets, setBudgets] = useState<Record<keyof Budgets, string> | null>(null);57 const [integrityEnabled, setIntegrityEnabled] = useState(true);58 const [examLockdown, setExamLockdown] = useState(false);59 const [crossCourse, setCrossCourse] = useState(false);60 const [sharing, setSharing] = useState(true);61 const [saving, setSaving] = useState(false);62 const [saveError, setSaveError] = useState<string | null>(null);63 const [flash, setFlash] = useState<string | null>(null);6465 useEffect(() => {66 if (!data) return;67 setBudgets({68 dailyPerUserUSD: String(data.budgets.dailyPerUserUSD),69 monthlyPerUserUSD: String(data.budgets.monthlyPerUserUSD),70 monthlyGlobalUSD: String(data.budgets.monthlyGlobalUSD),71 dailyRequestsPerUser: String(data.budgets.dailyRequestsPerUser),72 });73 setIntegrityEnabled(data.integrityPolicy.enabled);74 setExamLockdown(data.integrityPolicy.examLockdown);75 setCrossCourse(data.crossCourseEnabled);76 setSharing(data.sharingEnabled);77 }, [data]);7879 async function save() {80 if (!budgets) return;81 const parsed: Budgets = {82 dailyPerUserUSD: parseFloat(budgets.dailyPerUserUSD),83 monthlyPerUserUSD: parseFloat(budgets.monthlyPerUserUSD),84 monthlyGlobalUSD: parseFloat(budgets.monthlyGlobalUSD),85 dailyRequestsPerUser: Math.round(parseFloat(budgets.dailyRequestsPerUser)),86 };87 for (const f of BUDGET_FIELDS) {88 const v = parsed[f.key];89 if (!Number.isFinite(v) || v < 0 || (f.isInt && v < 1)) {90 setSaveError(`Valeur invalide pour « ${f.label} ».`);91 return;92 }93 }94 setSaving(true);95 setSaveError(null);96 setFlash(null);97 try {98 const res = await fetch("/api/admin/settings", {99 method: "POST",100 headers: { "Content-Type": "application/json" },101 body: JSON.stringify({102 budgets: parsed,103 integrityPolicy: { enabled: integrityEnabled, examLockdown },104 crossCourseEnabled: crossCourse,105 sharingEnabled: sharing,106 }),107 });108 const j = (await res.json().catch(() => null)) as { error?: string } | null;109 if (!res.ok) throw new Error(j?.error || `Erreur ${res.status}`);110 setFlash("Paramètres enregistrés — ils s'appliquent immédiatement.");111 setTimeout(() => setFlash(null), 4000);112 await reload();113 } catch (e) {114 setSaveError(e instanceof Error ? e.message : "L'enregistrement a échoué.");115 } finally {116 setSaving(false);117 }118 }119120 if (loading || !budgets) {121 return (122 <div className="animate-fade-up">123 <PageHeader title="Paramètres" subtitle="Budgets, intégrité académique et options de la plateforme" />124 {error ? (125 <ErrorBanner message={error} onRetry={reload} />126 ) : (127 <div className="space-y-4">128 <Skeleton className="h-64" />129 <Skeleton className="h-48" />130 </div>131 )}132 </div>133 );134 }135136 return (137 <div className="animate-fade-up">138 <PageHeader139 title="Paramètres"140 subtitle="Budgets, intégrité académique et options de la plateforme"141 actions={142 <Button onClick={save} disabled={saving}>143 {saving ? <Spinner /> : <Save size={15} />}144 Enregistrer145 </Button>146 }147 />148149 <div className="space-y-4">150 {saveError && <ErrorBanner message={saveError} />}151 {flash && <SuccessFlash message={flash} />}152153 {/* Budgets */}154 <Card className="p-5">155 <SectionTitle sub="Plafonds appliqués côté serveur avant chaque appel de modèle">Budgets d'usage</SectionTitle>156 <div className="grid gap-5 sm:grid-cols-2">157 {BUDGET_FIELDS.map((f) => (158 <div key={f.key}>159 <Label htmlFor={`budget-${f.key}`}>{f.label}</Label>160 <Input161 id={`budget-${f.key}`}162 type="number"163 min={0}164 step={f.step}165 inputMode="decimal"166 value={budgets[f.key]}167 onChange={(e) => setBudgets({ ...budgets, [f.key]: e.target.value })}168 className="tabular-nums"169 />170 <p className="mt-1.5 text-[12px] leading-relaxed text-muted">{f.description}</p>171 {data && (172 <p className="mt-0.5 text-[11.5px] text-muted">173 Par défaut :{" "}174 <span className="tabular-nums">175 {f.isInt ? data.defaultBudgets[f.key].toLocaleString("fr-CA") : fmtUSD(data.defaultBudgets[f.key])}176 </span>177 </p>178 )}179 </div>180 ))}181 </div>182 </Card>183184 {/* Intégrité académique */}185 <Card className="p-5">186 <SectionTitle sub="Encadre la manière dont l'assistant aide sans faire le travail à la place des étudiant·e·s">187 Intégrité académique188 </SectionTitle>189 <div className="space-y-4">190 <SettingRow191 title="Politique d'intégrité active"192 description="Injecte les consignes d'intégrité dans chaque conversation : l'assistant guide et explique, mais refuse de rédiger des travaux notés à la place de l'étudiant·e."193 checked={integrityEnabled}194 onChange={setIntegrityEnabled}195 />196 <SettingRow197 title="Verrouillage — période d'examen"198 description="Mode renforcé pour la période d'examen : l'assistant refuse de résoudre des questions qui ressemblent à des questions d'examen en cours et se limite à la révision conceptuelle. À activer uniquement pendant les évaluations."199 checked={examLockdown}200 onChange={setExamLockdown}201 disabled={!integrityEnabled}202 />203 </div>204 </Card>205206 {/* Options de la plateforme */}207 <Card className="p-5">208 <SectionTitle>Options de la plateforme</SectionTitle>209 <div className="space-y-4">210 <SettingRow211 title="Recherche croisée inter-cours"212 description="Permet à la recherche documentaire de puiser dans les DEUX cours (IMM1003 et IMM1033) à la fois. L'assistant signale alors explicitement lorsqu'une notion provient du cours voisin."213 checked={crossCourse}214 onChange={setCrossCourse}215 />216 <SettingRow217 title="Partage de conversations"218 description="Autorise les étudiant·e·s à générer un lien de partage en lecture seule vers une conversation. Désactivez pour couper tous les partages."219 checked={sharing}220 onChange={setSharing}221 />222 </div>223 </Card>224225 <div className="flex justify-end">226 <Button onClick={save} disabled={saving}>227 {saving ? <Spinner /> : <Save size={15} />}228 Enregistrer les paramètres229 </Button>230 </div>231 </div>232 </div>233 );234}235236function SettingRow({237 title,238 description,239 checked,240 onChange,241 disabled,242}: {243 title: string;244 description: string;245 checked: boolean;246 onChange: (v: boolean) => void;247 disabled?: boolean;248}) {249 return (250 <div className="flex items-start justify-between gap-4 border-t border-app pt-4 first:border-t-0 first:pt-0">251 <div className="min-w-0">252 <p className="text-[13.5px] font-medium text-fg">{title}</p>253 <p className="mt-0.5 text-[12.5px] leading-relaxed text-muted">{description}</p>254 </div>255 <Toggle checked={checked} onChange={onChange} disabled={disabled} label={title} />256 </div>257 );258}259