source: src/app/professor/students/page.tsx@ 298f9b3

Last change on this file since 298f9b3 was 298f9b3, checked in by Stefan-Saveski <stefansaveski19@…>, 8 months ago

feat: Implement professor portal with student management, grade handling, and profile display

  • Property mode set to 100644
File size: 10.4 KB
Line 
1"use client";
2
3import { useEffect, useMemo, useState } from "react";
4
5type UsersBySubject = {
6 Id?: string;
7 Name?: string;
8 Grade: number;
9};
10
11type SubjectsAndUsers = {
12 Name?: string;
13 Id?: number;
14 Users: UsersBySubject[];
15};
16
17type GradePayload = {
18 StudentId: number;
19 SubjectId: number;
20 grade: number;
21};
22
23type FlatRow = {
24 studentIdStr: string;
25 studentIdNum: number | null;
26 studentName: string;
27 subjectId: number;
28 subjectName: string;
29 grade: number;
30};
31
32function toInt(value: string): number | null {
33 const n = Number.parseInt(value, 10);
34 return Number.isFinite(n) ? n : null;
35}
36
37export default function ProfessorStudentsPage() {
38 const [subjects, setSubjects] = useState<SubjectsAndUsers[]>([]);
39 const [loading, setLoading] = useState(true);
40 const [error, setError] = useState<string | null>(null);
41
42 const [subjectFilter, setSubjectFilter] = useState<string>("all");
43 const [studentIdQuery, setStudentIdQuery] = useState<string>("");
44
45 const [gradeSelection, setGradeSelection] = useState<Record<string, number>>({});
46 const [actionBusyKey, setActionBusyKey] = useState<string | null>(null);
47
48 async function refresh() {
49 setLoading(true);
50 setError(null);
51 try {
52 const res = await fetch("/api/prof/students", { cache: "no-store" });
53 if (!res.ok) {
54 throw new Error(`Failed to fetch students (${res.status})`);
55 }
56 const data = (await res.json()) as SubjectsAndUsers[];
57 setSubjects(data);
58
59 const nextSelections: Record<string, number> = {};
60 for (const subj of data) {
61 if (!subj.Id) continue;
62 for (const u of subj.Users) {
63 const key = `${subj.Id}:${u.Id ?? ""}`;
64 const current = u.Grade;
65 nextSelections[key] = current >= 5 && current <= 10 ? current : 5;
66 }
67 }
68 setGradeSelection(nextSelections);
69 } catch (e) {
70 setError(e instanceof Error ? e.message : "Unknown error");
71 } finally {
72 setLoading(false);
73 }
74 }
75
76 useEffect(() => {
77 void refresh();
78 }, []);
79
80 const flatRows = useMemo<FlatRow[]>(() => {
81 const rows: FlatRow[] = [];
82 for (const subj of subjects) {
83 if (!subj.Id) continue;
84 for (const u of subj.Users) {
85 rows.push({
86 studentIdStr: u.Id ?? "",
87 studentIdNum: toInt(u.Id ?? ""),
88 studentName: u.Name ?? "",
89 subjectId: subj.Id,
90 subjectName: subj.Name ?? "",
91 grade: u.Grade,
92 });
93 }
94 }
95 return rows;
96 }, [subjects]);
97
98 const filteredRows = useMemo(() => {
99 const q = studentIdQuery.trim();
100 return flatRows.filter((r) => {
101 if (subjectFilter !== "all" && String(r.subjectId) !== subjectFilter) return false;
102 if (q.length > 0 && !r.studentIdStr.includes(q)) return false;
103 return true;
104 });
105 }, [flatRows, subjectFilter, studentIdQuery]);
106
107 async function postGrade(url: string, payload: GradePayload, busyKey: string) {
108 setActionBusyKey(busyKey);
109 setError(null);
110 try {
111 const res = await fetch(url, {
112 method: "POST",
113 headers: { "Content-Type": "application/json" },
114 body: JSON.stringify(payload),
115 });
116 const body = (await res.json()) as { ok: boolean; message?: string };
117 if (!res.ok || !body.ok) {
118 throw new Error(body.message || `Request failed (${res.status})`);
119 }
120 await refresh();
121 } catch (e) {
122 setError(e instanceof Error ? e.message : "Unknown error");
123 } finally {
124 setActionBusyKey(null);
125 }
126 }
127
128 return (
129 <div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
130 <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
131 <div>
132 <h1 className="text-2xl font-semibold text-gray-900">Professor • Students</h1>
133 <p className="text-gray-700 mt-1">
134 Data comes from <span className="font-mono">/api/prof/students</span> (demo store).
135 </p>
136 </div>
137
138 <div className="flex flex-col sm:flex-row gap-3">
139 <div className="flex flex-col">
140 <label className="text-sm text-gray-700">Filter by subject</label>
141 <select
142 className="border border-gray-300 rounded-lg px-3 py-2"
143 value={subjectFilter}
144 onChange={(e) => setSubjectFilter(e.target.value)}
145 >
146 <option value="all">All subjects</option>
147 {subjects
148 .filter((s) => typeof s.Id === "number")
149 .map((s) => (
150 <option key={String(s.Id)} value={String(s.Id)}>
151 {s.Name ?? `Subject ${s.Id}`}
152 </option>
153 ))}
154 </select>
155 </div>
156
157 <div className="flex flex-col">
158 <label className="text-sm text-gray-700">Find student by index / ID</label>
159 <input
160 className="border border-gray-300 rounded-lg px-3 py-2"
161 placeholder="e.g. 20001"
162 value={studentIdQuery}
163 onChange={(e) => setStudentIdQuery(e.target.value)}
164 />
165 </div>
166 </div>
167 </div>
168
169 {error && (
170 <div className="mt-4 rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3">
171 {error}
172 </div>
173 )}
174
175 <div className="mt-6 overflow-x-auto">
176 <table className="min-w-full border border-gray-200 rounded-lg overflow-hidden">
177 <thead className="bg-gray-50">
178 <tr>
179 <th className="text-left text-sm font-semibold text-gray-700 px-4 py-3 border-b">Student</th>
180 <th className="text-left text-sm font-semibold text-gray-700 px-4 py-3 border-b">ID</th>
181 <th className="text-left text-sm font-semibold text-gray-700 px-4 py-3 border-b">Subject</th>
182 <th className="text-left text-sm font-semibold text-gray-700 px-4 py-3 border-b">Grade</th>
183 <th className="text-left text-sm font-semibold text-gray-700 px-4 py-3 border-b">Actions</th>
184 </tr>
185 </thead>
186 <tbody>
187 {loading ? (
188 <tr>
189 <td className="px-4 py-4 text-gray-700" colSpan={5}>
190 Loading...
191 </td>
192 </tr>
193 ) : filteredRows.length === 0 ? (
194 <tr>
195 <td className="px-4 py-4 text-gray-700" colSpan={5}>
196 No students found.
197 </td>
198 </tr>
199 ) : (
200 filteredRows.map((r) => {
201 const key = `${r.subjectId}:${r.studentIdStr}`;
202 const selected = gradeSelection[key] ?? 5;
203 const busy = actionBusyKey === key;
204
205 return (
206 <tr key={key} className="hover:bg-gray-50">
207 <td className="px-4 py-3 border-b text-gray-900">{r.studentName}</td>
208 <td className="px-4 py-3 border-b text-gray-900">{r.studentIdStr}</td>
209 <td className="px-4 py-3 border-b text-gray-900">{r.subjectName}</td>
210 <td className="px-4 py-3 border-b">
211 <div className="flex items-center gap-3">
212 <select
213 className="border border-gray-300 rounded-lg px-3 py-2"
214 value={selected}
215 onChange={(e) =>
216 setGradeSelection((prev) => ({
217 ...prev,
218 [key]: Number.parseInt(e.target.value, 10),
219 }))
220 }
221 >
222 {[5, 6, 7, 8, 9, 10].map((g) => (
223 <option key={g} value={g}>
224 {g}
225 </option>
226 ))}
227 </select>
228 <span className="text-sm text-gray-600">
229 Current: {r.grade > 0 ? r.grade : "—"}
230 </span>
231 </div>
232 </td>
233 <td className="px-4 py-3 border-b">
234 <div className="flex flex-wrap gap-2">
235 <button
236 disabled={busy || r.studentIdNum === null}
237 className="px-3 py-2 rounded-lg bg-green-600 text-white text-sm font-medium disabled:opacity-50"
238 onClick={() => {
239 if (r.studentIdNum === null) return;
240 void postGrade(
241 "/api/prof/grade/add",
242 { StudentId: r.studentIdNum, SubjectId: r.subjectId, grade: selected },
243 key,
244 );
245 }}
246 >
247 Add grade
248 </button>
249
250 <button
251 disabled={busy || r.studentIdNum === null}
252 className="px-3 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium disabled:opacity-50"
253 onClick={() => {
254 if (r.studentIdNum === null) return;
255 void postGrade(
256 "/api/prof/grade/edit",
257 { StudentId: r.studentIdNum, SubjectId: r.subjectId, grade: selected },
258 key,
259 );
260 }}
261 >
262 Edit grade
263 </button>
264
265 <button
266 disabled={busy || r.studentIdNum === null}
267 className="px-3 py-2 rounded-lg bg-gray-800 text-white text-sm font-medium disabled:opacity-50"
268 onClick={() => {
269 if (r.studentIdNum === null) return;
270 void postGrade(
271 "/api/prof/grade/remove",
272 { StudentId: r.studentIdNum, SubjectId: r.subjectId, grade: 0 },
273 key,
274 );
275 }}
276 >
277 Remove grade
278 </button>
279 </div>
280 </td>
281 </tr>
282 );
283 })
284 )}
285 </tbody>
286 </table>
287 </div>
288 </div>
289 );
290}
Note: See TracBrowser for help on using the repository browser.