source: frontend/src/app/admin/subjects/page.tsx@ b8093a0

Last change on this file since b8093a0 was b8093a0, checked in by imbrsk <boris696boris@…>, 4 days ago

Merge iknow-remaster into frontend/

Bring the Next.js frontend into this repository as a monorepo subdirectory,
preserving its full commit history via a subtree merge.

  • Property mode set to 100644
File size: 15.9 KB
Line 
1"use client";
2
3import { useCallback, useEffect, useMemo, useState } from "react";
4import { useTranslation } from "react-i18next";
5import {
6 adminGet,
7 adminSend,
8 type AdminSubject,
9 type SubjectRef,
10} from "@/lib/admin-api";
11
12const EMPTY_FORM = { name: "", code: "", awardedCredits: 6, dependencyCredit: 0 };
13
14export default function AdminSubjectsPage() {
15 const { t } = useTranslation();
16
17 const [subjects, setSubjects] = useState<AdminSubject[]>([]);
18 const [majors, setMajors] = useState<SubjectRef[]>([]);
19 const [loading, setLoading] = useState(true);
20 const [busy, setBusy] = useState(false);
21 const [error, setError] = useState<string | null>(null);
22 const [notice, setNotice] = useState<string | null>(null);
23
24 const [form, setForm] = useState(EMPTY_FORM);
25 const [editingId, setEditingId] = useState<number | null>(null);
26 const [expandedId, setExpandedId] = useState<number | null>(null);
27 const [query, setQuery] = useState("");
28
29 const refresh = useCallback(async () => {
30 setLoading(true);
31 setError(null);
32 try {
33 const [s, m] = await Promise.all([
34 adminGet<AdminSubject[]>("/api/admin/subjects"),
35 adminGet<SubjectRef[]>("/api/admin/majors"),
36 ]);
37 setSubjects(s);
38 setMajors(m);
39 } catch (e) {
40 setError(e instanceof Error ? e.message : String(e));
41 } finally {
42 setLoading(false);
43 }
44 }, []);
45
46 useEffect(() => {
47 void refresh();
48 }, [refresh]);
49
50 async function run(action: () => Promise<{ message?: string }>) {
51 setBusy(true);
52 setError(null);
53 setNotice(null);
54 try {
55 const result = await action();
56 setNotice(result.message ?? null);
57 await refresh();
58 } catch (e) {
59 setError(e instanceof Error ? e.message : String(e));
60 } finally {
61 setBusy(false);
62 }
63 }
64
65 const filtered = useMemo(() => {
66 const q = query.trim().toLowerCase();
67 if (!q) return subjects;
68 return subjects.filter(
69 (s) =>
70 (s.name ?? "").toLowerCase().includes(q) ||
71 (s.code ?? "").toLowerCase().includes(q),
72 );
73 }, [subjects, query]);
74
75 function startEdit(s: AdminSubject) {
76 setEditingId(s.id);
77 setForm({
78 name: s.name ?? "",
79 code: s.code ?? "",
80 awardedCredits: s.awardedCredits,
81 dependencyCredit: s.dependencyCredit ?? 0,
82 });
83 window.scrollTo({ top: 0, behavior: "smooth" });
84 }
85
86 function cancelEdit() {
87 setEditingId(null);
88 setForm(EMPTY_FORM);
89 }
90
91 return (
92 <div className="min-h-screen pb-8">
93 <div className="bg-primary text-white rounded-xl p-8 mb-8">
94 <h1 className="text-3xl font-bold mb-2">{t("admin_subjects")}</h1>
95 <p className="text-lg opacity-90">{t("admin_subjects_intro")}</p>
96 </div>
97
98 {error && (
99 <div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
100 {error}
101 </div>
102 )}
103 {notice && (
104 <div className="mb-4 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">
105 {notice}
106 </div>
107 )}
108
109 {/* create / edit */}
110 <div className="bg-card rounded-xl shadow-sm border border-border p-6 mb-6">
111 <h2 className="text-xl font-semibold mb-4 text-card-foreground">
112 {editingId === null ? t("admin_new_subject") : t("admin_edit_subject")}
113 </h2>
114 <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
115 <label className="flex flex-col">
116 <span className="text-sm text-muted-foreground">{t("admin_subject_name")}</span>
117 <input
118 className="rounded-lg border border-border bg-background px-3 py-2"
119 value={form.name}
120 onChange={(e) => setForm({ ...form, name: e.target.value })}
121 />
122 </label>
123 <label className="flex flex-col">
124 <span className="text-sm text-muted-foreground">{t("admin_subject_code")}</span>
125 <input
126 className="rounded-lg border border-border bg-background px-3 py-2 font-mono"
127 value={form.code}
128 onChange={(e) => setForm({ ...form, code: e.target.value })}
129 />
130 </label>
131 <label className="flex flex-col">
132 <span className="text-sm text-muted-foreground">{t("admin_credits")}</span>
133 <input
134 type="number"
135 min={1}
136 className="rounded-lg border border-border bg-background px-3 py-2"
137 value={form.awardedCredits}
138 onChange={(e) => setForm({ ...form, awardedCredits: Number(e.target.value) })}
139 />
140 </label>
141 <label className="flex flex-col">
142 <span className="text-sm text-muted-foreground">{t("admin_dependency_credit")}</span>
143 <input
144 type="number"
145 min={0}
146 className="rounded-lg border border-border bg-background px-3 py-2"
147 value={form.dependencyCredit}
148 onChange={(e) => setForm({ ...form, dependencyCredit: Number(e.target.value) })}
149 />
150 </label>
151 </div>
152 <div className="mt-4 flex gap-3">
153 <button
154 disabled={busy}
155 className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
156 onClick={() =>
157 void run(async () => {
158 const body = {
159 Name: form.name,
160 Code: form.code,
161 AwardedCredits: form.awardedCredits,
162 DependencyCredit: form.dependencyCredit,
163 };
164 const result =
165 editingId === null
166 ? await adminSend("/api/admin/subjects", "POST", body)
167 : await adminSend(`/api/admin/subjects/${editingId}`, "PUT", body);
168 cancelEdit();
169 return result;
170 })
171 }
172 >
173 {editingId === null ? t("admin_create") : t("admin_save")}
174 </button>
175 {editingId !== null && (
176 <button
177 className="rounded-lg border border-border px-4 py-2 text-sm font-medium"
178 onClick={cancelEdit}
179 disabled={busy}
180 >
181 {t("cancel")}
182 </button>
183 )}
184 </div>
185 </div>
186
187 {/* list */}
188 <div className="bg-card rounded-xl shadow-sm border border-border overflow-hidden">
189 <div className="flex items-center justify-between gap-4 bg-primary text-white px-6 py-4">
190 <h2 className="text-xl font-bold">
191 {t("admin_subjects")} ({filtered.length})
192 </h2>
193 <input
194 className="rounded-lg px-3 py-2 text-card-foreground bg-background"
195 placeholder={t("admin_search_subject")}
196 value={query}
197 onChange={(e) => setQuery(e.target.value)}
198 />
199 </div>
200
201 <div className="overflow-x-auto">
202 <table className="w-full">
203 <thead className="bg-accent">
204 <tr>
205 <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("admin_subject_code")}</th>
206 <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("admin_subject_name")}</th>
207 <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("admin_credits")}</th>
208 <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("admin_majors")}</th>
209 <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("admin_prerequisites")}</th>
210 <th className="px-4 py-3 text-left text-xs font-medium uppercase text-muted-foreground">{t("actions")}</th>
211 </tr>
212 </thead>
213 <tbody>
214 {loading ? (
215 <tr><td className="px-4 py-4 text-muted-foreground" colSpan={6}>{t("loading")}</td></tr>
216 ) : filtered.length === 0 ? (
217 <tr><td className="px-4 py-4 text-muted-foreground" colSpan={6}>{t("admin_no_subjects")}</td></tr>
218 ) : (
219 filtered.map((s) => (
220 <ExpandableRow
221 key={s.id}
222 subject={s}
223 majors={majors}
224 subjects={subjects}
225 busy={busy}
226 expanded={expandedId === s.id}
227 onToggle={() => setExpandedId(expandedId === s.id ? null : s.id)}
228 onEdit={() => startEdit(s)}
229 run={run}
230 />
231 ))
232 )}
233 </tbody>
234 </table>
235 </div>
236 </div>
237 </div>
238 );
239}
240
241function ExpandableRow({
242 subject,
243 majors,
244 subjects,
245 busy,
246 expanded,
247 onToggle,
248 onEdit,
249 run,
250}: {
251 subject: AdminSubject;
252 majors: SubjectRef[];
253 subjects: AdminSubject[];
254 busy: boolean;
255 expanded: boolean;
256 onToggle: () => void;
257 onEdit: () => void;
258 run: (action: () => Promise<{ message?: string }>) => Promise<void>;
259}) {
260 const { t } = useTranslation();
261 const [majorId, setMajorId] = useState<number | "">("");
262 const [semester, setSemester] = useState(1);
263 const [prereqId, setPrereqId] = useState<number | "">("");
264
265 // A subject already taken by students cannot be deleted; the FK forbids it.
266 const deletable = subject.enrolledCount === 0;
267
268 return (
269 <>
270 <tr className="hover:bg-accent">
271 <td className="px-4 py-3 border-b font-mono text-card-foreground">{subject.code}</td>
272 <td className="px-4 py-3 border-b text-card-foreground">{subject.name}</td>
273 <td className="px-4 py-3 border-b text-card-foreground">{subject.awardedCredits}</td>
274 <td className="px-4 py-3 border-b text-sm text-muted-foreground">
275 {subject.majors.length === 0 ? "—" : subject.majors.map((m) => `${m.majorName} (${m.mandatorySemester})`).join(", ")}
276 </td>
277 <td className="px-4 py-3 border-b text-sm text-muted-foreground">
278 {subject.prerequisites.length === 0 ? "—" : subject.prerequisites.map((p) => p.code).join(", ")}
279 </td>
280 <td className="px-4 py-3 border-b">
281 <div className="flex flex-wrap gap-2">
282 <button className="rounded-lg border border-border px-3 py-1.5 text-sm" onClick={onToggle}>
283 {expanded ? t("admin_close") : t("admin_manage")}
284 </button>
285 <button className="rounded-lg bg-blue-600 px-3 py-1.5 text-sm text-white disabled:opacity-50" onClick={onEdit} disabled={busy}>
286 {t("edit_grade") === "Измени оценка" ? "Измени" : "Edit"}
287 </button>
288 <button
289 className="rounded-lg bg-gray-800 px-3 py-1.5 text-sm text-white disabled:opacity-40"
290 disabled={busy || !deletable}
291 title={deletable ? undefined : t("admin_delete_blocked", { count: subject.enrolledCount })}
292 onClick={() => void run(() => adminSend(`/api/admin/subjects/${subject.id}`, "DELETE"))}
293 >
294 {t("admin_delete")}
295 </button>
296 </div>
297 </td>
298 </tr>
299
300 {expanded && (
301 <tr>
302 <td colSpan={6} className="border-b bg-accent/40 px-4 py-4">
303 <div className="grid gap-6 lg:grid-cols-2">
304 {/* majors */}
305 <div>
306 <h3 className="font-semibold text-card-foreground mb-2">{t("admin_majors")}</h3>
307 <ul className="mb-3 space-y-1 text-sm">
308 {subject.majors.map((m) => (
309 <li key={m.majorId} className="flex items-center justify-between gap-2">
310 <span>{m.majorName} — {t("enroll_semester_short")} {m.mandatorySemester}</span>
311 <button
312 className="text-red-700 hover:underline"
313 disabled={busy}
314 onClick={() => void run(() => adminSend(`/api/admin/subjects/${subject.id}/majors/${m.majorId}`, "DELETE"))}
315 >
316 {t("admin_remove")}
317 </button>
318 </li>
319 ))}
320 {subject.majors.length === 0 && <li className="text-muted-foreground">—</li>}
321 </ul>
322 <div className="flex flex-wrap gap-2">
323 <select
324 className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
325 value={majorId}
326 onChange={(e) => setMajorId(e.target.value === "" ? "" : Number(e.target.value))}
327 >
328 <option value="">{t("admin_pick_major")}</option>
329 {majors.map((m) => (
330 <option key={m.id} value={m.id}>{m.name}</option>
331 ))}
332 </select>
333 <input
334 type="number"
335 min={1}
336 className="w-24 rounded-lg border border-border bg-background px-3 py-2 text-sm"
337 value={semester}
338 onChange={(e) => setSemester(Number(e.target.value))}
339 />
340 <button
341 className="rounded-lg bg-green-600 px-3 py-2 text-sm text-white disabled:opacity-50"
342 disabled={busy || majorId === ""}
343 onClick={() =>
344 void run(() =>
345 adminSend(`/api/admin/subjects/${subject.id}/majors`, "POST", {
346 MajorId: majorId,
347 MandatorySemester: semester,
348 }),
349 )
350 }
351 >
352 {t("admin_add")}
353 </button>
354 </div>
355 </div>
356
357 {/* prerequisites */}
358 <div>
359 <h3 className="font-semibold text-card-foreground mb-2">{t("admin_prerequisites")}</h3>
360 <ul className="mb-3 space-y-1 text-sm">
361 {subject.prerequisites.map((p) => (
362 <li key={p.id} className="flex items-center justify-between gap-2">
363 <span>{p.code} — {p.name}</span>
364 <button
365 className="text-red-700 hover:underline"
366 disabled={busy}
367 onClick={() => void run(() => adminSend(`/api/admin/subjects/${subject.id}/prerequisites/${p.id}`, "DELETE"))}
368 >
369 {t("admin_remove")}
370 </button>
371 </li>
372 ))}
373 {subject.prerequisites.length === 0 && <li className="text-muted-foreground">—</li>}
374 </ul>
375 <div className="flex flex-wrap gap-2">
376 <select
377 className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
378 value={prereqId}
379 onChange={(e) => setPrereqId(e.target.value === "" ? "" : Number(e.target.value))}
380 >
381 <option value="">{t("admin_pick_prerequisite")}</option>
382 {subjects
383 .filter((other) => other.id !== subject.id)
384 .map((other) => (
385 <option key={other.id} value={other.id}>{other.code} — {other.name}</option>
386 ))}
387 </select>
388 <button
389 className="rounded-lg bg-green-600 px-3 py-2 text-sm text-white disabled:opacity-50"
390 disabled={busy || prereqId === ""}
391 onClick={() =>
392 void run(() =>
393 adminSend(`/api/admin/subjects/${subject.id}/prerequisites`, "POST", {
394 DependencyId: prereqId,
395 }),
396 )
397 }
398 >
399 {t("admin_add")}
400 </button>
401 </div>
402 </div>
403 </div>
404 </td>
405 </tr>
406 )}
407 </>
408 );
409}
Note: See TracBrowser for help on using the repository browser.