source: src/components/exams.tsx@ 997d094

Last change on this file since 997d094 was 90f7842, checked in by Stefan-Saveski <stefansaveski19@…>, 9 months ago

fix: Update API endpoints to use production URL for user data retrieval and authentication

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