source: frontend/src/app/students/semesters/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: 16.0 KB
Line 
1"use client"
2
3import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
4import {
5 faCalendarAlt,
6 faCheck,
7 faTimes,
8 faFileAlt,
9 faMoneyBillWave,
10 faSignature,
11 faCheckCircle,
12 faTimesCircle
13} from '@fortawesome/free-solid-svg-icons';
14import { useEffect, useState } from 'react';
15import { getAccessToken } from '@/lib/auth';
16import { useTranslation } from 'react-i18next';
17import { apiUrl } from '@/lib/api';
18import EnrollSemesterDialog from '@/components/enroll-semester-dialog';
19
20type Semester = {
21 id: number | string;
22 semester: string;
23 direction: string;
24 quota: string;
25 note: string;
26 studentCom: string;
27 sum: string;
28 paid: string;
29 ukim: string;
30 createdOn: string;
31 dateChanged: string;
32 credits: string;
33 type: string;
34 doc: string;
35 doc1: string;
36 verified: string;
37 taxes: string;
38 signatures: string;
39 status: string;
40 completed: string;
41};
42
43type SemestersResponse = {
44 semesters: Semester[];
45};
46
47interface TableCellProps {
48 children: React.ReactNode;
49 className?: string;
50}
51
52const TableCell = ({ children, className = "" }: TableCellProps) => (
53 <td className={`px-4 py-3 text-sm border-b border-border ${className}`}>
54 {children}
55 </td>
56);
57
58const StatusBadge = ({ status }: { status: string }) => {
59 const { t } = useTranslation();
60 const baseClasses = "px-2 py-1 rounded-full text-xs font-medium";
61 if (status === 'Валидно') {
62 return (
63 <span className={`${baseClasses} bg-green-100 text-green-800 flex items-center gap-1`}>
64 <FontAwesomeIcon icon={faCheckCircle} className="w-3 h-3" />
65 {t('Валидно', 'Валидно')}
66 </span>
67 );
68 }
69 return (
70 <span className={`${baseClasses} bg-accent text-gray-800`}>
71 {t(status, status)}
72 </span>
73 );
74};
75
76const YesNoBadge = ({ value }: { value: string }) => {
77 const { t } = useTranslation();
78 if (value === 'Да') {
79 return (
80 <span className="inline-flex items-center justify-center w-6 h-6 bg-green-100 text-green-600 rounded-full">
81 <FontAwesomeIcon icon={faCheck} className="w-3 h-3" />
82 </span>
83 );
84 } else if (value === 'Не') {
85 return (
86 <span className="inline-flex items-center justify-center w-6 h-6 bg-red-100 text-red-600 rounded-full">
87 <FontAwesomeIcon icon={faTimes} className="w-3 h-3" />
88 </span>
89 );
90 }
91 return <span className="text-muted-foreground">—</span>;
92};
93
94const SignatureBadge = ({ signatures }: { signatures: string }) => {
95 const [completed, total] = signatures.split('/').map(Number);
96 const percentage = total > 0 ? (completed / total) * 100 : 0;
97
98 let colorClass = "text-red-600 bg-red-100";
99 if (percentage === 100) {
100 colorClass = "text-green-600 bg-green-100";
101 } else if (percentage >= 50) {
102 colorClass = "text-yellow-600 bg-yellow-100";
103 }
104
105 return (
106 <span className={`px-2 py-1 rounded-full text-xs font-medium flex items-center gap-1 ${colorClass}`}>
107 <FontAwesomeIcon icon={faSignature} className="w-3 h-3" />
108 {signatures}
109 </span>
110 );
111};
112
113export default function SemestersPage() {
114 const [semesters, setSemesters] = useState<Semester[]>([]);
115 const [isLoading, setIsLoading] = useState(true);
116 const [errorMessage, setErrorMessage] = useState<string | null>(null);
117 const [enrollOpen, setEnrollOpen] = useState(false);
118 // Bumped after a successful enrolment so the table reloads.
119 const [reloadKey, setReloadKey] = useState(0);
120 const { t } = useTranslation();
121
122 useEffect(() => {
123 let cancelled = false;
124
125 async function load() {
126 setIsLoading(true);
127 setErrorMessage(null);
128
129 const token = getAccessToken();
130 if (!token) {
131 setErrorMessage('Not authenticated. Please login again.');
132 setIsLoading(false);
133 return;
134 }
135
136 try {
137 const response = await fetch(apiUrl('/api/user/getSemesters'), {
138 method: 'GET',
139 headers: {
140 Authorization: `Bearer ${token}`,
141 },
142 });
143
144 if (!response.ok) {
145 const text = await response.text().catch(() => '');
146 throw new Error(text || `Failed to load semesters (${response.status})`);
147 }
148
149 const data = (await response.json()) as SemestersResponse;
150 if (!cancelled) setSemesters(Array.isArray(data?.semesters) ? data.semesters : []);
151 } catch (err) {
152 if (!cancelled) {
153 setErrorMessage(err instanceof Error ? err.message : 'Failed to load semesters.');
154 }
155 } finally {
156 if (!cancelled) setIsLoading(false);
157 }
158 }
159
160 void load();
161
162 return () => {
163 cancelled = true;
164 };
165 }, [reloadKey]);
166
167 if (isLoading) {
168 return (
169 <div className="min-h-screen pb-8">
170 <div className="bg-card rounded-xl shadow-sm border border-border p-6">
171 {t('loading')}
172 </div>
173 </div>
174 );
175 }
176
177 if (errorMessage) {
178 return (
179 <div className="min-h-screen pb-8">
180 <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
181 {errorMessage}
182 </div>
183 </div>
184 );
185 }
186
187 return (
188 <div className="min-h-screen pb-8">
189 {/* Header */}
190 <div className="bg-primary text-white rounded-xl p-8 mb-8">
191 <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
192 <div className="flex items-center gap-4">
193 <div className="bg-white rounded-full p-4">
194 <FontAwesomeIcon icon={faCalendarAlt} className="text-3xl text-[#0272D1]" />
195 </div>
196 <div>
197 <h1 className="text-3xl font-bold mb-2">{t('semesters')}</h1>
198 <p className="text-lg opacity-90">
199 {t('semesters_overview')}
200 </p>
201 </div>
202 </div>
203
204 <button
205 className="self-start rounded-lg bg-white px-5 py-3 font-semibold text-[#0272D1] shadow-sm hover:bg-white/90 md:self-auto"
206 onClick={() => setEnrollOpen(true)}
207 >
208 + {t('enroll_semester')}
209 </button>
210 </div>
211 </div>
212
213 {/* Table Container */}
214 <div className="bg-card rounded-xl shadow-sm border border-border overflow-hidden">
215 <div className="bg-primary text-white px-6 py-4">
216 <h2 className="text-xl font-bold">{t('semesters_list')}</h2>
217 </div>
218
219 <div className="overflow-x-auto">
220 <table className="w-full">
221 <thead className="bg-accent">
222 <tr>
223 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">#</th>
224 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('semester')}</th>
225 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('direction')}</th>
226 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('quota')}</th>
227 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('note')}</th>
228 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('student_com')}</th>
229 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('sum')}</th>
230 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('paid')}</th>
231 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('ukim')}</th>
232 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('created_on')}</th>
233 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('date_changed')}</th>
234 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('credits')}</th>
235 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('type')}</th>
236 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('doc')}</th>
237 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('doc1')}</th>
238 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('verified')}</th>
239 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('taxes')}</th>
240 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('signatures')}</th>
241 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('status')}</th>
242 <th className="px-4 py-4 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('completed')}</th>
243 </tr>
244 </thead>
245 <tbody className="divide-y divide-border">
246 {semesters.map((semester) => (
247 <tr key={semester.id} className="hover:bg-accent transition-colors">
248 <TableCell className="font-medium text-card-foreground">{semester.id}</TableCell>
249 <TableCell className="font-medium text-primary">{semester.semester}</TableCell>
250 <TableCell>{semester.direction}</TableCell>
251 <TableCell className="max-w-xs">
252 <div className="truncate" title={semester.quota}>
253 {semester.quota}
254 </div>
255 </TableCell>
256 <TableCell>
257 {semester.note ? (
258 <span className="text-yellow-600 font-medium" title={semester.note}>
259 {semester.note.length > 10 ? `${semester.note.substring(0, 10)}...` : semester.note}
260 </span>
261 ) : (
262 <span className="text-muted-foreground">—</span>
263 )}
264 </TableCell>
265 <TableCell>
266 {semester.studentCom ? (
267 <span className="text-blue-600 font-medium" title={semester.studentCom}>
268 {semester.studentCom.length > 10 ? `${semester.studentCom.substring(0, 10)}...` : semester.studentCom}
269 </span>
270 ) : (
271 <span className="text-muted-foreground">—</span>
272 )}
273 </TableCell>
274 <TableCell className="font-medium">
275 {semester.sum ? (
276 <span className="text-green-600 flex items-center gap-1">
277 <FontAwesomeIcon icon={faMoneyBillWave} className="w-3 h-3" />
278 {semester.sum}
279 </span>
280 ) : (
281 <span className="text-muted-foreground">—</span>
282 )}
283 </TableCell>
284 <TableCell className="font-medium">
285 {semester.paid ? (
286 <span className="text-green-600 flex items-center gap-1">
287 <FontAwesomeIcon icon={faMoneyBillWave} className="w-3 h-3" />
288 {semester.paid}
289 </span>
290 ) : (
291 <span className="text-muted-foreground">—</span>
292 )}
293 </TableCell>
294 <TableCell className="font-medium">
295 {semester.ukim ? (
296 <span className="text-blue-600">{semester.ukim}</span>
297 ) : (
298 <span className="text-muted-foreground">—</span>
299 )}
300 </TableCell>
301 <TableCell className="text-muted-foreground">{semester.createdOn}</TableCell>
302 <TableCell className="text-muted-foreground">{semester.dateChanged}</TableCell>
303 <TableCell className="font-medium text-primary">{semester.credits}</TableCell>
304 <TableCell>
305 <span className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
306 {semester.type}
307 </span>
308 </TableCell>
309 <TableCell className="text-center">
310 <YesNoBadge value={semester.doc} />
311 </TableCell>
312 <TableCell className="text-center">
313 <YesNoBadge value={semester.doc1} />
314 </TableCell>
315 <TableCell className="text-center">
316 <YesNoBadge value={semester.verified} />
317 </TableCell>
318 <TableCell className="font-medium text-green-600">{semester.taxes}</TableCell>
319 <TableCell>
320 <SignatureBadge signatures={semester.signatures} />
321 </TableCell>
322 <TableCell>
323 <StatusBadge status={semester.status} />
324 </TableCell>
325 <TableCell>
326 {semester.completed !== "Не" ? (
327 <span className="text-green-600 font-medium flex items-center gap-1">
328 <FontAwesomeIcon icon={faCheckCircle} className="w-3 h-3" />
329 {t(semester.completed, semester.completed)}
330 </span>
331 ) : (
332 <span className="text-red-600 font-medium flex items-center gap-1">
333 <FontAwesomeIcon icon={faTimesCircle} className="w-3 h-3" />
334 {t('Не', 'Не')}
335 </span>
336 )}
337 </TableCell>
338 </tr>
339 ))}
340 </tbody>
341 </table>
342 </div>
343 </div>
344
345 {/* Summary Cards */}
346 <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-8">
347 <div className="bg-card rounded-xl p-6 shadow-sm border border-border">
348 <div className="flex items-center gap-4">
349 <div className="bg-green-100 text-green-600 rounded-full p-3">
350 <FontAwesomeIcon icon={faCheckCircle} className="text-xl" />
351 </div>
352 <div>
353 <h3 className="text-lg font-bold text-card-foreground">{t('completed_semesters')}</h3>
354 <p className="text-2xl font-bold text-green-600">
355 {semesters.filter(s => s.completed !== "Не").length}
356 </p>
357 </div>
358 </div>
359 </div>
360
361 <div className="bg-card rounded-xl p-6 shadow-sm border border-border">
362 <div className="flex items-center gap-4">
363 <div className="bg-blue-100 text-blue-600 rounded-full p-3">
364 <FontAwesomeIcon icon={faFileAlt} className="text-xl" />
365 </div>
366 <div>
367 <h3 className="text-lg font-bold text-card-foreground">{t('verified_semesters')}</h3>
368 <p className="text-2xl font-bold text-blue-600">
369 {semesters.filter(s => s.verified === "Да").length}
370 </p>
371 </div>
372 </div>
373 </div>
374
375 <div className="bg-card rounded-xl p-6 shadow-sm border border-border">
376 <div className="flex items-center gap-4">
377 <div className="bg-primary text-white rounded-full p-3">
378 <FontAwesomeIcon icon={faCalendarAlt} className="text-xl" />
379 </div>
380 <div>
381 <h3 className="text-lg font-bold text-card-foreground">{t('total_semesters')}</h3>
382 <p className="text-2xl font-bold text-primary">
383 {semesters.length}
384 </p>
385 </div>
386 </div>
387 </div>
388 </div>
389
390 <EnrollSemesterDialog
391 open={enrollOpen}
392 onClose={() => setEnrollOpen(false)}
393 onEnrolled={() => setReloadKey((k) => k + 1)}
394 />
395 </div>
396 );
397}
Note: See TracBrowser for help on using the repository browser.