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