source: frontend/src/app/students/subjects/page.tsx

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