source: frontend/src/components/exams.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: 11.1 KB
Line 
1"use client"
2import { useEffect, useMemo, useState } from 'react';
3import { useTranslation } from 'react-i18next';
4import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5import { faSort, faSortUp, faSortDown } from '@fortawesome/free-solid-svg-icons';
6import { getAccessToken } from '@/lib/auth';
7import { apiUrl } from '@/lib/api';
8
9type PassedSubject = {
10 id: number;
11 subjectId: number;
12 code: string;
13 subject: string;
14 credits: number;
15 grade: number;
16 gradeText: string;
17 date: string; // DD.MM.YYYY
18 semester: string;
19 professor: string;
20};
21
22type PassedSubjectsResponse = {
23 passedSubjects: PassedSubject[];
24};
25
26type ExamRow = {
27 id: number;
28 code: string;
29 subject: string;
30 date: string;
31 semester: string;
32 credits: number;
33 grade: number;
34};
35
36const Exams = () => {
37 const { t } = useTranslation();
38 const [sortField, setSortField] = useState<'grade' | 'date' | null>(null);
39 const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | null>(null);
40 const [exams, setExams] = useState<ExamRow[]>([]);
41 const [isLoading, setIsLoading] = useState(true);
42 const [errorMessage, setErrorMessage] = useState<string | null>(null);
43
44 useEffect(() => {
45 let cancelled = false;
46
47 async function load() {
48 setIsLoading(true);
49 setErrorMessage(null);
50
51 const token = getAccessToken();
52 if (!token) {
53 setErrorMessage('Not authenticated. Please login again.');
54 setIsLoading(false);
55 return;
56 }
57
58 try {
59 const response = await fetch(apiUrl('/api/user/getPassedSubjects'), {
60 method: 'GET',
61 headers: {
62 Authorization: `Bearer ${token}`,
63 },
64 });
65
66 if (!response.ok) {
67 const text = await response.text().catch(() => '');
68 throw new Error(text || `Failed to load passed subjects (${response.status})`);
69 }
70
71 const data = (await response.json()) as PassedSubjectsResponse;
72 const rows: ExamRow[] = (data?.passedSubjects ?? []).map((s) => ({
73 id: s.id,
74 code: s.code,
75 subject: s.subject,
76 date: s.date,
77 semester: s.semester,
78 credits: s.credits,
79 grade: s.grade,
80 }));
81
82 if (!cancelled) setExams(rows);
83 } catch (err) {
84 if (!cancelled) {
85 setErrorMessage(err instanceof Error ? err.message : 'Failed to load passed subjects.');
86 }
87 } finally {
88 if (!cancelled) setIsLoading(false);
89 }
90 }
91
92 void load();
93
94 return () => {
95 cancelled = true;
96 };
97 }, []);
98
99 const stats = useMemo(() => {
100 const passed = exams.length;
101 const creditsCurrent = exams.reduce((sum, s) => sum + (typeof s.credits === 'number' ? s.credits : 0), 0);
102 const numericGrades = exams.map((e) => (typeof e.grade === 'number' ? e.grade : null)).filter((g): g is number => g !== null);
103 const average =
104 numericGrades.length > 0
105 ? numericGrades.reduce((sum, g) => sum + g, 0) / numericGrades.length
106 : 0;
107
108 const totalCredits = 240;
109 const totalExams = 40;
110 return {
111 average: Number.isFinite(average) ? Number(average.toFixed(2)) : 0,
112 credits: { current: creditsCurrent, total: totalCredits },
113 passed,
114 remaining: Math.max(0, totalExams - passed),
115 };
116 }, [exams]);
117
118 const getGradeColor = (grade: number) => {
119 if (grade >= 9) return "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20";
120 if (grade >= 8) return "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20";
121 if (grade >= 6) return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20";
122 return "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20";
123 };
124
125 const handleSortByGrade = () => {
126 if (sortField === 'grade') {
127 if (sortOrder === 'desc') {
128 setSortOrder('asc');
129 } else if (sortOrder === 'asc') {
130 setSortField(null);
131 setSortOrder(null);
132 } else {
133 setSortOrder('desc');
134 }
135 } else {
136 setSortField('grade');
137 setSortOrder('desc');
138 }
139 };
140
141 const handleSortByDate = () => {
142 if (sortField === 'date') {
143 if (sortOrder === 'desc') {
144 setSortOrder('asc');
145 } else if (sortOrder === 'asc') {
146 setSortField(null);
147 setSortOrder(null);
148 } else {
149 setSortOrder('desc');
150 }
151 } else {
152 setSortField('date');
153 setSortOrder('desc');
154 }
155 };
156
157 const getSortedExams = () => {
158 if (!sortField || !sortOrder) return exams;
159
160 return [...exams].sort((a, b) => {
161 if (sortField === 'grade') {
162 return sortOrder === 'asc' ? a.grade - b.grade : b.grade - a.grade;
163 }
164
165 if (sortField === 'date') {
166 // Convert date strings to Date objects for proper sorting
167 const dateA = new Date(a.date.split('.').reverse().join('-')); // Convert DD.MM.YYYY to YYYY-MM-DD
168 const dateB = new Date(b.date.split('.').reverse().join('-'));
169
170 return sortOrder === 'asc' ? dateA.getTime() - dateB.getTime() : dateB.getTime() - dateA.getTime();
171 }
172
173 return 0;
174 });
175 };
176
177 const getSortIcon = (field: 'grade' | 'date') => {
178 if (sortField === field) {
179 if (sortOrder === 'asc') return faSortUp;
180 if (sortOrder === 'desc') return faSortDown;
181 }
182 return faSort;
183 };
184
185 const creditsPercentage = stats.credits.total > 0 ? (stats.credits.current / stats.credits.total) * 100 : 0;
186
187 if (isLoading) {
188 return (
189 <div className="bg-card rounded-lg shadow-sm p-6">
190 {t('loading', 'Loading...')}
191 </div>
192 );
193 }
194
195 if (errorMessage) {
196 return (
197 <div className="bg-card rounded-lg shadow-sm p-6">
198 <div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400">
199 {t('failed_to_load_exams', errorMessage)}
200 </div>
201 </div>
202 );
203 }
204
205 return (
206 <div className="bg-card rounded-lg shadow-sm p-6 space-y-6">
207 {/* Statistics Section */}
208 <div className="space-y-4">
209 <div className="grid grid-cols-2 md:grid-cols-4 gap-6">
210 {/* Average Grade */}
211 <div className="text-center">
212 <div className="text-3xl font-bold text-card-foreground">{stats.average}</div>
213 <div className="text-sm text-muted-foreground mt-1">{t('average', 'Просек')}</div>
214 </div>
215
216 {/* Credits */}
217 <div className="text-center">
218 <div className="text-3xl font-bold text-card-foreground">
219 {stats.credits.current}
220 <span className="text-lg text-muted-foreground">/{stats.credits.total}</span>
221 </div>
222 <div className="text-sm text-muted-foreground mt-1">{t('credits', 'Кредити')}</div>
223 </div>
224
225 {/* Passed Exams */}
226 <div className="text-center">
227 <div className="text-3xl font-bold text-card-foreground">{stats.passed}</div>
228 <div className="text-sm text-muted-foreground mt-1">{t('passed', 'Положени')}</div>
229 </div>
230
231 {/* Remaining Exams */}
232 <div className="text-center">
233 <div className="text-3xl font-bold text-card-foreground">{stats.remaining}</div>
234 <div className="text-sm text-muted-foreground mt-1">{t('remaining', 'Останато')}</div>
235 </div>
236 </div>
237
238 {/* Progress Bar spanning entire statistics section */}
239 <div className="w-full bg-secondary rounded-full h-2">
240 <div
241 className="bg-primary h-2 rounded-full transition-all duration-300"
242 style={{ width: `${creditsPercentage}%` }}
243 ></div>
244 </div>
245 </div>
246
247 {/* Exams Table */}
248 <div>
249 <h3 className="text-xl font-semibold text-card-foreground mb-4">{t('exams', 'Испити')}</h3>
250
251 <div className="overflow-x-auto">
252 <table className="min-w-full">
253 <thead>
254 <tr className="border-b border-border">
255 <th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground">#</th>
256 <th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground">{t('subject', 'Предмет')}</th>
257 <th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground">{t('semester', 'Семестар')}</th>
258 <th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground">{t('credits', 'Кредити')}</th>
259 <th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground">
260 <button
261 onClick={handleSortByDate}
262 className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-accent hover:text-accent-foreground transition-all duration-200 transform hover:scale-105"
263 >
264 {t('date', 'Датум')}
265 <FontAwesomeIcon
266 icon={getSortIcon('date')}
267 className={`text-xs transition-colors duration-200 ${
268 sortField === 'date' ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'
269 }`}
270 />
271 </button>
272 </th>
273 <th className="text-left py-3 px-4 text-sm font-medium text-muted-foreground">
274 <button
275 onClick={handleSortByGrade}
276 className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-accent hover:text-accent-foreground transition-all duration-200 transform hover:scale-105"
277 >
278 {t('grade', 'Оценка')}
279 <FontAwesomeIcon
280 icon={getSortIcon('grade')}
281 className={`text-xs transition-colors duration-200 ${
282 sortField === 'grade' ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'
283 }`}
284 />
285 </button>
286 </th>
287 </tr>
288 </thead>
289 <tbody className="divide-y divide-border">
290 {getSortedExams().map((exam) => (
291 <tr key={exam.id} className="hover:bg-accent/50 transition-colors duration-150">
292 <td className="py-4 px-4 text-sm text-card-foreground">{exam.id}</td>
293 <td className="py-4 px-4 text-sm text-card-foreground font-medium">{t(exam.subject, exam.subject)}</td>
294 <td className="py-4 px-4 text-sm text-muted-foreground">{t(exam.semester, exam.semester)}</td>
295 <td className="py-4 px-4 text-sm text-muted-foreground">{exam.credits}</td>
296 <td className="py-4 px-4 text-sm text-muted-foreground">{exam.date}</td>
297 <td className="py-4 px-4">
298 <span className={`inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium border ${getGradeColor(exam.grade)}`}>
299 {exam.grade}
300 </span>
301 </td>
302 </tr>
303 ))}
304 </tbody>
305 </table>
306 </div>
307 </div>
308 </div>
309 );
310};
311
312export default Exams;
Note: See TracBrowser for help on using the repository browser.