Compare commits

...

198 Commits

Author SHA1 Message Date
52b2af097f feat: add options for campuses
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m27s
2025-02-06 17:26:18 +03:00
ea5e731bd2 feat: add notify for success upload
All checks were successful
Build and Deploy Angular App / build (push) Successful in 48s
2025-02-06 16:42:04 +03:00
74a7fe7eb6 fix: allow save if data is empty 2025-02-06 16:41:43 +03:00
2f9d552e43 refactor: remove unused code 2025-02-06 16:41:24 +03:00
004671c006 feat: add support for the new api
All checks were successful
Build and Deploy Angular App / build (push) Successful in 43s
2025-02-03 03:37:30 +03:00
0f6a1e7a45 fix: use is array instead typeof 2025-02-03 03:36:41 +03:00
437a3fcc58 refactor: clean code
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m17s
2025-02-02 22:38:52 +03:00
0002371265 feat: add new components to schedule configuration 2025-02-02 22:38:39 +03:00
0f25d5404c feat: add schedule files upload 2025-02-02 22:38:05 +03:00
e98a0db7ca feat: add term start configuration 2025-02-02 22:37:27 +03:00
324c7630ea feat: add dialog about force remove data 2025-02-02 22:36:08 +03:00
f1f1ed16e1 feat: add routes for admin panel 2025-02-02 20:58:24 +03:00
6fcd68b627 feat: add schedule configuration 2025-02-02 20:58:10 +03:00
d50da4db3e feat: add page cap 2025-02-02 20:57:56 +03:00
066b1444af feat: add admin panel 2025-02-02 20:57:36 +03:00
df4ea723b3 feat: add date adapter provider 2025-02-02 20:54:22 +03:00
434dec492d feat: add component for change cron expression 2025-02-02 20:34:57 +03:00
24d6b91553 feat: add component for skip update 2025-02-02 20:34:30 +03:00
2b988db70d feat: add new api 2025-02-02 20:33:35 +03:00
a3a19be5a4 refactor: clean code 2025-02-02 20:32:45 +03:00
9f742cab78 feat: add base component for configuration card 2025-02-02 18:52:37 +03:00
c8bcda8da2 refactor: move locale settings to app 2025-02-02 18:52:02 +03:00
1bf2868d00 refactor: rename AuthApiService 2025-02-02 03:45:25 +03:00
5b9b67d50c feat: add filter by lesson type 2025-02-01 17:11:28 +03:00
061307447e feat: show campus name with lecture hall 2025-02-01 17:09:24 +03:00
cf09738447 fix: check variable existing 2025-02-01 16:26:56 +03:00
79a992dc69 fix: mapping part of url if null or empty 2025-02-01 16:26:04 +03:00
612da04cbb refactor: use object RequestData 2025-02-01 16:25:05 +03:00
3d38b49839 build: upgrade to angular 19.1 2025-02-01 16:06:02 +03:00
fcd179166e build: change version
All checks were successful
Build and Deploy Angular App / build (push) Successful in 2m0s
2024-12-28 08:37:36 +03:00
224d7a3443 fix: add stretch for input 2024-12-28 08:36:26 +03:00
2370a2051b fix: add OAuthAction 2024-12-28 08:36:00 +03:00
1d691ccc09 refactor: rewrite oauth login and 2fa for the new api 2024-12-28 08:35:48 +03:00
a7542eaf32 refactor: rewrite oauth setup for the new api 2024-12-28 07:44:41 +03:00
a8b1485b0e fix: do not resend the request if it is not necessary
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m20s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-12-26 10:40:27 +03:00
90fca336f5 fix: remove css
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m15s
2024-12-23 08:03:30 +03:00
a7b8c15e3a build: update ref
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m46s
2024-12-23 06:45:34 +03:00
135570d384 fix: redesign the service for a new api 2024-12-23 06:45:19 +03:00
7830c5f21d refactor: put the input password in a separate component
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m17s
2024-12-23 06:41:28 +03:00
6e914caabc refactor: translate error to russia 2024-12-23 05:15:54 +03:00
f26d74aae5 refactor: change name arg 2024-12-23 05:15:35 +03:00
3aefee124a fix: calculating weeks in a year 2024-12-23 05:15:19 +03:00
eda6ca4b1a refactor: use RFC 7807 standard for error handling
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m30s
2024-12-22 07:17:21 +03:00
10bf53adec feat: add integration with seq 2024-12-22 07:16:54 +03:00
e10075dfed fix: message error text 2024-12-18 08:47:36 +03:00
2b482d2b2d fix: set max height 2024-12-18 08:47:22 +03:00
9017e87175 fix: bypassing cors 2024-12-18 08:41:29 +03:00
16e25905dc feat: add spinner 2024-12-18 08:40:54 +03:00
8138a63324 refactor: rename shared
All checks were successful
Build and Deploy Angular App / build (push) Successful in 57s
2024-12-18 07:53:25 +03:00
1ffbfad37a build: update ref
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m36s
2024-12-18 07:11:15 +03:00
c04c457211 fix: add alt 2024-12-18 07:10:51 +03:00
fba28b6bbe feat: rewrite setup wizard 2024-12-18 07:09:29 +03:00
86e6f59567 feat: add providers OAuth 2024-12-18 07:02:08 +03:00
a2d4151cc3 refactor: clean code 2024-12-18 06:57:27 +03:00
3af8c43cd9 refactor: adapt models to the new api 2024-12-18 06:50:41 +03:00
21f89132ff refactor: adapt models to the new api 2024-12-18 06:48:51 +03:00
99958a2383 Merge remote-tracking branch 'origin/master'
All checks were successful
Build and Deploy Angular App / build (push) Successful in 58s
2024-10-27 08:30:16 +03:00
38b877608f feat: add import to excel
Made at the request of the customer
2024-10-27 08:29:30 +03:00
7c66f31bac refactor: make compliance with the new api 2024-10-27 07:38:42 +03:00
72a5f37404 revert e92927addb9291f02d0d959f9d344b3f5354242d
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m40s
revert build: try set group owner

Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-10-25 03:06:07 +03:00
e92927addb build: try set group owner
Some checks failed
Build and Deploy Angular App / build (push) Failing after 46s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-10-25 02:55:53 +03:00
924c75ea79 build: remove sudo
Some checks failed
Build and Deploy Angular App / build (push) Failing after 57s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-10-25 02:40:41 +03:00
5d265e4b48 fix: combine typeOfOccupation
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m0s
2024-10-25 01:53:25 +03:00
a8159b4f27 build: update ref 2024-10-25 01:52:29 +03:00
9231bd0d4a fix: set the text based on the context
All checks were successful
Build and Deploy Angular App / build (push) Successful in 2m32s
2024-10-09 03:11:09 +03:00
0bbed93df2 refactor: remove unused methods 2024-10-09 03:10:32 +03:00
2b09086902 refactor: adapting token storage to the API 2024-10-09 03:10:11 +03:00
9209b31db2 feat: add more info to error 2024-10-07 03:25:13 +03:00
3ca6f56fec feat: replace custom notify to ngx-toastr
#https://github.com/scttcper/ngx-toastr
2024-10-07 01:17:49 +03:00
eded639cc3 build: update ref 2024-10-07 01:16:11 +03:00
844d91de7d fix: add correct font 2024-10-07 01:15:13 +03:00
380b2efa0d fix: delete data if top-level data is selected
All checks were successful
Build and Deploy Angular App / build (push) Successful in 2m3s
2024-09-30 04:55:33 +03:00
6211dd8889 fix: show spinner after click on retry 2024-09-30 01:30:23 +03:00
a86e88e087 ci: delete other files
All checks were successful
Build and Deploy Angular App / build (push) Successful in 3m46s
2024-09-30 01:21:48 +03:00
eb4b5d31df build: upgrade ref 2024-09-30 01:21:07 +03:00
1f901f0612 build: revert zone.js
All checks were successful
Build and Deploy Angular App / build (push) Successful in 56s
2024-09-16 00:12:38 +03:00
85b8bab530 build: upgrade ref
Some checks failed
Build and Deploy Angular App / build (push) Failing after 31s
2024-09-15 21:44:10 +03:00
80a7e71b84 fix: sort weeks 2024-09-15 21:13:55 +03:00
6450acf6a3 build: update ref
All checks were successful
Build and Deploy Angular App / build (push) Successful in 51s
2024-09-01 03:44:02 +03:00
207f54ae17 fix: set credentials and change get to post 2024-09-01 00:30:37 +03:00
916aa2fd9c fix: add correct language for datepicker 2024-09-01 00:29:54 +03:00
42d831892a feat: split group by graduate 2024-08-31 02:18:55 +03:00
5ee350b66c fix: error prevention if data is null of empty 2024-08-31 02:17:55 +03:00
578fdff6ca fix: correct show special weeks 2024-08-30 17:00:33 +03:00
4bfd919bbc feat: grouped pair by group
All checks were successful
Build and Deploy Angular App / build (push) Successful in 49s
2024-08-30 01:30:34 +03:00
9d9302525b fix: search with 'ё' like 'е' 2024-08-28 20:13:30 +03:00
b341d66f08 build: update version
All checks were successful
Build and Deploy Angular App / build (push) Successful in 50s
2024-08-28 03:54:03 +03:00
79393a39c3 feat: add parameters as navigation
Now there is automatic navigation on the inserted url and on localStorage
2024-08-28 03:52:47 +03:00
60218a73f2 refactor: move TabStorage to service 2024-08-28 02:01:39 +03:00
42e454c4d6 refactor: clean code 2024-08-28 01:53:55 +03:00
c6059a7a60 feat: add link to admin panel 2024-08-28 01:53:27 +03:00
6a3a6a8d47 feat: add show raw discipline if checked 2024-08-28 01:22:45 +03:00
1fa1e864da style: change style schedule content 2024-08-28 00:36:48 +03:00
fc828f3008 fix: if isExcludedWeeks or weeks is null then pass condition 2024-08-28 00:00:46 +03:00
49179d2a8a fix: set default value instead undefined
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m52s
2024-08-27 22:48:42 +03:00
ebf1066610 feat: return other-components filter
All checks were successful
Build and Deploy Angular App / build (push) Successful in 54s
2024-08-27 22:10:51 +03:00
8c9b798bff fix: set field before using 2024-08-27 21:53:39 +03:00
660f251b40 fix: set current day like start term if less
All checks were successful
Build and Deploy Angular App / build (push) Successful in 57s
2024-08-27 21:47:21 +03:00
80ab5c9b50 fix: negative or zero number
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m1s
2024-08-27 21:41:37 +03:00
dba0d3cd62 refactor: move custom adapter to providers
All checks were successful
Build and Deploy Angular App / build (push) Successful in 48s
2024-08-26 02:16:37 +03:00
7a9bca86bc build: update package 2024-08-26 02:15:48 +03:00
f24c1fd9c8 fix: remove double start token refresh 2024-08-26 02:13:27 +03:00
c945a1016b fix: don't try update if token is not found 2024-08-26 02:12:45 +03:00
8a584fd28a feat: try refreshing if error not related to 401 or 403 error 2024-08-26 02:08:51 +03:00
60d306f9c9 fix: unsubscribe from listening to refresh token 2024-08-26 01:24:57 +03:00
5d79d86c44 build: remove package 2024-08-24 04:29:06 +03:00
eada16110b refactor: clean code 2024-08-24 04:28:53 +03:00
b215d8909c refactor: remove log 2024-08-24 04:28:23 +03:00
1f03c2a9c3 refactor: refresh token
The service did not update tokens well, so it was rewritten
2024-08-24 04:27:13 +03:00
48a74ecbf5 refactor: remove log 2024-08-24 04:25:43 +03:00
fd5a1cb14f fix: delete saving states
Parallel use of the same Api Service instance is likely to replace requestData
2024-08-24 04:24:44 +03:00
2871505591 build: add package
All checks were successful
Build and Deploy Angular App / build (push) Successful in 49s
2024-08-24 02:23:39 +03:00
93929633d6 build: change version 2024-08-23 23:58:06 +03:00
9b7b4aba50 feat: add scroll style 2024-08-23 23:57:54 +03:00
ef0de7f709 feat: add header 2024-08-23 23:57:14 +03:00
35217726b0 refactor: remove log 2024-08-23 23:56:21 +03:00
52432fd00f fix: conflicting dependency
All checks were successful
Build and Deploy Angular App / build (push) Successful in 50s
2024-08-23 23:28:06 +03:00
8acb30c2c3 build: upgrade package
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m17s
2024-08-23 23:24:03 +03:00
1d79860df3 fix: disable scrolling if there is an overlay 2024-08-23 23:22:37 +03:00
48bc7ca005 fix: add ref to IScheduleTab 2024-08-23 23:21:53 +03:00
3cea5f7982 refactor: clean code 2024-08-23 23:21:12 +03:00
e0a2ba257c feat: add saving of user's selection 2024-08-23 23:19:25 +03:00
2e36b06aea build: fix port of test server 2024-08-23 23:16:20 +03:00
95a593bdb6 feat: add auth api
All checks were successful
Build and Deploy Angular App / build (push) Successful in 55s
2024-08-04 23:15:38 +03:00
f4b25f428d feat: add login page 2024-08-04 23:14:45 +03:00
b764f2a77b build: update ref 2024-08-04 23:13:56 +03:00
95165a0940 feat: create has-role directive for simple ACL 2024-08-04 23:13:43 +03:00
e9735a4e99 fix: 2024-08-04 23:03:06 +03:00
e82a0ecb5e build: upgrade ref
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m27s
2024-07-24 21:50:37 +03:00
1327131d69 build: upgrade ref
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m54s
2024-07-13 00:17:49 +03:00
8fa39614b7 build: try fix rsync error
All checks were successful
Build and Deploy Angular App / build (push) Successful in 1m3s
2024-07-13 00:15:21 +03:00
458cab9888 build: set group separately of rsync
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m2s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-07-04 11:01:05 +03:00
0c28514a8d build: add -p to rsync
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m30s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-07-04 10:53:24 +03:00
d5074eb0f3 build: test all in rsync
Some checks failed
Build and Deploy Angular App / build (push) Failing after 57s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-07-04 10:46:04 +03:00
c8f2c608b8 build: add -y
Some checks failed
Build and Deploy Angular App / build (push) Failing after 51s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-07-04 10:32:43 +03:00
ece19a663a build: install rsync before run
Some checks failed
Build and Deploy Angular App / build (push) Failing after 43s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-07-04 10:31:24 +03:00
28804e6f06 build: sync file instead copy
Some checks failed
Build and Deploy Angular App / build (push) Failing after 37s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-07-04 10:28:35 +03:00
b5cc4cd06f env: add '/' to end endpoint url
All checks were successful
Build and Deploy Angular App / build (push) Successful in 35s
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-07-03 11:34:10 +03:00
a71223b951 build: change path
All checks were successful
Build and Deploy Angular App / build (push) Successful in 42s
2024-07-02 01:10:56 +03:00
8cb05a7895 build: remove passphrase
Some checks failed
Build and Deploy Angular App / build (push) Failing after 35s
2024-07-02 01:04:53 +03:00
dedc8b258a feat: add TimeOnly class
Some checks failed
Build and Deploy Angular App / build (push) Failing after 38s
2024-07-02 00:58:45 +03:00
748421580a feat: add summary page 2024-07-02 00:58:35 +03:00
2fe2b11659 build: update ref
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m29s
2024-07-02 00:55:23 +03:00
86cf636e16 feat: add auth service 2024-07-02 00:54:21 +03:00
7d78295b9a feat: add token response and token request 2024-07-02 00:53:51 +03:00
0e4b57af51 feat: add auto refresh token service 2024-07-02 00:52:47 +03:00
ce5508fe7f build: add apiUrl to environment
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m29s
2024-07-02 00:49:42 +03:00
4dbcdf658e build: fix configuration 2024-07-02 00:49:26 +03:00
7d0a51696f build: fix ng to npm run build
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m23s
2024-07-02 00:45:46 +03:00
20e26fff6f build: add deploy to server
Some checks failed
Build and Deploy Angular App / build (push) Failing after 1m33s
2024-07-02 00:43:30 +03:00
0cefefb768 docs: add README.md 2024-06-28 22:45:19 +03:00
ae9808da30 Добавить LICENSE.txt
Signed-off-by: Polianin Nikita <wesser@noreply.git.winsomnia.net>
2024-06-28 22:20:19 +03:00
7eebe4632c feat: add withCredentials that doesn't send cookie if not needed 2024-06-28 21:32:10 +03:00
b498b0204c build: update ref 2024-06-27 01:52:35 +03:00
9d875f31ad build: change maximum budgets mb 2024-06-27 01:52:12 +03:00
8e738d9b3d refactor: implement RequestBuilder for main request 2024-06-27 01:50:58 +03:00
e36376db3a refactor: delete private for local action 2024-06-27 01:47:09 +03:00
aa2eab9a4c feat: add request builder 2024-06-27 01:46:32 +03:00
e56644c538 feat: add focus by id 2024-06-22 00:55:03 +03:00
8d075f8982 build: upgrade ref 2024-06-15 22:07:03 +03:00
c91973b185 refactor: use other way to save env 2024-06-15 00:37:12 +03:00
aca3eb457a feat: add setup/schedule page 2024-06-11 00:28:16 +03:00
99a77999fb feat: add setup/logging page 2024-06-11 00:27:18 +03:00
d764e84726 feat: add setup/create-admin page 2024-06-11 00:27:09 +03:00
f5c7ceb850 fix: change cache service to component 2024-06-11 00:26:51 +03:00
6fd78e7830 feat: add password match validator for password and retype fields 2024-06-11 00:26:00 +03:00
b0b41fcdc5 feat: add setup/cache page 2024-06-11 00:25:33 +03:00
02f7c33b91 feat: add setup/database page 2024-06-11 00:25:25 +03:00
1468a9766d fix: change protected to public readonly 2024-06-11 00:24:50 +03:00
d6f51a5d1c feat: add setup/welcome page 2024-06-11 00:24:25 +03:00
7e7b8b6c8f feat: add setup page 2024-06-11 00:24:04 +03:00
06f6efe023 feat: add navigator service 2024-06-11 00:23:48 +03:00
781599e2b7 feat: add schedule page 2024-06-11 00:21:29 +03:00
aec9175d1a fix: convert Date to DateOnly 2024-06-11 00:17:09 +03:00
69e080033f refactor: bind components to api 2024-06-11 00:16:49 +03:00
8a1921b6cb refactor: bind components to api 2024-06-11 00:16:17 +03:00
bd03cde151 refactor: use ScheduleResponse instead other schedule responses 2024-06-10 21:52:42 +03:00
704271cf6d feat: add link to creator website 2024-06-10 21:49:14 +03:00
922d238a05 fix: TS6385. Change HttpClientModule to provideHttpClient 2024-06-10 21:48:32 +03:00
f3e707a31a feat: add ru locale 2024-06-10 21:46:16 +03:00
c5f4b7b526 refactor: set readonly ticks 2024-06-10 21:44:41 +03:00
2e4b1ec876 fix: TS2732 2024-06-07 22:08:36 +03:00
77784f388d build update ref 2024-06-06 23:32:56 +03:00
a7d48f3deb fix: can get null for nullable request 2024-06-06 23:31:34 +03:00
b388fb039a fix: change from single typeOfOccupation to array 2024-06-05 21:48:38 +03:00
83fc7df137 refactor: rewrite for new models 2024-06-05 21:47:46 +03:00
799d2d8166 fix: change environment 2024-06-05 21:46:02 +03:00
3d9fabe217 feat: add ApiService implementations 2024-06-05 21:45:21 +03:00
5ff24d49b5 refactor: change apiService to abstract 2024-06-05 21:43:36 +03:00
ad0940c087 build: add custom refs 2024-06-05 21:43:29 +03:00
5bde568fab fix: change import 2024-06-05 21:39:25 +03:00
a4bcc1d920 feat: add custom api model 2024-06-05 21:39:00 +03:00
58bdc6aa69 feat: add api models 2024-06-05 21:36:51 +03:00
f1d0fdf31b build: upgrade packages 2024-05-30 19:11:08 +03:00
2642a00565 refactor: add space between Component and class 2024-05-26 23:42:09 +03:00
a1c6e2fa35 style: replace title 2024-05-23 02:57:34 +03:00
221682f1a6 fix: replace relative link to correct 2024-05-23 02:57:08 +03:00
8ea8e33e02 refactor: replace relative link 2024-05-23 02:56:21 +03:00
173 changed files with 11841 additions and 6076 deletions

View File

@ -0,0 +1,45 @@
name: Build and Deploy Angular App
on:
pull_request:
push:
branches:
[master, 'release/*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies
run: npm install
- name: Build Angular app
run: npm run build -- --configuration production
- name: Start ssh-agent
id: ssh-agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Deploy to Server
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_USER: ${{ secrets.SSH_USER }}
TARGET_DIR: ${{ secrets.TARGET_DIR }}
run: |
mkdir -p ~/.ssh
ssh-keyscan $SSH_HOST >> ~/.ssh/known_hosts
sudo apt update
sudo apt install rsync -y
rsync -avr -p --chmod=770 --no-times --delete ./dist/frontend/browser/ $SSH_USER@$SSH_HOST:$TARGET_DIR
ssh $SSH_USER@$SSH_HOST "chown -R :www-data $TARGET_DIR"

21
LICENSE.txt Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Winsomnia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

106
README.md
View File

@ -1,27 +1,105 @@
# Frontend
# MIREA schedule by Winsomnia
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.1.2.
[![Angular Release](https://img.shields.io/badge/v19.1-8?style=flat-square&label=Angular&labelColor=512BD4&color=606060)](https://github.com/angular/angular-cli)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT)
## Development server
This project provides a Web interface for working with the MIREA schedule.
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
The main task is to provide a convenient and flexible interface for accessing the schedule via a web browser.
## Code scaffolding
## Purpose
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
The purpose of this project is designed to provide a user-friendly web interface for working with the schedule of training sessions of the Moscow Technological University (MIREA).
## Build
In a situation where existing resources provide limited functionality or an inconvenient interface, this project aims to provide users with a simple and effective interface for accessing information about the class schedule.
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
Developing your own components and communicating with the schedule API allows you to provide flexibility, extensibility and usability of the application.
## Running unit tests
## Features
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
- View the schedule.
- Select data by professor, by group and other criteria.
- Use the convenient setup wizard (Wizard Installation) to configure the Backend via a Web browser.
- Administer the site through the built-in tools.
## Running end-to-end tests
## Project status
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
The project is under development. Further development will be aimed at expanding the functionality and improving the user experience.
## Further help
# Environment Variables
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
Before starting the project, you need to fill in your data in the [`environment.ts`](src/environments/environment.ts) file.
| Variable | Example | Description |
|------------|----------------------------------|----------------------------------------------------|
| apiUrl | https://mirea.winsomnia.net/api/ | Provides an address for accessing the API |
| maxRetry | 5 | The number of attempts in case of a failed attempt |
| retryDelay | 3000 | How long should wait in ms before the next attempt |
# Installation
If you want to make a fork of this project or place the Web application on your hosting yourself, then follow the instructions below.
To install using a pre-built application, follow these steps:
1. [Install Node.js](#install-nodejs)
2. [Clone Git](#clone-git)
3. [Install dependency](#install-dependency)
4. Serve or build static files
* [Serve](#serve)
* [Build](#build)
### Install Node.js
Install Node.js for further work or building the application. Go to the [official website Node.js](https://nodejs.org/en/download/package-manager ) and select the required packages.
### Clone Git
Clone the repository
```bash
git clone https://git.winsomnia.net/Winsomnia/MireaFrontend.git
```
Go to the project directory:
```bash
cd MireaFrontend
```
### Install dependency
Install the necessary dependencies. You can use npm or pnpm:
```bash
npm install
```
### Serve
Launch the application using:
```bash
ng serve
```
The application will be available at http://localhost:4200.
### Build
Run the build of the project using:
```bash
ng build
```
The project files will be in the following directory: `dist/frontend`
# Contribution and Feedback
You can contribute to the project by creating pull requests. Any feedback is welcome to improve the project.
# License
This project is licensed under the [MIT License](LICENSE.txt).

View File

@ -36,7 +36,7 @@
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
@ -49,7 +49,13 @@
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"

10528
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "1.0.0-a0",
"version": "1.0.0-rc0",
"scripts": {
"ng": "ng",
"start": "ng serve",
@ -10,33 +10,34 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^18.0.0",
"@angular/cdk": "~18.0.0",
"@angular/cdk-experimental": "^18.0.0",
"@angular/common": "^18.0.0",
"@angular/compiler": "^18.0.0",
"@angular/core": "^18.0.0",
"@angular/forms": "^18.0.0",
"@angular/material": "~18.0.0",
"@angular/platform-browser": "^18.0.0",
"@angular/platform-browser-dynamic": "^18.0.0",
"@angular/router": "^18.0.0",
"@progress/kendo-date-math": "^1.5.13",
"@angular/animations": "^19.1.4",
"@angular/cdk": "~19.1.2",
"@angular/cdk-experimental": "^19.1.2",
"@angular/common": "^19.1.4",
"@angular/compiler": "^19.1.4",
"@angular/core": "^19.1.4",
"@angular/forms": "^19.1.4",
"@angular/material": "~19.1.2",
"@angular/platform-browser": "^19.1.4",
"@angular/platform-browser-dynamic": "^19.1.4",
"@angular/router": "^19.1.4",
"@progress/kendo-date-math": "^1.5.14",
"ngx-toastr": "^19.0.0",
"rxjs": "~7.8.1",
"tslib": "^2.6.2",
"zone.js": "~0.14.6"
"tslib": "^2.8.1",
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.0.0",
"@angular/cli": "^18.0.0",
"@angular/compiler-cli": "^18.0.0",
"@types/jasmine": "~5.1.4",
"jasmine-core": "~5.1.2",
"karma": "~6.4.3",
"@angular-devkit/build-angular": "^19.1.5",
"@angular/cli": "^19.1.5",
"@angular/compiler-cli": "^19.1.4",
"@types/jasmine": "~5.1.5",
"jasmine-core": "~5.5.0",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.4.5"
"typescript": "^5.7.3"
}
}

54
src/api/RequestBuilder.ts Normal file
View File

@ -0,0 +1,54 @@
import {HttpHeaders} from "@angular/common/http";
export interface RequestData {
endpoint: string;
queryParams: Record<string, string | number | boolean | Array<any> | null> | null;
httpHeaders: HttpHeaders;
data: any;
silenceMode: boolean;
withCredentials: boolean;
needAuth: boolean;
}
export class RequestBuilder {
private result: RequestData = Object.create({});
constructor() {
}
public setEndpoint(endpoint: string): this {
this.result.endpoint = endpoint;
return this;
}
public setQueryParams(queryParams: Record<string, string | number | boolean | Array<any> | null>): RequestBuilder {
this.result.queryParams = queryParams;
return this;
}
public addHeaders(headers: Record<string, string>): RequestBuilder {
Object.keys(headers).forEach(key => {
this.result.httpHeaders = this.result.httpHeaders.set(key, headers[key]);
});
return this;
}
public setData(data: any): RequestBuilder {
this.result.data = data;
return this;
}
public setSilenceMode(silence: boolean = true): RequestBuilder {
this.result.silenceMode = silence;
return this;
}
public setWithCredentials(credentials: boolean = true): RequestBuilder {
this.result.withCredentials = credentials;
return this;
}
public get build(): RequestData {
return this.result;
}
}

209
src/api/api.service.ts Normal file
View File

@ -0,0 +1,209 @@
import {
BehaviorSubject,
catchError,
distinctUntilChanged,
filter,
first,
Observable,
of,
ReplaySubject,
switchMap
} from "rxjs";
import {HttpClient, HttpErrorResponse} from "@angular/common/http";
import {environment} from "@environment";
import {Router} from "@angular/router";
import {Injectable} from "@angular/core";
import {RequestBuilder, RequestData} from "@api/RequestBuilder";
import {ToastrService} from "ngx-toastr";
import {AuthRoles} from "@model/authRoles";
export enum AvailableVersion {
v1
}
@Injectable()
export default abstract class ApiService {
constructor(protected http: HttpClient, protected notify: ToastrService, private router: Router) {
}
private apiUrl = environment.apiUrl;
private static isRefreshingToken: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
private static refreshTokenSubject: ReplaySubject<any> = new ReplaySubject(1);
protected abstract basePath: string;
protected abstract version: AvailableVersion;
private static addQuery(endpoint: string, queryParams?: Record<string, string | number | boolean | Array<any> | null> | null): string {
const url = new URL(endpoint);
if (queryParams) {
Object.keys(queryParams).forEach(key => {
const value = queryParams[key];
if (value !== null && value !== undefined) {
if (Array.isArray(value)) {
(value as Array<any>).forEach(x => url.searchParams.append(key, x.toString()));
} else
url.searchParams.append(key, value.toString());
}
});
}
return url.href;
}
private static combineUrls(...parts: string[]): string {
return parts.map(part => (!part || part == '' ? '/' : part).replace(/(^\/+|\/+$)/g, '')).join('/');
}
protected combinedUrl(request: RequestData) {
return ApiService.addQuery(ApiService.combineUrls(this.apiUrl, AvailableVersion[this.version], this.basePath, request.endpoint), request.queryParams);
}
private sendHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put', request: RequestData, secondTry: boolean = false): Observable<Type> {
const doneEndpoint = this.combinedUrl(request);
return this.http.request<Type>(method, doneEndpoint, {
withCredentials: request.withCredentials,
headers: request.httpHeaders,
body: request.data
}).pipe(
catchError(error => {
if (request.needAuth && !secondTry && error.status === 401)
return this.handle401Error(error).pipe(
switchMap(() => this.sendHttpRequest<Type>(method, request, true))
);
else {
if (!request.silenceMode)
this.handleError(error);
throw error;
}
})
);
}
private refreshToken(): Observable<AuthRoles> {
return this.http.get<AuthRoles>(ApiService.combineUrls(this.apiUrl, AvailableVersion[AvailableVersion.v1], 'Auth', 'ReLogin'), {
withCredentials: true
});
}
private handle401Error(error: any): Observable<any> {
if (ApiService.isRefreshingToken.value)
return ApiService.refreshTokenSubject.asObservable();
ApiService.isRefreshingToken.next(true);
return this.refreshToken().pipe(
switchMap(_ => {
ApiService.isRefreshingToken.next(false);
ApiService.refreshTokenSubject.next(null);
return of(null);
}),
catchError(err => {
ApiService.isRefreshingToken.next(false);
ApiService.refreshTokenSubject.error(err);
ApiService.refreshTokenSubject = new ReplaySubject(1);
throw error;
})
);
}
private makeHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put', request: RequestData): Observable<Type> {
if (request.needAuth) {
return ApiService.isRefreshingToken.pipe(
distinctUntilChanged(),
filter(isRefreshing => !isRefreshing),
first(),
switchMap(() => this.sendHttpRequest<Type>(method, request))
);
} else {
return this.sendHttpRequest<Type>(method, request);
}
}
private getRequest(request: RequestData | string | null): RequestData {
if (request === null)
return this.createRequestBuilder().build;
if (typeof request === 'string')
return this.createRequestBuilder().setEndpoint(request as string).build;
return request as RequestData;
}
public createRequestBuilder() {
return new RequestBuilder();
}
public get<Type>(request: RequestData | string | null = null): Observable<Type> {
return this.makeHttpRequest<Type>('get', this.getRequest(request));
}
public post<Type>(request: RequestData | string | null = null): Observable<Type> {
return this.makeHttpRequest<Type>('post', this.getRequest(request));
}
public put<Type>(request: RequestData | string | null = null): Observable<Type> {
return this.makeHttpRequest<Type>('put', this.getRequest(request));
}
public delete<Type>(request: RequestData | string | null = null): Observable<Type> {
return this.makeHttpRequest<Type>('delete', this.getRequest(request));
}
public addAuth(request: RequestData) {
request.needAuth = true;
request.withCredentials = true;
return this;
}
private handleError(error: HttpErrorResponse): void {
// todo: change to Retry-After condition
if (error.error && error.error.detail && error.error.detail.includes("setup")) {
this.router.navigate(['/setup/']).then();
return;
}
let title: string;
let message: string | undefined = undefined;
if (error.error instanceof ErrorEvent) {
title = `Произошла ошибка: ${error.error.message}`;
} else {
if (error.error && error.error.type && error.error.title) {
title = error.error.title || `Ошибка с кодом ${error.status}`;
message = error.error.detail || 'Неизвестная ошибка';
} else {
switch (error.status) {
case 0:
title = 'Неизвестная ошибка. Пожалуйста, попробуйте позже.';
break;
case 400:
title = 'Ошибка запроса. Пожалуйста, проверьте отправленные данные.';
break;
case 401:
this.router.navigate(['/login/']).then();
title = 'Ошибка авторизации. Пожалуйста, выполните вход с правильными учетными данными.';
break;
case 403:
title = 'Отказано в доступе. У вас нет разрешения на выполнение этого действия.';
break;
case 404:
title = 'Запрашиваемый ресурс не найден.';
break;
case 500:
title = 'Внутренняя ошибка сервера. Пожалуйста, попробуйте позже.';
break;
case 503:
title = 'Сервер на обслуживании. Пожалуйста, попробуйте позже.';
break;
default:
title = `Сервер вернул код ошибки: ${error.status}`;
break;
}
}
if (!message)
message = error.error.statusMessage;
}
this.notify.error(message == '' ? undefined : message, title);
}
}

View File

@ -0,0 +1,119 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {LoginRequest} from "@api/v1/loginRequest";
import {catchError, map, Observable, of} from "rxjs";
import {AuthRoles} from "@model/authRoles";
import {AvailableOAuthProvidersResponse} from "@api/v1/availableProvidersResponse";
import {OAuthProvider} from "@model/oAuthProvider";
import {TwoFactorAuthentication} from "@model/twoFactorAuthentication";
import {TwoFactorAuthRequest} from "@api/v1/twoFactorAuthRequest";
import {OAuthAction} from "@model/oAuthAction";
export interface OAuthProviderData extends AvailableOAuthProvidersResponse {
icon: string;
}
@Injectable()
export default class AuthApiService extends ApiService {
public readonly basePath = 'Auth/';
public readonly version = AvailableVersion.v1;
public login(login: LoginRequest) {
let request = this.createRequestBuilder()
.setEndpoint('Login')
.setData(login)
.setWithCredentials()
.build;
return this.post<TwoFactorAuthentication>(request);
}
public twoFactorAuth(data: TwoFactorAuthRequest) {
let request = this.createRequestBuilder()
.setEndpoint('2FA')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public reLogin() {
let request = this.createRequestBuilder()
.setEndpoint('ReLogin')
.setWithCredentials()
.build;
return this.get<AuthRoles>(request);
}
public logout() {
let request = this.createRequestBuilder()
.setWithCredentials()
.setEndpoint('Logout')
.build;
return this.addAuth(request).get(request);
}
public getRole(isSilence: boolean = true) {
let request = this.createRequestBuilder()
.setSilenceMode(isSilence)
.setEndpoint('GetRole')
.build;
return this.addAuth(request)
.get<AuthRoles>(request)
.pipe(
catchError(_ => {
return of(null);
})
);
}
private getProviderIcon(provider: OAuthProvider): string {
switch (provider) {
case OAuthProvider.Google:
return 'assets/icons/google.svg';
case OAuthProvider.Yandex:
return 'assets/icons/yandex.svg';
case OAuthProvider.MailRu:
return 'assets/icons/mailru.svg';
default:
return '';
}
}
public availableProviders(callback: string): Observable<OAuthProviderData[]> {
let request = this.createRequestBuilder()
.setEndpoint('AvailableProviders')
.setQueryParams({callback: callback})
.setWithCredentials()
.build;
return this.get<Array<AvailableOAuthProvidersResponse>>(request).pipe(
map(data => {
return data.map((provider) => ({
...provider,
icon: this.getProviderIcon(provider.provider),
}) as OAuthProviderData);
}));
}
private handleTokenRequest(token: string, action: OAuthAction) {
return this.createRequestBuilder()
.setEndpoint('HandleToken')
.setQueryParams({token: token, action: action})
.setWithCredentials()
.build;
}
public loginOAuth(token: string) {
return this.get<TwoFactorAuthentication>(this.handleTokenRequest(token, OAuthAction.Login));
}
public linkAccount(token: string): Observable<null> {
const request = this.handleTokenRequest(token, OAuthAction.Bind);
return this.addAuth(request).get(request);
}
}

View File

@ -0,0 +1,18 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {CampusBasicInfoResponse} from "@api/v1/campusBasicInfoResponse";
import {CampusDetailsResponse} from "@api/v1/campusDetailsResponse";
@Injectable()
export class CampusService extends ApiService {
public readonly basePath = 'Campus/';
public readonly version = AvailableVersion.v1;
public getCampus() {
return this.get<CampusBasicInfoResponse[]>();
}
public getById(id: number) {
return this.get<CampusDetailsResponse>(id.toString());
}
}

View File

@ -0,0 +1,89 @@
import {Injectable} from '@angular/core';
import ApiService, {AvailableVersion} from "@api/api.service";
import {CronUpdateScheduleResponse} from "@api/v1/configuration/cronUpdateScheduleResponse";
import {DateOnly} from "@model/dateOnly";
import {map} from "rxjs";
import CronUpdateSkip from "@model/cronUpdateSkip";
@Injectable()
export class ScheduleService extends ApiService {
public readonly basePath = 'Configuration/Schedule';
public readonly version = AvailableVersion.v1;
public getCronUpdateSchedule() {
const request = this.createRequestBuilder()
.setEndpoint('CronUpdateSchedule')
.build;
return this.addAuth(request).get<CronUpdateScheduleResponse>(request);
}
public postCronUpdateSchedule(cron: string) {
const request = this.createRequestBuilder()
.setEndpoint('CronUpdateSchedule')
.setQueryParams({cron: cron})
.build;
return this.addAuth(request).post<CronUpdateScheduleResponse>(request);
}
public getStartTerm() {
const request = this.createRequestBuilder()
.setEndpoint('StartTerm')
.build;
return this.addAuth(request).get<string>(request).pipe(map(date => new DateOnly(date)));
}
public postStartTerm(startTerm: DateOnly, force: boolean) {
const request = this.createRequestBuilder()
.setEndpoint('StartTerm')
.setQueryParams({force: force, startTerm: startTerm.toString()})
.build;
return this.addAuth(request).post(request);
}
public getCronUpdateSkip() {
const request = this.createRequestBuilder()
.setEndpoint('CronUpdateSkip')
.build;
return this.addAuth(request).get<{ date?: string, start?: string, end?: string }[]>(request)
.pipe(
map(data => {
return data.map(x => <CronUpdateSkip>{
date: x.date ? new DateOnly(x.date) : null,
start: x.start ? new DateOnly(x.start) : null,
end: x.end ? new DateOnly(x.end) : null
});
}
));
}
public postCronUpdateSkip(data: CronUpdateSkip[]) {
const request = this.createRequestBuilder()
.setEndpoint('CronUpdateSkip')
.setData(data.map(x => <any>{
start: x.start?.toString(),
end: x.end?.toString(),
date: x.date?.toString()
}))
.build;
return this.addAuth(request).post<any>(request);
}
public uploadScheduleFile(files: File[], campus: string[], force: boolean) {
const formData = new FormData();
files.forEach(file => formData.append('files', file, file.name));
const request = this.createRequestBuilder()
.setEndpoint('Upload')
.setData(formData)
.setQueryParams({force: force, defaultCampus: campus})
.build;
return this.addAuth(request).post(request);
}
}

View File

@ -0,0 +1,21 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {DisciplineResponse} from "@api/v1/disciplineResponse";
@Injectable()
export class DisciplineService extends ApiService {
public readonly basePath = 'Discipline/';
public readonly version = AvailableVersion.v1;
public getDisciplines(page: number | null = null, pageSize: number | null = null) {
let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize})
.build;
return this.get<DisciplineResponse[]>(request);
}
public getById(id: number) {
return this.get<DisciplineResponse>(id.toString());
}
}

View File

@ -0,0 +1,17 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {FacultyResponse} from "@api/v1/facultyResponse";
@Injectable()
export class FacultyService extends ApiService {
public readonly basePath = 'Faculty/';
public readonly version = AvailableVersion.v1;
public getFaculties(page: number | null = null, pageSize: number | null = null) {
let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize})
.build;
return this.get<FacultyResponse[]>(request);
}
}

View File

@ -0,0 +1,26 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {GroupResponse} from "@api/v1/groupResponse";
import {GroupDetailsResponse} from "@api/v1/groupDetailsResponse";
@Injectable()
export class GroupService extends ApiService {
public readonly basePath = 'Group/';
public readonly version = AvailableVersion.v1;
public getGroups(page: number | null = null, pageSize: number | null = null) {
let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize})
.build;
return this.get<GroupResponse[]>(request);
}
public getById(id: number) {
return this.get<GroupDetailsResponse>(id.toString());
}
public getByFaculty(id: number) {
return this.get<GroupResponse[]>('GetByFaculty/' + id.toString());
}
}

View File

@ -0,0 +1,22 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {ScheduleRequest} from "@api/v1/scheduleRequest";
@Injectable()
export class ImportService extends ApiService {
public readonly basePath = 'Import/';
public readonly version = AvailableVersion.v1;
public importToExcel(data: ScheduleRequest) {
let request = this.createRequestBuilder()
.setData(data)
.setEndpoint('ImportToExcel')
.build;
console.log(this.combinedUrl(request));
console.log(data);
return this.http.post(this.combinedUrl(request), data, {
responseType: 'blob'
});
}
}

View File

@ -0,0 +1,22 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {LectureHallResponse} from "@api/v1/lectureHallResponse";
import {LectureHallDetailsResponse} from "@api/v1/lectureHallDetailsResponse";
@Injectable()
export class LectureHallService extends ApiService {
public readonly basePath = 'LectureHall/';
public readonly version = AvailableVersion.v1;
public getLectureHalls() {
return this.get<LectureHallResponse[]>();
}
public getById(id: number) {
return this.get<LectureHallDetailsResponse>(id.toString());
}
public getByCampus(id: number) {
return this.get<LectureHallResponse[]>('GetByCampus/' + id.toString());
}
}

View File

@ -0,0 +1,17 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {LessonTypeResponse} from "@api/v1/lessonTypeResponse";
@Injectable()
export class LessonTypeService extends ApiService {
public readonly basePath = 'LessonType/';
public readonly version = AvailableVersion.v1;
public getLessonTypes(page: number | null = null, pageSize: number | null = null) {
let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize})
.build;
return this.get<LessonTypeResponse[]>(request);
}
}

View File

@ -0,0 +1,21 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {ProfessorResponse} from "@api/v1/professorResponse";
@Injectable()
export class ProfessorService extends ApiService {
public readonly basePath = 'Professor/';
public readonly version = AvailableVersion.v1;
public getProfessors(page: number | null = null, pageSize: number | null = null) {
let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize})
.build;
return this.get<ProfessorResponse[]>(request);
}
public getById(id: number) {
return this.get<ProfessorResponse>(id.toString());
}
}

View File

@ -0,0 +1,65 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {DateOnly} from "@model/dateOnly";
import {PairPeriodTime} from "@model/pairPeriodTime";
import {ScheduleRequest} from "@api/v1/scheduleRequest";
import {ScheduleResponse} from "@api/v1/scheduleResponse";
import {map} from "rxjs";
@Injectable()
export class ScheduleService extends ApiService {
public readonly basePath = 'Schedule/';
public readonly version = AvailableVersion.v1;
public startTerm() {
return this.get<string>('StartTerm').pipe(map(date => new DateOnly(date)));
}
public pairPeriod() {
return this.get<PairPeriodTime>('PairPeriod');
}
public postSchedule(data: ScheduleRequest) {
let request = this.createRequestBuilder()
.setData(data)
.build;
return this.post<ScheduleResponse[]>(request);
}
public getByGroup(id: number, isEven: boolean | null = null, disciplines: Array<number> | null = null, professors: Array<number> | null = null, lectureHalls: Array<number> | null = null) {
let request = this.createRequestBuilder()
.setEndpoint('GetByGroup/' + id.toString())
.setQueryParams({isEven: isEven, disciplines: disciplines, professors: professors, lectureHalls: lectureHalls})
.build;
return this.get<ScheduleResponse[]>(request);
}
public getByProfessor(id: number, isEven: boolean | null = null, disciplines: Array<number> | null = null, groups: Array<number> | null = null, lectureHalls: Array<number> | null = null) {
let request = this.createRequestBuilder()
.setEndpoint('GetByProfessor/' + id.toString())
.setQueryParams({isEven: isEven, disciplines: disciplines, groups: groups, lectureHalls: lectureHalls})
.build;
return this.get<ScheduleResponse[]>(request);
}
public getByLectureHall(id: number, isEven: boolean | null = null, disciplines: Array<number> | null = null, groups: Array<number> | null = null, professors: Array<number> | null = null) {
let request = this.createRequestBuilder()
.setEndpoint('GetByLectureHall/' + id.toString())
.setQueryParams({isEven: isEven, disciplines: disciplines, groups: groups, professors: professors})
.build;
return this.get<ScheduleResponse[]>(request);
}
public getByDiscipline(id: number, isEven: boolean | null = null, groups: Array<number> | null = null, professors: Array<number> | null = null, lectureHalls: Array<number> | null = null) {
let request = this.createRequestBuilder()
.setEndpoint('GetByDiscipline/' + id.toString())
.setQueryParams({isEven: isEven, groups: groups, professors: professors, lectureHalls: lectureHalls})
.build;
return this.get<ScheduleResponse[]>(request);
}
}

View File

@ -0,0 +1,26 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {PasswordPolicy} from "@model/passwordPolicy";
@Injectable()
export default class SecurityService extends ApiService {
public readonly basePath = 'Security/';
public readonly version = AvailableVersion.v1;
public generateTotpQrCode(totpKey: string, username: string) {
let request = this.createRequestBuilder()
.setEndpoint('GenerateTotpQrCode')
.setQueryParams({totpKey: totpKey, label: username})
.build;
return this.combinedUrl(request);
}
public passwordPolicy() {
let request = this.createRequestBuilder()
.setEndpoint('PasswordPolicy')
.build;
return this.get<PasswordPolicy>(request);
}
}

238
src/api/v1/setup.service.ts Normal file
View File

@ -0,0 +1,238 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {catchError, of} from "rxjs";
import {DatabaseResponse} from "@api/v1/configuration/databaseResponse";
import {DatabaseRequest} from "@api/v1/configuration/databaseRequest";
import {CacheRequest} from "@api/v1/configuration/cacheRequest";
import {CreateUserRequest} from "@api/v1/createUserRequest";
import {LoggingRequest} from "@api/v1/configuration/loggingRequest";
import {ScheduleConfigurationRequest} from "@api/v1/configuration/scheduleConfigurationRequest";
import {EmailRequest} from "@api/v1/configuration/emailRequest";
import {DateOnly} from "@model/dateOnly";
import {CacheResponse} from "@api/v1/configuration/cacheResponse";
import {PasswordPolicy} from "@model/passwordPolicy";
import {UserResponse} from "@api/v1/userResponse";
@Injectable()
export default class SetupService extends ApiService {
public readonly basePath = 'Setup/';
public readonly version = AvailableVersion.v1;
public checkToken(token: string) {
let request = this.createRequestBuilder()
.setEndpoint('CheckToken')
.setQueryParams({token: token})
.setWithCredentials()
.build;
return this.get<boolean>(request);
}
public isConfiguredToken() {
let request = this.createRequestBuilder()
.setEndpoint('IsConfiguredToken')
.setWithCredentials()
.build;
return this.get<boolean>(request).pipe(catchError(_ => {
return of(false);
}));
}
public setPsql(data: DatabaseRequest) {
let request = this.createRequestBuilder()
.setEndpoint('SetPsql')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public setMysql(data: DatabaseRequest) {
let request = this.createRequestBuilder()
.setEndpoint('SetMysql')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public setSqlite(path: string | null = null) {
let request = this.createRequestBuilder()
.setEndpoint('SetSqlite')
.setQueryParams({path: path})
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public databaseConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('DatabaseConfiguration')
.setWithCredentials()
.build;
return this.get<DatabaseResponse>(request);
}
public setRedis(data: CacheRequest) {
let request = this.createRequestBuilder()
.setEndpoint('SetRedis')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public setMemcached() {
let request = this.createRequestBuilder()
.setEndpoint('SetMemcached')
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public cacheConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('CacheConfiguration')
.setWithCredentials()
.build;
return this.get<CacheResponse>(request);
}
public setPasswordPolicy(data: PasswordPolicy | null) {
let request = this.createRequestBuilder()
.setEndpoint('SetPasswordPolicy')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public passwordPolicyConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('PasswordPolicyConfiguration')
.setWithCredentials()
.build;
return this.get<PasswordPolicy>(request);
}
public createAdmin(data: CreateUserRequest) {
let request = this.createRequestBuilder()
.setEndpoint('CreateAdmin')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public adminConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('AdminConfiguration')
.setWithCredentials()
.build;
return this.get<UserResponse>(request);
}
public registerOAuth(token: string) {
let request = this.createRequestBuilder()
.setEndpoint('HandleToken')
.setQueryParams({token: token})
.setWithCredentials()
.build;
return this.get<null>(request);
}
public setLogging(data: LoggingRequest | null = null) {
let request = this.createRequestBuilder()
.setEndpoint('SetLogging')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public loggingConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('LoggingConfiguration')
.setWithCredentials()
.build;
return this.get<LoggingRequest>(request);
}
public setEmail(data: EmailRequest | null = null) {
let request = this.createRequestBuilder()
.setEndpoint('SetEmail')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public setSchedule(data: ScheduleConfigurationRequest) {
data.startTerm = new DateOnly(data.startTerm).toString();
let request = this.createRequestBuilder()
.setEndpoint('SetSchedule')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public generateTotpKey() {
let request = this.createRequestBuilder()
.setEndpoint('GenerateTotpKey')
.setWithCredentials()
.build;
return this.get<string>(request);
}
public verifyTotp(code: string) {
let request = this.createRequestBuilder()
.setEndpoint('VerifyTotp')
.setWithCredentials()
.setQueryParams({code: code})
.build;
return this.get<boolean>(request);
}
public scheduleConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('ScheduleConfiguration')
.setWithCredentials()
.build;
return this.get<ScheduleConfigurationRequest>(request);
}
public submit() {
let request = this.createRequestBuilder()
.setEndpoint('Submit')
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public isConfigured() {
return this.get<boolean>('IsConfigured');
}
}

View File

@ -1,14 +1,22 @@
import {Component} from '@angular/core';
import {RouterOutlet} from '@angular/router';
import {FooterComponent} from "@component/footer/footer.component";
import {FooterComponent} from "@component/common/footer/footer.component";
import localeRu from '@angular/common/locales/ru';
import {registerLocaleData} from '@angular/common';
import {FocusNextDirective} from "@/directives/focus-next.directive";
import {HeaderComponent} from "@component/common/header/header.component";
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, FooterComponent],
imports: [RouterOutlet, FooterComponent, FocusNextDirective, HeaderComponent],
template: `
<app-header/>
<router-outlet/>
<app-footer/>`
})
export class AppComponent {
constructor() {
registerLocaleData(localeRu);
}
}

View File

@ -1,9 +1,29 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import {ApplicationConfig, LOCALE_ID} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import {provideHttpClient} from "@angular/common/http";
import {provideToastr} from "ngx-toastr";
import {MAT_DATE_LOCALE, provideNativeDateAdapter} from "@angular/material/core";
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), provideAnimationsAsync()]
providers: [
provideRouter(routes),
provideAnimationsAsync(),
provideHttpClient(),
provideToastr({
timeOut: 5000,
extendedTimeOut: 2000,
positionClass: "toast-top-right",
progressBar: true,
progressAnimation: "decreasing",
newestOnTop: true,
tapToDismiss: true,
disableTimeOut: false,
autoDismiss: true,
maxOpened: 5
}),
provideNativeDateAdapter(),
{ provide: LOCALE_ID, useValue: 'ru' },
{ provide: MAT_DATE_LOCALE, useValue: 'ru' }]
};

View File

@ -1,3 +1,45 @@
import { Routes } from '@angular/router';
import {Routes} from '@angular/router';
import {ScheduleComponent} from "@page/schedule/schedule.component";
import {WelcomeComponent} from "@page/setup/welcome/welcome.component";
import {DatabaseComponent} from "@page/setup/database/database.component";
import {CacheComponent} from "@page/setup/cache/cache.component";
import {LoggingComponent} from "@page/setup/logging/logging.component";
import {ScheduleComponent as SetupScheduleComponent} from "@page/setup/schedule/schedule.component";
import {SetupComponent} from "@page/setup/setup.component";
import {CreateAdminComponent} from "@page/setup/create-admin/create-admin.component";
import {SummaryComponent} from "@page/setup/summary/summary.component";
import {LoginComponent} from "@page/login/login.component";
import {PasswordPolicyComponent} from "@page/setup/password-policy/password-policy.component";
import {TwoFactorComponent} from "@page/setup/two-factor/two-factor.component";
import {AdminComponent} from "@page/admin/admin.component";
import {UnderConstructionComponent} from "@page/admin/under-construction/under-construction.component";
import {ScheduleConfigurationComponent} from "@page/admin/schedule-configuration/schedule-configuration.component";
export const routes: Routes = [];
export const routes: Routes = [
{path: '', title: 'Расписание', pathMatch: 'full', component: ScheduleComponent},
{
path: 'setup', title: 'Установка', component: SetupComponent, children: [
{path: 'welcome', component: WelcomeComponent},
{path: 'database', component: DatabaseComponent},
{path: 'cache', component: CacheComponent},
{path: 'create-admin', component: CreateAdminComponent},
{path: 'schedule', component: SetupScheduleComponent},
{path: 'logging', component: LoggingComponent},
{path: 'summary', component: SummaryComponent},
{path: 'password-policy', component: PasswordPolicyComponent},
{path: 'two-factor', component: TwoFactorComponent},
{path: '', redirectTo: 'welcome', pathMatch: 'full'}
]
},
{path: 'login', title: 'Вход', component: LoginComponent},
{
path: 'admin', title: 'Админ панель', component: AdminComponent, children: [
{path: 'schedule', component: ScheduleConfigurationComponent},
{path: 'institute', component: UnderConstructionComponent},
{path: 'account', component: UnderConstructionComponent},
{path: 'server', component: UnderConstructionComponent},
{path: '', redirectTo: 'schedule', pathMatch: 'full'},
{path: '**', redirectTo: 'schedule', pathMatch: 'full'}
]
}
];

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Social_Icons" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<style>
.cls-1 {
fill: #ea4335;
}
.cls-1, .cls-2, .cls-3, .cls-4 {
fill-rule: evenodd;
}
.cls-5 {
fill: none;
}
.cls-2 {
fill: #4285f4;
}
.cls-3 {
fill: #fbbc05;
}
.cls-4 {
fill: #34a853;
}
</style>
</defs>
<g id="_x31__stroke">
<g id="Google">
<rect width="128" height="128" rx="96" ry="96" fill="#fff"/>
<rect class="cls-5" width="128" height="128"/>
<g transform="scale(0.67, 0.67)" transform-origin="center">
<path class="cls-3"
d="M27.58,64c0-4.16.69-8.14,1.92-11.88L7.94,35.65c-4.2,8.53-6.57,18.15-6.57,28.35s2.37,19.8,6.56,28.33l21.56-16.5c-1.22-3.72-1.9-7.69-1.9-11.83"/>
<path class="cls-1"
d="M65.46,26.18c9.03,0,17.19,3.2,23.6,8.44l18.64-18.62C96.34,6.11,81.77,0,65.46,0,40.13,0,18.36,14.48,7.94,35.65l21.57,16.47c4.97-15.09,19.14-25.94,35.95-25.94"/>
<path class="cls-4"
d="M65.46,101.82c-16.81,0-30.98-10.85-35.95-25.94l-21.57,16.47c10.42,21.17,32.19,35.65,57.52,35.65,15.63,0,30.56-5.55,41.76-15.95l-20.47-15.83c-5.78,3.64-13.05,5.6-21.28,5.6"/>
<path class="cls-2"
d="M126.63,64c0-3.78-.58-7.85-1.46-11.64h-59.72v24.73h34.38c-1.72,8.43-6.4,14.91-13.09,19.13l20.47,15.83c11.77-10.92,19.42-27.19,19.42-48.05"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="mailru" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 492.91 492.91">
<defs>
<style>
.cls-1 {
fill: #fff;
fill-rule: evenodd;
}
.cls-2 {
fill: #0874fc;
}
</style>
</defs>
<circle class="cls-2" cx="246.45" cy="246.46" r="246.46"/>
<g id="Logo">
<g id="yellow">
<path class="cls-1"
d="M241.66,168.96c21.11,0,40.96,9.33,55.53,23.94v.05c0-7.01,4.72-12.3,11.28-12.3l1.66-.02c10.25,0,12.35,9.7,12.35,12.78l.05,109.06c-.73,7.14,7.36,10.82,11.85,6.25,17.51-17.99,38.46-92.52-10.89-135.69-45.99-40.25-107.7-33.62-140.52-11-34.89,24.06-57.21,77.31-35.53,127.32,23.64,54.57,91.28,70.83,131.49,54.61,20.36-8.22,29.77,19.3,8.62,28.3-31.95,13.62-120.86,12.25-162.4-59.71-28.06-48.59-26.57-134.08,47.86-178.37,56.94-33.88,132.01-24.49,177.28,22.78,47.32,49.42,44.56,141.96-1.59,177.96-20.91,16.34-51.97.43-51.77-23.39l-.21-7.79c-14.56,14.45-33.94,22.88-55.05,22.88-41.71,0-78.41-36.71-78.41-78.4,0-42.13,36.7-79.25,78.41-79.25h0ZM294.16,245.19c-1.57-30.54-24.24-48.91-51.62-48.91h-1.03c-31.59,0-49.11,24.84-49.11,53.06,0,31.6,21.2,51.56,48.99,51.56,30.99,0,51.37-22.7,52.84-49.55l-.07-6.17Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="yandex" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<style>
.cls-1 {
fill: white;
}
</style>
</defs>
<g id="_x33_91-yandex">
<rect width="512" height="512" rx="256" ry="256" fill="red"/>
<g transform="scale(0.67, 0.67) translate(100, 128)">
<path class="cls-1"
d="M278.55,309.73l-78.52,176.27h-57.23l86.25-188.49c-40.52-20.58-67.56-57.86-67.56-126.77-.09-96.49,61.1-144.74,133.78-144.74h73.94v460h-49.5v-176.27h-41.15ZM319.7,67.78h-26.42c-39.89,0-78.52,26.41-78.52,102.96s35.4,97.75,78.52,97.75h26.42V67.78h0Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 665 B

View File

@ -0,0 +1,85 @@
.provider-container {
display: flex;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
margin: 0;
padding: 0;
}
.provider-container a {
display: inline-block;
text-align: center;
transition: transform 0.3s ease-in-out, filter 0.3s ease;
border-radius: 50%;
padding: 8px;
}
.provider-icon {
object-fit: contain;
user-select: none;
cursor: pointer;
transition: transform 0.3s ease-in-out, box-shadow 0.3s ease, filter 0.3s ease;
border-radius: 50%;
}
.provider-container a:hover .provider-icon {
transform: scale(1.1); /* Slight zoom-in effect on hover */
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); /* Adding shadow for effect */
}
.provider-container .provider-item.disabled {
pointer-events: none; /* Disables click */
opacity: 0.5; /* Dims the icon to indicate it is disabled */
}
.provider-container .provider-item.disabled .provider-icon {
filter: grayscale(100%) contrast(100%); /* Desaturates image if disabled */
}
.provider-item {
width: 48px;
height: 48px;
position: relative;
}
.provider-item.provider-unlink {
filter: grayscale(50%) contrast(60%);
transition: filter 0.3s ease;
}
.provider-item.provider-unlink:hover {
filter: grayscale(0%) contrast(100%);
}
.provider-item.provider-unlink::after {
content: '×';
position: absolute;
top: 10%;
left: 90%;
transform: translate(-100%, 0%) scale(0.0);
font-size: 24px;
color: white;
background-color: red;
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
cursor: pointer;
transition: transform 0.15s ease;
}
.provider-item.provider-unlink:hover::after {
transform: translate(-50%, -50%) scale(1.0);
}
.provider-item .provider-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(1.0);
border-radius: 50%;
}

View File

@ -0,0 +1,23 @@
@if (!loading && providers.length !== 0) {
<hr/>
<div>
<p class="mat-body-2 secondary">{{ message }}</p>
<div class="provider-container">
@for (provider of providers; track $index) {
<a class="provider-item" (click)="provider.disabled ? confirmDelete(provider) : openOAuth(provider)"
[class.disabled]="!canUnlink && provider.disabled || provider.active"
[class.provider-unlink]="canUnlink && provider.disabled">
<img [alt]="provider.providerName" [src]="provider.icon"
class="provider-icon" draggable="false"/>
@if (provider.active) {
<app-data-spinner class="provider-spinner"/>
}
</a>
}
</div>
</div>
} @else if (loading) {
<hr/>
<app-data-spinner style="display: flex; justify-content: center;"/>
}

View File

@ -0,0 +1,194 @@
import {Component, EventEmitter, Inject, Input, OnInit, Output} from '@angular/core';
import AuthApiService, {OAuthProviderData} from "@api/v1/authApi.service";
import {OAuthProvider} from "@model/oAuthProvider";
import {ToastrService} from "ngx-toastr";
import {
MAT_DIALOG_DATA,
MatDialog,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from "@angular/material/dialog";
import {MatButton} from "@angular/material/button";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {ActivatedRoute} from "@angular/router";
import {catchError, finalize, Observable, switchMap, tap} from "rxjs";
import {TwoFactorAuthentication} from "@model/twoFactorAuthentication";
import {OAuthAction} from "@model/oAuthAction";
import SetupService from "@api/v1/setup.service";
interface AvailableOAuthProviders extends OAuthProviderData {
disabled: boolean;
active: boolean;
}
@Component({
selector: 'app-delete-confirm-dialog',
template: `
<h1 mat-dialog-title>Удалить провайдера?</h1>
<mat-dialog-content>
<p>Вы уверены, что хотите удалить провайдера {{ data.provider.name }}?</p>
</mat-dialog-content>
<mat-dialog-actions style="display: flex; justify-content: flex-end;">
<button mat-button (click)="onCancel()">Отмена</button>
<button mat-raised-button color="warn" (click)="onConfirm()">Удалить</button>
</mat-dialog-actions>
`,
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatButton
]
})
export class DeleteConfirmDialog {
constructor(
public dialogRef: MatDialogRef<DeleteConfirmDialog>,
@Inject(MAT_DIALOG_DATA) public data: any
) {
}
onConfirm(): void {
this.dialogRef.close(true);
}
onCancel(): void {
this.dialogRef.close(false);
}
}
@Component({
selector: 'OAuthProviders',
imports: [
DataSpinnerComponent
],
templateUrl: './OAuthProviders.html',
styleUrl: './OAuthProviders.css',
providers: [SetupService, AuthApiService]
})
export class OAuthProviders implements OnInit {
protected providers: AvailableOAuthProviders[] = [];
protected _activeProvidersId: OAuthProvider[] = [];
protected _activeProviders: string[] = [];
protected loading = true;
@Input() message: string = 'Вы можете войти в аккаунт через';
@Input() set activeProviders(data: string[]) {
this._activeProviders = data;
this.updateDisabledProviders();
}
@Input() set activeProvidersId(data: OAuthProvider[]) {
this._activeProvidersId = data;
this.updateDisabledProviders();
}
@Input() canUnlink: boolean = false;
@Input() action: OAuthAction = OAuthAction.Login;
@Input() isSetup: boolean = false;
@Output() public oAuthUpdateProviders = new EventEmitter();
@Output() public oAuthLoginResult: EventEmitter<TwoFactorAuthentication> = new EventEmitter();
constructor(private setupApi: SetupService,
private authApi: AuthApiService,
private notify: ToastrService,
private dialog: MatDialog,
private route: ActivatedRoute) {
}
ngOnInit(): void {
const fullUrl = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
this.authApi.availableProviders(fullUrl).subscribe(providers => {
this.updateDisabledProviders(providers);
});
this.route.queryParamMap
.pipe(
switchMap(params => {
const result = params.get('result');
if (!result) {
this.loading = false; // Нет результата, завершение загрузки
return [];
}
return this.handleOAuthResult(result); // Обрабатываем результат
}),
catchError(_ => {
this.loading = false;
return [];
})
)
.subscribe();
}
private handleOAuthResult(result: string): Observable<any> {
switch (this.action) {
case OAuthAction.Login:
return this.authApi.loginOAuth(result).pipe(
tap(auth => {
this.oAuthLoginResult.emit(auth);
}),
finalize(() => {
this.loading = false;
})
);
case OAuthAction.Bind:
if (this.isSetup) {
return this.setupApi.registerOAuth(result).pipe(
tap(() => {
this.oAuthUpdateProviders.emit();
}),
finalize(() => {
this.loading = false;
})
);
} else
throw new Error('Action "Bind" requires setup mode to be enabled.');
break;
default:
throw new Error('Unknown action type for action ' + this.action);
}
}
private updateDisabledProviders(data: OAuthProviderData[] | null = null) {
this.providers = (data ?? this.providers).map(provider => {
return {
...provider,
disabled: this._activeProvidersId.includes(provider.provider) || this._activeProviders.includes(provider.providerName),
active: false
};
});
}
protected openOAuth(provider: AvailableOAuthProviders) {
const oauthWindow = window.open(
provider.redirect,
'_self'
);
if (!oauthWindow) {
this.notify.error('Не удалось открыть OAuth окно');
return;
}
}
protected confirmDelete(provider: AvailableOAuthProviders) {
const dialogRef = this.dialog.open(DeleteConfirmDialog, {data: {provider}});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.deleteProvider(provider);
}
});
}
protected deleteProvider(provider: AvailableOAuthProviders) {
// todo: remove provider
}
}

View File

@ -0,0 +1,17 @@
<mat-card style="margin: 16px; padding: 16px;">
<mat-card-header style="margin-bottom: 15px;">
<mat-card-title>{{ title }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-content></ng-content>
</mat-card-content>
<mat-card-actions style="display: flex; justify-content: end; margin-top: 15px;">
@if (isLoading) {
<app-data-spinner/>
} @else {
<button mat-raised-button color="accent" [disabled]="!isSaveEnabled" (click)="onSave()">
Сохранить
</button>
}
</mat-card-actions>
</mat-card>

View File

@ -0,0 +1,37 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {MatCardModule} from "@angular/material/card";
import {MatButton} from "@angular/material/button";
import {catchError, Observable, tap} from "rxjs";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
@Component({
selector: 'app-configuration-card',
imports: [
MatCardModule,
MatButton,
DataSpinnerComponent
],
templateUrl: './configuration-card.component.html'
})
export class ConfigurationCardComponent {
@Input() title: string = '';
@Input() isSaveEnabled: boolean = false;
@Input() saveFunction!: () => Observable<any>;
@Output() onSaveFunction = new EventEmitter<any>();
protected isLoading: boolean = false;
onSave(): void {
this.isLoading = true;
const result = this.saveFunction().pipe(catchError(err => {
this.isLoading = false;
throw err;
}),
tap(_ => {
this.isLoading = false;
}));
this.onSaveFunction.emit(result);
}
}

View File

@ -0,0 +1,8 @@
<h2 mat-dialog-title>Удаление расписания при изменении значения</h2>
<mat-dialog-content>
<p>Вы хотите удалить старое расписание?</p>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button [mat-dialog-close]="false">Нет</button>
<button mat-button [mat-dialog-close]="true" color="warn">Да, удалить</button>
</mat-dialog-actions>

View File

@ -0,0 +1,22 @@
import {Component} from '@angular/core';
import {
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle
} from "@angular/material/dialog";
import {MatButton} from "@angular/material/button";
@Component({
selector: 'app-confirm-delete-schedule-dialog',
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButton
],
templateUrl: './confirm-delete-schedule-dialog.component.html'
})
export class ConfirmDeleteScheduleDialogComponent {
}

View File

@ -0,0 +1,16 @@
<app-configuration-card [title]="'Cron для обновление расписания'"
[isSaveEnabled]="cronExpression != cronExpressionBefore"
[saveFunction]="saveFunction()"
(onSaveFunction)="onSave($event)">
<mat-form-field color="accent">
<mat-label>cron</mat-label>
<input matInput type="text" [(ngModel)]="cronExpression"/>
</mat-form-field>
<p>Следующие запуски:</p>
<ul>
@for (date of nextRunDates; track $index) {
<li>{{ date }}</li>
}
</ul>
</app-configuration-card>

View File

@ -0,0 +1,48 @@
import {Component} from '@angular/core';
import {ConfigurationCardComponent} from "@component/admin/configuration-card/configuration-card.component";
import {ScheduleService} from "@api/v1/configuration/schedule.service";
import {MatInputModule} from "@angular/material/input";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {Observable} from "rxjs";
import {CronUpdateScheduleResponse} from "@api/v1/configuration/cronUpdateScheduleResponse";
@Component({
selector: 'app-cron-update-schedule',
imports: [
ConfigurationCardComponent,
MatInputModule,
ReactiveFormsModule,
FormsModule
],
templateUrl: './cron-update-schedule.component.html',
providers: [ScheduleService]
})
export class CronUpdateScheduleComponent {
protected nextRunDates: string[] = [];
protected cronExpression: string = '';
protected cronExpressionBefore: string = '';
constructor(private api: ScheduleService) {
api.getCronUpdateSchedule().subscribe(data => {
this.nextRunDates = data.nextStart?.map(x => this.convertDateToString(x)) ?? [];
this.cronExpression = data.cron;
this.cronExpressionBefore = data.cron;
});
}
private convertDateToString(data: Date): string {
data = new Date(data);
return data.toLocaleDateString() + ' ' + data.toLocaleTimeString();
}
protected saveFunction() {
return () => this.api.postCronUpdateSchedule(this.cronExpression);
}
protected onSave(data: Observable<CronUpdateScheduleResponse>): void {
data.subscribe(apiData => {
this.nextRunDates = apiData.nextStart?.map(x => this.convertDateToString(x)) ?? [];
this.cronExpressionBefore = apiData.cron;
});
}
}

View File

@ -0,0 +1,40 @@
<app-configuration-card
[title]="'Загрузка расписания Excel'"
[isSaveEnabled]="selectedFiles.length > 0"
[saveFunction]="saveFunction()"
(onSaveFunction)="onUpload($event)">
<input type="file" #fileInput (change)="onFileSelected($event)" multiple accept=".xlsx, .xls" style="display: none;">
@if (fileLoading) {
<app-data-spinner/>
} @else {
<button mat-raised-button color="primary" (click)="onFileChooseClick()">
Выберите файлы
<mat-icon>attach_file</mat-icon>
</button>
}
@if (selectedFiles.length > 0) {
<div style="margin-top: 15px;">
<p>Выбранные файлы:</p>
@for (item of selectedFiles; track $index) {
<p>
{{ item.file.name }}
</p>
<mat-form-field color="accent" style="margin-bottom: 18px;">
<mat-label>Кампус по умолчанию</mat-label>
<input matInput type="text" [(ngModel)]="item.campus"
[matAutocomplete]="auto">
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="onSelectCampus($event.option.value, item)">
@for (option of onFilter(item.campus); track $index) {
<mat-option [value]="option">
{{ option }}
</mat-option>
}
</mat-autocomplete>
</mat-form-field>
}
</div>
}
</app-configuration-card>

View File

@ -0,0 +1,91 @@
import {Component, ElementRef, ViewChild} from '@angular/core';
import {ConfigurationCardComponent} from "@component/admin/configuration-card/configuration-card.component";
import {MatDialog} from "@angular/material/dialog";
import {
ConfirmDeleteScheduleDialogComponent
} from "@component/admin/confirm-delete-schedule-dialog/confirm-delete-schedule-dialog.component";
import {Observable, switchMap} from "rxjs";
import {MatButtonModule} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import {ScheduleService} from "@api/v1/configuration/schedule.service";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {MatFormFieldModule} from "@angular/material/form-field";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatInput} from "@angular/material/input";
import {ToastrService} from "ngx-toastr";
import {MatAutocomplete, MatAutocompleteTrigger, MatOption} from "@angular/material/autocomplete";
import {CampusService} from "@api/v1/campus.service";
@Component({
selector: 'app-schedule-file-upload',
imports: [
ConfigurationCardComponent,
MatButtonModule,
MatIcon,
DataSpinnerComponent,
MatFormFieldModule,
FormsModule,
MatInput,
MatAutocomplete,
MatAutocompleteTrigger,
MatOption,
ReactiveFormsModule
],
templateUrl: './schedule-file-upload.component.html',
providers: [ScheduleService, CampusService]
})
export class ScheduleFileUploadComponent {
protected selectedFiles: { file: File, campus: string }[] = [];
protected fileLoading: boolean = false;
protected campuses: string[] = [];
@ViewChild('fileInput') input!: ElementRef;
constructor(
private dialog: MatDialog,
private api: ScheduleService,
private notify: ToastrService,
campus: CampusService) {
campus.getCampus().subscribe(data => {
this.campuses = data.map(x => x.codeName);
});
}
protected onSelectCampus(value: string, item: { file: File, campus: string }) {
item.campus = value;
}
protected saveFunction() {
return () => {
const dialogRef = this.dialog.open(ConfirmDeleteScheduleDialogComponent);
return dialogRef.afterClosed().pipe(switchMap(result => {
return this.api.uploadScheduleFile(
this.selectedFiles.map(x => x.file),
this.selectedFiles.map(x => x.campus),
result);
}));
};
}
protected onFilter(value: string): string[] {
const filterValue = value?.toLowerCase() || '';
return this.campuses.filter(campus => campus.toLowerCase().includes(filterValue));
}
protected onFileChooseClick() {
this.fileLoading = true;
this.input.nativeElement.click();
}
protected onFileSelected(event: any): void {
this.fileLoading = false;
this.selectedFiles = Array.from(event.target.files).map(file => ({file: <File>file, campus: ''}));
}
protected onUpload(data: Observable<any>): void {
data.subscribe(_ => {
this.notify.info(`Файлы в размере ${this.selectedFiles.length} успешно загружены. Задача поставлена в очередь`);
this.selectedFiles = [];
});
}
}

View File

@ -0,0 +1,19 @@
.date-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.date-item {
display: flex;
align-items: center;
gap: 16px;
}
mat-form-field {
flex: 1;
}
button[mat-icon-button] {
margin-left: auto;
}

View File

@ -0,0 +1,51 @@
<app-configuration-card [title]="'Список пропуска обновления расписания'"
[isSaveEnabled]="validateSaveButton() && !isDisableAddNewItem()"
[saveFunction]="saveFunction()"
(onSaveFunction)="onSave($event)">
<div class="date-list">
@for (dateItem of dateItems; track $index) {
<div class="date-item">
<mat-form-field color="accent">
<mat-label>Диапазон дат</mat-label>
<mat-date-range-input [rangePicker]="rangePicker"
[disabled]="dateItems[$index].date">
<input matStartDate [(ngModel)]="dateItem.start"
placeholder="Начало"
(dateChange)="validateDate($index)"
[min]="CurrentDate">
<input matEndDate [(ngModel)]="dateItem.end"
placeholder="Конец"
(dateChange)="validateDate($index)"
[min]="CurrentDate">
</mat-date-range-input>
<mat-datepicker-toggle matSuffix [for]="rangePicker"></mat-datepicker-toggle>
<mat-date-range-picker #rangePicker></mat-date-range-picker>
</mat-form-field>
<mat-form-field color="accent">
<mat-label>Конкретная дата</mat-label>
<input matInput [matDatepicker]="specificDatePicker"
[(ngModel)]="dateItem.date"
(dateChange)="validateDate($index)"
[min]="CurrentDate"
[disabled]="dateItems[$index].start != null || dateItems[$index].end != null">
<mat-datepicker-toggle matSuffix [for]="specificDatePicker"></mat-datepicker-toggle>
<mat-datepicker #specificDatePicker></mat-datepicker>
</mat-form-field>
<button mat-icon-button color="warn" (click)="removeDate($index)" style="height: 100%;">
<mat-icon>delete</mat-icon>
</button>
</div>
}
</div>
<button mat-raised-button color="accent"
[disabled]="isDisableAddNewItem()"
(click)="addDate()">
<mat-icon>add</mat-icon>
Добавить строку
</button>
</app-configuration-card>

View File

@ -0,0 +1,93 @@
import {Component} from '@angular/core';
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatDatepickerModule} from "@angular/material/datepicker";
import {MatInput} from "@angular/material/input";
import {FormsModule} from "@angular/forms";
import {ConfigurationCardComponent} from "@component/admin/configuration-card/configuration-card.component";
import {MatButtonModule} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import {ScheduleService} from "@api/v1/configuration/schedule.service";
import CronUpdateSkip from "@model/cronUpdateSkip";
import {DateOnly} from "@model/dateOnly";
import {addDays} from "@progress/kendo-date-math";
import {Observable} from "rxjs";
@Component({
selector: 'app-skip-update-schedule',
imports: [
MatFormFieldModule,
MatDatepickerModule,
MatInput,
FormsModule,
ConfigurationCardComponent,
MatButtonModule,
MatIcon
],
templateUrl: './skip-update-schedule.component.html',
styleUrl: './skip-update-schedule.component.css',
providers: [ScheduleService]
})
export class SkipUpdateScheduleComponent {
dateItems: { start?: Date, end?: Date, date?: Date }[] = [];
dateItemsBefore: { start?: Date, end?: Date, date?: Date }[] = [];
constructor(private api: ScheduleService) {
api.getCronUpdateSkip().subscribe(data => {
this.dateItems = data.map(x => <{ start?: Date, end?: Date, date?: Date }>{
start: x.start?.date,
end: x.end?.date,
date: x.date?.date
});
if (this.dateItems.length == 0)
this.addDate();
this.dateItemsBefore = JSON.parse(JSON.stringify(this.dateItems));
});
}
addDate(): void {
this.dateItems.push({start: undefined, end: undefined, date: undefined});
}
removeDate(index: number): void {
this.dateItems.splice(index, 1);
}
validateDate(index: number): void {
const item = this.dateItems[index];
if (item.start && item.start < this.CurrentDate)
item.start = undefined;
if (item.end && item.end < this.CurrentDate)
item.end = undefined;
if (item.date && item.date < this.CurrentDate)
item.date = undefined;
}
isDisableAddNewItem() {
return this.dateItems.some(item => (!item.start || !item.end) && !item.date);
}
validateSaveButton(): boolean {
return (this.dateItems.length == 0 || this.dateItems.some(item =>
(item.start && item.end) || item.date
)) && JSON.stringify(this.dateItems) != JSON.stringify(this.dateItemsBefore);
}
saveFunction() {
return () => this.api.postCronUpdateSkip(this.dateItems.map(x =>
<CronUpdateSkip>{
start: x.start ? new DateOnly(x.start) : undefined,
end: x.end ? new DateOnly(x.end) : undefined,
date: x.date ? new DateOnly(x.date) : undefined
}));
}
onSave(event: Observable<any>): void {
event.subscribe(_ => {
this.dateItemsBefore = JSON.parse(JSON.stringify(this.dateItems));
});
}
protected CurrentDate: Date = addDays(new Date(), -1);
}

View File

@ -0,0 +1,12 @@
<app-configuration-card
[title]="'Дата начала семестра'"
[isSaveEnabled]="startDate != startDateBefore"
[saveFunction]="saveFunction()"
(onSaveFunction)="onSave($event)">
<mat-form-field color="accent">
<mat-label>Дата начала семестра</mat-label>
<input matInput [matDatepicker]="datePicker" [(ngModel)]="startDate" [min]="ValidMinDate">
<mat-datepicker-toggle matSuffix [for]="datePicker"></mat-datepicker-toggle>
<mat-datepicker #datePicker></mat-datepicker>
</mat-form-field>
</app-configuration-card>

View File

@ -0,0 +1,61 @@
import {Component} from '@angular/core';
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatDatepicker, MatDatepickerInput, MatDatepickerToggle} from "@angular/material/datepicker";
import {FormsModule} from "@angular/forms";
import {ConfigurationCardComponent} from "@component/admin/configuration-card/configuration-card.component";
import {MatInput} from "@angular/material/input";
import {addDays} from "@progress/kendo-date-math";
import {ScheduleService} from "@api/v1/configuration/schedule.service";
import {DateOnly} from "@model/dateOnly";
import {MatDialog} from "@angular/material/dialog";
import {
ConfirmDeleteScheduleDialogComponent
} from "@component/admin/confirm-delete-schedule-dialog/confirm-delete-schedule-dialog.component";
import {Observable, switchMap} from "rxjs";
@Component({
selector: 'app-term-start-date',
imports: [
MatFormFieldModule,
MatDatepickerToggle,
FormsModule,
MatDatepickerInput,
MatDatepicker,
ConfigurationCardComponent,
MatInput
],
templateUrl: './term-start-date.component.html',
providers: [ScheduleService]
})
export class TermStartDateComponent {
protected startDate: Date = new Date();
protected startDateBefore: Date = new Date();
constructor(private api: ScheduleService, private dialog: MatDialog) {
this.api.getStartTerm().subscribe(data => {
this.startDate = data.date;
this.startDateBefore = this.startDate;
console.log(this.startDate == this.startDateBefore);
});
}
protected saveFunction() {
return () => {
const dialogRef = this.dialog.open(ConfirmDeleteScheduleDialogComponent, {
data: {startDate: this.startDate}
});
return dialogRef.afterClosed().pipe(switchMap(result => {
return this.api.postStartTerm(new DateOnly(this.startDate), result);
}));
};
}
protected onSave(data: Observable<any>): void {
data.subscribe(_ => {
this.startDateBefore = this.startDate;
});
}
protected ValidMinDate = addDays(new Date(), -180);
}

View File

@ -1 +1 @@
<mat-progress-spinner [color]="color" mode="indeterminate" [diameter]="scale" />
<mat-progress-spinner [color]="color" mode="indeterminate" [diameter]="scale"/>

View File

@ -1,17 +1,15 @@
import {Component, Input} from '@angular/core';
import {MatProgressSpinner} from "@angular/material/progress-spinner";
import {NgStyle} from "@angular/common";
@Component({
selector: 'app-data-spinner',
standalone: true,
imports: [
MatProgressSpinner,
NgStyle
],
templateUrl: './data-spinner.component.html',
styleUrl: './data-spinner.component.css'
templateUrl: './data-spinner.component.html'
})
export class DataSpinnerComponent {
@Input() color: string = "accent";
@Input() scale: number = 50;

View File

@ -21,9 +21,9 @@
</div>
<hr/>
<div class="app-footer-copyright">
<span>Powered by Winsomnia &copy;{{ currentYear }}.</span>
<a href="https://opensource.org/license/mit/">Code licensed under an MIT-style License.</a>
<span>Current Version: {{ version }}</span>
<span>Powered by <a href="https://winsomnia.net">Winsomnia</a> &copy;{{ currentYear }}.</span>
<a href="https://opensource.org/license/mit/">Code licensed under an MIT-style License.</a>
<span>Current Version: {{ version }}</span>
</div>
</div>
</footer>

View File

@ -1,6 +1,6 @@
import {Component} from "@angular/core";
import {MatList, MatListItem} from "@angular/material/list";
import {version} from "../../../../package.json";
import {version} from "@/../package.json";
@Component({
selector: 'app-footer',
@ -12,6 +12,7 @@ import {version} from "../../../../package.json";
templateUrl: './footer.component.html',
styleUrl: './footer.component.css'
})
export class FooterComponent {
currentYear: number;
version: string;

View File

@ -0,0 +1,9 @@
mat-toolbar a {
color: inherit;
cursor: pointer;
text-decoration: none;
}
mat-toolbar a:hover {
text-decoration: underline;
}

View File

@ -0,0 +1,4 @@
<mat-toolbar style="justify-content: space-between;">
<a href="/" style="color: inherit;">Winsomnia</a>
<a href="/admin" style="color: inherit; font-size: 14px" *appHasRole="AuthRoles.Admin">Админ панель</a>
</mat-toolbar>

View File

@ -0,0 +1,19 @@
import {Component} from '@angular/core';
import {MatToolbar} from "@angular/material/toolbar";
import {HasRoleDirective} from "@/directives/has-role.directive";
import {AuthRoles} from "@model/authRoles";
@Component({
selector: 'app-header',
standalone: true,
imports: [
MatToolbar,
HasRoleDirective
],
templateUrl: './header.component.html',
styleUrl: './header.component.css'
})
export class HeaderComponent {
protected readonly AuthRoles = AuthRoles;
}

View File

@ -1,7 +1,7 @@
@if (loading) {
<app-data-spinner/>
} @else {
<button mat-fab color="primary" (click)="retryFunction.emit()">
<button mat-fab color="primary" (click)="retryLoad()">
<mat-icon>refresh</mat-icon>
</button>
}

View File

@ -1,20 +1,25 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {MatIcon} from "@angular/material/icon";
import {MatButton, MatFabButton} from "@angular/material/button";
import {MatFabButton} from "@angular/material/button";
@Component({
selector: 'app-loading-indicator',
standalone: true,
imports: [
DataSpinnerComponent,
MatButton,
MatIcon,
MatFabButton
],
templateUrl: './loading-indicator.component.html'
})
export class LoadingIndicatorComponent {
@Input() loading: boolean = true;
@Output() retryFunction: EventEmitter<void> = new EventEmitter<void>();
protected retryLoad() {
this.loading = true;
this.retryFunction.emit();
}
}

View File

@ -1,10 +0,0 @@
.notification-content {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.close-button {
margin-left: 8px;
}

View File

@ -1,11 +0,0 @@
<div class="notification-content" [class]="data.className">
<span>
{{ data.message }}
</span>
<button mat-icon-button class="close-button" (click)="dismiss()">
<mat-icon>close</mat-icon>
</button>
</div>
@if (showProgressBar) {
<mat-progress-bar mode="determinate" [value]="progress" [color]="color"/>
}

View File

@ -1,50 +0,0 @@
import {Component, Inject} from '@angular/core';
import {MatIcon} from "@angular/material/icon";
import {MatProgressBar} from "@angular/material/progress-bar";
import {MAT_SNACK_BAR_DATA, MatSnackBarRef} from "@angular/material/snack-bar";
import {MatIconButton} from "@angular/material/button";
@Component({
selector: 'app-notification',
standalone: true,
imports: [
MatIconButton,
MatIcon,
MatProgressBar
],
templateUrl: './notification.component.html',
styleUrl: './notification.component.css'
})
export class NotificationComponent {
showProgressBar: boolean = false;
progress: number = 100;
color: string = "primary";
constructor(@Inject(MAT_SNACK_BAR_DATA) public data: any, private snackBarRef: MatSnackBarRef<NotificationComponent>) {
if (data.duration) {
this.startProgress(data.duration);
this.showProgressBar = true;
}
if (data.color) {
this.color = data.color;
}
}
dismiss(): void {
this.snackBarRef.dismiss();
}
private startProgress(duration: number): void {
const interval: number = duration / 100;
const progressInterval = setInterval(async () => {
this.progress--;
if (this.progress === 0) {
clearInterval(progressInterval);
setTimeout(() => {
this.dismiss();
}, 1000);
}
}, interval);
}
}

View File

@ -0,0 +1,45 @@
<div [formGroup]="formGroup" style="display: flex; flex-direction: column; align-items: stretch;">
<mat-form-field color="accent">
<mat-label>Пароль</mat-label>
<input matInput
matTooltip="Укажите пароль"
formControlName="password"
required
[type]="hidePass ? 'password' : 'text'"
id="passwordNextFocus"
focusNext="focusNext">
<button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'"
[attr.aria-pressed]="hidePass">
<mat-icon>{{ hidePass ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
@if (formGroup.get('password')?.hasError('required')) {
<mat-error>
Пароль является <i>обязательным</i>
</mat-error>
}
@if (formGroup.get('password')?.hasError('minlength')) {
<mat-error>
Пароль должен быть не менее {{ policy.minimumLength }} символов
</mat-error>
}
@if (formGroup.get('password')?.hasError('pattern')) {
<mat-error>
Пароль должен содержать:
@if (policy.requireLettersDifferentCase) {
Латинские символы разных регистров
} @else if (policy.requireLetter) {
Один латинский символ
} @else if (policy.requireDigit) {
Одну цифру
}
@if (policy.requireSpecialCharacter) {
Специальный символ
}
</mat-error>
}
</mat-form-field>
</div>

View File

@ -0,0 +1,76 @@
import {Component, Input} from '@angular/core';
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import {MatIconButton} from "@angular/material/button";
import {FormGroup, ReactiveFormsModule, ValidatorFn, Validators} from "@angular/forms";
import {MatSelectModule} from "@angular/material/select";
import {MatTooltip} from "@angular/material/tooltip";
import {MatIcon} from "@angular/material/icon";
import {PasswordPolicy} from "@model/passwordPolicy";
import SecurityService from "@api/v1/securityService";
import {FocusNextDirective} from "@/directives/focus-next.directive";
import SetupService from "@api/v1/setup.service";
@Component({
selector: 'password-input',
imports: [
ReactiveFormsModule,
MatFormFieldModule,
MatSelectModule,
MatInput,
MatTooltip,
MatIconButton,
MatIcon,
FocusNextDirective
],
templateUrl: './password-input.component.html',
providers: [SecurityService, SetupService]
})
export class PasswordInputComponent {
protected hidePass = true;
protected policy!: PasswordPolicy;
@Input() formGroup!: FormGroup;
@Input() focusNext: string | undefined;
@Input() isSetupMode: boolean = false;
constructor(securityApi: SecurityService, setupApi: SetupService) {
if (this.isSetupMode)
setupApi.passwordPolicyConfiguration().subscribe(policy => {
this.policy = policy;
this.updateValueAndValidity(policy);
});
else
securityApi.passwordPolicy().subscribe(policy => {
this.policy = policy;
this.updateValueAndValidity(policy);
});
}
private updateValueAndValidity(policy: PasswordPolicy): void {
const validators: ValidatorFn[] = [Validators.required];
if (policy.minimumLength) {
validators.push(Validators.minLength(policy.minimumLength));
}
if (policy.requireLettersDifferentCase) {
validators.push(Validators.pattern(/(?=.*[a-z])(?=.*[A-Z])/));
} else if (policy.requireLetter) {
validators.push(Validators.pattern(/[A-Za-z]/));
} else if (policy.requireDigit) {
validators.push(Validators.pattern(/\d/));
}
if (policy.requireSpecialCharacter) {
validators.push(Validators.pattern(/[!@#$%^&*(),.?":{}|<>]/));
}
this.formGroup.get('password')?.setValidators(validators);
this.formGroup.get('password')?.updateValueAndValidity();
}
protected togglePassword(event: MouseEvent) {
this.hidePass = !this.hidePass;
event.stopPropagation();
}
}

View File

@ -1,4 +1,5 @@
<section class="mat-elevation-z8 table-section" tabindex="0">
<section class="mat-elevation-z8 table-section" tabindex="0"
[style.overflow]="(dataSource.length === 0 || isLoad ? 'hidden' : 'auto')">
@if (dataSource.length === 0 || isLoad) {
<div class="overlay">
@if (isLoad) {
@ -49,7 +50,14 @@
<!-- Discipline -->
<div class="mat-body-1">{{ elementData["discipline"] }}</div>
<!-- Type of Occupation -->
<div class="mat-body">({{ elementData["typeOfOccupation"] }})</div>
@for (typeOfOccupation of elementData["typeOfOccupations"]; track $index) {
@if ($index === 0 && elementData["typeOfOccupations"][$index - 1] !== typeOfOccupation) {
@if ($index !== 0) {
<br/>
}
<div class="mat-body">({{ typeOfOccupation }})</div>
}
}
<!-- Professors -->
@if (checkAvailableData(elementData["professors"])) {
@ -92,14 +100,12 @@
}
<!-- Group -->
@if (!isOneGroup) {
<div class="mat-body">
<i>
<mat-icon fontIcon="group"/>
</i>
{{ elementData["group"] }}
</div>
}
<div class="mat-body">
<i>
<mat-icon fontIcon="group"/>
</i>
{{ elementData["group"] }}
</div>
@if ($index + 1 !== element.data[daysOfWeek.indexOf(day) + 1].length) {
<hr style="margin: 10px 0;"/>

View File

@ -3,9 +3,8 @@ import {MatTableDataSource, MatTableModule} from "@angular/material/table";
import {MatIcon} from "@angular/material/icon";
import {DatePipe} from "@angular/common";
import {addDays} from "@progress/kendo-date-math";
import {MatDivider} from "@angular/material/divider";
import {Schedule} from "@page/schedule/schedule.component";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {ScheduleResponse} from "@api/v1/scheduleResponse";
interface TableData {
pairNumber: number;
@ -23,7 +22,6 @@ interface Dictionary {
MatTableModule,
MatIcon,
DatePipe,
MatDivider,
DataSpinnerComponent
],
templateUrl: './table.component.html',
@ -31,20 +29,25 @@ interface Dictionary {
})
export class TableComponent implements OnChanges {
protected tableDataSource: MatTableDataSource<TableData> = new MatTableDataSource<TableData>([]);
protected daysOfWeek: string[] = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'];
protected displayedColumns: string[] = ['pairNumber'];
protected dataSource: Schedule[] = [];
protected isOneGroup: boolean = false;
@Input() currentWeek!: number;
@Input() startWeek!: Date;
@Input() isLoad: boolean = false;
@Input() set data(schedule: Schedule[]) {
private isDisciplineWithWeeks: boolean = false;
protected tableDataSource: MatTableDataSource<TableData> = new MatTableDataSource<TableData>([]);
private backupDisciplines: string[] = [];
protected daysOfWeek: string[] = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'];
protected displayedColumns: string[] = ['pairNumber'];
protected dataSource: ScheduleResponse[] = [];
@Input() set disciplineWithWeeks(value: boolean) {
this.isDisciplineWithWeeks = value;
this.convertData();
}
@Input() set data(schedule: ScheduleResponse[]) {
this.dataSource = schedule;
this.convertData();
this.isOneGroup = schedule.every((item, _, array) => item.group === array[0].group);
}
ngOnChanges(changes: any) {
@ -61,17 +64,52 @@ export class TableComponent implements OnChanges {
this.isLoad = true;
let tableData: TableData[] = [];
for (let i: number = 1; i <= 7; i++) {
for (let pairNumber: number = 1; pairNumber <= 7; pairNumber++) {
let convertedData: TableData = {
pairNumber: i,
pairNumber: pairNumber,
data: {}
};
for (let k: number = 1; k < 7; k++) {
convertedData.data[k.toString()] = this.dataSource.filter(x =>
x.pairNumber === i
&& x.dayOfWeek === k
&& x.isEven === (this.currentWeek % 2 === 0));
for (let dayOfWeek: number = 1; dayOfWeek < 7; dayOfWeek++) {
let filteredData = this.dataSource.filter(x =>
x.pairNumber === pairNumber &&
x.dayOfWeek === dayOfWeek &&
x.isEven === (this.currentWeek % 2 === 0)
);
if (!this.isDisciplineWithWeeks)
filteredData = filteredData.filter(x =>
x.isExcludedWeeks == undefined ||
x.weeks == undefined ||
x.weeks.length == 0 ||
(x.isExcludedWeeks && !x.weeks.includes(this.currentWeek)) ||
(!x.isExcludedWeeks && x.weeks.includes(this.currentWeek))
);
const groupedData = filteredData.reduce((acc, item) => {
const key = `${item.typeOfOccupations.join(', ')}-${item.lectureHalls}-${item.campus}-${item.discipline}-${item.professors.join(', ')}-${item.isExcludedWeeks}-${item.weeks?.join(', ') || ''}`;
if (!acc[key])
acc[key] = {...item, groups: [item.group]};
else
acc[key].groups.push(item.group);
return acc;
}, {} as { [key: string]: ScheduleResponse & { groups: string[] } });
convertedData.data[dayOfWeek.toString()] = Object.values(groupedData).map(item => {
item.group = item.groups.join(', ');
if (this.isDisciplineWithWeeks && item.weeks !== undefined && item.weeks.length > 0 && item.isExcludedWeeks !== undefined) {
if (this.backupDisciplines[item.disciplineId])
item.discipline = this.backupDisciplines[item.disciplineId];
else
this.backupDisciplines[item.disciplineId] = item.discipline;
item.discipline = `${item.isExcludedWeeks ? 'кр.' : ''} ${item.weeks.sort((x, y) => x - y).join(', ')} н. ${item.discipline}`;
} else if (this.backupDisciplines[item.disciplineId])
item.discipline = this.backupDisciplines[item.disciplineId];
return item;
});
}
tableData.push(convertedData);
@ -81,8 +119,8 @@ export class TableComponent implements OnChanges {
this.isLoad = false;
}
protected checkAvailableData(data: any[]): boolean {
return data && data.length !== 0 && data.every(x => x !== null);
protected checkAvailableData(data: any[] | any): boolean {
return data && data.length !== 0 && (!Array.isArray(data) || data.every(x => x !== null));
}
protected readonly addDays = addDays;

View File

@ -0,0 +1,5 @@
.div-wrapper {
display: block;
width: 100%;
padding: 10px 0;
}

View File

@ -5,13 +5,18 @@
Факультет
</mat-panel-title>
</mat-expansion-panel-header>
<mat-chip-listbox hideSingleSelectionIndicator (change)="chooseFaculty($event)">
@for (faculty of faculties | async; track $index) {
<mat-chip-listbox hideSingleSelectionIndicator (change)="onFacultySelected($event.value)" #facultyChip>
@for (faculty of faculties; track $index) {
<mat-chip-option [value]="faculty.id" color="accent">
{{ faculty.name }}
</mat-chip-option>
} @empty {
<app-loading-indicator [loading]="facultiesLoaded !== null" (retryFunction)="facultiesLoadRetry.emit()"/>
}
@if (faculties === null) {
<app-loading-indicator [loading]="true"
(retryFunction)="loadFaculties()"
#facultyIndicator/>
}
</mat-chip-listbox>
</mat-expansion-panel>
@ -22,14 +27,20 @@
Курс
</mat-panel-title>
</mat-expansion-panel-header>
<mat-chip-listbox hideSingleSelectionIndicator (change)="chooseCourseNumber($event)" [formControl]="chipCourse">
@for (course of courseNumbers | async; track $index) {
<mat-chip-listbox hideSingleSelectionIndicator (change)="onCourseSelected($event.value)"
[formControl]="formChipCourse"
#courseChip>
@for (course of courseNumbers; track $index) {
<mat-chip-option [value]="course" color="accent">
{{ course }}
</mat-chip-option>
} @empty {
<app-loading-indicator [loading]="groupsLoaded !== null"
(retryFunction)="groupsLoadRetry.emit(this.facultyId!)"/>
}
@if (courseNumbers === null) {
<app-loading-indicator [loading]="true"
(retryFunction)="loadCourseGroup()"
#courseIndicator/>
}
</mat-chip-listbox>
</mat-expansion-panel>
@ -40,14 +51,70 @@
Группа
</mat-panel-title>
</mat-expansion-panel-header>
<mat-chip-listbox hideSingleSelectionIndicator (change)="chooseGroup($event)" [formControl]="chipGroup">
@for (group of filteredGroups | async; track $index) {
<mat-chip-option [value]="group.id" color="accent">
{{ group.name }}
</mat-chip-option>
} @empty {
<app-loading-indicator [loading]="groupsLoaded !== null"
(retryFunction)="groupsLoadRetry.emit(this.facultyId!)"/>
<mat-chip-listbox hideSingleSelectionIndicator (change)="onGroupSelected($event.value)"
[formControl]="formChipGroup"
#groupChip>
@if (filteredGroupsSpecialist && filteredGroupsSpecialist.length > 0) {
<div class="div-wrapper">
Специалитет:
</div>
<div class="div-wrapper">
@for (group of filteredGroupsSpecialist; track $index) {
<mat-chip-option [value]="group.id" color="accent">
{{ group.name }}
</mat-chip-option>
}
</div>
}
@if (filteredGroupsSpecialist && filteredGroupsSpecialist.length > 0 && (filteredGroupsBehaviour && filteredGroupsBehaviour.length > 0 || filteredGroupsMagistracy && filteredGroupsMagistracy.length > 0)) {
<div class="div-wrapper">
<hr/>
</div>
}
@if (filteredGroupsBehaviour && filteredGroupsBehaviour.length > 0) {
<div class="div-wrapper">
Бакалавариат:
</div>
<div class="div-wrapper">
@for (group of filteredGroupsBehaviour; track $index) {
<mat-chip-option [value]="group.id" color="accent">
{{ group.name }}
</mat-chip-option>
}
</div>
}
@if (((filteredGroupsSpecialist && filteredGroupsSpecialist.length > 0 && filteredGroupsBehaviour && filteredGroupsBehaviour.length > 0) ||
((!filteredGroupsSpecialist || filteredGroupsSpecialist.length === 0) && filteredGroupsBehaviour && filteredGroupsBehaviour.length > 0)) &&
filteredGroupsMagistracy && filteredGroupsMagistracy.length > 0) {
<div class="div-wrapper">
<hr/>
</div>
}
@if (filteredGroupsMagistracy && filteredGroupsMagistracy.length > 0) {
<div class="div-wrapper">
Магистратура:
</div>
<div>
@for (group of filteredGroupsMagistracy; track $index) {
<mat-chip-option [value]="group.id" color="accent">
{{ group.name }}
</mat-chip-option>
}
</div>
}
@if (faculties === null) {
<app-loading-indicator [loading]="true"
(retryFunction)="loadCourseGroup()"
#groupIndicator/>
}
</mat-chip-listbox>
</mat-expansion-panel>

View File

@ -1,12 +1,19 @@
import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core';
import {Component, EventEmitter, ViewChild} from '@angular/core';
import {MatExpansionModule, MatExpansionPanel} from "@angular/material/expansion";
import {MatChipListboxChange, MatChipsModule} from '@angular/material/chips';
import {FormControl, ReactiveFormsModule} from "@angular/forms";
import {AsyncPipe} from "@angular/common";
import {map, Observable, of} from "rxjs";
import {FacultyResponse} from "@model/facultyResponse";
import {GroupResponse} from "@model/groupResponse";
import {MatChipListbox, MatChipsModule} from '@angular/material/chips';
import {FormControl, FormsModule, ReactiveFormsModule} from "@angular/forms";
import {catchError} from "rxjs";
import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loading-indicator.component";
import {GroupResponse} from "@api/v1/groupResponse";
import {FacultyResponse} from "@api/v1/facultyResponse";
import {FacultyService} from "@api/v1/faculty.service";
import {GroupService} from "@api/v1/group.service";
import {IScheduleTab} from "@component/schedule/tabs/ischedule-tab";
import {TabSelect, TabStorageService} from "@service/tab-storage.service";
enum Enclosure {
faculty, course, group
}
@Component({
selector: 'app-group',
@ -16,80 +23,224 @@ import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loa
MatChipsModule,
ReactiveFormsModule,
LoadingIndicatorComponent,
AsyncPipe
FormsModule
],
templateUrl: './group.component.html',
styleUrl: './group.component.css'
styleUrl: './group.component.css',
providers: [FacultyService, GroupService]
})
export class GroupComponent implements IScheduleTab {
protected faculties: FacultyResponse[] | null = null;
protected courseNumbers: number[] | null = null;
private groups: GroupResponse[] | null = null;
protected filteredGroupsBehaviour: GroupResponse[] | null = null;
protected filteredGroupsMagistracy: GroupResponse[] | null = null;
protected filteredGroupsSpecialist: GroupResponse[] | null = null;
export class GroupComponent {
protected facultyId: number | null = null;
protected courseNumber: number | null = null;
protected filteredGroups: Observable<GroupResponse[]> = of([]);
protected courseNumbers: Observable<number[]> = of([]);
protected groups: Observable<GroupResponse[]> = of([]);
protected chipCourse: FormControl = new FormControl();
protected chipGroup: FormControl = new FormControl();
protected formChipCourse: FormControl = new FormControl();
protected formChipGroup: FormControl = new FormControl();
@ViewChild('courseNumberPanel') courseNumberPanel!: MatExpansionPanel;
@ViewChild('groupPanel') groupPanel!: MatExpansionPanel;
@Input() faculties: Observable<FacultyResponse[]> = of([]);
@Input() facultiesLoaded: boolean | null = false;
@Output() facultiesLoadRetry: EventEmitter<void> = new EventEmitter<void>();
@Input() groupsLoaded: boolean | null = false;
@Output() groupsLoadRetry: EventEmitter<number> = new EventEmitter<number>();
@ViewChild('facultyChip') facultyChip!: MatChipListbox;
@ViewChild('courseChip') courseChip!: MatChipListbox;
@ViewChild('groupChip') groupChip!: MatChipListbox;
@Input() set setGroups(data: Observable<GroupResponse[]>) {
this.groups = data;
this.courseNumbers = this.groups.pipe(
map(data => data.map(g => g.courseNumber)),
map(courseNumbersArray => courseNumbersArray.filter((value, index, self) => self.indexOf(value) === index)),
map(uniqueCourseNumbers => uniqueCourseNumbers.sort((a, b) => a - b))
);
@ViewChild('facultyIndicator') facultyIndicator!: LoadingIndicatorComponent;
@ViewChild('courseIndicator') courseIndicator!: LoadingIndicatorComponent;
@ViewChild('groupIndicator') groupIndicator!: LoadingIndicatorComponent;
private resetCourse() {
this.courseNumber = null;
this.groups = [];
this.formChipCourse.reset();
this.courseChip.value = undefined;
}
@Output() groupSelected = new EventEmitter<number>();
@Output() facultySelected = new EventEmitter<number>();
private resetGroup() {
this.filteredGroupsBehaviour = [];
this.filteredGroupsMagistracy = [];
this.filteredGroupsSpecialist = [];
this.formChipGroup.reset();
this.groupChip.value = undefined;
}
protected chooseFaculty(event: MatChipListboxChange) {
this.courseNumber = null;
this.groups = of([]);
this.chipGroup.reset();
this.chipCourse.reset();
public eventResult = new EventEmitter<number>();
public selectChangeEvent = new EventEmitter<TabSelect[]>();
if (event.value === undefined || event.value === null) {
constructor(private facultyApi: FacultyService, private groupApi: GroupService) {
}
private getSelectedTabs(): TabSelect[] {
const faculty = this.facultyChip.value;
const course = this.courseChip.value;
const group = this.groupChip.value;
const result: TabSelect[] = [];
if (faculty)
result.push(new TabSelect(faculty, this.faculties!.find(x => x.id === faculty)?.name ?? ''));
if (course)
result.push(new TabSelect(course, course.toString()));
if (group)
result.push(new TabSelect(group, this.groups!.find(x => x.id == group)?.name ?? ''));
return result;
}
public getEnclosureList(): string[] {
return Object.keys(Enclosure).filter((item) => {
return isNaN(Number(item));
});
}
protected loadFaculties() {
this.facultyApi.getFaculties()
.pipe(catchError(error => {
this.facultyIndicator.loading = false;
throw error;
}))
.subscribe(data => {
this.faculties = data;
let selected = TabStorageService.selected?.selected[this.getEnclosureList()[Enclosure.faculty]];
if (selected) {
let selectedFaculty = data.find(x => x.id === selected.index);
if (selectedFaculty === undefined || selectedFaculty.name !== selected.name)
selectedFaculty = data.find(x => x.name === selected.name);
if (selectedFaculty !== undefined) {
TabStorageService.trySelectChip(selectedFaculty.id, this.facultyChip);
this.onFacultySelected(selectedFaculty.id, true);
}
}
});
}
protected loadCourseGroup() {
if (this.facultyId === null)
return;
if (this.groups === null || this.groups.length === 0 || this.groups[0].facultyId !== this.facultyId) {
this.groupApi.getByFaculty(this.facultyId)
.pipe(catchError(error => {
this.groupIndicator.loading = false;
this.courseIndicator.loading = false;
throw error;
}))
.subscribe(data => {
this.groups = data;
this.courseNumbers = Array.from(
new Set(
this.groups!
.map(x => x.courseNumber)
.sort((a, b) => a - b))
);
let selected = TabStorageService.selected?.selected[this.getEnclosureList()[Enclosure.course]];
if (selected) {
let selectedCourse = this.courseNumbers.find(x => x === selected.index);
if (selectedCourse === undefined)
selectedCourse = this.courseNumbers.find(x => x.toString() === selected.name);
if (selectedCourse !== undefined) {
TabStorageService.trySelectChip(selectedCourse, this.courseChip);
this.onCourseSelected(selectedCourse, true);
}
}
let selectedGroupStorage = TabStorageService.selected?.selected[this.getEnclosureList()[Enclosure.group]];
if (selectedGroupStorage) {
let selectedGroup = data.find(x => x.id === selectedGroupStorage.index);
if (selectedGroup === undefined || selectedGroup.name !== selectedGroupStorage.name)
selectedGroup = data.find(x => x.name === selectedGroupStorage.name);
if (selectedGroup !== undefined) {
TabStorageService.trySelectChip(selectedGroup.id, this.groupChip);
this.onGroupSelected(selectedGroup.id, true);
}
}
});
return;
}
if (this.courseNumber !== null) {
const groupByCourse = this.groups!.filter(x => x.courseNumber === this.courseNumber);
groupByCourse.forEach(x => {
if (x.name[2].toUpperCase() === 'Б')
this.filteredGroupsBehaviour?.push(x);
else if (x.name[2].toUpperCase() === 'С')
this.filteredGroupsSpecialist?.push(x);
else
this.filteredGroupsMagistracy?.push(x);
});
}
}
protected onFacultySelected(index: number, loadMode: boolean = false) {
this.resetCourse();
this.resetGroup();
if (index === undefined) {
this.facultyId = null;
return;
}
this.facultyId = event.value;
if (loadMode)
this.facultyChip.value = index;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
this.facultyId = index;
this.courseNumberPanel.open();
this.facultySelected.emit(this.facultyId!);
this.loadCourseGroup();
}
protected chooseCourseNumber(event: MatChipListboxChange) {
this.filteredGroups = of([]);
this.chipGroup.reset();
protected onCourseSelected(course: number, loadMode: boolean = false) {
this.resetGroup();
if (event.value === undefined || event.value === null) {
if (course === undefined) {
this.courseNumber = null;
return;
}
this.courseNumber = event.value;
if (loadMode)
this.courseChip.value = course;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
this.courseNumber = course;
this.groupPanel.open();
this.groups.subscribe(data =>
this.filteredGroups = of(data.filter(g => g.courseNumber === this.courseNumber)));
this.loadCourseGroup();
}
protected chooseGroup(event: MatChipListboxChange) {
if (event.value === undefined || event.value === null)
protected onGroupSelected(index: number, loadMode: boolean = false) {
if (index === undefined)
return;
if (loadMode)
this.groupChip.value = index;
this.selectChangeEvent.emit(this.getSelectedTabs());
this.groupPanel.close();
this.groupSelected.emit(event.value);
this.eventResult.emit(index);
}
public load() {
if (this.faculties === null)
this.loadFaculties();
}
}

View File

@ -0,0 +1,12 @@
import {EventEmitter} from "@angular/core";
import {TabSelect} from "@service/tab-storage.service";
export interface IScheduleTab {
load(): void;
getEnclosureList(): string[];
eventResult: EventEmitter<number>;
selectChangeEvent: EventEmitter<TabSelect[]>;
}

View File

@ -5,13 +5,14 @@
Кампус
</mat-panel-title>
</mat-expansion-panel-header>
<mat-chip-listbox hideSingleSelectionIndicator (change)="chooseCampus($event)">
@for (campus of campuses | async; track $index) {
<mat-chip-listbox hideSingleSelectionIndicator (change)="onCampusSelected($event.value)" #campusChip>
@for (campus of campuses; track $index) {
<mat-chip-option [value]="campus.id" color="accent">
{{ campus.codeName }}
</mat-chip-option>
} @empty {
<app-loading-indicator [loading]="campusesLoaded !== null" (retryFunction)="campusesLoadRetry.emit()"/>
}
@if (campuses === null) {
<app-loading-indicator [loading]="true" (retryFunction)="loadCampuses()" #campusIndicator/>
}
</mat-chip-listbox>
</mat-expansion-panel>
@ -22,13 +23,15 @@
Кабинет
</mat-panel-title>
</mat-expansion-panel-header>
<mat-chip-listbox hideSingleSelectionIndicator (change)="chooseLectureHall($event)" [formControl]="chipLecture">
@for (lectureHall of lectureHalls | async; track $index) {
<mat-chip-listbox hideSingleSelectionIndicator (change)="onLectureHallSelected($event.value)"
[formControl]="formLectureHalls" #lectureChip>
@for (lectureHall of lectureHallsFiltered; track $index) {
<mat-chip-option [value]="lectureHall.id" color="accent">
{{ lectureHall.name }}
</mat-chip-option>
} @empty {
<app-loading-indicator [loading]="lectureHallsLoaded !== null" (retryFunction)="lectureHallsLoadRetry.emit()"/>
}
@if (lectureHallsFiltered === null) {
<app-loading-indicator [loading]="true" (retryFunction)="loadLectureHalls()" #lectureIndicator/>
}
</mat-chip-listbox>
</mat-expansion-panel>

View File

@ -1,12 +1,19 @@
import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core';
import {AsyncPipe} from "@angular/common";
import {Component, EventEmitter, ViewChild} from '@angular/core';
import {MatAccordion, MatExpansionModule, MatExpansionPanel} from "@angular/material/expansion";
import {MatChipListboxChange, MatChipsModule} from "@angular/material/chips";
import {Observable, of} from "rxjs";
import {CampusBasicInfoResponse} from "@model/campusBasicInfoResponse";
import {MatChipListbox, MatChipsModule} from "@angular/material/chips";
import {catchError} from "rxjs";
import {FormControl, ReactiveFormsModule} from "@angular/forms";
import {LectureHallResponse} from "@model/lectureHallResponse";
import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loading-indicator.component";
import {CampusBasicInfoResponse} from "@api/v1/campusBasicInfoResponse";
import {LectureHallResponse} from "@api/v1/lectureHallResponse";
import {CampusService} from "@api/v1/campus.service";
import {LectureHallService} from "@api/v1/lectureHall.service";
import {IScheduleTab} from "@component/schedule/tabs/ischedule-tab";
import {TabSelect, TabStorageService} from "@service/tab-storage.service";
enum Enclosure {
campus, lecture
}
@Component({
selector: 'app-lecture-hall',
@ -14,50 +21,149 @@ import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loa
imports: [
MatChipsModule,
MatExpansionModule,
AsyncPipe,
ReactiveFormsModule,
MatAccordion,
LoadingIndicatorComponent
],
templateUrl: './lecture-hall.component.html',
styleUrl: './lecture-hall.component.css'
styleUrl: './lecture-hall.component.css',
providers: [CampusService, LectureHallService]
})
export class LectureHallComponent {
export class LectureHallComponent implements IScheduleTab {
protected campusId: number | null = null;
protected chipLecture: FormControl = new FormControl();
protected formLectureHalls: FormControl = new FormControl();
protected campuses: CampusBasicInfoResponse[] | null = null;
protected lectureHallsFiltered: LectureHallResponse[] | null = null;
@ViewChild('lecturePanel') lecturePanel!: MatExpansionPanel;
@ViewChild('lectureIndicator') lectureIndicator!: LoadingIndicatorComponent;
@ViewChild('campusIndicator') campusIndicator!: LoadingIndicatorComponent;
@Input() campuses: Observable<CampusBasicInfoResponse[]> = of([]);
@Input() campusesLoaded: boolean | null = false;
@Output() campusesLoadRetry: EventEmitter<void> = new EventEmitter<void>();
@Input() lectureHalls: Observable<LectureHallResponse[]> = of([]);
@Input() lectureHallsLoaded: boolean | null = false;
@Output() lectureHallsLoadRetry: EventEmitter<number> = new EventEmitter<number>();
@ViewChild('campusChip') campusChip!: MatChipListbox;
@ViewChild('lectureChip') lectureChip!: MatChipListbox;
@Output() campusSelected = new EventEmitter<number>();
@Output() lectureHallSelected = new EventEmitter<number>();
private lectureHalls: LectureHallResponse[] | null = null;
protected chooseCampus(event: MatChipListboxChange) {
this.chipLecture.reset();
public eventResult = new EventEmitter<number>();
public selectChangeEvent = new EventEmitter<TabSelect[]>();
if (event.value === undefined || event.value === null) {
constructor(private campusApi: CampusService, private lectureHallApi: LectureHallService) {
}
private getSelectedTabs(): TabSelect[] {
const campus = this.campusChip.value;
const lecture = this.lectureChip.value;
const result: TabSelect[] = [];
if (campus)
result.push(new TabSelect(campus, this.campuses!.find(x => x.id === campus)?.codeName ?? ''));
if (lecture)
result.push(new TabSelect(lecture, this.lectureHallsFiltered!.find(x => x.id === lecture)?.name ?? ''));
return result;
}
getEnclosureList(): string[] {
return Object.keys(Enclosure).filter((item) => {
return isNaN(Number(item));
});
}
protected loadCampuses() {
this.campusApi.getCampus()
.pipe(catchError(error => {
this.campusIndicator.loading = false;
throw error;
}))
.subscribe(data => {
this.campuses = data;
let selected = TabStorageService.selected?.selected[this.getEnclosureList()[Enclosure.campus]];
if (selected) {
let selectedCampus = data.find(x => x.id === selected.index);
if (selectedCampus === undefined || selectedCampus.codeName !== selected.name)
selectedCampus = data.find(x => x.codeName === selected.name);
if (selectedCampus !== undefined) {
TabStorageService.trySelectChip(selectedCampus.id, this.campusChip);
this.onCampusSelected(selectedCampus.id, true);
}
}
});
}
private filteringLectureHalls() {
this.lectureHallsFiltered = this.lectureHalls?.filter(x => x.campusId === this.campusId) ?? null;
}
protected onCampusSelected(index: number, loadMode: boolean = false) {
this.formLectureHalls.reset();
this.lectureChip.value = undefined;
if (loadMode)
this.campusChip.value = index;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
if (index === undefined) {
this.campusId = null;
this.lectureHalls = of([]);
this.lectureHalls = [];
return;
}
this.campusId = event.value;
this.campusId = index;
this.lecturePanel.open();
this.campusSelected.emit(this.campusId!);
if (this.lectureHalls === null)
this.loadLectureHalls();
else
this.filteringLectureHalls();
}
protected chooseLectureHall(event: MatChipListboxChange) {
if (event.value === undefined || event.value === null)
protected loadLectureHalls() {
this.lectureHallApi.getLectureHalls()
.pipe(catchError(error => {
this.lectureIndicator.loading = false;
throw error;
}))
.subscribe(data => {
this.lectureHalls = data;
this.filteringLectureHalls();
let selected = TabStorageService.selected?.selected[this.getEnclosureList()[Enclosure.lecture]];
if (selected) {
let selectedLecture = data.find(x => x.id === selected.index);
if (selectedLecture === undefined || selectedLecture.name !== selected.name)
selectedLecture = data.find(x => x.name === selected.name);
if (selectedLecture !== undefined) {
TabStorageService.trySelectChip(selectedLecture.id, this.lectureChip);
this.onLectureHallSelected(selectedLecture.id, true);
}
}
});
}
protected onLectureHallSelected(index: number, loadMode: boolean = false) {
if (index === undefined)
return;
if (loadMode)
this.lectureChip.value = index;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
this.lecturePanel.close();
this.lectureHallSelected.emit(event.value);
this.eventResult.emit(index);
}
public load() {
if (this.campuses === null)
this.loadCampuses();
}
}

View File

@ -1,24 +1,27 @@
<!--suppress CssInvalidPropertyValue -->
<button mat-button [matMenuTriggerFor]="menu" #menuTrigger="matMenuTrigger" [id]="idButton">{{ textButton }}</button>
<button mat-button [matMenuTriggerFor]="menu" #menuTrigger="matMenuTrigger" [id]="idButton"
style="margin-bottom: 10px;">{{ textButton }}
</button>
<mat-menu #menu="matMenu" [hasBackdrop]="false" class="menu-options">
<div (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()" style="padding: 0 15px 15px">
<div class="header-menu">
<mat-form-field appearance="outline" color="accent" style="display:flex;">
<input matInput placeholder="Поиск..." [(ngModel)]="searchQuery" [disabled]="data.length === 0">
<button mat-icon-button matSuffix (click)="clearSearchQuery()" [disabled]="data.length === 0">
<input matInput placeholder="Поиск..." [(ngModel)]="searchQuery"
[disabled]="data === null || data.length === 0">
<button mat-icon-button matSuffix (click)="clearSearchQuery()" [disabled]="data === null || data.length === 0">
<mat-icon style="color: var(--mdc-filled-button-label-text-color);">close</mat-icon>
</button>
</mat-form-field>
<div class="button-group">
<mat-checkbox (click)="checkData()" [disabled]="data.length === 0" #chooseCheckbox/>
<button mat-button (click)="clearAll()" [disabled]="data.length === 0">Очистить</button>
<mat-checkbox (click)="checkData()" [disabled]="data === null || data.length === 0" #chooseCheckbox/>
<button mat-button (click)="clearAll()" [disabled]="data === null || data.length === 0">Очистить</button>
</div>
<hr/>
</div>
@if (data.length === 0) {
<app-loading-indicator style="display: flex; justify-content: center;" [loading]="dataLoaded !== null"
@if (data === null || data.length === 0) {
<app-loading-indicator style="display: flex; justify-content: center;" [loading]="data === null"
(retryFunction)="retryLoadData.emit()"/>
} @else {
<mat-selection-list>

View File

@ -43,20 +43,23 @@ export interface SelectData {
templateUrl: './other.component.html',
styleUrl: './other.component.css'
})
export class OtherComponent {
private _searchQuery: string = '';
protected filteredData: BehaviorSubject<SelectData[]> = new BehaviorSubject<SelectData[]>([]);
protected data: SelectData[] = [];
protected data: SelectData[] | null = null;
@Input() idButton!: string;
@Input() textButton!: string;
@ViewChild('menuTrigger') menuTrigger!: MatMenuTrigger;
@ViewChild('chooseCheckbox') chooseCheckbox!: MatCheckbox;
@Input() dataLoaded: boolean | null = false;
@Output() retryLoadData: EventEmitter<void> = new EventEmitter<void>();
get selectedIds(): number[] {
if (this.data === null)
return [];
return this.data.filter(x => x.selected).map(x => x.id);
}
@ -71,6 +74,9 @@ export class OtherComponent {
}
private updateCheckBox() {
if (this.data === null)
return;
this.chooseCheckbox.checked = this.data.every(x => x.selected);
this.chooseCheckbox.indeterminate = this.data.some(x => x.selected) && !this.chooseCheckbox.checked;
}
@ -81,6 +87,9 @@ export class OtherComponent {
}
protected updateFilteredData(): void {
if (this.data === null)
return;
this.filteredData.next(this.data.filter(x =>
x.name.toLowerCase().includes(this.searchQuery.toLowerCase())
));
@ -91,7 +100,7 @@ export class OtherComponent {
}
protected clearAll(): void {
this.data.forEach(x => x.selected = false);
this.data?.forEach(x => x.selected = false);
if (this.searchQuery !== '') {
const updatedData = this.filteredData.value.map(x => {
@ -108,7 +117,7 @@ export class OtherComponent {
const check: boolean = this.filteredData.value.some(x => !x.selected) && !this.filteredData.value.every(x => x.selected);
const updatedData = this.filteredData.value.map(data => {
this.data.find(x => x.id === data.id)!.selected = check;
this.data!.find(x => x.id === data.id)!.selected = check;
return {...data, selected: check};
});
@ -117,7 +126,7 @@ export class OtherComponent {
}
protected checkboxStateChange(item: number) {
const data = this.data.find(x => x.id === item)!;
const data = this.data!.find(x => x.id === item)!;
data.selected = !data.selected;
const updatedData = this.filteredData.value;
updatedData.find(x => x.id === item)!.selected = data.selected;

View File

@ -1,11 +1,12 @@
<div class="search-content">
@if (professors.length === 0) {
<app-loading-indicator [loading]="professorsLoaded !== null" (retryFunction)="professorsLoadRetry.emit()"/>
@if (professors === null) {
<app-loading-indicator [loading]="true" (retryFunction)="loadProfessors()"
#professorIndicator/>
} @else {
<mat-form-field color="accent" style="width: 100%;">
<input type="text" placeholder="Поиск..." matInput [formControl]="professorControl" [matAutocomplete]="auto">
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="onOptionSelected($event)"
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="onOptionSelected($event.option.value)"
[autoActiveFirstOption]="false" [hideSingleSelectionIndicator]="true">
@for (option of filteredProfessors | async; track option) {
<mat-option [value]="option.id">

View File

@ -1,11 +1,14 @@
import {Component, EventEmitter, Input, OnInit, Output} from "@angular/core";
import {Component, EventEmitter, OnInit, ViewChild} from "@angular/core";
import {MatFormField, MatInput} from "@angular/material/input";
import {FormControl, ReactiveFormsModule} from "@angular/forms";
import {MatAutocompleteModule, MatAutocompleteSelectedEvent} from "@angular/material/autocomplete";
import {MatAutocompleteModule} from "@angular/material/autocomplete";
import {AsyncPipe} from "@angular/common";
import {map, Observable, startWith} from "rxjs";
import {ProfessorResponse} from "@model/professorResponse";
import {catchError, map, Observable, startWith} from "rxjs";
import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loading-indicator.component";
import {ProfessorResponse} from "@api/v1/professorResponse";
import {ProfessorService} from "@api/v1/professor.service";
import {IScheduleTab} from "@component/schedule/tabs/ischedule-tab";
import {TabSelect, TabStorageService} from "@service/tab-storage.service";
@Component({
selector: 'app-professor',
@ -19,17 +22,50 @@ import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loa
LoadingIndicatorComponent
],
templateUrl: './professor.component.html',
styleUrl: './professor.component.css'
styleUrl: './professor.component.css',
providers: [ProfessorService]
})
export class ProfessorComponent implements OnInit {
export class ProfessorComponent implements OnInit, IScheduleTab {
protected professorControl = new FormControl();
protected filteredProfessors!: Observable<ProfessorResponse[]>;
@Input() professors: ProfessorResponse[] = [];
@Output() professorSelected = new EventEmitter<number>();
protected professors: ProfessorResponse[] | null = null;
@Input() professorsLoaded: boolean | null = false;
@Output() professorsLoadRetry: EventEmitter<void> = new EventEmitter<void>();
@ViewChild('professorIndicator') professorIndicator!: LoadingIndicatorComponent;
public eventResult = new EventEmitter<number>();
public selectChangeEvent = new EventEmitter<TabSelect[]>();
constructor(private api: ProfessorService) {
}
getEnclosureList(): string[] {
return ['professor'];
}
protected loadProfessors() {
if (this.professors === null || this.professors.length === 0) {
this.api.getProfessors()
.pipe(catchError(error => {
this.professorIndicator.loading = false;
throw error;
}))
.subscribe(data => {
this.professors = data;
let selected = TabStorageService.selected?.selected['professor'];
if (selected) {
let selectedProfessor = data.find(x => x.id === selected.index);
if (selectedProfessor === undefined || selectedProfessor.name !== selected.name)
selectedProfessor = data.find(x => x.name === selected.name);
if (selectedProfessor !== undefined)
this.onOptionSelected(selectedProfessor.id);
}
});
}
}
ngOnInit(): void {
this.filteredProfessors = this.professorControl.valueChanges.pipe(
@ -40,20 +76,32 @@ export class ProfessorComponent implements OnInit {
private _filterProfessors(value: string | number): ProfessorResponse[] {
if (typeof value === 'string') {
if (value === '') return [];
const filterValue = value.toLowerCase();
return this.professors.filter(teacher => teacher.name.toLowerCase().includes(filterValue));
if (value === '')
return [];
const filterValue = value.toLowerCase().replace('ё', 'е');
return this.professors?.filter(teacher => teacher.name.toLowerCase().replace('ё', 'е').includes(filterValue)) ?? [];
} else {
const selectedTeacher = this.professors.find(teacher => teacher.id === value);
const selectedTeacher = this.professors?.find(teacher => teacher.id === value);
return selectedTeacher ? [selectedTeacher] : [];
}
}
protected onOptionSelected(event: MatAutocompleteSelectedEvent) {
const selectedOption = this.professors.find(teacher => teacher.id === event.option.value);
protected onOptionSelected(index: number) {
if (index === undefined)
return;
const selectedOption = this.professors?.find(teacher => teacher.id === index);
if (selectedOption) {
this.professorControl.setValue(selectedOption.name);
this.professorSelected.emit(selectedOption.id);
this.eventResult.emit(selectedOption.id);
this.selectChangeEvent.emit([new TabSelect(selectedOption.id, selectedOption.name)]);
}
}
public load() {
if (this.professors === null)
this.loadProfessors();
}
}

View File

@ -1,5 +1,5 @@
.padding-content div {
padding: 30px 15px;
padding: 15px 0;
}
.margin-other-button {

View File

@ -1,35 +1,31 @@
<mat-tab-group dynamicHeight mat-stretch-tabs="false" mat-align-tabs="start" color="accent" class="padding-content"
(selectedTabChange)="chooseTabs($event)">
#tabGroup
(selectedTabChange)="chooseTabs($event.index)">
<mat-tab label="Группа">
<div>
<app-group (groupSelected)="groupSelected.emit($event)" (facultySelected)="groupLoad($event)"
[setGroups]="groups" [faculties]="faculties" [facultiesLoaded]="facultiesLoaded"
[groupsLoaded]="groupLoaded" (groupsLoadRetry)="groupLoad($event)"
(facultiesLoadRetry)="facultyLoad()"/>
<app-group #groupTab/>
</div>
</mat-tab>
<mat-tab label="Преподаватель">
<div>
<app-professor (professorSelected)="professorSelected.emit($event)" [professors]="professorsData" [professorsLoaded]="professorsLoaded" (professorsLoadRetry)="professorsLoad()"/>
<app-professor #professorTab/>
</div>
</mat-tab>
<mat-tab label="Кабинет">
<div>
<app-lecture-hall (lectureHallSelected)="lectureHallSelected.emit($event)" [campuses]="campuses"
(campusSelected)="lectureHallLoad($event)" [lectureHalls]="lectureHalls"
[campusesLoaded]="campusesLoaded" [lectureHallsLoaded]="lectureHallsLoaded"
(campusesLoadRetry)="campusLoad()"
(lectureHallsLoadRetry)="lectureHallLoad($event)"/>
<app-lecture-hall #lectureHallTab/>
</div>
</mat-tab>
<mat-tab label="Другое">
<mat-tab label="Другое" *appHasRole="AuthRoles.Admin">
<div class="margin-other-button">
<app-other idButton="disciplines-button" textButton="Дисциплины" #discipline [dataLoaded]="disciplinesLoaded" (retryLoadData)="loadDisciplines()"/>
<app-other idButton="lecture-button" textButton="Кабинеты" #lecture [dataLoaded]="campusesLoaded && lectureHallsLoaded" (retryLoadData)="loadLectureHalls()"/>
<app-other idButton="group-button" textButton="Группы" #group [dataLoaded]="facultiesLoaded && groupLoaded" (retryLoadData)="loadGroups()"/>
<app-other idButton="professor-button" textButton="Профессоры" #professor [dataLoaded]="professorsLoaded" (retryLoadData)="professorsLoad()"/>
<app-other idButton="disciplines-button" textButton="Дисциплины" #discipline (retryLoadData)="loadDisciplines()"/>
<app-other idButton="lecture-button" textButton="Кабинеты" #lecture (retryLoadData)="loadLectureHalls()"/>
<app-other idButton="group-button" textButton="Группы" #group (retryLoadData)="loadGroups()"/>
<app-other idButton="professor-button" textButton="Профессоры" #professor (retryLoadData)="loadProfessors()"/>
<app-other idButton="lesson-type-button" textButton="Тип занятия" #lesson_type (retryLoadData)="loadLessonType()"/>
<section>
<button mat-flat-button (click)="onClickNagmi()">Отфильтровать</button>
<button mat-flat-button (click)="otherFilter()">Отфильтровать</button>
</section>
</div>
</mat-tab>

View File

@ -1,277 +1,243 @@
import {Component, EventEmitter, Output, ViewChild} from '@angular/core';
import {HttpClientModule} from "@angular/common/http";
import {ApiService} from '@service/api.service';
import {OtherComponent} from "@component/schedule/tabs/other/other.component";
import {MatTab, MatTabChangeEvent, MatTabGroup} from "@angular/material/tabs";
import {
ProfessorComponent
} from "@component/schedule/tabs/professor/professor.component";
import {GroupComponent} from "@component/schedule/tabs/group/group.component";
import {
LectureHallComponent
} from "@component/schedule/tabs/lecture-hall/lecture-hall.component";
import {ProfessorResponse} from "@model/professorResponse";
import {catchError, map, Observable, of, switchMap, tap} from "rxjs";
import {GroupResponse} from "@model/groupResponse";
import {FacultyResponse} from "@model/facultyResponse";
import {CampusBasicInfoResponse} from "@model/campusBasicInfoResponse";
import {LectureHallResponse} from "@model/lectureHallResponse";
import {ReactiveFormsModule} from "@angular/forms";
import {AsyncPipe, NgIf} from "@angular/common";
import {DisciplineResponse} from "@model/disciplineResponse";
import {AfterViewInit, Component, EventEmitter, Output, ViewChild} from '@angular/core';
import {OtherComponent, SelectData} from "@component/schedule/tabs/other/other.component";
import {MatTab, MatTabGroup} from "@angular/material/tabs";
import {Observable} from "rxjs";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatButton} from "@angular/material/button";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {GroupComponent} from "@component/schedule/tabs/group/group.component";
import {ProfessorComponent} from "@component/schedule/tabs/professor/professor.component";
import {LectureHallComponent} from "@component/schedule/tabs/lecture-hall/lecture-hall.component";
import {ScheduleService} from "@api/v1/schedule.service";
import {ScheduleResponse} from "@api/v1/scheduleResponse";
import {IScheduleTab} from "@component/schedule/tabs/ischedule-tab";
import {DisciplineService} from "@api/v1/discipline.service";
import {LectureHallService} from "@api/v1/lectureHall.service";
import {GroupService} from "@api/v1/group.service";
import {ProfessorService} from "@api/v1/professor.service";
import {AuthRoles} from "@model/authRoles";
import {HasRoleDirective} from "@/directives/has-role.directive";
import {TabSelectType, TabStorageService} from "@service/tab-storage.service";
import {ScheduleRequest} from "@api/v1/scheduleRequest";
import {CampusService} from "@api/v1/campus.service";
import {LessonTypeService} from "@api/v1/lessonType.service";
export enum TabsSelect {
Group,
Professor,
LectureHall,
Other
}
@Component({
selector: 'app-tabs',
standalone: true,
imports: [
HttpClientModule,
OtherComponent,
MatTabGroup,
MatTab,
ProfessorComponent,
GroupComponent,
LectureHallComponent,
ReactiveFormsModule,
AsyncPipe,
NgIf,
MatButton,
DataSpinnerComponent
GroupComponent,
ProfessorComponent,
LectureHallComponent,
FormsModule,
HasRoleDirective
],
templateUrl: './tabs.component.html',
styleUrl: './tabs.component.css'
styleUrl: './tabs.component.css',
providers: [
ScheduleService,
DisciplineService,
LectureHallService,
GroupService,
ProfessorService,
TabStorageService,
CampusService,
LessonTypeService]
})
export class TabsComponent {
protected professorsData: ProfessorResponse[] = [];
export class TabsComponent implements AfterViewInit {
@Output() eventResult = new EventEmitter<[TabsSelect, number, Observable<ScheduleResponse[]>, ScheduleRequest]>();
private currentTab: number = -1;
protected faculties: Observable<FacultyResponse[]> = of([]);
protected groups: Observable<GroupResponse[]> = of([]);
private groupsData: Observable<GroupResponse[]> = of([]);
protected campuses: Observable<CampusBasicInfoResponse[]> = of([]);
protected lectureHalls: Observable<LectureHallResponse[]> = of([]);
private lectureHallsData: Observable<LectureHallResponse[]> = of([]);
// States
protected facultiesLoaded: boolean | null = false;
protected groupLoaded: boolean | null = false;
protected campusesLoaded: boolean | null = false;
protected lectureHallsLoaded: boolean | null = false;
protected disciplinesLoaded: boolean | null = false;
protected professorsLoaded: boolean | null = false;
@Output() groupSelected: EventEmitter<number> = new EventEmitter<number>();
@Output() lectureHallSelected: EventEmitter<number> = new EventEmitter<number>();
@Output() professorSelected: EventEmitter<number> = new EventEmitter<number>();
constructor(private api: ApiService) {
this.facultyLoad().then();
constructor(private scheduleApi: ScheduleService,
private disciplineApi: DisciplineService,
private lectureApi: LectureHallService,
private groupApi: GroupService,
private professorApi: ProfessorService,
private tabStorage: TabStorageService,
private campusApi: CampusService,
private lessonTypeApi: LessonTypeService) {
}
protected async chooseTabs(event: MatTabChangeEvent) {
console.log(event);
switch (event.index) {
ngAfterViewInit(): void {
this.groupTab.selectChangeEvent.subscribe(event => this.tabStorage.select(TabSelectType.group, event));
this.professorTab.selectChangeEvent.subscribe(event => this.tabStorage.select(TabSelectType.professor, event));
this.lectureHallTab.selectChangeEvent.subscribe(event => this.tabStorage.select(TabSelectType.lecture, event));
this.groupTab.eventResult.subscribe(event => this.eventResult.emit(
[
TabsSelect.Group,
event,
this.scheduleApi.getByGroup(event),
{groups: [event]}
]
));
this.professorTab.eventResult.subscribe(event => this.eventResult.emit(
[
TabsSelect.Professor,
event,
this.scheduleApi.getByProfessor(event),
{professors: [event]}
]
));
this.lectureHallTab.eventResult.subscribe(event => this.eventResult.emit(
[
TabsSelect.LectureHall,
event,
this.scheduleApi.getByLectureHall(event),
{lectureHalls: [event]}
]
));
let selected = TabStorageService.selected;
let index = 0;
if (selected !== null) {
const selectedKeys = Object.keys(selected?.selected);
if (selected.type === null) {
if (this.groupTab.getEnclosureList().every((value, index) => value === selectedKeys[index]))
index = 0;
else if (this.professorTab.getEnclosureList().every((value, index) => value === selectedKeys[index]))
index = 1;
else if (this.lectureHallTab.getEnclosureList().every((value, index) => value === selectedKeys[index]))
index = 2;
} else
index = selected.type;
}
if (index === 0)
this.chooseTabs(0).then();
else
this.tabs.selectedIndex = index;
}
protected async chooseTabs(index: number) {
let needGetEnclosure = false;
if (this.currentTab !== index) {
this.currentTab = index;
needGetEnclosure = true;
}
switch (index) {
case 0:
await this.facultyLoad();
this.groupTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.groupTab.getEnclosureList();
break;
case 1:
this.professorsLoad();
this.professorTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.professorTab.getEnclosureList();
break;
case 2:
await this.campusLoad();
this.lectureHallTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.lectureHallTab.getEnclosureList();
break;
case 3:
await this.extensionLoad();
await this.loadDisciplines();
await this.loadLectureHalls();
await this.loadGroups();
await this.loadProfessors();
await this.loadLessonType();
break;
default:
await this.chooseTabs(0);
break;
}
}
protected async facultyLoad() {
if (this.facultiesLoaded === null) this.facultiesLoaded = false;
if (this.facultiesLoaded) return;
this.faculties = this.api.get<FacultyResponse[]>("Faculty/Get").pipe(
tap(() => {
this.facultiesLoaded = true;
}),
catchError((error) => {
this.facultiesLoaded = null;
throw error;
})
);
}
protected groupLoad(id: number) {
if (this.groupLoaded === null) this.groupLoaded = false;
if (this.groupLoaded)
this.groups = this.groupsData.pipe(map(data => data.filter(x => x.facultyId === id)));
else
this.groups = this.api.get<GroupResponse[]>("Group/GetByFaculty/" + id).pipe(
tap(() => {
this.groupLoaded = false;
}),
catchError((error) => {
this.groupLoaded = null;
throw error;
})
);
}
protected professorsLoad() {
if (this.professorsLoaded === null) this.professorsLoaded = false;
if (this.professorsLoaded) return;
this.api.get<ProfessorResponse[]>("Professor/Get").pipe(
catchError((error) => {
this.professorsLoaded = null;
throw error;
})
).subscribe(data => {
this.professorsData = data;
this.professorEx.Data = data.map(x =>
({
id: x.id,
name: x.altName ? x.altName : x.name,
selected: false
}));
});
}
protected async campusLoad() {
if (this.campusesLoaded === null) this.campusesLoaded = false;
if (this.campusesLoaded) return;
this.campuses = this.api.get<CampusBasicInfoResponse[]>("Campus/Get").pipe(
tap(() => {
this.campusesLoaded = true;
}),
catchError((error) => {
this.campusesLoaded = null;
throw error;
})
);
}
protected lectureHallLoad(id: number) {
if (this.lectureHallsLoaded === null) this.lectureHallsLoaded = false;
if (this.lectureHallsLoaded)
this.lectureHalls = this.lectureHallsData.pipe(map(data => data.filter(x => x.campusId === id)));
else
this.lectureHalls = this.api.get<LectureHallResponse[]>("LectureHall/GetByCampus/" + id).pipe(
tap(() => {
this.lectureHallsLoaded = false;
}),
catchError((error) => {
this.lectureHallsLoaded = null;
throw error;
})
);
}
protected async loadLectureHalls() {
if (!this.campusesLoaded)
await this.campusLoad();
if (!this.lectureHallsLoaded) {
this.lectureHallsData = this.api.get<LectureHallResponse[]>("LectureHall/Get");
this.lectureHallsData.pipe(
switchMap(lectureHalls => this.campuses.pipe(
map(campuses => {
return lectureHalls.map(x => {
const campus = campuses.find(c => c.id === x.campusId);
const codeName = campus ? campus.codeName : '';
return {
id: x.id,
name: `${x.name} (${codeName})`,
selected: false
};
});
})
)),
).subscribe(data => {
this.lectureHallEx.Data = data;
this.lectureHallsLoaded = true;
this.campusesLoaded = true;
});
}
}
protected async loadDisciplines() {
if (!this.disciplinesLoaded) {
this.api.get<DisciplineResponse[]>("Discipline/Get").pipe(
catchError((error) => {
this.disciplinesLoaded = null;
throw error;
})).subscribe(data => {
this.disciplineEx.Data = data.map(x =>
({
id: x.id,
name: x.name,
selected: false
}));
this.disciplinesLoaded = true;
this.disciplineApi.getDisciplines().subscribe(data => {
this.disciplineEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
});
}
protected async loadLectureHalls() {
this.campusApi.getCampus().subscribe(campus => {
this.lectureApi.getLectureHalls().subscribe(data => {
this.lectureHallEx.Data = data.map(x => ({
id: x.id,
name: x.name + ` (${campus.find(c => c.id == x.campusId)?.codeName})`
}) as SelectData);
});
}
});
}
protected async loadGroups() {
if (!this.facultiesLoaded)
await this.facultyLoad();
if (!this.groupLoaded) {
this.groupsData = this.api.get<GroupResponse[]>("Group/Get");
this.groupsData.pipe(
switchMap(groups => this.faculties.pipe(
map(campuses => {
return groups.map(x => {
const faculties = campuses.find(c => c.id === x.facultyId);
const name = faculties ? faculties.name : '';
return {
id: x.id,
name: `${x.name} (${name})`,
selected: false
};
});
})
))
).subscribe(data => {
this.groupEx.Data = data;
this.groupLoaded = true;
});
}
this.groupApi.getGroups().subscribe(data => {
this.groupEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
});
}
protected async extensionLoad() {
// Lecture Hall
await this.loadLectureHalls();
// Disciplines
await this.loadDisciplines();
// Groups
await this.loadGroups();
// Professors
if (this.professorsData.length === 0)
this.professorsLoad();
protected async loadProfessors() {
this.professorApi.getProfessors().subscribe(data => {
this.professorEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
});
}
protected async loadLessonType() {
this.lessonTypeApi.getLessonTypes().subscribe(data => {
this.lessonTypeEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
});
}
@ViewChild('groupTab') groupTab!: IScheduleTab;
@ViewChild('professorTab') professorTab!: IScheduleTab;
@ViewChild('lectureHallTab') lectureHallTab!: IScheduleTab;
@ViewChild('discipline') disciplineEx!: OtherComponent;
@ViewChild('lecture') lectureHallEx!: OtherComponent;
@ViewChild('group') groupEx!: OtherComponent;
@ViewChild('professor') professorEx!: OtherComponent;
@ViewChild('lesson_type') lessonTypeEx!: OtherComponent;
onClickNagmi() {
console.log('huy = ' + this.disciplineEx.selectedIds);
console.log('huy2 = ' + this.lectureHallEx.selectedIds);
console.log('huy3 = ' + this.groupEx.selectedIds);
console.log('huy3 = ' + this.professorEx.selectedIds);
@ViewChild('tabGroup') tabs!: MatTabGroup;
protected readonly AuthRoles = AuthRoles;
protected otherFilter() {
const data: ScheduleRequest = ({
groups: this.groupEx.selectedIds,
disciplines: this.disciplineEx.selectedIds,
professors: this.professorEx.selectedIds,
lectureHalls: this.lectureHallEx.selectedIds,
lessonType: this.lessonTypeEx.selectedIds
});
this.eventResult.emit(
[
TabsSelect.Other,
0,
this.scheduleApi.postSchedule(data),
data
]
);
}
}

View File

@ -1,6 +0,0 @@
export const environment = {
apiUrl: 'http://localhost:5269/api/v1/',
production: false,
maxRetry: 30,
retryDelay: 15000
}

View File

@ -0,0 +1,28 @@
import {Directive, HostListener, Input} from '@angular/core';
@Directive({
selector: '[focusNext]',
standalone: true
})
export class FocusNextDirective {
@Input('focusNext') nextElementSelector!: string;
@HostListener('keydown.enter', ['$event'])
handleEnter(event: KeyboardEvent) {
event.preventDefault();
const nextElement = document.getElementById(this.nextElementSelector) as HTMLElement;
if (nextElement) {
nextElement.focus();
if (this.isClickable(nextElement)) {
nextElement.click();
}
}
}
private isClickable(element: HTMLElement): boolean {
const clickableTags = ['BUTTON', 'A', 'INPUT'];
const hasTabIndex = element.hasAttribute('tabindex');
return clickableTags.includes(element.tagName) || hasTabIndex;
}
}

View File

@ -0,0 +1,35 @@
import {Directive, Input, TemplateRef, ViewContainerRef} from '@angular/core';
import AuthApiService from "@api/v1/authApi.service";
import {AuthRoles} from "@model/authRoles";
import {catchError, of} from "rxjs";
@Directive({
selector: '[appHasRole]',
standalone: true,
providers: [AuthApiService]
})
export class HasRoleDirective {
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private authService: AuthApiService
) {
}
@Input() set appHasRole(role: AuthRoles) {
this.viewContainer.clear();
this.authService
.getRole()
.pipe(catchError(_ => {
this.viewContainer.clear();
return of(null);
}))
.subscribe(data => {
if (data === role)
this.viewContainer.createEmbeddedView(this.templateRef);
else
this.viewContainer.clear();
});
}
}

View File

@ -0,0 +1,5 @@
export const environment = {
apiUrl: 'http://localhost:8080/api/',
maxRetry: 5,
retryDelay: 1500
};

View File

@ -0,0 +1,5 @@
export const environment = {
apiUrl: 'https://mirea.winsomnia.net/api/',
maxRetry: 3,
retryDelay: 1500
};

View File

@ -2,14 +2,18 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>Frontend</title>
<title>Расписание МИРЭА</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
<app-root></app-root>
</body>
</html>

View File

@ -1,6 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
import {bootstrapApplication} from '@angular/platform-browser';
import {appConfig} from './app/app.config';
import {AppComponent} from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

@ -0,0 +1,28 @@
mat-sidenav-container {
min-height: 100vh;
}
mat-sidenav {
width: auto;
min-width: 200px;
max-width: 20vw;
}
mat-nav-list a {
display: flex;
align-items: center;
text-decoration: none;
padding: 10px 16px;
border-radius: 4px;
mat-icon {
margin-right: 16px;
font-size: 24px;
vertical-align: middle;
line-height: 1;
}
}
.active-link {
backdrop-filter: contrast(75%);
}

View File

@ -0,0 +1,21 @@
<mat-card *appHasRole="AuthRoles.Admin">
<mat-sidenav-container>
<mat-sidenav mode="side" opened>
<mat-nav-list>
@for (link of navLinks; track $index) {
<a
mat-list-item
[class.active-link]="isActive(link.route)"
(click)="navigate(link.route)"
[disabled]="isActive(link.route)">
<mat-icon>{{ link.icon }}</mat-icon>
<span>{{ link.label }}</span>
</a>
}
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content>
<router-outlet></router-outlet>
</mat-sidenav-content>
</mat-sidenav-container>
</mat-card>

View File

@ -0,0 +1,59 @@
import {Component} from '@angular/core';
import {MatCard} from "@angular/material/card";
import {MatSidenavModule} from "@angular/material/sidenav";
import {HasRoleDirective} from "@/directives/has-role.directive";
import {Router, RouterOutlet} from "@angular/router";
import AuthApiService from "@api/v1/authApi.service";
import {MatListItem, MatNavList} from "@angular/material/list";
import {AuthRoles} from "@model/authRoles";
import {MatIcon} from "@angular/material/icon";
@Component({
selector: 'app-admin',
standalone: true,
imports: [
MatCard,
HasRoleDirective,
MatNavList,
MatSidenavModule,
RouterOutlet,
MatListItem,
MatIcon,
],
templateUrl: './admin.component.html',
styleUrl: './admin.component.css',
providers: [AuthApiService]
})
export class AdminComponent {
navLinks = [
{label: 'Расписание', route: 'schedule', icon: 'calendar_month'},
{label: 'Институт', route: 'institute', icon: 'school'},
{label: 'Аккаунт', route: 'account', icon: 'person'},
{label: 'Сервер', route: 'server', icon: 'settings'},
];
constructor(private auth: AuthApiService, private router: Router) {
this.auth.getRole()
.subscribe(data => {
if (data === null)
router.navigate(['login']).then();
});
}
isActive(route: string): boolean {
return this.router.isActive(`/admin/${route}`, {
paths: 'exact',
queryParams: 'ignored',
fragment: 'ignored',
matrixParams: 'ignored',
});
}
navigate(route: string): void {
if (!this.isActive(route)) {
this.router.navigate([`/admin/${route}`]).then();
}
}
protected readonly AuthRoles = AuthRoles;
}

View File

@ -0,0 +1,22 @@
/* Основной контейнер */
.container {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.container > * {
flex: 1 1 calc(50% - 16px);
min-width: 0;
}
.container > :first-child:nth-last-child(1),
.container > :first-child:nth-last-child(1) ~ * {
flex: 1 1 100%;
}
@media (max-width: 1500px) {
.container > * {
flex: 1 1 100%;
}
}

View File

@ -0,0 +1,10 @@
<div>
<h2 style="margin: 15px;">Конфигурация расписания</h2>
<div class="container">
<app-term-start-date></app-term-start-date>
<app-schedule-file-upload></app-schedule-file-upload>
<app-cron-update-schedule></app-cron-update-schedule>
<app-skip-update-schedule></app-skip-update-schedule>
</div>
</div>

View File

@ -0,0 +1,20 @@
import { Component } from '@angular/core';
import {CronUpdateScheduleComponent} from "@component/admin/cron-update-schedule/cron-update-schedule.component";
import {SkipUpdateScheduleComponent} from "@component/admin/skip-update-schedule/skip-update-schedule.component";
import {TermStartDateComponent} from "@component/admin/term-start-date/term-start-date.component";
import {ScheduleFileUploadComponent} from "@component/admin/schedule-file-upload/schedule-file-upload.component";
@Component({
selector: 'app-schedule-configuration',
imports: [
CronUpdateScheduleComponent,
SkipUpdateScheduleComponent,
TermStartDateComponent,
ScheduleFileUploadComponent
],
templateUrl: './schedule-configuration.component.html',
styleUrl: './schedule-configuration.component.css'
})
export class ScheduleConfigurationComponent {
}

View File

@ -0,0 +1,14 @@
.under-construction {
text-align: center;
margin-top: 50px;
}
.under-construction h1 {
font-size: 2rem;
margin-bottom: 16px;
}
.under-construction p {
font-size: 1.2rem;
color: rgba(255, 255, 255, 0.7);
}

View File

@ -0,0 +1,4 @@
<div class="under-construction">
<h1>Страница находится в разработке</h1>
<p>Пожалуйста, зайдите позже.</p>
</div>

View File

@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-under-construction',
imports: [],
templateUrl: './under-construction.component.html',
styleUrl: './under-construction.component.css'
})
export class UnderConstructionComponent {
}

View File

@ -0,0 +1,26 @@
.formLogin {
display: flex;
padding: 20px;
justify-content: center;
align-items: center;
min-height: 40vh;
height: auto;
}
.formLogin mat-card {
padding: 25px;
}
.formLogin p {
text-align: center
}
.formLogin form {
display: flex;
flex-direction: column;
}
.formLoginButton {
display: flex;
justify-content: flex-end;
}

View File

@ -0,0 +1,69 @@
<mat-sidenav-container class="formLogin">
<mat-card>
<p class="mat-h3">
Вход в систему
</p>
<form [formGroup]="loginForm">
@if (!requiresTwoFactorAuth) {
<mat-form-field color="accent">
<mat-label>Имя пользователя/email</mat-label>
<input matInput
formControlName="user"
matTooltip='Укажите имя пользователя используя латинские буквы и цифры без пробелов или email'
required
focusNext="passwordNextFocus">
@if (loginForm.get('user')?.hasError('required')) {
<mat-error>
Имя пользователя или email является <i>обязательным</i>
</mat-error>
}
@if (loginForm.get('user')?.hasError('minlength')) {
<mat-error>
Количество символов должно быть не менее 4
</mat-error>
}
</mat-form-field>
<password-input [focusNext]="'loginNextFocus'" [formGroup]="loginForm"/>
} @else {
<mat-form-field color="accent">
<mat-label>Код 2FA</mat-label>
<input matInput
formControlName="twoFactorCode"
matTooltip="Введите код из приложения"
required
focusNext="loginNextFocus">
@if (loginForm.get('twoFactorCode')?.hasError('required')) {
<mat-error>
Код 2FA обязателен.
</mat-error>
}
</mat-form-field>
}
</form>
@if (!requiresTwoFactorAuth) {
<OAuthProviders (oAuthLoginResult)="loginOAuth($event)"/>
}
<mat-error>
{{ errorText }}
</mat-error>
<div class="formLoginButton">
@if (loaderActive) {
<app-data-spinner [scale]="40"/>
} @else {
<button mat-flat-button color="accent"
[disabled]="loginButtonIsDisable"
(click)="requiresTwoFactorAuth ? login2Fa() : login()"
id="loginNextFocus">
Войти
</button>
}
</div>
</mat-card>
</mat-sidenav-container>

View File

@ -0,0 +1,132 @@
import {Component} from '@angular/core';
import {MatSidenavContainer} from "@angular/material/sidenav";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {MatButton} from "@angular/material/button";
import {MatCard} from "@angular/material/card";
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {FocusNextDirective} from "@/directives/focus-next.directive";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import AuthApiService from "@api/v1/authApi.service";
import {Router} from "@angular/router";
import {catchError} from "rxjs";
import {TwoFactorAuthentication} from "@model/twoFactorAuthentication";
import {PasswordInputComponent} from "@component/common/password-input/password-input.component";
import {OAuthProviders} from "@component/OAuthProviders/OAuthProviders";
@Component({
selector: 'app-login',
standalone: true,
imports: [
MatSidenavContainer,
MatFormFieldModule,
MatInput,
MatTooltip,
MatButton,
MatCard,
ReactiveFormsModule,
FocusNextDirective,
DataSpinnerComponent,
PasswordInputComponent,
OAuthProviders
],
templateUrl: './login.component.html',
styleUrl: './login.component.css',
providers: [AuthApiService]
})
export class LoginComponent {
protected loginForm!: FormGroup;
protected loaderActive: boolean = false;
protected loginButtonIsDisable: boolean = true;
protected errorText: string = '';
protected requiresTwoFactorAuth: boolean = false;
constructor(private formBuilder: FormBuilder, private auth: AuthApiService, private router: Router) {
this.auth.getRole()
.subscribe(data => {
if (data !== null)
router.navigate(['admin']).then();
});
this.loginForm = this.formBuilder.group({
user: ['',],
password: ['',]
}
);
this.loginForm.get('password')?.setValidators([
Validators.required,
Validators.minLength(8)
]);
this.loginForm.get('user')?.setValidators([
Validators.required,
Validators.minLength(4)
]);
this.loginForm.valueChanges.subscribe(() => {
this.loginButtonIsDisable = !this.loginForm.valid;
});
}
private updateTwoFactorValidation(data: TwoFactorAuthentication) {
if (data === TwoFactorAuthentication.None) {
this.router.navigate(['admin']).then();
return;
}
this.requiresTwoFactorAuth = true;
this.loginForm.addControl(
'twoFactorCode',
new FormControl('', Validators.required)
);
this.loginForm.removeControl('user');
this.loginForm.removeControl('password');
this.loginButtonIsDisable = !this.loginForm.valid;
}
protected login() {
this.loaderActive = true;
this.auth.login({
username: this.loginForm.get('user')?.value,
password: this.loginForm.get('password')?.value
})
.pipe(catchError(error => {
this.loaderActive = false;
this.errorText = error.error.detail;
this.loginButtonIsDisable = true;
throw error;
}))
.subscribe(x => {
this.loaderActive = false;
this.errorText = '';
this.updateTwoFactorValidation(x);
});
}
protected login2Fa() {
this.loaderActive = true;
this.auth.twoFactorAuth({
code: this.loginForm.get('twoFactorCode')?.value,
method: TwoFactorAuthentication.TotpRequired
})
.pipe(catchError(error => {
this.loaderActive = false;
this.errorText = error.error.detail;
this.loginButtonIsDisable = true;
throw error;
}))
.subscribe(_ => {
this.loaderActive = false;
this.errorText = '';
this.router.navigate(['admin']).then();
});
}
protected loginOAuth(result: TwoFactorAuthentication) {
this.updateTwoFactorValidation(result);
}
}

View File

@ -0,0 +1,8 @@
<h1 mat-dialog-title>Подтверждение</h1>
<div mat-dialog-content>
<p>Вы уверены, что хотите запросить Excel с выбранными данными?</p>
</div>
<div mat-dialog-actions>
<button mat-button color="accent" (click)="onConfirm()">Запросить</button>
<button mat-button (click)="onCancel()">Отмена</button>
</div>

View File

@ -0,0 +1,28 @@
import {Component} from '@angular/core';
import {MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle} from '@angular/material/dialog';
import {MatButton} from "@angular/material/button";
@Component({
selector: 'app-confirm-dialog',
templateUrl: './confirm-dialog.component.html',
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatButton
],
standalone: true
})
export class ConfirmDialogComponent {
constructor(public dialogRef: MatDialogRef<ConfirmDialogComponent>) {
}
protected onConfirm(): void {
this.dialogRef.close(true);
}
protected onCancel(): void {
this.dialogRef.close(false);
}
}

View File

@ -0,0 +1,22 @@
.schedule {
padding: 50px 15%;
min-height: 60vh;
overflow: visible;
}
.schedule mat-sidenav-content {
overflow: inherit;
margin-bottom: 15px;
}
@media screen and (max-width: 599px) {
.schedule {
padding: 25px;
}
}
@media screen and (min-width: 600px) and (max-width: 959px) {
.schedule {
padding: 30px 10%;
}
}

View File

@ -0,0 +1,26 @@
<mat-sidenav-container class="schedule">
<mat-sidenav-content>
<app-tabs (eventResult)="result($event)"/>
</mat-sidenav-content>
<mat-sidenav-content>
<app-table-header [startWeek]="startWeek" [currentWeek]="currentWeek" (weekEvent)="handleWeekEvent($event)"
#tableHeader/>
</mat-sidenav-content>
<mat-sidenav-content>
<app-table [currentWeek]="currentWeek" [startWeek]="startWeek" [data]="data" [isLoad]="isLoadTable"
[disciplineWithWeeks]="disciplineWithWeeks"/>
</mat-sidenav-content>
<mat-sidenav-content style="display: flex; justify-content: space-between; align-items: center;">
<mat-checkbox (change)="changeDisciplineWeeksView($event.checked)" [checked]="disciplineWithWeeks">Показать недели в
дисциплине
</mat-checkbox>
@if (excelImportLoader) {
<app-data-spinner/>
} @else {
<button mat-button (click)="openDialog()" *appHasRole="AuthRoles.Admin">Импортировать расписание (.xlsx)</button>
}
</mat-sidenav-content>
</mat-sidenav-container>

View File

@ -0,0 +1,199 @@
import {Component, ViewChild} from '@angular/core';
import {AdditionalText, TableHeaderComponent} from "@component/schedule/table-header/table-header.component";
import {addDays, weekInYear} from "@progress/kendo-date-math";
import {TabsComponent, TabsSelect} from "@component/schedule/tabs/tabs.component";
import {catchError, Observable} from "rxjs";
import {ScheduleService} from "@api/v1/schedule.service";
import {ScheduleResponse} from "@api/v1/scheduleResponse";
import {PairPeriodTime} from "@model/pairPeriodTime";
import {ActivatedRoute} from "@angular/router";
import {TabStorageService} from "@service/tab-storage.service";
import {MatDialog} from "@angular/material/dialog";
import {ConfirmDialogComponent} from "@page/schedule/confirm-dialog.component";
import {AuthRoles} from "@model/authRoles";
import {ImportService} from "@api/v1/import.service";
import {ScheduleRequest} from "@api/v1/scheduleRequest";
import {ToastrService} from "ngx-toastr";
import {MatSidenavModule} from "@angular/material/sidenav";
import {TableComponent} from "@component/schedule/table/table.component";
import {MatCheckbox} from "@angular/material/checkbox";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {MatButton} from "@angular/material/button";
import {HasRoleDirective} from "@/directives/has-role.directive";
@Component({
selector: 'app-schedule',
standalone: true,
imports: [
MatSidenavModule,
TabsComponent,
TableHeaderComponent,
TableComponent,
MatCheckbox,
DataSpinnerComponent,
MatButton,
HasRoleDirective
],
templateUrl: './schedule.component.html',
styleUrl: './schedule.component.css',
providers: [
ScheduleService,
ImportService
]
})
export class ScheduleComponent {
private lastRequest: ScheduleRequest | null = null;
protected startWeek: Date;
protected data: ScheduleResponse[] = [];
protected startTerm: Date;
protected isLoadTable: boolean = false;
protected pairPeriods: PairPeriodTime | null = null;
protected disciplineWithWeeks: boolean = false;
protected excelImportLoader: boolean = false;
@ViewChild('tableHeader') childComponent!: TableHeaderComponent;
constructor(api: ScheduleService,
route: ActivatedRoute,
private importApi: ImportService,
private notify: ToastrService,
public dialog: MatDialog) {
route.queryParams.subscribe(params => {
TabStorageService.selectDataFromQuery(params);
});
this.startTerm = new Date(1, 1, 1);
this.startWeek = new Date(1, 1, 1);
let disciplineWithWeeksStorage = localStorage.getItem('disciplineWithWeeks');
if (disciplineWithWeeksStorage)
this.disciplineWithWeeks = disciplineWithWeeksStorage.toLowerCase() === 'true';
api.pairPeriod().subscribe(date => {
this.pairPeriods = date;
});
api.startTerm().subscribe(date => {
this.startTerm = date.date;
this.calculateCurrentWeek();
});
}
protected result(data: [TabsSelect, number, Observable<ScheduleResponse[]>, ScheduleRequest]) {
this.isLoadTable = true;
this.lastRequest = data[3];
data[2]
.pipe(catchError(error => {
this.data = [];
throw error;
}))
.subscribe(x => {
if (x == undefined || x.length === 0) {
this.isLoadTable = false;
return;
}
this.data = x;
switch (data[0]) {
case TabsSelect.Group:
this.childComponent.AdditionalText(AdditionalText.Group, this.data[0].group);
break;
case TabsSelect.Professor:
let indexProfessor = this.data[0].professorsId.findIndex(p => p === data[1]);
this.childComponent.AdditionalText(AdditionalText.Professor, this.data[0].professors[indexProfessor]);
break;
case TabsSelect.LectureHall:
this.childComponent.AdditionalText(AdditionalText.LectureHall, `${this.data[0].lectureHalls[0]} (${this.data[0].campus[0]})`);
break;
case TabsSelect.Other:
this.childComponent.AdditionalText(AdditionalText.Other, '');
break;
}
this.isLoadTable = false;
});
}
private calculateCurrentWeek() {
let currentDate = new Date();
if (currentDate.getDate() < this.startTerm.getDate())
currentDate = this.startTerm;
function startOfWeek(date: Date) {
return addDays(date, -date.getDay() + 1);
}
this.startWeek = currentDate.getDay() === 0 ? startOfWeek(addDays(currentDate, 1)) : startOfWeek(currentDate);
if (this.startWeek < this.startTerm)
this.startWeek = this.startTerm;
}
protected handleWeekEvent(forward: boolean | null) {
if (forward === null) {
this.calculateCurrentWeek();
} else if (forward) {
this.startWeek = addDays(this.startWeek, 7);
} else {
this.startWeek = addDays(this.startWeek, -7);
}
}
get currentWeek(): number {
const startTermWeek = weekInYear(this.startTerm);
let startWeekNumber;
const startWeek = addDays(this.startWeek, 6);
if (startWeek.getFullYear() > this.startTerm.getFullYear())
startWeekNumber = weekInYear(new Date(this.startTerm.getFullYear(), 11, 29)) + weekInYear(startWeek);
else
startWeekNumber = weekInYear(startWeek);
let result = startWeekNumber - startTermWeek + 1;
if (result <= 0)
result = 1;
return result;
}
protected changeDisciplineWeeksView(checked: boolean) {
localStorage.setItem('disciplineWithWeeks', checked.toString());
this.disciplineWithWeeks = checked;
}
protected openDialog() {
if (this.lastRequest == null) {
this.notify.error("Запрос на импорт невозможен, поскольку данные таблицы не были выбраны", "Ошибка импорта");
return;
}
const dialogRef = this.dialog.open(ConfirmDialogComponent);
dialogRef.afterClosed().subscribe(result => {
if (result && this.lastRequest != null) {
this.excelImportLoader = true;
this.importApi.importToExcel(this.lastRequest).subscribe({
next: (blob: Blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'schedule.xlsx';
a.click();
window.URL.revokeObjectURL(url);
this.excelImportLoader = false;
},
error: _ => {
this.excelImportLoader = false;
this.notify.error("Не удалось импортировать файл Excel");
}
});
}
});
}
protected readonly AuthRoles = AuthRoles;
}

View File

@ -0,0 +1,86 @@
<h1>Настройка кэша</h1>
<hr/>
<p class="mat-body-2 secondary">
На этой странице вы можете выбрать и настроить параметры кэширования для приложения.
</p>
<p class="mat-body-2 secondary">
Укажите тип кэша, например, Redis, и настройте параметры подключения, чтобы улучшить производительность и снизить
нагрузку на базу данных.
</p>
<form [formGroup]="databaseForm">
<p>
Выберите базу данных для хранения кэша:
</p>
<mat-form-field color="accent">
<mat-label>База данных</mat-label>
<mat-select (valueChange)="onDatabaseChange($event)" [value]="database">
<mat-option value="redis">Redis</mat-option>
<mat-option value="memcached">Memcached</mat-option>
</mat-select>
</mat-form-field>
<div style="display:flex; flex-direction: column;">
@if (database && database !== "memcached") {
<hr/>
<mat-form-field color="accent">
<mat-label>Сервер</mat-label>
<input matInput
matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6'
required
formControlName="server"
focusNext="serverNextFocus">
@if (databaseForm.get('server')?.hasError('required')) {
<mat-error>
Сервер является <i>обязательным</i>
</mat-error>
}
@if (databaseForm.get('server')?.hasError('pattern')) {
<mat-error>
Сервер должен содержать доменное имя сервера или ip адрес IPv4 или IPv6
</mat-error>
}
</mat-form-field>
<mat-form-field color="accent">
<mat-label>Порт</mat-label>
<input matInput
matTooltip="Укажите порт сервера"
required
formControlName="port"
id="serverNextFocus"
focusNext="passwordNextFocus">
@if (databaseForm.get('port')?.hasError('required')) {
<mat-error>
Порт является <i>обязательным</i>
</mat-error>
}
@if (databaseForm.get('port')?.hasError('pattern')) {
<mat-error>
Порт должен содержать цифры НЕ начиная с цифры 0 и далее не менее 2 цифр
</mat-error>
}
</mat-form-field>
<mat-form-field color="accent">
<mat-label>Пароль</mat-label>
<input matInput
matTooltip="Укажите пароль"
formControlName="password"
[type]="hidePass ? 'password' : 'text'"
id="passwordNextFocus"
focusNext="nextButtonFocus">
<button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'"
[attr.aria-pressed]="hidePass">
<mat-icon>{{ hidePass ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
</mat-form-field>
}
</div>
</form>

102
src/pages/setup/cache/cache.component.ts vendored Normal file
View File

@ -0,0 +1,102 @@
import {Component} from '@angular/core';
import {NavigationService} from "@service/navigation.service";
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import SetupService from "@api/v1/setup.service";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select";
import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import {of} from "rxjs";
import {CacheType} from "@model/cacheType";
import {FocusNextDirective} from "@/directives/focus-next.directive";
@Component({
selector: 'app-cache',
standalone: true,
imports: [
ReactiveFormsModule,
MatFormFieldModule,
MatSelectModule,
MatInput,
MatTooltip,
MatIconButton,
MatIcon,
FocusNextDirective
],
templateUrl: './cache.component.html'
})
export class CacheComponent {
protected databaseForm!: FormGroup;
protected database = '';
protected hidePass = true;
constructor(private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
this.databaseForm = this.formBuilder.group({
server: ['', Validators.pattern(/^([A-Za-z0-9]+\.)+[A-Za-z]{2,}$|^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$|^([A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}$|^::1$/)],
port: ['', Validators.pattern(/^[1-9][0-9]{2,}$/)],
password: ['']
});
this.navigationService.setNextButtonState(false);
this.databaseForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.databaseForm.valid);
});
this.api.cacheConfiguration().subscribe(response => {
if (!response)
return;
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
this.databaseForm.patchValue({
server: response.server,
port: response.port,
password: response.password,
});
let type: string;
switch (response.type) {
case CacheType.Redis:
type = "redis";
break;
case CacheType.Memcached:
type = "memcached";
break;
}
this.database = type;
this.onDatabaseChange(type);
});
}
onDatabaseChange(selectedDatabase: string) {
this.database = selectedDatabase;
if (selectedDatabase === 'memcached') {
this.navigationService.nextButtonAction = () => {
return this.api.setMemcached();
};
this.navigationService.setNextButtonState(true);
} else {
this.navigationService.nextButtonAction = () => {
return this.api.setRedis({
"server": this.databaseForm.get('server')?.value,
"port": this.databaseForm.get('port')?.value,
"password": this.databaseForm.get('password')?.value
});
};
this.navigationService.setNextButtonState(this.databaseForm.valid);
}
}
protected togglePassword(event: MouseEvent) {
this.hidePass = !this.hidePass;
event.stopPropagation();
}
}

View File

@ -0,0 +1,84 @@
<h1>Создание администратора</h1>
<hr/>
<p class="mat-body-2 secondary">
На этой странице вы можете создать учетную запись администратора.
</p>
<p class="mat-body-2 secondary">
Заполните необходимые поля, такие как имя пользователя, адрес электронной почты и пароль, чтобы создать учетную запись
с правами администратора для управления приложением.
</p>
<form [formGroup]="createAdminForm">
<p>
Ведите данные для создания аккаунта администратора:
</p>
<div style="display:flex; flex-direction: column;">
<mat-form-field color="accent">
<mat-label>Имя пользователя</mat-label>
<input matInput
matTooltip='Укажите имя пользователя используя латинские буквы и цифры без пробелов'
required
formControlName="user">
@if (createAdminForm.get('user')?.hasError('required')) {
<mat-error>
Имя пользователя является <i>обязательным</i>
</mat-error>
}
@if (createAdminForm.get('user')?.hasError('pattern')) {
<mat-error>
Имя пользователя должен содержать латинские символы и цифры и быть не менее 4 символов
</mat-error>
}
</mat-form-field>
<mat-form-field color="accent">
<mat-label>Email</mat-label>
<input matInput
matTooltip="Укажите email администратора"
required
formControlName="email">
@if (createAdminForm.get('email')?.hasError('required')) {
<mat-error>
Email является <i>обязательным</i>
</mat-error>
}
@if (createAdminForm.get('email')?.hasError('email')) {
<mat-error>
Введите корректный Email адрес
</mat-error>
}
</mat-form-field>
<password-input [formGroup]="createAdminForm" [isSetupMode]="true"/>
<mat-form-field color="accent">
<mat-label>Повторите пароль</mat-label>
<input matInput
matTooltip="Укажите пароль, который был указан ранее"
formControlName="retype"
required
[type]="hideRetypePass ? 'password' : 'text'"
onpaste="return false;">
<button mat-icon-button matSuffix (click)="toggleRetypePassword($event)" [attr.aria-label]="'Hide password'"
[attr.aria-pressed]="hideRetypePass">
<mat-icon>{{ hideRetypePass ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
@if (createAdminForm.get('retype')?.hasError('passwordsMismatch')) {
<mat-error>
Пароли не совпадают
</mat-error>
}
</mat-form-field>
<OAuthProviders [canUnlink]="true" [activeProvidersId]="activatedProviders"
(oAuthUpdateProviders)="updateProviders()"
[message]="'Или можете получить часть данных от сторонних сервисов'"
[action]="OAuthAction.Bind" [isSetup]="true"/>
</div>
</form>

Some files were not shown because too many files have changed in this diff Show More