| [8496f3c] | 1 | "use client";
|
|---|
| 2 |
|
|---|
| 3 | import { useCallback, useEffect, useState } from "react";
|
|---|
| 4 | import { useTranslation } from "react-i18next";
|
|---|
| 5 | import { adminGet, adminSend, type AdminSemester } from "@/lib/admin-api";
|
|---|
| 6 |
|
|---|
| 7 | export default function AdminSemestersPage() {
|
|---|
| 8 | const { t } = useTranslation();
|
|---|
| 9 |
|
|---|
| 10 | const [semesters, setSemesters] = useState<AdminSemester[]>([]);
|
|---|
| 11 | const [loading, setLoading] = useState(true);
|
|---|
| 12 | const [busy, setBusy] = useState(false);
|
|---|
| 13 | const [error, setError] = useState<string | null>(null);
|
|---|
| 14 | const [notice, setNotice] = useState<string | null>(null);
|
|---|
| 15 |
|
|---|
| 16 | const [year, setYear] = useState(new Date().getFullYear());
|
|---|
| 17 | const [type, setType] = useState<"winter" | "summer">("winter");
|
|---|
| 18 | const [expandedId, setExpandedId] = useState<number | null>(null);
|
|---|
| 19 |
|
|---|
| 20 | const refresh = useCallback(async () => {
|
|---|
| 21 | setLoading(true);
|
|---|
| 22 | setError(null);
|
|---|
| 23 | try {
|
|---|
| 24 | setSemesters(await adminGet<AdminSemester[]>("/api/admin/semesters"));
|
|---|
| 25 | } catch (e) {
|
|---|
| 26 | setError(e instanceof Error ? e.message : String(e));
|
|---|
| 27 | } finally {
|
|---|
| 28 | setLoading(false);
|
|---|
| 29 | }
|
|---|
| 30 | }, []);
|
|---|
| 31 |
|
|---|
| 32 | useEffect(() => {
|
|---|
| 33 | void refresh();
|
|---|
| 34 | }, [refresh]);
|
|---|
| 35 |
|
|---|
| 36 | async function create() {
|
|---|
| 37 | setBusy(true);
|
|---|
| 38 | setError(null);
|
|---|
| 39 | setNotice(null);
|
|---|
| 40 | try {
|
|---|
| 41 | const result = await adminSend("/api/admin/semesters", "POST", { Year: year, Type: type });
|
|---|
| 42 | setNotice(result.message ?? null);
|
|---|
| 43 | await refresh();
|
|---|
| 44 | } catch (e) {
|
|---|
| 45 | setError(e instanceof Error ? e.message : String(e));
|
|---|
| 46 | } finally {
|
|---|
| 47 | setBusy(false);
|
|---|
| 48 | }
|
|---|
| 49 | }
|
|---|
| 50 |
|
|---|
| 51 | return (
|
|---|
| 52 | <div className="min-h-screen pb-8">
|
|---|
| 53 | <div className="bg-primary text-white rounded-xl p-8 mb-8">
|
|---|
| 54 | <h1 className="text-3xl font-bold mb-2">{t("admin_semesters")}</h1>
|
|---|
| 55 | <p className="text-lg opacity-90">{t("admin_semesters_intro")}</p>
|
|---|
| 56 | </div>
|
|---|
| 57 |
|
|---|
| 58 | {error && (
|
|---|
| 59 | <div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{error}</div>
|
|---|
| 60 | )}
|
|---|
| 61 | {notice && (
|
|---|
| 62 | <div className="mb-4 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">{notice}</div>
|
|---|
| 63 | )}
|
|---|
| 64 |
|
|---|
| 65 | <div className="bg-card rounded-xl shadow-sm border border-border p-6 mb-6">
|
|---|
| 66 | <h2 className="text-xl font-semibold mb-4 text-card-foreground">{t("admin_new_semester")}</h2>
|
|---|
| 67 | <div className="flex flex-wrap items-end gap-4">
|
|---|
| 68 | <label className="flex flex-col">
|
|---|
| 69 | <span className="text-sm text-muted-foreground">{t("admin_year")}</span>
|
|---|
| 70 | <input
|
|---|
| 71 | type="number"
|
|---|
| 72 | min={2000}
|
|---|
| 73 | max={2100}
|
|---|
| 74 | className="rounded-lg border border-border bg-background px-3 py-2"
|
|---|
| 75 | value={year}
|
|---|
| 76 | onChange={(e) => setYear(Number(e.target.value))}
|
|---|
| 77 | />
|
|---|
| 78 | </label>
|
|---|
| 79 | <label className="flex flex-col">
|
|---|
| 80 | <span className="text-sm text-muted-foreground">{t("admin_type")}</span>
|
|---|
| 81 | <select
|
|---|
| 82 | className="rounded-lg border border-border bg-background px-3 py-2"
|
|---|
| 83 | value={type}
|
|---|
| 84 | onChange={(e) => setType(e.target.value as "winter" | "summer")}
|
|---|
| 85 | >
|
|---|
| 86 | <option value="winter">{t("admin_winter")}</option>
|
|---|
| 87 | <option value="summer">{t("admin_summer")}</option>
|
|---|
| 88 | </select>
|
|---|
| 89 | </label>
|
|---|
| 90 | <button
|
|---|
| 91 | disabled={busy}
|
|---|
| 92 | className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
|
|---|
| 93 | onClick={() => void create()}
|
|---|
| 94 | >
|
|---|
| 95 | {t("admin_open_semester")}
|
|---|
| 96 | </button>
|
|---|
| 97 | </div>
|
|---|
| 98 | </div>
|
|---|
| 99 |
|
|---|
| 100 | <div className="bg-card rounded-xl shadow-sm border border-border overflow-hidden">
|
|---|
| 101 | <div className="bg-primary text-white px-6 py-4">
|
|---|
| 102 | <h2 className="text-xl font-bold">{t("admin_open_semesters")}</h2>
|
|---|
| 103 | </div>
|
|---|
| 104 | <div className="overflow-x-auto">
|
|---|
| 105 | <table className="w-full">
|
|---|
| 106 | <thead className="bg-accent">
|
|---|
| 107 | <tr>
|
|---|
| 108 | <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("semester")}</th>
|
|---|
| 109 | <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("admin_enrolments")}</th>
|
|---|
| 110 | <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("admin_coverage")}</th>
|
|---|
| 111 | <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("actions")}</th>
|
|---|
| 112 | </tr>
|
|---|
| 113 | </thead>
|
|---|
| 114 | <tbody>
|
|---|
| 115 | {loading ? (
|
|---|
| 116 | <tr><td className="px-4 py-4 text-muted-foreground" colSpan={4}>{t("loading")}</td></tr>
|
|---|
| 117 | ) : semesters.length === 0 ? (
|
|---|
| 118 | <tr><td className="px-4 py-4 text-muted-foreground" colSpan={4}>{t("admin_no_semesters")}</td></tr>
|
|---|
| 119 | ) : (
|
|---|
| 120 | semesters.map((s) => {
|
|---|
| 121 | const uncovered = s.uncoveredSubjects.length;
|
|---|
| 122 | return (
|
|---|
| 123 | <>
|
|---|
| 124 | <tr key={s.id} className="hover:bg-accent">
|
|---|
| 125 | <td className="px-4 py-3 border-b text-card-foreground">{s.name}</td>
|
|---|
| 126 | <td className="px-4 py-3 border-b text-card-foreground">{s.enrolmentCount}</td>
|
|---|
| 127 | <td className="px-4 py-3 border-b">
|
|---|
| 128 | {uncovered === 0 ? (
|
|---|
| 129 | <span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
|
|---|
| 130 | {t("admin_fully_covered")}
|
|---|
| 131 | </span>
|
|---|
| 132 | ) : (
|
|---|
| 133 | <span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs text-yellow-900">
|
|---|
| 134 | {t("admin_uncovered_count", { count: uncovered })}
|
|---|
| 135 | </span>
|
|---|
| 136 | )}
|
|---|
| 137 | </td>
|
|---|
| 138 | <td className="px-4 py-3 border-b">
|
|---|
| 139 | {uncovered > 0 && (
|
|---|
| 140 | <button
|
|---|
| 141 | className="rounded-lg border border-border px-3 py-1.5 text-sm"
|
|---|
| 142 | onClick={() => setExpandedId(expandedId === s.id ? null : s.id)}
|
|---|
| 143 | >
|
|---|
| 144 | {expandedId === s.id ? t("admin_close") : t("admin_show_uncovered")}
|
|---|
| 145 | </button>
|
|---|
| 146 | )}
|
|---|
| 147 | </td>
|
|---|
| 148 | </tr>
|
|---|
| 149 | {expandedId === s.id && uncovered > 0 && (
|
|---|
| 150 | <tr key={`${s.id}-detail`}>
|
|---|
| 151 | <td colSpan={4} className="border-b bg-accent/40 px-4 py-4 text-sm">
|
|---|
| 152 | <p className="mb-2 text-muted-foreground">{t("admin_uncovered_hint")}</p>
|
|---|
| 153 | <ul className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
|---|
| 154 | {s.uncoveredSubjects.map((u) => (
|
|---|
| 155 | <li key={u.id} className="text-card-foreground">
|
|---|
| 156 | <span className="font-mono">{u.code}</span> — {u.name}
|
|---|
| 157 | </li>
|
|---|
| 158 | ))}
|
|---|
| 159 | </ul>
|
|---|
| 160 | </td>
|
|---|
| 161 | </tr>
|
|---|
| 162 | )}
|
|---|
| 163 | </>
|
|---|
| 164 | );
|
|---|
| 165 | })
|
|---|
| 166 | )}
|
|---|
| 167 | </tbody>
|
|---|
| 168 | </table>
|
|---|
| 169 | </div>
|
|---|
| 170 | </div>
|
|---|
| 171 | </div>
|
|---|
| 172 | );
|
|---|
| 173 | }
|
|---|