source: frontend/src/components/enroll-semester-dialog.tsx

Last change on this file was b8093a0, checked in by imbrsk <boris696boris@…>, 3 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: 9.2 KB
Line 
1"use client";
2
3import { useEffect, useMemo, useState } from "react";
4import { useTranslation } from "react-i18next";
5import { apiUrl } from "@/lib/api";
6import { getAccessToken } from "@/lib/auth";
7
8type SubjectOption = {
9 id: number;
10 name?: string;
11 code?: string;
12 credits: number;
13 mandatorySemester: number;
14 alreadyPassed: boolean;
15};
16
17type MajorOption = {
18 id: number;
19 name?: string;
20 subjects: SubjectOption[];
21};
22
23type SemesterOption = {
24 id: number;
25 name?: string;
26 year: number;
27 type?: string;
28};
29
30type EnrollmentOptions = {
31 requiredSubjects: number;
32 semesters: SemesterOption[];
33 majors: MajorOption[];
34 defaultMajorId: number | null;
35};
36
37export default function EnrollSemesterDialog({
38 open,
39 onClose,
40 onEnrolled,
41}: {
42 open: boolean;
43 onClose: () => void;
44 onEnrolled: () => void;
45}) {
46 const { t } = useTranslation();
47
48 const [options, setOptions] = useState<EnrollmentOptions | null>(null);
49 const [loading, setLoading] = useState(false);
50 const [submitting, setSubmitting] = useState(false);
51 const [error, setError] = useState<string | null>(null);
52
53 const [semesterId, setSemesterId] = useState<number | null>(null);
54 const [majorId, setMajorId] = useState<number | null>(null);
55 const [picked, setPicked] = useState<number[]>([]);
56
57 useEffect(() => {
58 if (!open) return;
59
60 let cancelled = false;
61
62 async function load() {
63 setLoading(true);
64 setError(null);
65 try {
66 const token = getAccessToken();
67 if (!token) throw new Error(t("enroll_not_signed_in"));
68
69 const res = await fetch(apiUrl("/api/enrollment/options"), {
70 cache: "no-store",
71 headers: { Authorization: `Bearer ${token}` },
72 });
73 if (!res.ok) throw new Error(`${t("enroll_options_failed")} (${res.status})`);
74
75 const data = (await res.json()) as EnrollmentOptions;
76 if (cancelled) return;
77
78 setOptions(data);
79 setSemesterId(data.semesters[0]?.id ?? null);
80 setMajorId(data.defaultMajorId ?? data.majors[0]?.id ?? null);
81 setPicked([]);
82 } catch (e) {
83 if (!cancelled) setError(e instanceof Error ? e.message : String(e));
84 } finally {
85 if (!cancelled) setLoading(false);
86 }
87 }
88
89 void load();
90 return () => {
91 cancelled = true;
92 };
93 }, [open, t]);
94
95 const required = options?.requiredSubjects ?? 5;
96
97 const subjects = useMemo(
98 () => options?.majors.find((m) => m.id === majorId)?.subjects ?? [],
99 [options, majorId],
100 );
101
102 function toggle(subjectId: number) {
103 setPicked((prev) => {
104 if (prev.includes(subjectId)) return prev.filter((id) => id !== subjectId);
105 if (prev.length >= required) return prev; // the cap is the point of the form
106 return [...prev, subjectId];
107 });
108 }
109
110 async function submit() {
111 setSubmitting(true);
112 setError(null);
113 try {
114 const token = getAccessToken();
115 if (!token) throw new Error(t("enroll_not_signed_in"));
116
117 const res = await fetch(apiUrl("/api/enrollment"), {
118 method: "POST",
119 headers: {
120 "Content-Type": "application/json",
121 Authorization: `Bearer ${token}`,
122 },
123 body: JSON.stringify({
124 SemesterId: semesterId,
125 MajorId: majorId,
126 SubjectIds: picked,
127 }),
128 });
129
130 const body = (await res.json()) as { ok: boolean; message?: string };
131 if (!res.ok || !body.ok) {
132 throw new Error(body.message || `${t("enroll_failed")} (${res.status})`);
133 }
134
135 onEnrolled();
136 onClose();
137 } catch (e) {
138 setError(e instanceof Error ? e.message : String(e));
139 } finally {
140 setSubmitting(false);
141 }
142 }
143
144 if (!open) return null;
145
146 const noSemestersLeft = !loading && options !== null && options.semesters.length === 0;
147 const canSubmit =
148 !submitting && semesterId !== null && majorId !== null && picked.length === required;
149
150 return (
151 <div
152 className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 sm:items-center"
153 role="dialog"
154 aria-modal="true"
155 onClick={onClose}
156 >
157 <div
158 className="w-full max-w-3xl rounded-xl border border-border bg-card shadow-lg"
159 onClick={(e) => e.stopPropagation()}
160 >
161 <div className="flex items-center justify-between rounded-t-xl bg-primary px-6 py-4 text-white">
162 <h2 className="text-xl font-bold">{t("enroll_semester")}</h2>
163 <button
164 className="rounded-lg px-3 py-1 text-sm hover:bg-white/20"
165 onClick={onClose}
166 aria-label={t("close")}
167 >
168
169 </button>
170 </div>
171
172 <div className="p-6">
173 {loading ? (
174 <p className="text-muted-foreground">{t("loading")}</p>
175 ) : noSemestersLeft ? (
176 <p className="text-muted-foreground">{t("enroll_no_semesters")}</p>
177 ) : (
178 <>
179 <div className="grid gap-4 sm:grid-cols-2">
180 <div className="flex flex-col">
181 <label className="text-sm text-muted-foreground">{t("semester")}</label>
182 <select
183 className="rounded-lg border border-border bg-background px-3 py-2"
184 value={semesterId ?? ""}
185 onChange={(e) => setSemesterId(Number.parseInt(e.target.value, 10))}
186 >
187 {options?.semesters.map((s) => (
188 <option key={s.id} value={s.id}>
189 {s.name}
190 </option>
191 ))}
192 </select>
193 </div>
194
195 <div className="flex flex-col">
196 <label className="text-sm text-muted-foreground">{t("direction")}</label>
197 <select
198 className="rounded-lg border border-border bg-background px-3 py-2"
199 value={majorId ?? ""}
200 onChange={(e) => {
201 setMajorId(Number.parseInt(e.target.value, 10));
202 setPicked([]); // subjects differ per programme
203 }}
204 >
205 {options?.majors.map((m) => (
206 <option key={m.id} value={m.id}>
207 {m.name}
208 </option>
209 ))}
210 </select>
211 </div>
212 </div>
213
214 <div className="mt-6 flex items-center justify-between">
215 <h3 className="font-semibold text-card-foreground">{t("subjects")}</h3>
216 <span
217 className={`text-sm font-medium ${
218 picked.length === required ? "text-green-700" : "text-muted-foreground"
219 }`}
220 >
221 {picked.length} / {required}
222 </span>
223 </div>
224
225 <div className="mt-2 max-h-80 overflow-y-auto rounded-lg border border-border">
226 {subjects.map((s) => {
227 const checked = picked.includes(s.id);
228 const atLimit = !checked && picked.length >= required;
229 return (
230 <label
231 key={s.id}
232 className={`flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0 ${
233 atLimit ? "opacity-50" : "cursor-pointer hover:bg-accent"
234 }`}
235 >
236 <input
237 type="checkbox"
238 checked={checked}
239 disabled={atLimit}
240 onChange={() => toggle(s.id)}
241 />
242 <span className="flex-1">
243 <span className="text-card-foreground">{s.name}</span>
244 <span className="ml-2 font-mono text-xs text-muted-foreground">{s.code}</span>
245 {s.alreadyPassed && (
246 <span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
247 {t("enroll_already_passed")}
248 </span>
249 )}
250 </span>
251 <span className="text-xs text-muted-foreground">
252 {t("enroll_semester_short")} {s.mandatorySemester} · {s.credits} {t("enroll_credits")}
253 </span>
254 </label>
255 );
256 })}
257 </div>
258 </>
259 )}
260
261 {error && (
262 <div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
263 {error}
264 </div>
265 )}
266
267 <div className="mt-6 flex justify-end gap-3">
268 <button
269 className="rounded-lg border border-border px-4 py-2 text-sm font-medium"
270 onClick={onClose}
271 disabled={submitting}
272 >
273 {t("cancel")}
274 </button>
275 <button
276 className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
277 onClick={() => void submit()}
278 disabled={!canSubmit}
279 >
280 {submitting ? t("loading") : t("enroll_confirm")}
281 </button>
282 </div>
283 </div>
284 </div>
285 </div>
286 );
287}
Note: See TracBrowser for help on using the repository browser.