| 1 | "use client";
|
|---|
| 2 |
|
|---|
| 3 | import { useCallback, useEffect, useState } from "react";
|
|---|
| 4 | import { useTranslation } from "react-i18next";
|
|---|
| 5 | import { adminGet, adminSend, type AdminSemester, type Schedule } from "@/lib/admin-api";
|
|---|
| 6 |
|
|---|
| 7 | export default function AdminSchedulePage() {
|
|---|
| 8 | const { t } = useTranslation();
|
|---|
| 9 |
|
|---|
| 10 | const [semesters, setSemesters] = useState<AdminSemester[]>([]);
|
|---|
| 11 | const [semesterId, setSemesterId] = useState<number | null>(null);
|
|---|
| 12 | const [schedule, setSchedule] = useState<Schedule | null>(null);
|
|---|
| 13 |
|
|---|
| 14 | const [loading, setLoading] = useState(true);
|
|---|
| 15 | const [busy, setBusy] = useState(false);
|
|---|
| 16 | const [error, setError] = useState<string | null>(null);
|
|---|
| 17 | const [notice, setNotice] = useState<string | null>(null);
|
|---|
| 18 |
|
|---|
| 19 | const [subjectId, setSubjectId] = useState<number | "">("");
|
|---|
| 20 | const [professorId, setProfessorId] = useState<number | "">("");
|
|---|
| 21 |
|
|---|
| 22 | useEffect(() => {
|
|---|
| 23 | (async () => {
|
|---|
| 24 | try {
|
|---|
| 25 | const list = await adminGet<AdminSemester[]>("/api/admin/semesters");
|
|---|
| 26 | setSemesters(list);
|
|---|
| 27 | setSemesterId(list[0]?.id ?? null);
|
|---|
| 28 | } catch (e) {
|
|---|
| 29 | setError(e instanceof Error ? e.message : String(e));
|
|---|
| 30 | } finally {
|
|---|
| 31 | setLoading(false);
|
|---|
| 32 | }
|
|---|
| 33 | })();
|
|---|
| 34 | }, []);
|
|---|
| 35 |
|
|---|
| 36 | const loadSchedule = useCallback(async (id: number) => {
|
|---|
| 37 | setLoading(true);
|
|---|
| 38 | setError(null);
|
|---|
| 39 | try {
|
|---|
| 40 | setSchedule(await adminGet<Schedule>(`/api/admin/schedule/${id}`));
|
|---|
| 41 | } catch (e) {
|
|---|
| 42 | setError(e instanceof Error ? e.message : String(e));
|
|---|
| 43 | } finally {
|
|---|
| 44 | setLoading(false);
|
|---|
| 45 | }
|
|---|
| 46 | }, []);
|
|---|
| 47 |
|
|---|
| 48 | useEffect(() => {
|
|---|
| 49 | if (semesterId !== null) void loadSchedule(semesterId);
|
|---|
| 50 | }, [semesterId, loadSchedule]);
|
|---|
| 51 |
|
|---|
| 52 | async function run(action: () => Promise<{ message?: string }>) {
|
|---|
| 53 | setBusy(true);
|
|---|
| 54 | setError(null);
|
|---|
| 55 | setNotice(null);
|
|---|
| 56 | try {
|
|---|
| 57 | const result = await action();
|
|---|
| 58 | setNotice(result.message ?? null);
|
|---|
| 59 | if (semesterId !== null) await loadSchedule(semesterId);
|
|---|
| 60 | } catch (e) {
|
|---|
| 61 | setError(e instanceof Error ? e.message : String(e));
|
|---|
| 62 | } finally {
|
|---|
| 63 | setBusy(false);
|
|---|
| 64 | }
|
|---|
| 65 | }
|
|---|
| 66 |
|
|---|
| 67 | return (
|
|---|
| 68 | <div className="min-h-screen pb-8">
|
|---|
| 69 | <div className="bg-primary text-white rounded-xl p-8 mb-8">
|
|---|
| 70 | <h1 className="text-3xl font-bold mb-2">{t("admin_schedule")}</h1>
|
|---|
| 71 | <p className="text-lg opacity-90">{t("admin_schedule_intro")}</p>
|
|---|
| 72 | </div>
|
|---|
| 73 |
|
|---|
| 74 | {error && (
|
|---|
| 75 | <div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">{error}</div>
|
|---|
| 76 | )}
|
|---|
| 77 | {notice && (
|
|---|
| 78 | <div className="mb-4 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">{notice}</div>
|
|---|
| 79 | )}
|
|---|
| 80 |
|
|---|
| 81 | <div className="bg-card rounded-xl shadow-sm border border-border p-6 mb-6">
|
|---|
| 82 | <div className="flex flex-wrap items-end gap-4">
|
|---|
| 83 | <label className="flex flex-col">
|
|---|
| 84 | <span className="text-sm text-muted-foreground">{t("semester")}</span>
|
|---|
| 85 | <select
|
|---|
| 86 | className="rounded-lg border border-border bg-background px-3 py-2"
|
|---|
| 87 | value={semesterId ?? ""}
|
|---|
| 88 | onChange={(e) => setSemesterId(Number(e.target.value))}
|
|---|
| 89 | >
|
|---|
| 90 | {semesters.map((s) => (
|
|---|
| 91 | <option key={s.id} value={s.id}>{s.name}</option>
|
|---|
| 92 | ))}
|
|---|
| 93 | </select>
|
|---|
| 94 | </label>
|
|---|
| 95 |
|
|---|
| 96 | <label className="flex flex-col">
|
|---|
| 97 | <span className="text-sm text-muted-foreground">{t("subject")}</span>
|
|---|
| 98 | <select
|
|---|
| 99 | className="rounded-lg border border-border bg-background px-3 py-2"
|
|---|
| 100 | value={subjectId}
|
|---|
| 101 | onChange={(e) => setSubjectId(e.target.value === "" ? "" : Number(e.target.value))}
|
|---|
| 102 | >
|
|---|
| 103 | <option value="">{t("admin_pick_subject")}</option>
|
|---|
| 104 | {schedule?.allSubjects.map((s) => (
|
|---|
| 105 | <option key={s.id} value={s.id}>{s.code} — {s.name}</option>
|
|---|
| 106 | ))}
|
|---|
| 107 | </select>
|
|---|
| 108 | </label>
|
|---|
| 109 |
|
|---|
| 110 | <label className="flex flex-col">
|
|---|
| 111 | <span className="text-sm text-muted-foreground">{t("admin_professor")}</span>
|
|---|
| 112 | <select
|
|---|
| 113 | className="rounded-lg border border-border bg-background px-3 py-2"
|
|---|
| 114 | value={professorId}
|
|---|
| 115 | onChange={(e) => setProfessorId(e.target.value === "" ? "" : Number(e.target.value))}
|
|---|
| 116 | >
|
|---|
| 117 | <option value="">{t("admin_pick_professor")}</option>
|
|---|
| 118 | {schedule?.professors.map((p) => (
|
|---|
| 119 | <option key={p.id} value={p.id}>{p.name}</option>
|
|---|
| 120 | ))}
|
|---|
| 121 | </select>
|
|---|
| 122 | </label>
|
|---|
| 123 |
|
|---|
| 124 | <button
|
|---|
| 125 | className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
|
|---|
| 126 | disabled={busy || subjectId === "" || professorId === "" || semesterId === null}
|
|---|
| 127 | onClick={() =>
|
|---|
| 128 | void run(() =>
|
|---|
| 129 | adminSend("/api/admin/schedule", "POST", {
|
|---|
| 130 | ProfessorId: professorId,
|
|---|
| 131 | SemesterId: semesterId,
|
|---|
| 132 | SubjectId: subjectId,
|
|---|
| 133 | }),
|
|---|
| 134 | )
|
|---|
| 135 | }
|
|---|
| 136 | >
|
|---|
| 137 | {t("admin_assign")}
|
|---|
| 138 | </button>
|
|---|
| 139 | </div>
|
|---|
| 140 | </div>
|
|---|
| 141 |
|
|---|
| 142 | {schedule && schedule.uncoveredSubjects.length > 0 && (
|
|---|
| 143 | <div className="mb-6 rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3 text-sm text-yellow-900">
|
|---|
| 144 | <strong>{t("admin_uncovered_count", { count: schedule.uncoveredSubjects.length })}</strong>
|
|---|
| 145 | <span className="ml-2">{t("admin_uncovered_hint")}</span>
|
|---|
| 146 | <div className="mt-2 font-mono text-xs">
|
|---|
| 147 | {schedule.uncoveredSubjects.map((u) => u.code).join(", ")}
|
|---|
| 148 | </div>
|
|---|
| 149 | </div>
|
|---|
| 150 | )}
|
|---|
| 151 |
|
|---|
| 152 | <div className="grid gap-6 lg:grid-cols-3">
|
|---|
| 153 | <div className="lg:col-span-2 bg-card rounded-xl shadow-sm border border-border overflow-hidden">
|
|---|
| 154 | <div className="bg-primary text-white px-6 py-4">
|
|---|
| 155 | <h2 className="text-xl font-bold">
|
|---|
| 156 | {t("admin_assignments")} {schedule ? `— ${schedule.semesterName}` : ""}
|
|---|
| 157 | </h2>
|
|---|
| 158 | </div>
|
|---|
| 159 | <div className="overflow-x-auto">
|
|---|
| 160 | <table className="w-full">
|
|---|
| 161 | <thead className="bg-accent">
|
|---|
| 162 | <tr>
|
|---|
| 163 | <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("admin_subject_code")}</th>
|
|---|
| 164 | <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("subject")}</th>
|
|---|
| 165 | <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("admin_professor")}</th>
|
|---|
| 166 | <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("actions")}</th>
|
|---|
| 167 | </tr>
|
|---|
| 168 | </thead>
|
|---|
| 169 | <tbody>
|
|---|
| 170 | {loading ? (
|
|---|
| 171 | <tr><td className="px-4 py-4 text-muted-foreground" colSpan={4}>{t("loading")}</td></tr>
|
|---|
| 172 | ) : !schedule || schedule.assignments.length === 0 ? (
|
|---|
| 173 | <tr><td className="px-4 py-4 text-muted-foreground" colSpan={4}>{t("admin_no_assignments")}</td></tr>
|
|---|
| 174 | ) : (
|
|---|
| 175 | schedule.assignments.map((a) => (
|
|---|
| 176 | <tr key={`${a.subjectId}-${a.professorId}`} className="hover:bg-accent">
|
|---|
| 177 | <td className="px-4 py-3 border-b font-mono text-card-foreground">{a.subjectCode}</td>
|
|---|
| 178 | <td className="px-4 py-3 border-b text-card-foreground">{a.subjectName}</td>
|
|---|
| 179 | <td className="px-4 py-3 border-b text-card-foreground">{a.professorName}</td>
|
|---|
| 180 | <td className="px-4 py-3 border-b">
|
|---|
| 181 | <button
|
|---|
| 182 | className="rounded-lg bg-gray-800 px-3 py-1.5 text-sm text-white disabled:opacity-50"
|
|---|
| 183 | disabled={busy}
|
|---|
| 184 | onClick={() =>
|
|---|
| 185 | void run(() =>
|
|---|
| 186 | adminSend("/api/admin/schedule", "DELETE", {
|
|---|
| 187 | ProfessorId: a.professorId,
|
|---|
| 188 | SemesterId: schedule.semesterId,
|
|---|
| 189 | SubjectId: a.subjectId,
|
|---|
| 190 | }),
|
|---|
| 191 | )
|
|---|
| 192 | }
|
|---|
| 193 | >
|
|---|
| 194 | {t("admin_remove")}
|
|---|
| 195 | </button>
|
|---|
| 196 | </td>
|
|---|
| 197 | </tr>
|
|---|
| 198 | ))
|
|---|
| 199 | )}
|
|---|
| 200 | </tbody>
|
|---|
| 201 | </table>
|
|---|
| 202 | </div>
|
|---|
| 203 | </div>
|
|---|
| 204 |
|
|---|
| 205 | <div className="bg-card rounded-xl shadow-sm border border-border overflow-hidden">
|
|---|
| 206 | <div className="bg-primary text-white px-6 py-4">
|
|---|
| 207 | <h2 className="text-xl font-bold">{t("admin_load")}</h2>
|
|---|
| 208 | </div>
|
|---|
| 209 | <ul className="divide-y divide-border">
|
|---|
| 210 | {schedule?.load.map((l) => (
|
|---|
| 211 | <li key={l.professorId} className="flex items-center justify-between px-4 py-3">
|
|---|
| 212 | <span className="text-card-foreground">{l.professorName}</span>
|
|---|
| 213 | <span className="rounded-full bg-accent px-2 py-0.5 text-sm text-muted-foreground">{l.subjects}</span>
|
|---|
| 214 | </li>
|
|---|
| 215 | ))}
|
|---|
| 216 | {!schedule?.load.length && (
|
|---|
| 217 | <li className="px-4 py-3 text-muted-foreground">{t("admin_no_professors")}</li>
|
|---|
| 218 | )}
|
|---|
| 219 | </ul>
|
|---|
| 220 | </div>
|
|---|
| 221 | </div>
|
|---|
| 222 | </div>
|
|---|
| 223 | );
|
|---|
| 224 | }
|
|---|