source: src/app/students/subjects/page.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: 20.2 KB
Line 
1"use client"
2
3import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
4import {
5 faBook,
6 faChevronDown,
7 faFileInvoice
8} from '@fortawesome/free-solid-svg-icons';
9import { useEffect, useState } from 'react';
10import { getAccessToken } from '@/lib/auth';
11
12interface Subject {
13 id: number;
14 code: string;
15 hours: string;
16 kojPat: number;
17 name: string;
18 semester: number;
19 status: string;
20 signature: string;
21 group: string;
22 professor: string;
23}
24
25type SemesterInfo = {
26 id: number;
27 name: string;
28 status: string;
29 serviceNumber: string | number;
30};
31
32type FinancialInfo = {
33 sum: string | number;
34 paid: string;
35 due: string;
36 materialCosts: string;
37 credits: string;
38 MKSA: string;
39 electronicRegistration: string;
40 eUKIM: string;
41 bankProvision: string;
42 total: string;
43};
44
45type CurrentSemester = {
46 id: string;
47 name: string;
48 status: string;
49 serviceNumber: string | number;
50 ticketNumber: string;
51 debt: string;
52 financialInfo: FinancialInfo;
53};
54
55type SubjectsResponse = {
56 currentSemester: CurrentSemester;
57 semesters: SemesterInfo[];
58 subjectsBySemester: Record<string, Subject[]>;
59 semesterKeyById: Record<number, string>;
60};
61
62type ApiCurrentSemester = {
63 id: string;
64 name: string;
65 status: string;
66 serviceNumber: string | number;
67 ticketNumber: string;
68 debt: string;
69 financialInfo: {
70 sum: string | number;
71 paid: string;
72 due: string;
73 materialCosts: string;
74 credits: string;
75 totalCredits?: string;
76 mksa?: string;
77 MKSA?: string;
78 electronicRegistration: string;
79 eUKIM: string;
80 bankProvision: string;
81 total: string;
82 };
83};
84
85type ApiSubjectsResponse = {
86 semesters: SemesterInfo[];
87 currentSemester?: ApiCurrentSemester;
88 currentSemestar?: ApiCurrentSemester;
89 subjectsBySemester: Record<string, Subject[]>;
90};
91
92function normalizeKey(input: string) {
93 return input.toLowerCase().replace(/\s|\(|\)|\.|,/g, '');
94}
95
96function detectSeasonFromName(name: string): 'summer' | 'winter' | null {
97 const n = name.toLowerCase();
98 if (n.includes('летен')) return 'summer';
99 if (n.includes('зимски')) return 'winter';
100 return null;
101}
102
103function buildSemesterKeyById(semesters: SemesterInfo[], keys: string[]) {
104 const keyBySeason: Partial<Record<'summer' | 'winter', string>> = {};
105 for (const key of keys) {
106 const k = key.toLowerCase();
107 if (k.includes('summer')) keyBySeason.summer = key;
108 if (k.includes('winter')) keyBySeason.winter = key;
109 }
110
111 const mapping: Record<number, string> = {};
112 for (const s of semesters) {
113 const season = detectSeasonFromName(s.name);
114 const mapped = season ? keyBySeason[season] : undefined;
115 if (mapped) mapping[s.id] = mapped;
116 }
117
118 // Fallback: if we couldn't infer seasons, try matching by normalized names.
119 if (Object.keys(mapping).length === 0) {
120 for (const s of semesters) {
121 const ns = normalizeKey(s.name);
122 const match = keys.find((k) => normalizeKey(k).includes(ns) || ns.includes(normalizeKey(k)));
123 if (match) mapping[s.id] = match;
124 }
125 }
126
127 // Last resort: map in order.
128 if (Object.keys(mapping).length === 0) {
129 semesters.forEach((s, idx) => {
130 if (keys[idx]) mapping[s.id] = keys[idx];
131 });
132 }
133
134 return mapping;
135}
136
137interface TableCellProps {
138 children: React.ReactNode;
139 className?: string;
140}
141
142const TableCell = ({ children, className = "" }: TableCellProps) => (
143 <td className={`px-4 py-3 text-sm border-b border-gray-200 ${className}`}>
144 {children}
145 </td>
146);
147
148const StatusBadge = ({ status }: { status: string }) => {
149 const baseClasses = "px-3 py-1 rounded-md text-xs font-medium";
150
151 if (status === "Зад.") {
152 return (
153 <span className={`${baseClasses} bg-blue-100 text-blue-800`}>
154 {status}
155 </span>
156 );
157 } else if (status === "Изб.") {
158 return (
159 <span className={`${baseClasses} bg-green-100 text-green-800`}>
160 {status}
161 </span>
162 );
163 }
164
165 return (
166 <span className={`${baseClasses} bg-gray-100 text-gray-800`}>
167 {status}
168 </span>
169 );
170};
171
172export default function SubjectsPage() {
173 const [subjectsData, setSubjectsData] = useState<SubjectsResponse | null>(null);
174 const [selectedSemester, setSelectedSemester] = useState<number | null>(null);
175 const [isDropdownOpen, setIsDropdownOpen] = useState(false);
176 const [isLoading, setIsLoading] = useState(true);
177 const [errorMessage, setErrorMessage] = useState<string | null>(null);
178
179 useEffect(() => {
180 let cancelled = false;
181
182 async function load() {
183 setIsLoading(true);
184 setErrorMessage(null);
185
186 const token = getAccessToken();
187 if (!token) {
188 setErrorMessage('Not authenticated. Please login again.');
189 setIsLoading(false);
190 return;
191 }
192
193 try {
194 const response = await fetch('https://iknow-api.onrender.com/api/user/getSubjects', {
195 method: 'GET',
196 headers: {
197 Authorization: `Bearer ${token}`,
198 },
199 });
200
201 if (!response.ok) {
202 const text = await response.text().catch(() => '');
203 throw new Error(text || `Failed to load subjects (${response.status})`);
204 }
205
206 const apiData = (await response.json()) as ApiSubjectsResponse;
207 if (cancelled) return;
208
209 const current = apiData.currentSemester ?? apiData.currentSemestar;
210 if (!current) {
211 throw new Error('Response missing currentSemestar/currentSemester');
212 }
213
214 const keys = Object.keys(apiData.subjectsBySemester ?? {});
215 const semesterKeyById = buildSemesterKeyById(apiData.semesters ?? [], keys);
216
217 const normalized: SubjectsResponse = {
218 semesters: apiData.semesters ?? [],
219 subjectsBySemester: apiData.subjectsBySemester ?? {},
220 semesterKeyById,
221 currentSemester: {
222 id: current.id,
223 name: current.name,
224 status: current.status,
225 serviceNumber: current.serviceNumber,
226 ticketNumber: current.ticketNumber,
227 debt: current.debt,
228 financialInfo: {
229 sum: current.financialInfo.sum,
230 paid: current.financialInfo.paid,
231 due: current.financialInfo.due,
232 materialCosts: current.financialInfo.materialCosts,
233 credits: current.financialInfo.credits,
234 MKSA: current.financialInfo.MKSA ?? current.financialInfo.mksa ?? '',
235 electronicRegistration: current.financialInfo.electronicRegistration,
236 eUKIM: current.financialInfo.eUKIM,
237 bankProvision: current.financialInfo.bankProvision,
238 total: current.financialInfo.total,
239 },
240 },
241 };
242
243 setSubjectsData(normalized);
244
245 setSelectedSemester((prev) => {
246 if (prev !== null) return prev;
247 const season = detectSeasonFromName(current.name);
248 if (season) {
249 const key = keys.find((k) => k.toLowerCase().includes(season));
250 if (key) {
251 const matchId = normalized.semesters.find((s) => normalized.semesterKeyById[s.id] === key)?.id;
252 if (typeof matchId === 'number') return matchId;
253 }
254 }
255 return normalized.semesters[0]?.id ?? null;
256 });
257 } catch (err) {
258 if (!cancelled) {
259 setErrorMessage(err instanceof Error ? err.message : 'Failed to load subjects.');
260 }
261 } finally {
262 if (!cancelled) setIsLoading(false);
263 }
264 }
265
266 void load();
267
268 return () => {
269 cancelled = true;
270 };
271 }, []);
272
273 if (isLoading) {
274 return (
275 <div className="min-h-screen pb-8">
276 <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
277 Loading...
278 </div>
279 </div>
280 );
281 }
282
283 if (errorMessage || !subjectsData || selectedSemester === null) {
284 return (
285 <div className="min-h-screen pb-8">
286 <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
287 {errorMessage ?? 'Failed to load subjects.'}
288 </div>
289 </div>
290 );
291 }
292
293 const currentSemesterData =
294 subjectsData.semesters.find((s) => s.id === selectedSemester) ?? subjectsData.semesters[0];
295 const semesterKey =
296 subjectsData.semesterKeyById[selectedSemester] ??
297 Object.keys(subjectsData.subjectsBySemester)[0];
298 const currentSubjects: Subject[] = semesterKey ? subjectsData.subjectsBySemester[semesterKey] || [] : [];
299 const { currentSemester } = subjectsData;
300
301 return (
302 <div className="min-h-screen pb-8">
303 {/* Header */}
304 <div className="bg-primary text-white rounded-xl p-8 mb-8">
305 <div className="flex items-center gap-4">
306 <div className="bg-white bg-opacity-20 rounded-full p-4">
307 <FontAwesomeIcon icon={faBook} className="text-3xl text-primary" />
308 </div>
309 <div>
310 <h1 className="text-3xl font-bold mb-2">Предмети</h1>
311 <p className="text-lg opacity-90">
312 Преглед на запишани предмети по семестри
313 </p>
314 </div>
315 </div>
316 </div>
317
318 {/* Status and Selection Section */}
319 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
320
321 {/* Left Column - Status and Dropdown */}
322 <div className="lg:col-span-1 space-y-6">
323
324 {/* Status Card */}
325 <div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
326 <div className="text-sm text-gray-600 mb-2">
327 Статус: <span className="text-primary font-semibold">Запишан од студент</span>
328 </div>
329 <div className="text-sm text-gray-600">
330 Број на тикет: <span className="font-semibold">{currentSemester.ticketNumber}</span>
331 </div>
332 </div>
333
334 {/* Semester Selection */}
335 <div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
336 <div className="text-sm text-gray-600 mb-2">
337 Долг од документи: <span className="font-semibold">{currentSemester.debt}</span>
338 </div>
339
340 <div className="relative mt-4">
341 <label className="block text-sm font-medium text-gray-700 mb-2">
342 Избери семестар:
343 </label>
344 <div className="relative">
345 <button
346 onClick={() => setIsDropdownOpen(!isDropdownOpen)}
347 className="w-full bg-white border border-gray-300 rounded-lg px-4 py-3 text-left focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary"
348 >
349 <div className="flex items-center justify-between">
350 <span className="text-sm font-medium text-primary">
351 {currentSemesterData.name}
352 </span>
353 <FontAwesomeIcon
354 icon={faChevronDown}
355 className={`w-4 h-4 text-gray-400 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
356 />
357 </div>
358 </button>
359
360 {isDropdownOpen && (
361 <div className="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg">
362 {subjectsData.semesters.map((semester) => (
363 <button
364 key={semester.id}
365 onClick={() => {
366 setSelectedSemester(semester.id);
367 setIsDropdownOpen(false);
368 }}
369 className="w-full px-4 py-3 text-left text-sm hover:bg-gray-50 focus:outline-none focus:bg-gray-50 first:rounded-t-lg last:rounded-b-lg"
370 >
371 <div className="font-medium text-gray-900">{semester.name}</div>
372 <div className="text-xs text-gray-500">Статус: {semester.status}</div>
373 </button>
374 ))}
375 </div>
376 )}
377 </div>
378 </div>
379
380 <div className="mt-4 text-sm">
381 <div className="text-primary font-medium">
382 Сериски број: {currentSemesterData.serviceNumber}
383 </div>
384 </div>
385 </div>
386
387 {/* Enrolled Subjects */}
388 <div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
389 <h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
390 <FontAwesomeIcon icon={faBook} className="w-4 h-4 text-primary" />
391 Запишани предмети
392 </h3>
393 <div className="text-3xl font-bold text-primary">
394 {currentSubjects.length}
395 </div>
396 </div>
397 </div>
398
399 {/* Right Column - Financial Information */}
400 <div className="lg:col-span-2">
401 <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
402 <div className="bg-primary text-white px-6 py-4">
403 <h2 className="text-xl font-bold flex items-center gap-2">
404 <FontAwesomeIcon icon={faFileInvoice} />
405 Финансиски информации
406 </h2>
407 </div>
408
409 <div className="p-6">
410 <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
411
412 {/* Left Financial Column */}
413 <div className="space-y-4">
414 <div className="flex justify-between items-center py-2 border-b border-gray-100">
415 <span className="text-gray-600">Сума:</span>
416 <span className="font-semibold text-primary">{currentSemester.financialInfo.sum}</span>
417 </div>
418 <div className="flex justify-between items-center py-2 border-b border-gray-100">
419 <span className="text-gray-600">Платено:</span>
420 <span className="font-semibold text-green-600">{currentSemester.financialInfo.paid}</span>
421 </div>
422 <div className="flex justify-between items-center py-2 border-b border-gray-100">
423 <span className="text-gray-600">Должи:</span>
424 <span className="font-semibold text-red-600">{currentSemester.financialInfo.due}</span>
425 </div>
426 <div className="mt-4 p-4 bg-blue-50 rounded-lg">
427 <div className="text-sm font-medium text-blue-800 mb-1">Материјални трошоци:</div>
428 <div className="text-sm text-blue-700">{currentSemester.financialInfo.materialCosts}</div>
429 </div>
430 </div>
431
432 {/* Right Financial Column */}
433 <div className="space-y-4">
434 <div className="flex justify-between items-center py-2 border-b border-gray-100">
435 <span className="text-gray-600">Кредити:</span>
436 <span className="font-semibold text-primary">{currentSemester.financialInfo.credits}</span>
437 </div>
438 <div className="flex justify-between items-center py-2 border-b border-gray-100">
439 <span className="text-gray-600">МКСА:</span>
440 <span className="font-semibold">{currentSemester.financialInfo.MKSA}</span>
441 </div>
442 <div className="flex justify-between items-center py-2 border-b border-gray-100">
443 <span className="text-gray-600">Електронско запишување:</span>
444 <span className="font-semibold">{currentSemester.financialInfo.electronicRegistration}</span>
445 </div>
446 <div className="flex justify-between items-center py-2 border-b border-gray-100">
447 <span className="text-gray-600">Е-УКИМ:</span>
448 <span className="font-semibold">{currentSemester.financialInfo.eUKIM}</span>
449 </div>
450 <div className="flex justify-between items-center py-2 border-b border-gray-100">
451 <span className="text-gray-600">Банкарска провизија:</span>
452 <span className="font-semibold">{currentSemester.financialInfo.bankProvision}</span>
453 </div>
454 <div className="flex justify-between items-center py-3 border-t-2 border-primary bg-primary bg-opacity-5 rounded-lg px-4">
455 <span className="font-bold text-white">Тотал:</span>
456 <span className="font-bold text-xl text-white">{currentSemester.financialInfo.total}</span>
457 </div>
458 </div>
459 </div>
460 </div>
461 </div>
462 </div>
463 </div>
464
465 {/* Subjects Table */}
466 <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
467 <div className="bg-primary text-white px-6 py-4">
468 <h2 className="text-xl font-bold">Листа на предмети</h2>
469 </div>
470
471 <div className="overflow-x-auto">
472 <table className="w-full">
473 <thead className="bg-gray-50">
474 <tr>
475 <th className="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
476 <th className="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Код</th>
477 <th className="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Часови</th>
478 <th className="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Кој пат</th>
479 <th className="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Предмет</th>
480 <th className="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Семестар</th>
481 <th className="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Статус</th>
482 <th className="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Потпис</th>
483 <th className="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Група</th>
484 <th className="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Професор</th>
485 </tr>
486 </thead>
487 <tbody className="divide-y divide-gray-100">
488 {currentSubjects.map((subject: Subject) => (
489 <tr key={subject.id} className="hover:bg-gray-50 transition-colors">
490 <TableCell className="font-medium text-gray-900">{subject.id}</TableCell>
491 <TableCell className="font-mono text-sm text-primary font-medium">{subject.code}</TableCell>
492 <TableCell className="font-medium">{subject.hours}</TableCell>
493 <TableCell className="text-center font-medium">{subject.kojPat}</TableCell>
494 <TableCell className="font-medium text-gray-900 max-w-xs">
495 <div className="flex items-center gap-2">
496 <FontAwesomeIcon icon={faBook} className="w-4 h-4 text-primary" />
497 {subject.name}
498 </div>
499 </TableCell>
500 <TableCell className="text-center font-medium text-primary">{subject.semester}</TableCell>
501 <TableCell>
502 <StatusBadge status={subject.status} />
503 </TableCell>
504 <TableCell className="text-center">
505 {subject.signature || (
506 <span className="text-gray-400">—</span>
507 )}
508 </TableCell>
509 <TableCell className="text-center">
510 {subject.group || (
511 <span className="text-gray-400">—</span>
512 )}
513 </TableCell>
514 <TableCell>
515 {subject.professor || (
516 <span className="text-gray-400">—</span>
517 )}
518 </TableCell>
519 </tr>
520 ))}
521 </tbody>
522 </table>
523 </div>
524 </div>
525 </div>
526 );
527}
Note: See TracBrowser for help on using the repository browser.