Compare commits

...

172 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
165 changed files with 11067 additions and 6465 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"

10276
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.2",
"@angular/cdk": "~18.0.2",
"@angular/cdk-experimental": "^18.0.2",
"@angular/common": "^18.0.2",
"@angular/compiler": "^18.0.2",
"@angular/core": "^18.0.2",
"@angular/forms": "^18.0.2",
"@angular/material": "~18.0.2",
"@angular/platform-browser": "^18.0.2",
"@angular/platform-browser-dynamic": "^18.0.2",
"@angular/router": "^18.0.2",
"@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.3",
"zone.js": "~0.14.6"
"tslib": "^2.8.1",
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.0.3",
"@angular/cli": "^18.0.3",
"@angular/compiler-cli": "^18.0.2",
"@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;
}
}

View File

@ -1,38 +1,21 @@
import {catchError, mergeMap, Observable, retryWhen, timer} from "rxjs";
import {
BehaviorSubject,
catchError,
distinctUntilChanged,
filter,
first,
Observable,
of,
ReplaySubject,
switchMap
} from "rxjs";
import {HttpClient, HttpErrorResponse} from "@angular/common/http";
import {NotifyColor, OpenNotifyService} from "@service/open-notify.service";
import {environment} from "@/config/environment";
import {environment} from "@environment";
import {Router} from "@angular/router";
import {Injectable} from "@angular/core";
export function retryWithInterval<T>(): (source: Observable<T>) => Observable<T> {
return (source: Observable<T>) =>
source.pipe(
retryWhen((errors: Observable<any>) =>
errors.pipe(
mergeMap((error, index) => {
if (index < (environment.maxRetry < 0 ? Infinity : environment.maxRetry - 1) && !error.status.toString().startsWith('4') && !error.status.toString().startsWith('5')) {
console.log(`Retrying after ${environment.retryDelay}ms...`);
return timer(environment.retryDelay);
} else {
if (error.status.toString().startsWith('4'))
console.error(`Server returned a client code error`);
else
console.error(`Exceeded maximum retries (${environment.maxRetry})`);
throw error;
}
})
)
)
);
}
/*
@Injectable({
providedIn: 'root'
})
*/
import {RequestBuilder, RequestData} from "@api/RequestBuilder";
import {ToastrService} from "ngx-toastr";
import {AuthRoles} from "@model/authRoles";
export enum AvailableVersion {
v1
@ -40,10 +23,13 @@ export enum AvailableVersion {
@Injectable()
export default abstract class ApiService {
constructor(private http: HttpClient, private notify: OpenNotifyService, private router: Router) {
constructor(protected http: HttpClient, protected notify: ToastrService, private router: Router) {
}
private urlApi = environment.apiUrl;
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;
@ -54,10 +40,9 @@ export default abstract class ApiService {
Object.keys(queryParams).forEach(key => {
const value = queryParams[key];
if (value !== null && value !== undefined) {
if (typeof(value) === typeof(Array)) {
if (Array.isArray(value)) {
(value as Array<any>).forEach(x => url.searchParams.append(key, x.toString()));
}
else
} else
url.searchParams.append(key, value.toString());
}
});
@ -67,92 +52,158 @@ export default abstract class ApiService {
}
private static combineUrls(...parts: string[]): string {
let test = parts.map(part => part.replace(/(^\/+|\/+$)/g, '')).join('/');
console.log(test);
return test;
return parts.map(part => (!part || part == '' ? '/' : part).replace(/(^\/+|\/+$)/g, '')).join('/');
}
public get<Type>(endpoint: string = '', queryParams: Record<string, string | number | boolean | Array<any> | null> | null = null): Observable<Type> {
return this.http.get<Type>(ApiService.addQuery(ApiService.combineUrls(this.urlApi, AvailableVersion[this.version], this.basePath, endpoint), queryParams), {withCredentials: true}).pipe(
retryWithInterval<Type>(),
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 => {
this.handleError(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;
})
);
}
public post<Type>(endpoint: string, data: any, queryParams: Record<string, string | number | boolean | Array<any> | null> | null = null): Observable<Type> {
return this.http.post<Type>(ApiService.addQuery(ApiService.combineUrls(this.urlApi, AvailableVersion[this.version], this.basePath, endpoint), queryParams), data, {withCredentials: true}).pipe(
retryWithInterval<Type>(),
catchError(error => {
this.handleError(error);
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);
}
}
public put<Type>(endpoint: string, data: any, queryParams: Record<string, string | number | boolean | Array<any> | null> | null = null): Observable<Type> {
return this.http.put<Type>(ApiService.addQuery(ApiService.combineUrls(this.urlApi, AvailableVersion[this.version], this.basePath, endpoint), queryParams), data, {withCredentials: true}).pipe(
retryWithInterval<Type>(),
catchError(error => {
this.handleError(error);
throw error;
})
);
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 delete<Type>(endpoint: string): Observable<Type> {
return this.http.delete<Type>(ApiService.combineUrls(this.urlApi, AvailableVersion[this.version], this.basePath, endpoint), {withCredentials: true}).pipe(
retryWithInterval<Type>(),
catchError(error => {
this.handleError(error);
throw error;
})
);
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.toString().includes("setup")) {
this.router.navigate(['/setup/']);
if (error.error && error.error.detail && error.error.detail.includes("setup")) {
this.router.navigate(['/setup/']).then();
return;
}
let message: string;
let title: string;
let message: string | undefined = undefined;
if (error.error instanceof ErrorEvent) {
message = `Произошла ошибка: ${error.error.message}`;
title = `Произошла ошибка: ${error.error.message}`;
} else {
switch (error.status) {
case 0:
message = 'Неизвестная ошибка. Пожалуйста, попробуйте позже.';
break;
case 400:
message = 'Ошибка запроса. Пожалуйста, проверьте отправленные данные.';
break;
case 401:
message = 'Ошибка авторизации. Пожалуйста, выполните вход с правильными учетными данными.';
break;
case 403:
message = 'Отказано в доступе. У вас нет разрешения на выполнение этого действия.';
break;
case 404:
message = 'Запрашиваемый ресурс не найден.';
break;
case 500:
message = 'Внутренняя ошибка сервера. Пожалуйста, попробуйте позже.';
break;
case 503:
message = 'Сервер на обслуживании. Пожалуйста, попробуйте позже.';
break;
default:
message = `Сервер вернул код ошибки: ${error.status}`;
break;
}
if (error.error?.Error) {
message += ` ${error.error.Error}`;
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.open(message, NotifyColor.Danger);
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

@ -5,8 +5,8 @@ import {CampusDetailsResponse} from "@api/v1/campusDetailsResponse";
@Injectable()
export class CampusService extends ApiService {
protected basePath = 'Campus/';
protected version = AvailableVersion.v1;
public readonly basePath = 'Campus/';
public readonly version = AvailableVersion.v1;
public getCampus() {
return this.get<CampusBasicInfoResponse[]>();

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

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

View File

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

View File

@ -5,11 +5,15 @@ import {GroupDetailsResponse} from "@api/v1/groupDetailsResponse";
@Injectable()
export class GroupService extends ApiService {
protected basePath = 'Group/';
protected version = AvailableVersion.v1;
public readonly basePath = 'Group/';
public readonly version = AvailableVersion.v1;
public getGroups(page: number | null = null, pageSize: number | null = null) {
return this.get<GroupResponse[]>('', {page: page, pageSize: pageSize});
let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize})
.build;
return this.get<GroupResponse[]>(request);
}
public getById(id: number) {

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

@ -5,8 +5,8 @@ import {LectureHallDetailsResponse} from "@api/v1/lectureHallDetailsResponse";
@Injectable()
export class LectureHallService extends ApiService {
protected basePath = 'LectureHall/';
protected version = AvailableVersion.v1;
public readonly basePath = 'LectureHall/';
public readonly version = AvailableVersion.v1;
public getLectureHalls() {
return this.get<LectureHallResponse[]>();

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

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

View File

@ -1,41 +1,65 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {DateOnly} from "@model/DateOnly";
import {PeriodTimes} from "@model/pairPeriodTime";
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 {
protected basePath = 'Schedule/';
protected version = AvailableVersion.v1;
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<PeriodTimes>('PairPeriod');
return this.get<PairPeriodTime>('PairPeriod');
}
public postSchedule(data: ScheduleRequest) {
return this.post<ScheduleResponse[]>('', data);
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) {
return this.get<ScheduleResponse[]>('GetByGroup/' + id.toString(), {isEven: isEven, disciplines: disciplines, professors: professors, lectureHalls: lectureHalls});
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) {
return this.get<ScheduleResponse[]>('GetByProfessor/' + id.toString(), {isEven: isEven, disciplines: disciplines, groups: groups, lectureHalls: lectureHalls});
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) {
return this.get<ScheduleResponse[]>('GetByLectureHall/' + id.toString(), {isEven: isEven, disciplines: disciplines, groups: groups, professors: professors});
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) {
return this.get<ScheduleResponse[]>('GetByDiscipline/' + id.toString(), {isEven: isEven, groups: groups, professors: professors, lectureHalls: lectureHalls});
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);
}
}

View File

@ -1,60 +1,238 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {DatabaseRequest} from "@api/v1/databaseRequest";
import {CacheRequest} from "@api/v1/cacheRequest";
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/loggingRequest";
import {EmailRequest} from "@api/v1/emailRequest";
import {ScheduleConfigurationRequest} from "@api/v1/scheduleConfigurationRequest";
import {DateOnly} from "@model/DateOnly";
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 {
protected basePath = 'Setup/';
protected version = AvailableVersion.v1;
public readonly basePath = 'Setup/';
public readonly version = AvailableVersion.v1;
public checkToken(token: string) {
return this.get<boolean>('CheckToken', {token: token});
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) {
return this.post<boolean>('SetPsql', data);
let request = this.createRequestBuilder()
.setEndpoint('SetPsql')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public setMysql(data: DatabaseRequest) {
return this.post<boolean>('SetMysql', data);
let request = this.createRequestBuilder()
.setEndpoint('SetMysql')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public setSqlite(path: string | null = null) {
return this.post<boolean>('SetSqlite', null, {path: path});
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) {
return this.post<boolean>('SetRedis', data);
let request = this.createRequestBuilder()
.setEndpoint('SetRedis')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public setMemcached() {
return this.post<boolean>('SetMemcached', null);
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) {
return this.post<boolean>('CreateAdmin', data);
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) {
return this.post<boolean>('SetLogging', data);
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) {
return this.post<boolean>('SetEmail', data);
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();
return this.post<boolean>('SetSchedule', data);
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() {
return this.post<boolean>('Submit', null);
let request = this.createRequestBuilder()
.setEndpoint('Submit')
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public isConfigured() {
return this.get<boolean>('IsConfigured');
}
}

View File

@ -2,13 +2,16 @@ import {Component} from '@angular/core';
import {RouterOutlet} from '@angular/router';
import {FooterComponent} from "@component/common/footer/footer.component";
import localeRu from '@angular/common/locales/ru';
import { registerLocaleData } from '@angular/common';
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/>`
})

View File

@ -1,10 +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(), provideHttpClient()]
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,7 +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,13 +1,11 @@
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'
})

View File

@ -22,8 +22,8 @@
<hr/>
<div class="app-footer-copyright">
<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>
<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

@ -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,14 +1,13 @@
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
],
@ -18,4 +17,9 @@ import {MatButton, MatFabButton} from "@angular/material/button";
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) {
@ -50,10 +51,12 @@
<div class="mat-body-1">{{ elementData["discipline"] }}</div>
<!-- Type of Occupation -->
@for (typeOfOccupation of elementData["typeOfOccupations"]; track $index) {
@if ($index !== 0) {
<br/>
@if ($index === 0 && elementData["typeOfOccupations"][$index - 1] !== typeOfOccupation) {
@if ($index !== 0) {
<br/>
}
<div class="mat-body">({{ typeOfOccupation }})</div>
}
<div class="mat-body">({{typeOfOccupation}})</div>
}
<!-- Professors -->
@ -97,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,7 +3,6 @@ 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 {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {ScheduleResponse} from "@api/v1/scheduleResponse";
@ -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: ScheduleResponse[] = [];
protected isOneGroup: boolean = false;
@Input() currentWeek!: number;
@Input() startWeek!: Date;
@Input() isLoad: boolean = false;
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,21 +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) &&
(
(x.isExcludedWeeks && (!x.weeks || !x.weeks.includes(this.currentWeek))) ||
(!x.isExcludedWeeks && (!x.weeks || x.weeks.includes(this.currentWeek)))
));
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);

View File

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

View File

@ -5,14 +5,18 @@
Факультет
</mat-panel-title>
</mat-expansion-panel-header>
<mat-chip-listbox hideSingleSelectionIndicator (change)="chooseFaculty($event)">
<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)="loadFaculties()"/>
}
@if (faculties === null) {
<app-loading-indicator [loading]="true"
(retryFunction)="loadFaculties()"
#facultyIndicator/>
}
</mat-chip-listbox>
</mat-expansion-panel>
@ -23,14 +27,20 @@
Курс
</mat-panel-title>
</mat-expansion-panel-header>
<mat-chip-listbox hideSingleSelectionIndicator (change)="chooseCourseNumber($event)" [formControl]="formChipCourse">
<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)="loadCourseGroup()"/>
}
@if (courseNumbers === null) {
<app-loading-indicator [loading]="true"
(retryFunction)="loadCourseGroup()"
#courseIndicator/>
}
</mat-chip-listbox>
</mat-expansion-panel>
@ -41,14 +51,70 @@
Группа
</mat-panel-title>
</mat-expansion-panel-header>
<mat-chip-listbox hideSingleSelectionIndicator (change)="chooseGroup($event)" [formControl]="formChipGroup">
@for (group of filteredGroups; track $index) {
<mat-chip-option [value]="group.id" color="accent">
{{ group.name }}
</mat-chip-option>
} @empty {
<app-loading-indicator [loading]="groupsLoaded !== null"
(retryFunction)="loadCourseGroup()"/>
<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,6 +1,6 @@
import {Component, EventEmitter, 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 {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";
@ -8,6 +8,12 @@ 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',
@ -23,122 +29,218 @@ import {GroupService} from "@api/v1/group.service";
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: GroupResponse[] = [];
protected courseNumbers: number[] = [];
private groups: GroupResponse[] = [];
protected formChipCourse: FormControl = new FormControl();
protected formChipGroup: FormControl = new FormControl();
protected faculties: FacultyResponse[] = [];
@ViewChild('courseNumberPanel') courseNumberPanel!: MatExpansionPanel;
@ViewChild('groupPanel') groupPanel!: MatExpansionPanel;
protected facultiesLoaded: boolean | null = false;
protected groupsLoaded: boolean | null = false;
@ViewChild('facultyChip') facultyChip!: MatChipListbox;
@ViewChild('courseChip') courseChip!: MatChipListbox;
@ViewChild('groupChip') groupChip!: MatChipListbox;
@Output() eventResult = new EventEmitter<number>();
@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;
}
private resetGroup() {
this.filteredGroupsBehaviour = [];
this.filteredGroupsMagistracy = [];
this.filteredGroupsSpecialist = [];
this.formChipGroup.reset();
this.groupChip.value = undefined;
}
public eventResult = new EventEmitter<number>();
public selectChangeEvent = new EventEmitter<TabSelect[]>();
constructor(private facultyApi: FacultyService, private groupApi: GroupService) {
this.loadFaculties();
}
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.facultiesLoaded = false;
this.facultyApi.getFaculties()
.pipe(catchError(error => {
this.facultiesLoaded = null;
this.facultyIndicator.loading = false;
throw error;
}))
.subscribe(data => {
this.faculties = data;
this.facultiesLoaded = true;
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);
}
}
});
}
private filteringCourseNumber() {
this.courseNumbers = Array.from(
new Set(
this.groups
.filter(x => x.facultyId === this.facultyId)
.map(x => x.courseNumber)
)
).sort((a, b) => a - b);
}
private filteringGroup() {
this.filteredGroups = this.groups.filter(x => x.facultyId === this.facultyId && x.courseNumber === this.courseNumber);
}
protected loadCourseGroup() {
if (this.groups.length === 0) {
this.groupsLoaded = false;
if (this.facultyId === null)
return;
this.groupApi.getGroups().pipe(
catchError(error => {
this.groupsLoaded = null;
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;
if (this.courseNumber === null)
this.filteringCourseNumber();
else
this.filteringGroup();
}))
.subscribe(data => {
this.groups = data;
this.courseNumbers = Array.from(
new Set(
this.groups!
.map(x => x.courseNumber)
.sort((a, b) => a - b))
);
this.groupsLoaded = true;
});
let selected = TabStorageService.selected?.selected[this.getEnclosureList()[Enclosure.course]];
if (selected) {
let selectedCourse = this.courseNumbers.find(x => x === selected.index);
return
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)
this.filteringCourseNumber();
else
this.filteringGroup();
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 chooseFaculty(event: MatChipListboxChange) {
this.courseNumber = null;
this.groups = [];
this.formChipGroup.reset();
this.formChipCourse.reset();
protected onFacultySelected(index: number, loadMode: boolean = false) {
this.resetCourse();
this.resetGroup();
if (event.value === undefined || event.value === null) {
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.loadCourseGroup();
}
protected chooseCourseNumber(event: MatChipListboxChange) {
this.filteredGroups = [];
this.formChipGroup.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.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.eventResult.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)">
<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)="loadCampuses()"/>
}
@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">
<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)="loadLectureHalls()"/>
}
@if (lectureHallsFiltered === null) {
<app-loading-indicator [loading]="true" (retryFunction)="loadLectureHalls()" #lectureIndicator/>
}
</mat-chip-listbox>
</mat-expansion-panel>

View File

@ -1,14 +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 {catchError, Observable, of} from "rxjs";
import {MatChipListbox, MatChipsModule} from "@angular/material/chips";
import {catchError} from "rxjs";
import {FormControl, ReactiveFormsModule} from "@angular/forms";
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',
@ -16,7 +21,6 @@ import {LectureHallService} from "@api/v1/lectureHall.service";
imports: [
MatChipsModule,
MatExpansionModule,
AsyncPipe,
ReactiveFormsModule,
MatAccordion,
LoadingIndicatorComponent
@ -25,80 +29,141 @@ import {LectureHallService} from "@api/v1/lectureHall.service";
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;
protected campuses: CampusBasicInfoResponse[] = [];
protected campusesLoaded: boolean | null = false;
@ViewChild('campusChip') campusChip!: MatChipListbox;
@ViewChild('lectureChip') lectureChip!: MatChipListbox;
private lectureHalls: LectureHallResponse[] = [];
protected lectureHallsFiltered: LectureHallResponse[] = [];
protected lectureHallsLoaded: boolean | null = false;
private lectureHalls: LectureHallResponse[] | null = null;
@Output() eventResult = new EventEmitter<number>();
public eventResult = new EventEmitter<number>();
public selectChangeEvent = new EventEmitter<TabSelect[]>();
constructor(private campusApi: CampusService, private lectureHallApi: LectureHallService) {
this.loadCampuses();
}
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.campusesLoaded = false;
this.campusApi.getCampus()
.pipe(catchError(error => {
this.campusesLoaded = null;
this.campusIndicator.loading = false;
throw error;
}))
.subscribe(data => {
this.campuses = data;
this.campusesLoaded = true;
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);
this.lectureHallsFiltered = this.lectureHalls?.filter(x => x.campusId === this.campusId) ?? null;
}
protected chooseCampus(event: MatChipListboxChange) {
this.chipLecture.reset();
protected onCampusSelected(index: number, loadMode: boolean = false) {
this.formLectureHalls.reset();
this.lectureChip.value = undefined;
if (event.value === undefined || event.value === null) {
if (loadMode)
this.campusChip.value = index;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
if (index === undefined) {
this.campusId = null;
this.lectureHalls = [];
return;
}
this.campusId = event.value;
this.campusId = index;
this.lecturePanel.open();
if (this.lectureHalls.length === 0)
if (this.lectureHalls === null)
this.loadLectureHalls();
else
this.filteringLectureHalls();
}
protected loadLectureHalls() {
this.lectureHallsLoaded = false;
this.lectureHallApi.getLectureHalls()
.pipe(catchError(error => {
this.lectureHallsLoaded = null;
this.lectureIndicator.loading = false;
throw error;
}))
.subscribe(data => {
this.lectureHalls = data;
this.filteringLectureHalls();
this.lectureHallsLoaded = true;
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 chooseLectureHall(event: MatChipListboxChange) {
if (event.value === undefined || event.value === null)
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.eventResult.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

@ -47,17 +47,19 @@ export interface SelectData {
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);
}
@ -72,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;
}
@ -82,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())
));
@ -92,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 => {
@ -109,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};
});
@ -118,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)="loadProfessors()"/>
@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,12 +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 {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',
@ -23,31 +25,44 @@ import {ProfessorService} from "@api/v1/professor.service";
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[]>;
protected professors: ProfessorResponse[] = [];
protected professorsLoaded: boolean | null = false;
protected professors: ProfessorResponse[] | null = null;
@Output() eventResult = new EventEmitter<number>();
@ViewChild('professorIndicator') professorIndicator!: LoadingIndicatorComponent;
public eventResult = new EventEmitter<number>();
public selectChangeEvent = new EventEmitter<TabSelect[]>();
constructor(private api: ProfessorService) {
this.loadProfessors();
}
getEnclosureList(): string[] {
return ['professor'];
}
protected loadProfessors() {
if (this.professors.length === 0) {
this.professorsLoaded = false;
if (this.professors === null || this.professors.length === 0) {
this.api.getProfessors()
.pipe(catchError(error => {
this.professorsLoaded = null;
this.professorIndicator.loading = false;
throw error;
}))
.subscribe(data => {
this.professors = data;
this.professorsLoaded = true;
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);
}
});
}
}
@ -61,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.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,32 @@
<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 (eventResult)="groupSelected($event)"/>
<app-group #groupTab/>
</div>
</mat-tab>
<mat-tab label="Преподаватель">
<div>
<app-professor (eventResult)="professorSelected($event)"/>
<app-professor #professorTab/>
</div>
</mat-tab>
<mat-tab label="Кабинет">
<div>
<app-lecture-hall (eventResult)="lectureHallSelected($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>Отфильтровать</button>
<button mat-flat-button (click)="otherFilter()">Отфильтровать</button>
</section>
</div>
</mat-tab>
-->
</mat-tab-group>

View File

@ -1,15 +1,25 @@
import {Component, EventEmitter, Output} from '@angular/core';
import {OtherComponent} from "@component/schedule/tabs/other/other.component";
import {MatTab, MatTabChangeEvent, MatTabGroup} from "@angular/material/tabs";
import {map, Observable} from "rxjs";
import {ReactiveFormsModule} from "@angular/forms";
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,
@ -27,133 +37,207 @@ export enum TabsSelect {
MatTab,
ReactiveFormsModule,
MatButton,
DataSpinnerComponent,
GroupComponent,
ProfessorComponent,
LectureHallComponent
LectureHallComponent,
FormsModule,
HasRoleDirective
],
templateUrl: './tabs.component.html',
styleUrl: './tabs.component.css',
providers: [ScheduleService]
providers: [
ScheduleService,
DisciplineService,
LectureHallService,
GroupService,
ProfessorService,
TabStorageService,
CampusService,
LessonTypeService]
})
export class TabsComponent {
@Output() eventResult = new EventEmitter<[TabsSelect, number, Observable<ScheduleResponse[]>]>();
export class TabsComponent implements AfterViewInit {
@Output() eventResult = new EventEmitter<[TabsSelect, number, Observable<ScheduleResponse[]>, ScheduleRequest]>();
private currentTab: number = -1;
constructor(private scheduleApi: ScheduleService) {
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 groupSelected(id: number) {
this.eventResult.emit(
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,
id,
this.scheduleApi.getByGroup(id)
.pipe(
map(g =>
g.map(data =>
({
dayOfWeek: data.dayOfWeek,
pairNumber: data.pairNumber,
isEven: data.isEven,
discipline: data.discipline,
disciplineId: data.disciplineId,
isExcludedWeeks: data.isExcludedWeeks,
weeks: data.weeks,
typeOfOccupations: data.typeOfOccupations,
group: data.group,
groupId: data.groupId,
lectureHalls: data.lectureHalls,
lectureHallsId: data.lectureHallsId,
professors: data.professors,
professorsId: data.professorsId,
campus: data.campus,
campusId: data.campusId,
linkToMeet: data.linkToMeet
}))
)
)
event,
this.scheduleApi.getByGroup(event),
{groups: [event]}
]
);
}
));
protected professorSelected(id: number) {
this.eventResult.emit(
this.professorTab.eventResult.subscribe(event => this.eventResult.emit(
[
TabsSelect.Professor,
id,
this.scheduleApi.getByProfessor(id)
.pipe(
map(p =>
p.map(data =>
({
dayOfWeek: data.dayOfWeek,
pairNumber: data.pairNumber,
isEven: data.isEven,
discipline: data.discipline,
disciplineId: data.disciplineId,
isExcludedWeeks: data.isExcludedWeeks,
weeks: data.weeks,
typeOfOccupations: data.typeOfOccupations,
group: data.group,
groupId: data.groupId,
lectureHalls: data.lectureHalls,
lectureHallsId: data.lectureHallsId,
professors: data.professors,
professorsId: data.professorsId,
campus: data.campus,
campusId: data.campusId,
linkToMeet: data.linkToMeet
}))
)
)
event,
this.scheduleApi.getByProfessor(event),
{professors: [event]}
]
);
}
));
protected lectureHallSelected(id: number) {
this.eventResult.emit(
this.lectureHallTab.eventResult.subscribe(event => this.eventResult.emit(
[
TabsSelect.LectureHall,
id,
this.scheduleApi.getByLectureHall(id)
.pipe(
map(lh =>
lh.map(data =>
({
dayOfWeek: data.dayOfWeek,
pairNumber: data.pairNumber,
isEven: data.isEven,
discipline: data.discipline,
disciplineId: data.disciplineId,
isExcludedWeeks: data.isExcludedWeeks,
weeks: data.weeks,
typeOfOccupations: data.typeOfOccupations,
group: data.group,
groupId: data.groupId,
lectureHalls: data.lectureHalls,
lectureHallsId: data.lectureHallsId,
professors: data.professors,
professorsId: data.professorsId,
campus: data.campus,
campusId: data.campusId,
linkToMeet: data.linkToMeet
}))
)
)
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(event: MatTabChangeEvent) {
switch (event.index) {
protected async chooseTabs(index: number) {
let needGetEnclosure = false;
if (this.currentTab !== index) {
this.currentTab = index;
needGetEnclosure = true;
}
switch (index) {
case 0:
this.groupTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.groupTab.getEnclosureList();
break;
case 1:
this.professorTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.professorTab.getEnclosureList();
break;
case 2:
this.lectureHallTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.lectureHallTab.getEnclosureList();
break;
case 3:
await this.loadDisciplines();
await this.loadLectureHalls();
await this.loadGroups();
await this.loadProfessors();
await this.loadLessonType();
break;
default:
await this.chooseTabs(0);
break;
}
}
/*
protected async loadDisciplines() {
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() {
this.groupApi.getGroups().subscribe(data => {
this.groupEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
});
}
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;
@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

@ -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

@ -1,6 +1,5 @@
export const environment = {
apiUrl: 'http://localhost:5269/api/',
production: false,
apiUrl: 'https://mirea.winsomnia.net/api/',
maxRetry: 3,
retryDelay: 1500
}
};

View File

@ -6,10 +6,14 @@
<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

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

View File

@ -1,5 +1,26 @@
<mat-sidenav-container class="schedule">
<app-tabs (eventResult)="result($event)"/>
<app-table-header [startWeek]="startWeek" [currentWeek]="currentWeek" (weekEvent)="handleWeekEvent($event)" #tableHeader/>
<app-table [currentWeek]="currentWeek" [startWeek]="startWeek" [data]="data" [isLoad]="isLoadTable"/>
<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

@ -1,68 +1,101 @@
import {Component, LOCALE_ID, ViewChild} from '@angular/core';
import {TableComponent} from "@component/schedule/table/table.component";
import {MatFormField, MatInput} from "@angular/material/input";
import {MatButton} from "@angular/material/button";
import {FormsModule} from "@angular/forms";
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 {MatCard} from "@angular/material/card";
import {MatSidenavContainer} from "@angular/material/sidenav";
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 {PeriodTimes} from "@model/pairPeriodTime";
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: [
TableComponent,
MatInput,
MatFormField,
MatButton,
FormsModule,
MatSidenavModule,
TabsComponent,
TableHeaderComponent,
MatCard,
MatSidenavContainer,
TabsComponent
TableComponent,
MatCheckbox,
DataSpinnerComponent,
MatButton,
HasRoleDirective
],
templateUrl: './schedule.component.html',
styleUrl: './schedule.component.css',
providers: [
ScheduleService,
{provide: LOCALE_ID, useValue: 'ru-RU'}
ImportService
]
})
export class ScheduleComponent {
protected startWeek!: Date;
private lastRequest: ScheduleRequest | null = null;
protected startWeek: Date;
protected data: ScheduleResponse[] = [];
protected startTerm: Date;
protected isLoadTable: boolean = false;
protected pairPeriods: PeriodTimes = {};
protected pairPeriods: PairPeriodTime | null = null;
protected disciplineWithWeeks: boolean = false;
protected excelImportLoader: boolean = false;
@ViewChild('tableHeader') childComponent!: TableHeaderComponent;
constructor(private api: ScheduleService) {
this.calculateCurrentWeek();
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[]>]) {
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:
@ -70,8 +103,6 @@ export class ScheduleComponent {
break;
case TabsSelect.Professor:
let indexProfessor = this.data[0].professorsId.findIndex(p => p === data[1]);
console.log(indexProfessor);
console.log(data[1]);
this.childComponent.AdditionalText(AdditionalText.Professor, this.data[0].professors[indexProfessor]);
break;
case TabsSelect.LectureHall:
@ -89,6 +120,9 @@ export class ScheduleComponent {
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);
}
@ -99,10 +133,10 @@ export class ScheduleComponent {
this.startWeek = this.startTerm;
}
protected handleWeekEvent(eventData: boolean | null) {
if (eventData === null) {
protected handleWeekEvent(forward: boolean | null) {
if (forward === null) {
this.calculateCurrentWeek();
} else if (eventData) {
} else if (forward) {
this.startWeek = addDays(this.startWeek, 7);
} else {
this.startWeek = addDays(this.startWeek, -7);
@ -110,6 +144,56 @@ export class ScheduleComponent {
}
get currentWeek(): number {
return (weekInYear(this.startWeek) - weekInYear(this.startTerm)) + 1;
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>

View File

@ -0,0 +1,99 @@
import {Component} from '@angular/core';
import {Location} from '@angular/common';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {NavigationService} from "@service/navigation.service";
import {passwordMatchValidator} from '@service/password-match.validator';
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 AuthApiService from "@api/v1/authApi.service";
import {OAuthProviders} from "@component/OAuthProviders/OAuthProviders";
import {OAuthProvider} from "@model/oAuthProvider";
import {PasswordInputComponent} from "@component/common/password-input/password-input.component";
import {OAuthAction} from "@model/oAuthAction";
import {Router} from "@angular/router";
@Component({
selector: 'app-create-admin',
standalone: true,
imports: [
ReactiveFormsModule,
MatFormFieldModule,
MatSelectModule,
MatInput,
MatTooltip,
MatIconButton,
MatIcon,
OAuthProviders,
PasswordInputComponent
],
templateUrl: './create-admin.component.html',
providers: [AuthApiService, Location]
})
export class CreateAdminComponent {
protected createAdminForm!: FormGroup;
protected hideRetypePass = true;
protected activatedProviders: OAuthProvider[] = [];
constructor(private router: Router,
private location: Location,
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
this.createAdminForm = this.formBuilder.group({
user: ['', Validators.pattern(/^([A-Za-z0-9]){4,}$/)],
email: ['', Validators.email],
password: ['', Validators.required],
retype: ['', Validators.required]
},
{validators: passwordMatchValidator('password', 'retype')}
);
this.navigationService.setNextButtonState(false);
this.createAdminForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.createAdminForm.valid);
});
this.navigationService.nextButtonAction = () => {
return this.api.createAdmin({
"email": this.createAdminForm.get('email')?.value,
"username": this.createAdminForm.get('user')?.value,
"password": this.createAdminForm.get('password')?.value
}
);
};
this.updateAdminData();
}
private updateAdminData() {
this.api.adminConfiguration().subscribe(configuration => {
if (configuration) {
if (this.createAdminForm.get('email')?.value == 0)
this.createAdminForm.get('email')?.setValue(configuration.email);
if (this.createAdminForm.get('user')?.value == 0)
this.createAdminForm.get('user')?.setValue(configuration.username);
this.activatedProviders = configuration.usedOAuthProviders;
}
const currentPath = this.router.url.split('?')[0];
this.location.replaceState(currentPath);
});
}
protected toggleRetypePassword(event: MouseEvent) {
this.hideRetypePass = !this.hideRetypePass;
event.stopPropagation();
}
protected updateProviders() {
this.updateAdminData();
}
protected readonly OAuthAction = OAuthAction;
}

View File

@ -0,0 +1,165 @@
<h1>Настройка базы данных</h1>
<hr/>
<p class="mat-body-2 secondary">
На этой странице вы можете выбрать и настроить параметры подключения к базе данных.
</p>
<p class="mat-body-2 secondary">
Укажите необходимую информацию, чтобы обеспечить правильное функционирование приложения.
</p>
<p class="mat-headline-6" style="color: red;font-weight: lighter;">
Данные настройки нельзя будет изменить в будущем!
</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="mysql">MySQL</mat-option>
<mat-option value="psql">PostgreSQL</mat-option>
<mat-option value="sqlite">Sqlite</mat-option>
</mat-select>
</mat-form-field>
@if (database) {
<hr/>
}
<div style="display:flex; flex-direction: column;">
@if (database === "sqlite") {
<mat-form-field color="accent">
<mat-label>Папка</mat-label>
<input matInput
matTooltip="Укажите папку, в которой будет находиться база данных"
formControlName="folder"
value="database"
required>
@if (databaseForm.get('folder')?.hasError('required')) {
<mat-error>
Название папки является <i>обязательным</i>
</mat-error>
}
@if (databaseForm.get('folder')?.hasError('pattern')) {
<mat-error>
Название не может быть меньше 2 символов
</mat-error>
}
</mat-form-field>
} @else if (database) {
<mat-form-field color="accent">
<mat-label>Сервер</mat-label>
<input matInput
matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6'
required
formControlName="server"
focusNext="portNextFocus">
@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="portNextFocus"
focusNext="databaseNextFocus">
@if (databaseForm.get('port')?.hasError('required')) {
<mat-error>
Порт является <i>обязательным</i>
</mat-error>
}
@if (databaseForm.get('port')?.hasError('pattern')) {
<mat-error>
Порт должен содержать цифры начиная НЕ с 0 и далее не менее 2 и не более 5 цифр
</mat-error>
}
</mat-form-field>
<mat-form-field color="accent">
<mat-label>Название базы данных</mat-label>
<input matInput
matTooltip="Укажите название базы данных"
required
formControlName="database_name"
id="databaseNextFocus"
focusNext="userNextFocus">
@if (databaseForm.get('database_name')?.hasError('required')) {
<mat-error>
Название базы данных является <i>обязательным</i>
</mat-error>
}
@if (databaseForm.get('database_name')?.hasError('pattern')) {
<mat-error>
Название не может быть меньше 2 символов
</mat-error>
}
</mat-form-field>
<mat-form-field color="accent">
<mat-label>Пользователь</mat-label>
<input matInput
matTooltip="Укажите пользователя, который имеет доступ к базе данных"
required
formControlName="user"
id="userNextFocus"
focusNext="passwordNextFocus">
@if (databaseForm.get('user')?.hasError('required')) {
<mat-error>
Имя пользователя базы данных является <i>обязательным</i>
</mat-error>
}
@if (databaseForm.get('user')?.hasError('pattern')) {
<mat-error>
Имя не может быть меньше 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>
<mat-checkbox
matTooltip="Использовать SSL/TLS для подключения к базе данных"
formControlName="ssl">
Использовать SSL/TLS
</mat-checkbox>
}
</div>
</form>

View File

@ -0,0 +1,148 @@
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 {MatCheckbox} from "@angular/material/checkbox";
import {DatabaseRequest} from "@api/v1/configuration/databaseRequest";
import {of} from "rxjs";
import {DatabaseType} from "@model/databaseType";
import {FocusNextDirective} from "@/directives/focus-next.directive";
@Component({
selector: 'app-database',
standalone: true,
imports: [
ReactiveFormsModule,
MatFormFieldModule,
MatSelectModule,
MatInput,
MatTooltip,
MatIconButton,
MatIcon,
MatCheckbox,
FocusNextDirective
],
templateUrl: './database.component.html'
})
export class DatabaseComponent {
protected databaseForm!: FormGroup;
protected database = '';
protected hidePass = true;
constructor(
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
this.databaseForm = this.formBuilder.group({
folder: ['', Validators.pattern(/^.{2,}$/)],
server: ['', Validators.pattern(/^localhost$|^([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]{1,4}$/)],
database_name: ['', Validators.pattern(/^.{2,}$/)],
user: ['', Validators.pattern(/^.{2,}$/)],
password: [''],
ssl: ['']
});
this.databaseForm.get('ssl')?.setValue(false);
this.navigationService.setNextButtonState(false);
this.databaseForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.databaseForm.valid);
});
this.api.databaseConfiguration().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,
database_name: response.database,
user: response.user,
ssl: response.ssl,
password: response.password,
folder: response.pathToDatabase
});
let type: string;
switch (response.type) {
case DatabaseType.Mysql:
type = "mysql";
break;
case DatabaseType.PostgresSql:
type = "psql";
break;
case DatabaseType.Sqlite:
type = "sqlite";
break;
}
this.database = type;
this.onDatabaseChange(type);
});
}
private createForm(database: string) {
if (database === 'sqlite') {
this.disableControls(['server', 'port', 'database_name', 'user', 'password']);
this.enableControls(['folder']);
} else {
this.enableControls(['server', 'port', 'database_name', 'user', 'password']);
this.disableControls(['folder']);
}
}
private disableControls(controls: string[]) {
controls.forEach(control => {
this.databaseForm.get(control)!.disable({emitEvent: false});
this.databaseForm.get(control)!.updateValueAndValidity({emitEvent: false});
});
}
private enableControls(controls: string[]) {
controls.forEach(control => {
this.databaseForm.get(control)!.enable({emitEvent: false});
this.databaseForm.get(control)!.updateValueAndValidity({emitEvent: false});
});
}
protected onDatabaseChange(selectedDatabase: string) {
this.createForm(selectedDatabase);
this.database = selectedDatabase;
if (this.database === "sqlite") {
this.navigationService.nextButtonAction = () => {
return this.api.setSqlite(this.databaseForm.get('folder')?.value ?? '');
};
} else {
this.navigationService.nextButtonAction = () => {
let databaseRequest: DatabaseRequest = {
"server": this.databaseForm.get('server')?.value,
"port": this.databaseForm.get('port')?.value,
"database": this.databaseForm.get('database_name')?.value,
"user": this.databaseForm.get('user')?.value,
"ssl": this.databaseForm.get('ssl')?.value,
"password": this.databaseForm.get('password')?.value,
};
if (this.database === "mysql")
return this.api.setMysql(databaseRequest);
else
return this.api.setPsql(databaseRequest);
};
}
}
protected togglePassword(event: MouseEvent) {
this.hidePass = !this.hidePass;
event.stopPropagation();
}
}

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