source: frontend/src/app/professor/students/page.tsx

Last change on this file 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: 11.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 UsersBySubject = {
9 /** users.id - what the grading endpoints expect as StudentId. */
10 id: number;
11 name?: string;
12 /** users.index - the number shown to the professor and searched on. */
13 index?: string;
14 grade: number;
15 semester?: string;
16};
17
18type SubjectsAndUsers = {
19 name?: string;
20 id?: number;
21 code?: string;
22 users: UsersBySubject[];
23};
24
25type GradePayload = {
26 StudentId: number;
27 SubjectId: number;
28 Grade: number;
29};
30
31type FlatRow = {
32 studentIdNum: number;
33 studentIndex: string;
34 studentName: string;
35 subjectId: number;
36 subjectName: string;
37 semester: string;
38 grade: number;
39};
40
41export default function ProfessorStudentsPage() {
42 const { t } = useTranslation();
43 const [subjects, setSubjects] = useState<SubjectsAndUsers[]>([]);
44 const [loading, setLoading] = useState(true);
45 const [error, setError] = useState<string | null>(null);
46
47 const [subjectFilter, setSubjectFilter] = useState<string>("all");
48 const [studentIdQuery, setStudentIdQuery] = useState<string>("");
49
50 const [gradeSelection, setGradeSelection] = useState<Record<string, number>>({});
51 const [actionBusyKey, setActionBusyKey] = useState<string | null>(null);
52
53 async function refresh() {
54 setLoading(true);
55 setError(null);
56 try {
57 const token = getAccessToken();
58 if (!token) {
59 throw new Error("You are not signed in.");
60 }
61
62 const res = await fetch(apiUrl("/api/prof/students"), {
63 cache: "no-store",
64 headers: { Authorization: `Bearer ${token}` },
65 });
66 if (!res.ok) {
67 throw new Error(`Failed to fetch students (${res.status})`);
68 }
69 const data = (await res.json()) as SubjectsAndUsers[];
70 setSubjects(data);
71
72 const nextSelections: Record<string, number> = {};
73 for (const subj of data) {
74 if (!subj.id) continue;
75 for (const u of subj.users) {
76 const key = `${subj.id}:${u.id}`;
77 // grade_type only declares 6..10, so an ungraded row starts at 6.
78 nextSelections[key] = u.grade >= 6 && u.grade <= 10 ? u.grade : 6;
79 }
80 }
81 setGradeSelection(nextSelections);
82 } catch (e) {
83 setError(e instanceof Error ? e.message : "Unknown error");
84 } finally {
85 setLoading(false);
86 }
87 }
88
89 useEffect(() => {
90 void refresh();
91 }, []);
92
93 const flatRows = useMemo<FlatRow[]>(() => {
94 const rows: FlatRow[] = [];
95 for (const subj of subjects) {
96 if (!subj.id) continue;
97 for (const u of subj.users) {
98 rows.push({
99 studentIdNum: u.id,
100 studentIndex: u.index ?? "",
101 studentName: u.name ?? "",
102 subjectId: subj.id,
103 subjectName: subj.name ?? "",
104 semester: u.semester ?? "",
105 grade: u.grade,
106 });
107 }
108 }
109 return rows;
110 }, [subjects]);
111
112 const filteredRows = useMemo(() => {
113 const q = studentIdQuery.trim();
114 return flatRows.filter((r) => {
115 if (subjectFilter !== "all" && String(r.subjectId) !== subjectFilter) return false;
116 if (q.length > 0 && !r.studentIndex.includes(q)) return false;
117 return true;
118 });
119 }, [flatRows, subjectFilter, studentIdQuery]);
120
121 async function postGrade(url: string, payload: GradePayload, busyKey: string) {
122 setActionBusyKey(busyKey);
123 setError(null);
124 try {
125 const token = getAccessToken();
126 if (!token) {
127 throw new Error("You are not signed in.");
128 }
129
130 const res = await fetch(apiUrl(url), {
131 method: "POST",
132 headers: {
133 "Content-Type": "application/json",
134 Authorization: `Bearer ${token}`,
135 },
136 body: JSON.stringify(payload),
137 });
138 const body = (await res.json()) as { ok: boolean; message?: string };
139 if (!res.ok || !body.ok) {
140 throw new Error(body.message || `Request failed (${res.status})`);
141 }
142 await refresh();
143 } catch (e) {
144 setError(e instanceof Error ? e.message : "Unknown error");
145 } finally {
146 setActionBusyKey(null);
147 }
148 }
149
150 return (
151 <div className="bg-card rounded-xl shadow-sm border border-border p-6">
152 <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
153
154 <div>
155 <h1 className="text-2xl font-semibold text-card-foreground">{t('prof_students_title')}</h1>
156 <p className="text-muted-foreground mt-1">
157 {t('prof_students_data_note')} <span className="font-mono">/api/prof/students</span>.
158 </p>
159 </div>
160
161 <div className="flex flex-col sm:flex-row gap-3">
162 <div className="flex flex-col">
163 <label className="text-sm text-muted-foreground">{t('filter_by_subject')}</label>
164 <select
165 className="border border-border rounded-lg px-3 py-2"
166 value={subjectFilter}
167 onChange={(e) => setSubjectFilter(e.target.value)}
168 >
169 <option value="all">{t('all_subjects')}</option>
170 {subjects
171 .filter((s) => typeof s.id === "number")
172 .map((s) => (
173 <option key={String(s.id)} value={String(s.id)}>
174 {s.name ?? `${t('subject')} ${s.id}`}
175 </option>
176 ))}
177 </select>
178 </div>
179
180 <div className="flex flex-col">
181 <label className="text-sm text-muted-foreground">{t('find_student_by_id')}</label>
182 <input
183 className="border border-border rounded-lg px-3 py-2"
184 placeholder={t('student_id_placeholder')}
185 value={studentIdQuery}
186 onChange={(e) => setStudentIdQuery(e.target.value)}
187 />
188 </div>
189 </div>
190 </div>
191
192 {error && (
193 <div className="mt-4 rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3">
194 {error}
195 </div>
196 )}
197
198 <div className="mt-6 overflow-x-auto">
199 <table className="min-w-full border border-border rounded-lg overflow-hidden">
200 <thead className="bg-accent">
201 <tr>
202 <th className="text-left text-sm font-semibold text-muted-foreground px-4 py-3 border-b">{t('student')}</th>
203 <th className="text-left text-sm font-semibold text-muted-foreground px-4 py-3 border-b">{t('id')}</th>
204 <th className="text-left text-sm font-semibold text-muted-foreground px-4 py-3 border-b">{t('subject')}</th>
205 <th className="text-left text-sm font-semibold text-muted-foreground px-4 py-3 border-b">{t('grade')}</th>
206 <th className="text-left text-sm font-semibold text-muted-foreground px-4 py-3 border-b">{t('actions')}</th>
207 </tr>
208 </thead>
209 <tbody>
210 {loading ? (
211 <tr>
212 <td className="px-4 py-4 text-muted-foreground" colSpan={5}>
213 {t('loading')}
214 </td>
215 </tr>
216 ) : filteredRows.length === 0 ? (
217 <tr>
218 <td className="px-4 py-4 text-muted-foreground" colSpan={5}>
219 {t('no_students_found')}
220 </td>
221 </tr>
222 ) : (
223 filteredRows.map((r) => {
224 const key = `${r.subjectId}:${r.studentIdNum}`;
225 const selected = gradeSelection[key] ?? 6;
226 const busy = actionBusyKey === key;
227
228 return (
229 <tr key={key} className="hover:bg-accent">
230 <td className="px-4 py-3 border-b text-card-foreground">{r.studentName}</td>
231 <td className="px-4 py-3 border-b text-card-foreground">{r.studentIndex}</td>
232 <td className="px-4 py-3 border-b text-card-foreground">
233 {r.subjectName}
234 {r.semester && (
235 <span className="block text-xs text-muted-foreground">{r.semester}</span>
236 )}
237 </td>
238 <td className="px-4 py-3 border-b">
239 <div className="flex items-center gap-3">
240 <select
241 className="border border-border rounded-lg px-3 py-2"
242 value={selected}
243 onChange={(e) =>
244 setGradeSelection((prev) => ({
245 ...prev,
246 [key]: Number.parseInt(e.target.value, 10),
247 }))
248 }
249 >
250 {[6, 7, 8, 9, 10].map((g) => (
251 <option key={g} value={g}>
252 {g}
253 </option>
254 ))}
255 </select>
256 <span className="text-sm text-muted-foreground">
257 {t('current')}: {r.grade > 0 ? r.grade : t('none')}
258 </span>
259 </div>
260 </td>
261 <td className="px-4 py-3 border-b">
262 <div className="flex flex-wrap gap-2">
263 <button
264 disabled={busy}
265 className="px-3 py-2 rounded-lg bg-green-600 text-white text-sm font-medium disabled:opacity-50"
266 onClick={() => {
267 void postGrade(
268 "/api/prof/grade/add",
269 { StudentId: r.studentIdNum, SubjectId: r.subjectId, Grade: selected },
270 key,
271 );
272 }}
273 >
274 {t('add_grade')}
275 </button>
276
277 <button
278 disabled={busy}
279 className="px-3 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium disabled:opacity-50"
280 onClick={() => {
281 void postGrade(
282 "/api/prof/grade/edit",
283 { StudentId: r.studentIdNum, SubjectId: r.subjectId, Grade: selected },
284 key,
285 );
286 }}
287 >
288 {t('edit_grade')}
289 </button>
290
291 <button
292 disabled={busy}
293 className="px-3 py-2 rounded-lg bg-gray-800 text-white text-sm font-medium disabled:opacity-50"
294 onClick={() => {
295 void postGrade(
296 "/api/prof/grade/remove",
297 { StudentId: r.studentIdNum, SubjectId: r.subjectId, Grade: 0 },
298 key,
299 );
300 }}
301 >
302 {t('remove_grade')}
303 </button>
304 </div>
305 </td>
306 </tr>
307 );
308 })
309 )}
310 </tbody>
311 </table>
312 </div>
313 </div>
314 );
315}
Note: See TracBrowser for help on using the repository browser.