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
RevLine 
[298f9b3]1"use client";
2
3import { useEffect, useMemo, useState } from "react";
[50f2fb4]4import { useTranslation } from 'react-i18next';
[8496f3c]5import { apiUrl } from '@/lib/api';
6import { getAccessToken } from '@/lib/auth';
[298f9b3]7
8type UsersBySubject = {
[8496f3c]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;
[298f9b3]16};
17
18type SubjectsAndUsers = {
[8496f3c]19 name?: string;
20 id?: number;
21 code?: string;
22 users: UsersBySubject[];
[298f9b3]23};
24
25type GradePayload = {
26 StudentId: number;
27 SubjectId: number;
[8496f3c]28 Grade: number;
[298f9b3]29};
30
31type FlatRow = {
[8496f3c]32 studentIdNum: number;
33 studentIndex: string;
[298f9b3]34 studentName: string;
35 subjectId: number;
36 subjectName: string;
[8496f3c]37 semester: string;
[298f9b3]38 grade: number;
39};
40
41export default function ProfessorStudentsPage() {
[50f2fb4]42 const { t } = useTranslation();
[298f9b3]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 {
[8496f3c]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 });
[298f9b3]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) {
[8496f3c]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;
[298f9b3]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) {
[8496f3c]96 if (!subj.id) continue;
97 for (const u of subj.users) {
[298f9b3]98 rows.push({
[8496f3c]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,
[298f9b3]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;
[8496f3c]116 if (q.length > 0 && !r.studentIndex.includes(q)) return false;
[298f9b3]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 {
[8496f3c]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), {
[298f9b3]131 method: "POST",
[8496f3c]132 headers: {
133 "Content-Type": "application/json",
134 Authorization: `Bearer ${token}`,
135 },
[298f9b3]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 (
[ba52069]151 <div className="bg-card rounded-xl shadow-sm border border-border p-6">
[298f9b3]152 <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
[50f2fb4]153
[298f9b3]154 <div>
[ba52069]155 <h1 className="text-2xl font-semibold text-card-foreground">{t('prof_students_title')}</h1>
156 <p className="text-muted-foreground mt-1">
[8496f3c]157 {t('prof_students_data_note')} <span className="font-mono">/api/prof/students</span>.
[298f9b3]158 </p>
159 </div>
160
161 <div className="flex flex-col sm:flex-row gap-3">
162 <div className="flex flex-col">
[ba52069]163 <label className="text-sm text-muted-foreground">{t('filter_by_subject')}</label>
[298f9b3]164 <select
[ba52069]165 className="border border-border rounded-lg px-3 py-2"
[298f9b3]166 value={subjectFilter}
167 onChange={(e) => setSubjectFilter(e.target.value)}
168 >
[50f2fb4]169 <option value="all">{t('all_subjects')}</option>
[298f9b3]170 {subjects
[8496f3c]171 .filter((s) => typeof s.id === "number")
[298f9b3]172 .map((s) => (
[8496f3c]173 <option key={String(s.id)} value={String(s.id)}>
174 {s.name ?? `${t('subject')} ${s.id}`}
[298f9b3]175 </option>
176 ))}
177 </select>
178 </div>
179
180 <div className="flex flex-col">
[ba52069]181 <label className="text-sm text-muted-foreground">{t('find_student_by_id')}</label>
[298f9b3]182 <input
[ba52069]183 className="border border-border rounded-lg px-3 py-2"
[50f2fb4]184 placeholder={t('student_id_placeholder')}
[298f9b3]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">
[ba52069]199 <table className="min-w-full border border-border rounded-lg overflow-hidden">
200 <thead className="bg-accent">
[298f9b3]201 <tr>
[ba52069]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>
[298f9b3]207 </tr>
208 </thead>
209 <tbody>
210 {loading ? (
211 <tr>
[ba52069]212 <td className="px-4 py-4 text-muted-foreground" colSpan={5}>
[50f2fb4]213 {t('loading')}
[298f9b3]214 </td>
215 </tr>
216 ) : filteredRows.length === 0 ? (
217 <tr>
[ba52069]218 <td className="px-4 py-4 text-muted-foreground" colSpan={5}>
[50f2fb4]219 {t('no_students_found')}
[298f9b3]220 </td>
221 </tr>
222 ) : (
223 filteredRows.map((r) => {
[8496f3c]224 const key = `${r.subjectId}:${r.studentIdNum}`;
225 const selected = gradeSelection[key] ?? 6;
[298f9b3]226 const busy = actionBusyKey === key;
227
228 return (
[ba52069]229 <tr key={key} className="hover:bg-accent">
230 <td className="px-4 py-3 border-b text-card-foreground">{r.studentName}</td>
[8496f3c]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>
[298f9b3]238 <td className="px-4 py-3 border-b">
239 <div className="flex items-center gap-3">
240 <select
[ba52069]241 className="border border-border rounded-lg px-3 py-2"
[298f9b3]242 value={selected}
243 onChange={(e) =>
244 setGradeSelection((prev) => ({
245 ...prev,
246 [key]: Number.parseInt(e.target.value, 10),
247 }))
248 }
249 >
[8496f3c]250 {[6, 7, 8, 9, 10].map((g) => (
[298f9b3]251 <option key={g} value={g}>
252 {g}
253 </option>
254 ))}
255 </select>
[ba52069]256 <span className="text-sm text-muted-foreground">
[50f2fb4]257 {t('current')}: {r.grade > 0 ? r.grade : t('none')}
[298f9b3]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
[8496f3c]264 disabled={busy}
[298f9b3]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",
[8496f3c]269 { StudentId: r.studentIdNum, SubjectId: r.subjectId, Grade: selected },
[298f9b3]270 key,
271 );
272 }}
273 >
[50f2fb4]274 {t('add_grade')}
[298f9b3]275 </button>
276
277 <button
[8496f3c]278 disabled={busy}
[298f9b3]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",
[8496f3c]283 { StudentId: r.studentIdNum, SubjectId: r.subjectId, Grade: selected },
[298f9b3]284 key,
285 );
286 }}
287 >
[50f2fb4]288 {t('edit_grade')}
[298f9b3]289 </button>
290
291 <button
[8496f3c]292 disabled={busy}
[298f9b3]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",
[8496f3c]297 { StudentId: r.studentIdNum, SubjectId: r.subjectId, Grade: 0 },
[298f9b3]298 key,
299 );
300 }}
301 >
[50f2fb4]302 {t('remove_grade')}
[298f9b3]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.