SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
9.5 KB · 259 lines tsx
Raw Blame History
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&nbsp;:{" "}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