source: src/app/students/subjects/page.tsx@ d7deae5

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

Added dark theme.

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