source: src/app/professor/students/page.tsx@ 50f2fb4

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

Added dual language feature.

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