Compare commits

...

153 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
157 changed files with 9945 additions and 6618 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).

10204
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,102 +1,54 @@
import {HttpHeaders} from "@angular/common/http"; import {HttpHeaders} from "@angular/common/http";
export interface SetRequestBuilderAfterBuild {
setRequestBuilder(request: RequestData): void;
}
export interface RequestData { export interface RequestData {
endpoint: string; endpoint: string;
queryParams: Record<string, string | number | boolean | Array<any> | null> | null; queryParams: Record<string, string | number | boolean | Array<any> | null> | null;
httpHeaders: HttpHeaders; httpHeaders: HttpHeaders;
data: any; data: any;
silenceMode: boolean; silenceMode: boolean;
withCredentials: boolean;
needAuth: boolean;
} }
export class RequestBuilder { export class RequestBuilder {
private endpoint: string = ''; private result: RequestData = Object.create({});
private queryParams: Record<string, string | number | boolean | Array<any> | null> | null = null;
private httpHeaders: HttpHeaders = new HttpHeaders();
private data: any = null;
private silenceMode: boolean = false;
private readonly object: any;
constructor(obj: any) { constructor() {
this.object = obj;
} }
public setEndpoint(endpoint: string): this { public setEndpoint(endpoint: string): this {
this.endpoint = endpoint; this.result.endpoint = endpoint;
return this; return this;
} }
public setQueryParams(queryParams: Record<string, string | number | boolean | Array<any> | null>): RequestBuilder { public setQueryParams(queryParams: Record<string, string | number | boolean | Array<any> | null>): RequestBuilder {
this.queryParams = queryParams; this.result.queryParams = queryParams;
return this; return this;
} }
public addHeaders(headers: Record<string, string>): RequestBuilder { public addHeaders(headers: Record<string, string>): RequestBuilder {
Object.keys(headers).forEach(key => { Object.keys(headers).forEach(key => {
this.httpHeaders = this.httpHeaders.set(key, headers[key]); this.result.httpHeaders = this.result.httpHeaders.set(key, headers[key]);
}); });
return this; return this;
} }
public setData(data: any): RequestBuilder { public setData(data: any): RequestBuilder {
this.data = data; this.result.data = data;
return this; return this;
} }
public setSilenceMode(silence: boolean): RequestBuilder { public setSilenceMode(silence: boolean = true): RequestBuilder {
this.silenceMode = silence; this.result.silenceMode = silence;
return this; return this;
} }
public build<Type>(): Type { public setWithCredentials(credentials: boolean = true): RequestBuilder {
(this.object as SetRequestBuilderAfterBuild).setRequestBuilder({ this.result.withCredentials = credentials;
endpoint: this.endpoint, return this;
queryParams: this.queryParams,
httpHeaders: this.httpHeaders,
data: this.data,
silenceMode: this.silenceMode
});
return this.object as Type;
} }
public getEndpoint(): string { public get build(): RequestData {
return this.endpoint; return this.result;
}
public getQueryParams(): Record<string, string | number | boolean | Array<any> | null> | null {
return this.queryParams;
}
public getHttpHeaders(): HttpHeaders {
return this.httpHeaders;
}
public getData(): any {
return this.data;
}
public getSilenceMode(): boolean {
return this.silenceMode;
}
public static getStandardRequestData(): RequestData {
return {
endpoint: '',
queryParams: null,
httpHeaders: new HttpHeaders(),
data: null,
silenceMode: false
}
}
public reset(): void {
this.endpoint = '';
this.queryParams = null;
this.httpHeaders = new HttpHeaders();
this.data = null;
this.silenceMode = false;
} }
} }

View File

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

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

@ -8,10 +8,11 @@ export class DisciplineService extends ApiService {
public readonly version = AvailableVersion.v1; public readonly version = AvailableVersion.v1;
public getDisciplines(page: number | null = null, pageSize: number | null = null) { public getDisciplines(page: number | null = null, pageSize: number | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize}) .setQueryParams({page: page, pageSize: pageSize})
.build<ApiService>() .build;
.get<DisciplineResponse[]>();
return this.get<DisciplineResponse[]>(request);
} }
public getById(id: number) { public getById(id: number) {

View File

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

View File

@ -9,10 +9,11 @@ export class GroupService extends ApiService {
public readonly version = AvailableVersion.v1; public readonly version = AvailableVersion.v1;
public getGroups(page: number | null = null, pageSize: number | null = null) { public getGroups(page: number | null = null, pageSize: number | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize}) .setQueryParams({page: page, pageSize: pageSize})
.build<ApiService>() .build;
.get<GroupResponse[]>();
return this.get<GroupResponse[]>(request);
} }
public getById(id: number) { 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

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

@ -8,10 +8,11 @@ export class ProfessorService extends ApiService {
public readonly version = AvailableVersion.v1; public readonly version = AvailableVersion.v1;
public getProfessors(page: number | null = null, pageSize: number | null = null) { public getProfessors(page: number | null = null, pageSize: number | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize}) .setQueryParams({page: page, pageSize: pageSize})
.build<ApiService>() .build;
.get<ProfessorResponse[]>();
return this.get<ProfessorResponse[]>(request);
} }
public getById(id: number) { public getById(id: number) {

View File

@ -1,7 +1,7 @@
import {Injectable} from "@angular/core"; import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service"; import ApiService, {AvailableVersion} from "@api/api.service";
import {DateOnly} from "@model/DateOnly"; import {DateOnly} from "@model/dateOnly";
import {PeriodTimes} from "@model/pairPeriodTime"; import {PairPeriodTime} from "@model/pairPeriodTime";
import {ScheduleRequest} from "@api/v1/scheduleRequest"; import {ScheduleRequest} from "@api/v1/scheduleRequest";
import {ScheduleResponse} from "@api/v1/scheduleResponse"; import {ScheduleResponse} from "@api/v1/scheduleResponse";
import {map} from "rxjs"; import {map} from "rxjs";
@ -16,45 +16,50 @@ export class ScheduleService extends ApiService {
} }
public pairPeriod() { public pairPeriod() {
return this.get<PeriodTimes>('PairPeriod'); return this.get<PairPeriodTime>('PairPeriod');
} }
public postSchedule(data: ScheduleRequest) { public postSchedule(data: ScheduleRequest) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setData(data) .setData(data)
.build<ApiService>() .build;
.post<ScheduleResponse[]>();
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) { 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.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('GetByGroup/' + id.toString()) .setEndpoint('GetByGroup/' + id.toString())
.setQueryParams({isEven: isEven, disciplines: disciplines, professors: professors, lectureHalls: lectureHalls}) .setQueryParams({isEven: isEven, disciplines: disciplines, professors: professors, lectureHalls: lectureHalls})
.build<ApiService>() .build;
.get<ScheduleResponse[]>();
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) { 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.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('GetByProfessor/' + id.toString()) .setEndpoint('GetByProfessor/' + id.toString())
.setQueryParams({isEven: isEven, disciplines: disciplines, groups: groups, lectureHalls: lectureHalls}) .setQueryParams({isEven: isEven, disciplines: disciplines, groups: groups, lectureHalls: lectureHalls})
.build<ApiService>() .build;
.get<ScheduleResponse[]>();
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) { 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.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('GetByLectureHall/' + id.toString()) .setEndpoint('GetByLectureHall/' + id.toString())
.setQueryParams({isEven: isEven, disciplines: disciplines, groups: groups, professors: professors}) .setQueryParams({isEven: isEven, disciplines: disciplines, groups: groups, professors: professors})
.build<ApiService>() .build;
.get<ScheduleResponse[]>();
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) { 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.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('GetByDiscipline/' + id.toString()) .setEndpoint('GetByDiscipline/' + id.toString())
.setQueryParams({isEven: isEven, groups: groups, professors: professors, lectureHalls: lectureHalls}) .setQueryParams({isEven: isEven, groups: groups, professors: professors, lectureHalls: lectureHalls})
.build<ApiService>() .build;
.get<ScheduleResponse[]>();
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,12 +1,17 @@
import {Injectable} from "@angular/core"; import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service"; import ApiService, {AvailableVersion} from "@api/api.service";
import {DatabaseRequest} from "@api/v1/databaseRequest"; import {catchError, of} from "rxjs";
import {CacheRequest} from "@api/v1/cacheRequest"; 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 {CreateUserRequest} from "@api/v1/createUserRequest";
import {LoggingRequest} from "@api/v1/loggingRequest"; import {LoggingRequest} from "@api/v1/configuration/loggingRequest";
import {EmailRequest} from "@api/v1/emailRequest"; import {ScheduleConfigurationRequest} from "@api/v1/configuration/scheduleConfigurationRequest";
import {ScheduleConfigurationRequest} from "@api/v1/scheduleConfigurationRequest"; import {EmailRequest} from "@api/v1/configuration/emailRequest";
import {DateOnly} from "@model/DateOnly"; import {DateOnly} from "@model/dateOnly";
import {CacheResponse} from "@api/v1/configuration/cacheResponse";
import {PasswordPolicy} from "@model/passwordPolicy";
import {UserResponse} from "@api/v1/userResponse";
@Injectable() @Injectable()
export default class SetupService extends ApiService { export default class SetupService extends ApiService {
@ -14,84 +19,220 @@ export default class SetupService extends ApiService {
public readonly version = AvailableVersion.v1; public readonly version = AvailableVersion.v1;
public checkToken(token: string) { public checkToken(token: string) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('CheckToken') .setEndpoint('CheckToken')
.setQueryParams({token: token}) .setQueryParams({token: token})
.build<ApiService>() .setWithCredentials()
.get<boolean>(); .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) { public setPsql(data: DatabaseRequest) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetPsql') .setEndpoint('SetPsql')
.setData(data) .setData(data)
.build<ApiService>() .setWithCredentials()
.post<boolean>(); .build;
return this.post<boolean>(request);
} }
public setMysql(data: DatabaseRequest) { public setMysql(data: DatabaseRequest) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetMysql') .setEndpoint('SetMysql')
.setData(data) .setData(data)
.build<ApiService>() .setWithCredentials()
.post<boolean>(); .build;
return this.post<boolean>(request);
} }
public setSqlite(path: string | null = null) { public setSqlite(path: string | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetSqlite') .setEndpoint('SetSqlite')
.setQueryParams({path: path}) .setQueryParams({path: path})
.build<ApiService>() .setWithCredentials()
.get<boolean>(); .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) { public setRedis(data: CacheRequest) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetRedis') .setEndpoint('SetRedis')
.setData(data) .setData(data)
.build<ApiService>() .setWithCredentials()
.post<boolean>(); .build;
return this.post<boolean>(request);
} }
public setMemcached() { public setMemcached() {
return this.post<boolean>('SetMemcached'); let request = this.createRequestBuilder()
.setEndpoint('SetMemcached')
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public cacheConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('CacheConfiguration')
.setWithCredentials()
.build;
return this.get<CacheResponse>(request);
}
public setPasswordPolicy(data: PasswordPolicy | null) {
let request = this.createRequestBuilder()
.setEndpoint('SetPasswordPolicy')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public passwordPolicyConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('PasswordPolicyConfiguration')
.setWithCredentials()
.build;
return this.get<PasswordPolicy>(request);
} }
public createAdmin(data: CreateUserRequest) { public createAdmin(data: CreateUserRequest) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('CreateAdmin') .setEndpoint('CreateAdmin')
.setData(data) .setData(data)
.build<ApiService>() .setWithCredentials()
.post<boolean>(); .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) { public setLogging(data: LoggingRequest | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetLogging') .setEndpoint('SetLogging')
.setData(data) .setData(data)
.build<ApiService>() .setWithCredentials()
.post<boolean>(); .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) { public setEmail(data: EmailRequest | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetEmail') .setEndpoint('SetEmail')
.setData(data) .setData(data)
.build<ApiService>() .setWithCredentials()
.post<boolean>(); .build;
return this.post<boolean>(request);
} }
public setSchedule(data: ScheduleConfigurationRequest) { public setSchedule(data: ScheduleConfigurationRequest) {
data.startTerm = new DateOnly(data.startTerm).toString(); data.startTerm = new DateOnly(data.startTerm).toString();
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetSchedule') .setEndpoint('SetSchedule')
.setData(data) .setData(data)
.build<ApiService>() .setWithCredentials()
.post<boolean>(); .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() { public submit() {
return this.post<boolean>('Submit'); let request = this.createRequestBuilder()
.setEndpoint('Submit')
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public isConfigured() {
return this.get<boolean>('IsConfigured');
} }
} }

View File

@ -2,14 +2,16 @@ import {Component} from '@angular/core';
import {RouterOutlet} from '@angular/router'; import {RouterOutlet} from '@angular/router';
import {FooterComponent} from "@component/common/footer/footer.component"; import {FooterComponent} from "@component/common/footer/footer.component";
import localeRu from '@angular/common/locales/ru'; 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 {FocusNextDirective} from "@/directives/focus-next.directive";
import {HeaderComponent} from "@component/common/header/header.component";
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterOutlet, FooterComponent, FocusNextDirective], imports: [RouterOutlet, FooterComponent, FocusNextDirective, HeaderComponent],
template: ` template: `
<app-header/>
<router-outlet/> <router-outlet/>
<app-footer/>` <app-footer/>`
}) })

View File

@ -1,10 +1,29 @@
import { ApplicationConfig } from '@angular/core'; import {ApplicationConfig, LOCALE_ID} from '@angular/core';
import { provideRouter } from '@angular/router'; import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
import { routes } from './app.routes'; import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import {provideHttpClient} from "@angular/common/http"; import {provideHttpClient} from "@angular/common/http";
import {provideToastr} from "ngx-toastr";
import {MAT_DATE_LOCALE, provideNativeDateAdapter} from "@angular/material/core";
export const appConfig: ApplicationConfig = { 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

@ -8,6 +8,12 @@ import {ScheduleComponent as SetupScheduleComponent} from "@page/setup/schedule/
import {SetupComponent} from "@page/setup/setup.component"; import {SetupComponent} from "@page/setup/setup.component";
import {CreateAdminComponent} from "@page/setup/create-admin/create-admin.component"; import {CreateAdminComponent} from "@page/setup/create-admin/create-admin.component";
import {SummaryComponent} from "@page/setup/summary/summary.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: '', title: 'Расписание', pathMatch: 'full', component: ScheduleComponent},
@ -20,9 +26,20 @@ export const routes: Routes = [
{path: 'schedule', component: SetupScheduleComponent}, {path: 'schedule', component: SetupScheduleComponent},
{path: 'logging', component: LoggingComponent}, {path: 'logging', component: LoggingComponent},
{path: 'summary', component: SummaryComponent}, {path: 'summary', component: SummaryComponent},
{path: 'password-policy', component: PasswordPolicyComponent},
{path: 'two-factor', component: TwoFactorComponent},
{path: '', redirectTo: 'welcome', pathMatch: 'full'} {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'}
]
} }
/*{path: 'not-found', title: '404 страница не найдена'},
{path: '**', redirectTo: '/not-found'}*/
]; ];

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 {Component, Input} from '@angular/core';
import {MatProgressSpinner} from "@angular/material/progress-spinner"; import {MatProgressSpinner} from "@angular/material/progress-spinner";
import {NgStyle} from "@angular/common";
@Component({ @Component({
selector: 'app-data-spinner', selector: 'app-data-spinner',
standalone: true, standalone: true,
imports: [ imports: [
MatProgressSpinner, MatProgressSpinner,
NgStyle
], ],
templateUrl: './data-spinner.component.html' templateUrl: './data-spinner.component.html'
}) })

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

View File

@ -1,14 +1,13 @@
import {Component, EventEmitter, Input, Output} from '@angular/core'; import {Component, EventEmitter, Input, Output} from '@angular/core';
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component"; import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {MatIcon} from "@angular/material/icon"; import {MatIcon} from "@angular/material/icon";
import {MatButton, MatFabButton} from "@angular/material/button"; import {MatFabButton} from "@angular/material/button";
@Component({ @Component({
selector: 'app-loading-indicator', selector: 'app-loading-indicator',
standalone: true, standalone: true,
imports: [ imports: [
DataSpinnerComponent, DataSpinnerComponent,
MatButton,
MatIcon, MatIcon,
MatFabButton MatFabButton
], ],
@ -18,4 +17,9 @@ import {MatButton, MatFabButton} from "@angular/material/button";
export class LoadingIndicatorComponent { export class LoadingIndicatorComponent {
@Input() loading: boolean = true; @Input() loading: boolean = true;
@Output() retryFunction: EventEmitter<void> = new EventEmitter<void>(); @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) { @if (dataSource.length === 0 || isLoad) {
<div class="overlay"> <div class="overlay">
@if (isLoad) { @if (isLoad) {
@ -50,10 +51,12 @@
<div class="mat-body-1">{{ elementData["discipline"] }}</div> <div class="mat-body-1">{{ elementData["discipline"] }}</div>
<!-- Type of Occupation --> <!-- Type of Occupation -->
@for (typeOfOccupation of elementData["typeOfOccupations"]; track $index) { @for (typeOfOccupation of elementData["typeOfOccupations"]; track $index) {
@if ($index === 0 && elementData["typeOfOccupations"][$index - 1] !== typeOfOccupation) {
@if ($index !== 0) { @if ($index !== 0) {
<br/> <br/>
} }
<div class="mat-body">({{typeOfOccupation}})</div> <div class="mat-body">({{ typeOfOccupation }})</div>
}
} }
<!-- Professors --> <!-- Professors -->
@ -97,14 +100,12 @@
} }
<!-- Group --> <!-- Group -->
@if (!isOneGroup) {
<div class="mat-body"> <div class="mat-body">
<i> <i>
<mat-icon fontIcon="group"/> <mat-icon fontIcon="group"/>
</i> </i>
{{ elementData["group"] }} {{ elementData["group"] }}
</div> </div>
}
@if ($index + 1 !== element.data[daysOfWeek.indexOf(day) + 1].length) { @if ($index + 1 !== element.data[daysOfWeek.indexOf(day) + 1].length) {
<hr style="margin: 10px 0;"/> <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 {MatIcon} from "@angular/material/icon";
import {DatePipe} from "@angular/common"; import {DatePipe} from "@angular/common";
import {addDays} from "@progress/kendo-date-math"; import {addDays} from "@progress/kendo-date-math";
import {MatDivider} from "@angular/material/divider";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component"; import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {ScheduleResponse} from "@api/v1/scheduleResponse"; import {ScheduleResponse} from "@api/v1/scheduleResponse";
@ -23,7 +22,6 @@ interface Dictionary {
MatTableModule, MatTableModule,
MatIcon, MatIcon,
DatePipe, DatePipe,
MatDivider,
DataSpinnerComponent DataSpinnerComponent
], ],
templateUrl: './table.component.html', templateUrl: './table.component.html',
@ -31,20 +29,25 @@ interface Dictionary {
}) })
export class TableComponent implements OnChanges { 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() currentWeek!: number;
@Input() startWeek!: Date; @Input() startWeek!: Date;
@Input() isLoad: boolean = false; @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[]) { @Input() set data(schedule: ScheduleResponse[]) {
this.dataSource = schedule; this.dataSource = schedule;
this.convertData(); this.convertData();
this.isOneGroup = schedule.every((item, _, array) => item.group === array[0].group);
} }
ngOnChanges(changes: any) { ngOnChanges(changes: any) {
@ -61,21 +64,52 @@ export class TableComponent implements OnChanges {
this.isLoad = true; this.isLoad = true;
let tableData: TableData[] = []; let tableData: TableData[] = [];
for (let i: number = 1; i <= 7; i++) { for (let pairNumber: number = 1; pairNumber <= 7; pairNumber++) {
let convertedData: TableData = { let convertedData: TableData = {
pairNumber: i, pairNumber: pairNumber,
data: {} data: {}
}; };
for (let k: number = 1; k < 7; k++) { for (let dayOfWeek: number = 1; dayOfWeek < 7; dayOfWeek++) {
convertedData.data[k.toString()] = this.dataSource.filter(x => let filteredData = this.dataSource.filter(x =>
x.pairNumber === i && x.pairNumber === pairNumber &&
x.dayOfWeek === k && x.dayOfWeek === dayOfWeek &&
x.isEven === (this.currentWeek % 2 === 0) && x.isEven === (this.currentWeek % 2 === 0)
( );
(x.isExcludedWeeks && (!x.weeks || !x.weeks.includes(this.currentWeek))) ||
(!x.isExcludedWeeks && (!x.weeks || x.weeks.includes(this.currentWeek))) 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); 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-panel-title>
</mat-expansion-panel-header> </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) { @for (faculty of faculties; track $index) {
<mat-chip-option [value]="faculty.id" color="accent"> <mat-chip-option [value]="faculty.id" color="accent">
{{ faculty.name }} {{ faculty.name }}
</mat-chip-option> </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-chip-listbox>
</mat-expansion-panel> </mat-expansion-panel>
@ -23,14 +27,20 @@
Курс Курс
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </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) { @for (course of courseNumbers; track $index) {
<mat-chip-option [value]="course" color="accent"> <mat-chip-option [value]="course" color="accent">
{{ course }} {{ course }}
</mat-chip-option> </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-chip-listbox>
</mat-expansion-panel> </mat-expansion-panel>
@ -41,14 +51,70 @@
Группа Группа
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<mat-chip-listbox hideSingleSelectionIndicator (change)="chooseGroup($event)" [formControl]="formChipGroup"> <mat-chip-listbox hideSingleSelectionIndicator (change)="onGroupSelected($event.value)"
@for (group of filteredGroups; track $index) { [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"> <mat-chip-option [value]="group.id" color="accent">
{{ group.name }} {{ group.name }}
</mat-chip-option> </mat-chip-option>
} @empty { }
<app-loading-indicator [loading]="groupsLoaded !== null" </div>
(retryFunction)="loadCourseGroup()"/> }
@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-chip-listbox>
</mat-expansion-panel> </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 {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 {FormControl, FormsModule, ReactiveFormsModule} from "@angular/forms";
import {catchError} from "rxjs"; import {catchError} from "rxjs";
import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loading-indicator.component"; 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 {FacultyResponse} from "@api/v1/facultyResponse";
import {FacultyService} from "@api/v1/faculty.service"; import {FacultyService} from "@api/v1/faculty.service";
import {GroupService} from "@api/v1/group.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({ @Component({
selector: 'app-group', selector: 'app-group',
@ -23,122 +29,218 @@ import {GroupService} from "@api/v1/group.service";
styleUrl: './group.component.css', styleUrl: './group.component.css',
providers: [FacultyService, GroupService] 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 facultyId: number | null = null;
protected courseNumber: number | null = null; protected courseNumber: number | null = null;
protected filteredGroups: GroupResponse[] = [];
protected courseNumbers: number[] = [];
private groups: GroupResponse[] = [];
protected formChipCourse: FormControl = new FormControl(); protected formChipCourse: FormControl = new FormControl();
protected formChipGroup: FormControl = new FormControl(); protected formChipGroup: FormControl = new FormControl();
protected faculties: FacultyResponse[] = [];
@ViewChild('courseNumberPanel') courseNumberPanel!: MatExpansionPanel; @ViewChild('courseNumberPanel') courseNumberPanel!: MatExpansionPanel;
@ViewChild('groupPanel') groupPanel!: MatExpansionPanel; @ViewChild('groupPanel') groupPanel!: MatExpansionPanel;
protected facultiesLoaded: boolean | null = false; @ViewChild('facultyChip') facultyChip!: MatChipListbox;
protected groupsLoaded: boolean | null = false; @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) { 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() { protected loadFaculties() {
this.facultiesLoaded = false;
this.facultyApi.getFaculties() this.facultyApi.getFaculties()
.pipe(catchError(error => { .pipe(catchError(error => {
this.facultiesLoaded = null; this.facultyIndicator.loading = false;
throw error; throw error;
})) }))
.subscribe(data => { .subscribe(data => {
this.faculties = 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() { protected loadCourseGroup() {
if (this.groups.length === 0) { if (this.facultyId === null)
this.groupsLoaded = false; return;
this.groupApi.getGroups().pipe( if (this.groups === null || this.groups.length === 0 || this.groups[0].facultyId !== this.facultyId) {
catchError(error => { this.groupApi.getByFaculty(this.facultyId)
this.groupsLoaded = null; .pipe(catchError(error => {
this.groupIndicator.loading = false;
this.courseIndicator.loading = false;
throw error; throw error;
}) }))
).subscribe(data => { .subscribe(data => {
this.groups = data; this.groups = data;
if (this.courseNumber === null) this.courseNumbers = Array.from(
this.filteringCourseNumber(); new Set(
else this.groups!
this.filteringGroup(); .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);
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 return;
} }
if (this.courseNumber === null) if (this.courseNumber !== null) {
this.filteringCourseNumber(); 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 else
this.filteringGroup(); this.filteredGroupsMagistracy?.push(x);
});
}
} }
protected chooseFaculty(event: MatChipListboxChange) { protected onFacultySelected(index: number, loadMode: boolean = false) {
this.courseNumber = null; this.resetCourse();
this.groups = []; this.resetGroup();
this.formChipGroup.reset();
this.formChipCourse.reset();
if (event.value === undefined || event.value === null) { if (index === undefined) {
this.facultyId = null; this.facultyId = null;
return; return;
} }
this.facultyId = event.value;
if (loadMode)
this.facultyChip.value = index;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
this.facultyId = index;
this.courseNumberPanel.open(); this.courseNumberPanel.open();
this.loadCourseGroup(); this.loadCourseGroup();
} }
protected chooseCourseNumber(event: MatChipListboxChange) { protected onCourseSelected(course: number, loadMode: boolean = false) {
this.filteredGroups = []; this.resetGroup();
this.formChipGroup.reset();
if (event.value === undefined || event.value === null) { if (course === undefined) {
this.courseNumber = null; this.courseNumber = null;
return; return;
} }
this.courseNumber = event.value; if (loadMode)
this.courseChip.value = course;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
this.courseNumber = course;
this.groupPanel.open(); this.groupPanel.open();
this.loadCourseGroup(); this.loadCourseGroup();
} }
protected chooseGroup(event: MatChipListboxChange) { protected onGroupSelected(index: number, loadMode: boolean = false) {
if (event.value === undefined || event.value === null) if (index === undefined)
return; return;
if (loadMode)
this.groupChip.value = index;
this.selectChangeEvent.emit(this.getSelectedTabs());
this.groupPanel.close(); 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-panel-title>
</mat-expansion-panel-header> </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) { @for (campus of campuses; track $index) {
<mat-chip-option [value]="campus.id" color="accent"> <mat-chip-option [value]="campus.id" color="accent">
{{ campus.codeName }} {{ campus.codeName }}
</mat-chip-option> </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-chip-listbox>
</mat-expansion-panel> </mat-expansion-panel>
@ -22,13 +23,15 @@
Кабинет Кабинет
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </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) { @for (lectureHall of lectureHallsFiltered; track $index) {
<mat-chip-option [value]="lectureHall.id" color="accent"> <mat-chip-option [value]="lectureHall.id" color="accent">
{{ lectureHall.name }} {{ lectureHall.name }}
</mat-chip-option> </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-chip-listbox>
</mat-expansion-panel> </mat-expansion-panel>

View File

@ -1,14 +1,19 @@
import {Component, EventEmitter, Input, Output, ViewChild} from '@angular/core'; import {Component, EventEmitter, ViewChild} from '@angular/core';
import {AsyncPipe} from "@angular/common";
import {MatAccordion, MatExpansionModule, MatExpansionPanel} from "@angular/material/expansion"; import {MatAccordion, MatExpansionModule, MatExpansionPanel} from "@angular/material/expansion";
import {MatChipListboxChange, MatChipsModule} from "@angular/material/chips"; import {MatChipListbox, MatChipsModule} from "@angular/material/chips";
import {catchError, Observable, of} from "rxjs"; import {catchError} from "rxjs";
import {FormControl, ReactiveFormsModule} from "@angular/forms"; import {FormControl, ReactiveFormsModule} from "@angular/forms";
import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loading-indicator.component"; import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loading-indicator.component";
import {CampusBasicInfoResponse} from "@api/v1/campusBasicInfoResponse"; import {CampusBasicInfoResponse} from "@api/v1/campusBasicInfoResponse";
import {LectureHallResponse} from "@api/v1/lectureHallResponse"; import {LectureHallResponse} from "@api/v1/lectureHallResponse";
import {CampusService} from "@api/v1/campus.service"; import {CampusService} from "@api/v1/campus.service";
import {LectureHallService} from "@api/v1/lectureHall.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({ @Component({
selector: 'app-lecture-hall', selector: 'app-lecture-hall',
@ -16,7 +21,6 @@ import {LectureHallService} from "@api/v1/lectureHall.service";
imports: [ imports: [
MatChipsModule, MatChipsModule,
MatExpansionModule, MatExpansionModule,
AsyncPipe,
ReactiveFormsModule, ReactiveFormsModule,
MatAccordion, MatAccordion,
LoadingIndicatorComponent LoadingIndicatorComponent
@ -25,80 +29,141 @@ import {LectureHallService} from "@api/v1/lectureHall.service";
styleUrl: './lecture-hall.component.css', styleUrl: './lecture-hall.component.css',
providers: [CampusService, LectureHallService] providers: [CampusService, LectureHallService]
}) })
export class LectureHallComponent implements IScheduleTab {
export class LectureHallComponent {
protected campusId: number | null = null; 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('lecturePanel') lecturePanel!: MatExpansionPanel;
@ViewChild('lectureIndicator') lectureIndicator!: LoadingIndicatorComponent;
@ViewChild('campusIndicator') campusIndicator!: LoadingIndicatorComponent;
protected campuses: CampusBasicInfoResponse[] = []; @ViewChild('campusChip') campusChip!: MatChipListbox;
protected campusesLoaded: boolean | null = false; @ViewChild('lectureChip') lectureChip!: MatChipListbox;
private lectureHalls: LectureHallResponse[] = []; private lectureHalls: LectureHallResponse[] | null = null;
protected lectureHallsFiltered: LectureHallResponse[] = [];
protected lectureHallsLoaded: boolean | null = false;
@Output() eventResult = new EventEmitter<number>(); public eventResult = new EventEmitter<number>();
public selectChangeEvent = new EventEmitter<TabSelect[]>();
constructor(private campusApi: CampusService, private lectureHallApi: LectureHallService) { 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() { protected loadCampuses() {
this.campusesLoaded = false;
this.campusApi.getCampus() this.campusApi.getCampus()
.pipe(catchError(error => { .pipe(catchError(error => {
this.campusesLoaded = null; this.campusIndicator.loading = false;
throw error; throw error;
})) }))
.subscribe(data => { .subscribe(data => {
this.campuses = 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() { 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) { protected onCampusSelected(index: number, loadMode: boolean = false) {
this.chipLecture.reset(); 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.campusId = null;
this.lectureHalls = []; this.lectureHalls = [];
return; return;
} }
this.campusId = event.value; this.campusId = index;
this.lecturePanel.open(); this.lecturePanel.open();
if (this.lectureHalls.length === 0) if (this.lectureHalls === null)
this.loadLectureHalls(); this.loadLectureHalls();
else else
this.filteringLectureHalls(); this.filteringLectureHalls();
} }
protected loadLectureHalls() { protected loadLectureHalls() {
this.lectureHallsLoaded = false;
this.lectureHallApi.getLectureHalls() this.lectureHallApi.getLectureHalls()
.pipe(catchError(error => { .pipe(catchError(error => {
this.lectureHallsLoaded = null; this.lectureIndicator.loading = false;
throw error; throw error;
})) }))
.subscribe(data => { .subscribe(data => {
this.lectureHalls = data; this.lectureHalls = data;
this.filteringLectureHalls(); 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) { protected onLectureHallSelected(index: number, loadMode: boolean = false) {
if (event.value === undefined || event.value === null) if (index === undefined)
return; return;
if (loadMode)
this.lectureChip.value = index;
else
this.selectChangeEvent.emit(this.getSelectedTabs());
this.lecturePanel.close(); 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 --> <!--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"> <mat-menu #menu="matMenu" [hasBackdrop]="false" class="menu-options">
<div (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()" style="padding: 0 15px 15px"> <div (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()" style="padding: 0 15px 15px">
<div class="header-menu"> <div class="header-menu">
<mat-form-field appearance="outline" color="accent" style="display:flex;"> <mat-form-field appearance="outline" color="accent" style="display:flex;">
<input matInput placeholder="Поиск..." [(ngModel)]="searchQuery" [disabled]="data.length === 0"> <input matInput placeholder="Поиск..." [(ngModel)]="searchQuery"
<button mat-icon-button matSuffix (click)="clearSearchQuery()" [disabled]="data.length === 0"> [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> <mat-icon style="color: var(--mdc-filled-button-label-text-color);">close</mat-icon>
</button> </button>
</mat-form-field> </mat-form-field>
<div class="button-group"> <div class="button-group">
<mat-checkbox (click)="checkData()" [disabled]="data.length === 0" #chooseCheckbox/> <mat-checkbox (click)="checkData()" [disabled]="data === null || data.length === 0" #chooseCheckbox/>
<button mat-button (click)="clearAll()" [disabled]="data.length === 0">Очистить</button> <button mat-button (click)="clearAll()" [disabled]="data === null || data.length === 0">Очистить</button>
</div> </div>
<hr/> <hr/>
</div> </div>
@if (data.length === 0) { @if (data === null || data.length === 0) {
<app-loading-indicator style="display: flex; justify-content: center;" [loading]="dataLoaded !== null" <app-loading-indicator style="display: flex; justify-content: center;" [loading]="data === null"
(retryFunction)="retryLoadData.emit()"/> (retryFunction)="retryLoadData.emit()"/>
} @else { } @else {
<mat-selection-list> <mat-selection-list>

View File

@ -47,17 +47,19 @@ export interface SelectData {
export class OtherComponent { export class OtherComponent {
private _searchQuery: string = ''; private _searchQuery: string = '';
protected filteredData: BehaviorSubject<SelectData[]> = new BehaviorSubject<SelectData[]>([]); protected filteredData: BehaviorSubject<SelectData[]> = new BehaviorSubject<SelectData[]>([]);
protected data: SelectData[] = []; protected data: SelectData[] | null = null;
@Input() idButton!: string; @Input() idButton!: string;
@Input() textButton!: string; @Input() textButton!: string;
@ViewChild('menuTrigger') menuTrigger!: MatMenuTrigger; @ViewChild('menuTrigger') menuTrigger!: MatMenuTrigger;
@ViewChild('chooseCheckbox') chooseCheckbox!: MatCheckbox; @ViewChild('chooseCheckbox') chooseCheckbox!: MatCheckbox;
@Input() dataLoaded: boolean | null = false;
@Output() retryLoadData: EventEmitter<void> = new EventEmitter<void>(); @Output() retryLoadData: EventEmitter<void> = new EventEmitter<void>();
get selectedIds(): number[] { get selectedIds(): number[] {
if (this.data === null)
return [];
return this.data.filter(x => x.selected).map(x => x.id); return this.data.filter(x => x.selected).map(x => x.id);
} }
@ -72,6 +74,9 @@ export class OtherComponent {
} }
private updateCheckBox() { private updateCheckBox() {
if (this.data === null)
return;
this.chooseCheckbox.checked = this.data.every(x => x.selected); this.chooseCheckbox.checked = this.data.every(x => x.selected);
this.chooseCheckbox.indeterminate = this.data.some(x => x.selected) && !this.chooseCheckbox.checked; this.chooseCheckbox.indeterminate = this.data.some(x => x.selected) && !this.chooseCheckbox.checked;
} }
@ -82,6 +87,9 @@ export class OtherComponent {
} }
protected updateFilteredData(): void { protected updateFilteredData(): void {
if (this.data === null)
return;
this.filteredData.next(this.data.filter(x => this.filteredData.next(this.data.filter(x =>
x.name.toLowerCase().includes(this.searchQuery.toLowerCase()) x.name.toLowerCase().includes(this.searchQuery.toLowerCase())
)); ));
@ -92,7 +100,7 @@ export class OtherComponent {
} }
protected clearAll(): void { protected clearAll(): void {
this.data.forEach(x => x.selected = false); this.data?.forEach(x => x.selected = false);
if (this.searchQuery !== '') { if (this.searchQuery !== '') {
const updatedData = this.filteredData.value.map(x => { 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 check: boolean = this.filteredData.value.some(x => !x.selected) && !this.filteredData.value.every(x => x.selected);
const updatedData = this.filteredData.value.map(data => { 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}; return {...data, selected: check};
}); });
@ -118,7 +126,7 @@ export class OtherComponent {
} }
protected checkboxStateChange(item: number) { 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; data.selected = !data.selected;
const updatedData = this.filteredData.value; const updatedData = this.filteredData.value;
updatedData.find(x => x.id === item)!.selected = data.selected; updatedData.find(x => x.id === item)!.selected = data.selected;

View File

@ -1,11 +1,12 @@
<div class="search-content"> <div class="search-content">
@if (professors.length === 0) { @if (professors === null) {
<app-loading-indicator [loading]="professorsLoaded !== null" (retryFunction)="loadProfessors()"/> <app-loading-indicator [loading]="true" (retryFunction)="loadProfessors()"
#professorIndicator/>
} @else { } @else {
<mat-form-field color="accent" style="width: 100%;"> <mat-form-field color="accent" style="width: 100%;">
<input type="text" placeholder="Поиск..." matInput [formControl]="professorControl" [matAutocomplete]="auto"> <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"> [autoActiveFirstOption]="false" [hideSingleSelectionIndicator]="true">
@for (option of filteredProfessors | async; track option) { @for (option of filteredProfessors | async; track option) {
<mat-option [value]="option.id"> <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 {MatFormField, MatInput} from "@angular/material/input";
import {FormControl, ReactiveFormsModule} from "@angular/forms"; 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 {AsyncPipe} from "@angular/common";
import {catchError, map, Observable, startWith} from "rxjs"; import {catchError, map, Observable, startWith} from "rxjs";
import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loading-indicator.component"; import {LoadingIndicatorComponent} from "@component/common/loading-indicator/loading-indicator.component";
import {ProfessorResponse} from "@api/v1/professorResponse"; import {ProfessorResponse} from "@api/v1/professorResponse";
import {ProfessorService} from "@api/v1/professor.service"; import {ProfessorService} from "@api/v1/professor.service";
import {IScheduleTab} from "@component/schedule/tabs/ischedule-tab";
import {TabSelect, TabStorageService} from "@service/tab-storage.service";
@Component({ @Component({
selector: 'app-professor', selector: 'app-professor',
@ -23,31 +25,44 @@ import {ProfessorService} from "@api/v1/professor.service";
styleUrl: './professor.component.css', styleUrl: './professor.component.css',
providers: [ProfessorService] providers: [ProfessorService]
}) })
export class ProfessorComponent implements OnInit { export class ProfessorComponent implements OnInit, IScheduleTab {
protected professorControl = new FormControl(); protected professorControl = new FormControl();
protected filteredProfessors!: Observable<ProfessorResponse[]>; protected filteredProfessors!: Observable<ProfessorResponse[]>;
protected professors: ProfessorResponse[] = []; protected professors: ProfessorResponse[] | null = null;
protected professorsLoaded: boolean | null = false;
@Output() eventResult = new EventEmitter<number>(); @ViewChild('professorIndicator') professorIndicator!: LoadingIndicatorComponent;
public eventResult = new EventEmitter<number>();
public selectChangeEvent = new EventEmitter<TabSelect[]>();
constructor(private api: ProfessorService) { constructor(private api: ProfessorService) {
this.loadProfessors(); }
getEnclosureList(): string[] {
return ['professor'];
} }
protected loadProfessors() { protected loadProfessors() {
if (this.professors.length === 0) { if (this.professors === null || this.professors.length === 0) {
this.professorsLoaded = false;
this.api.getProfessors() this.api.getProfessors()
.pipe(catchError(error => { .pipe(catchError(error => {
this.professorsLoaded = null; this.professorIndicator.loading = false;
throw error; throw error;
})) }))
.subscribe(data => { .subscribe(data => {
this.professors = 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[] { private _filterProfessors(value: string | number): ProfessorResponse[] {
if (typeof value === 'string') { if (typeof value === 'string') {
if (value === '') return []; if (value === '')
const filterValue = value.toLowerCase(); return [];
return this.professors.filter(teacher => teacher.name.toLowerCase().includes(filterValue));
const filterValue = value.toLowerCase().replace('ё', 'е');
return this.professors?.filter(teacher => teacher.name.toLowerCase().replace('ё', 'е').includes(filterValue)) ?? [];
} else { } else {
const selectedTeacher = this.professors.find(teacher => teacher.id === value); const selectedTeacher = this.professors?.find(teacher => teacher.id === value);
return selectedTeacher ? [selectedTeacher] : []; return selectedTeacher ? [selectedTeacher] : [];
} }
} }
protected onOptionSelected(event: MatAutocompleteSelectedEvent) { protected onOptionSelected(index: number) {
const selectedOption = this.professors.find(teacher => teacher.id === event.option.value); if (index === undefined)
return;
const selectedOption = this.professors?.find(teacher => teacher.id === index);
if (selectedOption) { if (selectedOption) {
this.professorControl.setValue(selectedOption.name); this.professorControl.setValue(selectedOption.name);
this.eventResult.emit(selectedOption.id); this.eventResult.emit(selectedOption.id);
this.selectChangeEvent.emit([new TabSelect(selectedOption.id, selectedOption.name)]);
} }
} }
public load() {
if (this.professors === null)
this.loadProfessors();
}
} }

View File

@ -1,5 +1,5 @@
.padding-content div { .padding-content div {
padding: 30px 15px; padding: 15px 0;
} }
.margin-other-button { .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" <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="Группа"> <mat-tab label="Группа">
<div> <div>
<app-group (eventResult)="groupSelected($event)"/> <app-group #groupTab/>
</div> </div>
</mat-tab> </mat-tab>
<mat-tab label="Преподаватель"> <mat-tab label="Преподаватель">
<div> <div>
<app-professor (eventResult)="professorSelected($event)"/> <app-professor #professorTab/>
</div> </div>
</mat-tab> </mat-tab>
<mat-tab label="Кабинет"> <mat-tab label="Кабинет">
<div> <div>
<app-lecture-hall (eventResult)="lectureHallSelected($event)"/> <app-lecture-hall #lectureHallTab/>
</div> </div>
</mat-tab> </mat-tab>
<!--
<mat-tab label="Другое"> <mat-tab label="Другое" *appHasRole="AuthRoles.Admin">
<div class="margin-other-button"> <div class="margin-other-button">
<app-other idButton="disciplines-button" textButton="Дисциплины" #discipline [dataLoaded]="disciplinesLoaded" <app-other idButton="disciplines-button" textButton="Дисциплины" #discipline (retryLoadData)="loadDisciplines()"/>
(retryLoadData)="loadDisciplines()"/> <app-other idButton="lecture-button" textButton="Кабинеты" #lecture (retryLoadData)="loadLectureHalls()"/>
<app-other idButton="lecture-button" textButton="Кабинеты" #lecture <app-other idButton="group-button" textButton="Группы" #group (retryLoadData)="loadGroups()"/>
[dataLoaded]="campusesLoaded && lectureHallsLoaded" (retryLoadData)="loadLectureHalls()"/> <app-other idButton="professor-button" textButton="Профессоры" #professor (retryLoadData)="loadProfessors()"/>
<app-other idButton="group-button" textButton="Группы" #group [dataLoaded]="facultiesLoaded && groupLoaded" <app-other idButton="lesson-type-button" textButton="Тип занятия" #lesson_type (retryLoadData)="loadLessonType()"/>
(retryLoadData)="loadGroups()"/>
<app-other idButton="professor-button" textButton="Профессоры" #professor [dataLoaded]="professorsLoaded"
(retryLoadData)="professorsLoad()"/>
<section> <section>
<button mat-flat-button>Отфильтровать</button> <button mat-flat-button (click)="otherFilter()">Отфильтровать</button>
</section> </section>
</div> </div>
</mat-tab> </mat-tab>
-->
</mat-tab-group> </mat-tab-group>

View File

@ -1,15 +1,25 @@
import {Component, EventEmitter, Output} from '@angular/core'; import {AfterViewInit, Component, EventEmitter, Output, ViewChild} from '@angular/core';
import {OtherComponent} from "@component/schedule/tabs/other/other.component"; import {OtherComponent, SelectData} from "@component/schedule/tabs/other/other.component";
import {MatTab, MatTabChangeEvent, MatTabGroup} from "@angular/material/tabs"; import {MatTab, MatTabGroup} from "@angular/material/tabs";
import {map, Observable} from "rxjs"; import {Observable} from "rxjs";
import {ReactiveFormsModule} from "@angular/forms"; import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatButton} from "@angular/material/button"; 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 {GroupComponent} from "@component/schedule/tabs/group/group.component";
import {ProfessorComponent} from "@component/schedule/tabs/professor/professor.component"; import {ProfessorComponent} from "@component/schedule/tabs/professor/professor.component";
import {LectureHallComponent} from "@component/schedule/tabs/lecture-hall/lecture-hall.component"; import {LectureHallComponent} from "@component/schedule/tabs/lecture-hall/lecture-hall.component";
import {ScheduleService} from "@api/v1/schedule.service"; import {ScheduleService} from "@api/v1/schedule.service";
import {ScheduleResponse} from "@api/v1/scheduleResponse"; 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 { export enum TabsSelect {
Group, Group,
@ -27,133 +37,207 @@ export enum TabsSelect {
MatTab, MatTab,
ReactiveFormsModule, ReactiveFormsModule,
MatButton, MatButton,
DataSpinnerComponent,
GroupComponent, GroupComponent,
ProfessorComponent, ProfessorComponent,
LectureHallComponent LectureHallComponent,
FormsModule,
HasRoleDirective
], ],
templateUrl: './tabs.component.html', templateUrl: './tabs.component.html',
styleUrl: './tabs.component.css', styleUrl: './tabs.component.css',
providers: [ScheduleService] providers: [
ScheduleService,
DisciplineService,
LectureHallService,
GroupService,
ProfessorService,
TabStorageService,
CampusService,
LessonTypeService]
}) })
export class TabsComponent { export class TabsComponent implements AfterViewInit {
@Output() eventResult = new EventEmitter<[TabsSelect, number, Observable<ScheduleResponse[]>]>(); @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) { ngAfterViewInit(): void {
this.eventResult.emit( 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, TabsSelect.Group,
id, event,
this.scheduleApi.getByGroup(id) this.scheduleApi.getByGroup(event),
.pipe( {groups: [event]}
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
}))
)
)
] ]
); ));
}
protected professorSelected(id: number) { this.professorTab.eventResult.subscribe(event => this.eventResult.emit(
this.eventResult.emit(
[ [
TabsSelect.Professor, TabsSelect.Professor,
id, event,
this.scheduleApi.getByProfessor(id) this.scheduleApi.getByProfessor(event),
.pipe( {professors: [event]}
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
}))
)
)
] ]
); ));
}
protected lectureHallSelected(id: number) { this.lectureHallTab.eventResult.subscribe(event => this.eventResult.emit(
this.eventResult.emit(
[ [
TabsSelect.LectureHall, TabsSelect.LectureHall,
id, event,
this.scheduleApi.getByLectureHall(id) this.scheduleApi.getByLectureHall(event),
.pipe( {lectureHalls: [event]}
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
}))
)
)
] ]
); ));
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;
} }
protected async chooseTabs(event: MatTabChangeEvent) { if (index === 0)
switch (event.index) { this.chooseTabs(0).then();
else
this.tabs.selectedIndex = index;
}
protected async chooseTabs(index: number) {
let needGetEnclosure = false;
if (this.currentTab !== index) {
this.currentTab = index;
needGetEnclosure = true;
}
switch (index) {
case 0:
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('discipline') disciplineEx!: OtherComponent;
@ViewChild('lecture') lectureHallEx!: OtherComponent; @ViewChild('lecture') lectureHallEx!: OtherComponent;
@ViewChild('group') groupEx!: OtherComponent; @ViewChild('group') groupEx!: OtherComponent;
@ViewChild('professor') professorEx!: 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,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

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

View File

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

View File

@ -6,10 +6,14 @@
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <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"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head> </head>
<body class="mat-typography"> <body class="mat-typography">
<app-root></app-root> <app-root></app-root>
</body> </body>
</html> </html>

View File

@ -1,6 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser'; import {bootstrapApplication} from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import {appConfig} from './app/app.config';
import { AppComponent } from './app/app.component'; import {AppComponent} from './app/app.component';
bootstrapApplication(AppComponent, appConfig) bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err)); .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 { .schedule {
padding: 50px 15%; padding: 50px 15%;
min-height: 60vh; min-height: 60vh;
overflow: visible;
}
.schedule mat-sidenav-content {
overflow: inherit;
margin-bottom: 15px;
} }
@media screen and (max-width: 599px) { @media screen and (max-width: 599px) {

View File

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

View File

@ -1,68 +1,101 @@
import {Component, LOCALE_ID, ViewChild} from '@angular/core'; import {Component, 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 {AdditionalText, TableHeaderComponent} from "@component/schedule/table-header/table-header.component"; import {AdditionalText, TableHeaderComponent} from "@component/schedule/table-header/table-header.component";
import {addDays, weekInYear} from "@progress/kendo-date-math"; 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 {TabsComponent, TabsSelect} from "@component/schedule/tabs/tabs.component";
import {catchError, Observable} from "rxjs"; import {catchError, Observable} from "rxjs";
import {ScheduleService} from "@api/v1/schedule.service"; import {ScheduleService} from "@api/v1/schedule.service";
import {ScheduleResponse} from "@api/v1/scheduleResponse"; 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({ @Component({
selector: 'app-schedule', selector: 'app-schedule',
standalone: true, standalone: true,
imports: [ imports: [
TableComponent, MatSidenavModule,
MatInput, TabsComponent,
MatFormField,
MatButton,
FormsModule,
TableHeaderComponent, TableHeaderComponent,
MatCard, TableComponent,
MatSidenavContainer, MatCheckbox,
TabsComponent DataSpinnerComponent,
MatButton,
HasRoleDirective
], ],
templateUrl: './schedule.component.html', templateUrl: './schedule.component.html',
styleUrl: './schedule.component.css', styleUrl: './schedule.component.css',
providers: [ providers: [
ScheduleService, ScheduleService,
{provide: LOCALE_ID, useValue: 'ru-RU'} ImportService
] ]
}) })
export class ScheduleComponent { export class ScheduleComponent {
protected startWeek!: Date; private lastRequest: ScheduleRequest | null = null;
protected startWeek: Date;
protected data: ScheduleResponse[] = []; protected data: ScheduleResponse[] = [];
protected startTerm: Date; protected startTerm: Date;
protected isLoadTable: boolean = false; protected isLoadTable: boolean = false;
protected pairPeriods: PeriodTimes = {}; protected pairPeriods: PairPeriodTime | null = null;
protected disciplineWithWeeks: boolean = false;
protected excelImportLoader: boolean = false;
@ViewChild('tableHeader') childComponent!: TableHeaderComponent; @ViewChild('tableHeader') childComponent!: TableHeaderComponent;
constructor(api: ScheduleService) { constructor(api: ScheduleService,
this.calculateCurrentWeek(); 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.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 => { api.pairPeriod().subscribe(date => {
this.pairPeriods = date; this.pairPeriods = date;
}); });
api.startTerm().subscribe(date => { api.startTerm().subscribe(date => {
this.startTerm = date.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.isLoadTable = true;
this.lastRequest = data[3];
data[2] data[2]
.pipe(catchError(error => { .pipe(catchError(error => {
this.data = []; this.data = [];
throw error; throw error;
})) }))
.subscribe(x => { .subscribe(x => {
if (x == undefined || x.length === 0) {
this.isLoadTable = false;
return;
}
this.data = x; this.data = x;
switch (data[0]) { switch (data[0]) {
case TabsSelect.Group: case TabsSelect.Group:
@ -70,8 +103,6 @@ export class ScheduleComponent {
break; break;
case TabsSelect.Professor: case TabsSelect.Professor:
let indexProfessor = this.data[0].professorsId.findIndex(p => p === data[1]); 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]); this.childComponent.AdditionalText(AdditionalText.Professor, this.data[0].professors[indexProfessor]);
break; break;
case TabsSelect.LectureHall: case TabsSelect.LectureHall:
@ -89,6 +120,9 @@ export class ScheduleComponent {
private calculateCurrentWeek() { private calculateCurrentWeek() {
let currentDate = new Date(); let currentDate = new Date();
if (currentDate.getDate() < this.startTerm.getDate())
currentDate = this.startTerm;
function startOfWeek(date: Date) { function startOfWeek(date: Date) {
return addDays(date, -date.getDay() + 1); return addDays(date, -date.getDay() + 1);
} }
@ -99,10 +133,10 @@ export class ScheduleComponent {
this.startWeek = this.startTerm; this.startWeek = this.startTerm;
} }
protected handleWeekEvent(eventData: boolean | null) { protected handleWeekEvent(forward: boolean | null) {
if (eventData === null) { if (forward === null) {
this.calculateCurrentWeek(); this.calculateCurrentWeek();
} else if (eventData) { } else if (forward) {
this.startWeek = addDays(this.startWeek, 7); this.startWeek = addDays(this.startWeek, 7);
} else { } else {
this.startWeek = addDays(this.startWeek, -7); this.startWeek = addDays(this.startWeek, -7);
@ -110,6 +144,56 @@ export class ScheduleComponent {
} }
get currentWeek(): number { 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

@ -15,7 +15,7 @@
<mat-form-field color="accent"> <mat-form-field color="accent">
<mat-label>База данных</mat-label> <mat-label>База данных</mat-label>
<mat-select (valueChange)="onDatabaseChange($event)"> <mat-select (valueChange)="onDatabaseChange($event)" [value]="database">
<mat-option value="redis">Redis</mat-option> <mat-option value="redis">Redis</mat-option>
<mat-option value="memcached">Memcached</mat-option> <mat-option value="memcached">Memcached</mat-option>
</mat-select> </mat-select>
@ -29,7 +29,8 @@
<input matInput <input matInput
matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6' matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6'
required required
formControlName="server"> formControlName="server"
focusNext="serverNextFocus">
@if (databaseForm.get('server')?.hasError('required')) { @if (databaseForm.get('server')?.hasError('required')) {
<mat-error> <mat-error>
@ -49,7 +50,9 @@
<input matInput <input matInput
matTooltip="Укажите порт сервера" matTooltip="Укажите порт сервера"
required required
formControlName="port"> formControlName="port"
id="serverNextFocus"
focusNext="passwordNextFocus">
@if (databaseForm.get('port')?.hasError('required')) { @if (databaseForm.get('port')?.hasError('required')) {
<mat-error> <mat-error>
@ -69,7 +72,9 @@
<input matInput <input matInput
matTooltip="Укажите пароль" matTooltip="Укажите пароль"
formControlName="password" formControlName="password"
[type]="hidePass ? 'password' : 'text'"> [type]="hidePass ? 'password' : 'text'"
id="passwordNextFocus"
focusNext="nextButtonFocus">
<button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'" <button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'"
[attr.aria-pressed]="hidePass"> [attr.aria-pressed]="hidePass">

View File

@ -8,6 +8,9 @@ import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip"; import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button"; import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon"; import {MatIcon} from "@angular/material/icon";
import {of} from "rxjs";
import {CacheType} from "@model/cacheType";
import {FocusNextDirective} from "@/directives/focus-next.directive";
@Component({ @Component({
selector: 'app-cache', selector: 'app-cache',
@ -19,7 +22,8 @@ import {MatIcon} from "@angular/material/icon";
MatInput, MatInput,
MatTooltip, MatTooltip,
MatIconButton, MatIconButton,
MatIcon MatIcon,
FocusNextDirective
], ],
templateUrl: './cache.component.html' templateUrl: './cache.component.html'
}) })
@ -40,6 +44,35 @@ export class CacheComponent {
this.databaseForm.valueChanges.subscribe(() => { this.databaseForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.databaseForm.valid); 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) { onDatabaseChange(selectedDatabase: string) {

View File

@ -28,7 +28,7 @@
@if (createAdminForm.get('user')?.hasError('pattern')) { @if (createAdminForm.get('user')?.hasError('pattern')) {
<mat-error> <mat-error>
Имя пользователя должен содержать латинские сиволы и цифры и быть не менее 4 символов Имя пользователя должен содержать латинские символы и цифры и быть не менее 4 символов
</mat-error> </mat-error>
} }
</mat-form-field> </mat-form-field>
@ -53,37 +53,7 @@
} }
</mat-form-field> </mat-form-field>
<mat-form-field color="accent" style="margin-bottom: 20px"> <password-input [formGroup]="createAdminForm" [isSetupMode]="true"/>
<mat-label>Пароль</mat-label>
<input matInput
matTooltip="Укажите пароль"
formControlName="password"
required
[type]="hidePass ? 'password' : 'text'">
<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 (createAdminForm.get('password')?.hasError('required')) {
<mat-error>
Пароль является <i>обязательным</i>
</mat-error>
}
@if (createAdminForm.get('password')?.hasError('minlength')) {
<mat-error>
Пароль должен быть не менее 8 символов
</mat-error>
}
@if (createAdminForm.get('password')?.hasError('pattern')) {
<mat-error>
Пароль должен содержать хотя бы один латинский символ верхнего регистра и специальный символ (!&#x40;#$%^&*)
</mat-error>
}
</mat-form-field>
<mat-form-field color="accent"> <mat-form-field color="accent">
<mat-label>Повторите пароль</mat-label> <mat-label>Повторите пароль</mat-label>
@ -105,5 +75,10 @@
</mat-error> </mat-error>
} }
</mat-form-field> </mat-form-field>
<OAuthProviders [canUnlink]="true" [activeProvidersId]="activatedProviders"
(oAuthUpdateProviders)="updateProviders()"
[message]="'Или можете получить часть данных от сторонних сервисов'"
[action]="OAuthAction.Bind" [isSetup]="true"/>
</div> </div>
</form> </form>

View File

@ -1,4 +1,5 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {Location} from '@angular/common';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {NavigationService} from "@service/navigation.service"; import {NavigationService} from "@service/navigation.service";
import {passwordMatchValidator} from '@service/password-match.validator'; import {passwordMatchValidator} from '@service/password-match.validator';
@ -9,6 +10,12 @@ import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip"; import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button"; import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon"; 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({ @Component({
selector: 'app-create-admin', selector: 'app-create-admin',
@ -20,17 +27,21 @@ import {MatIcon} from "@angular/material/icon";
MatInput, MatInput,
MatTooltip, MatTooltip,
MatIconButton, MatIconButton,
MatIcon MatIcon,
OAuthProviders,
PasswordInputComponent
], ],
templateUrl: './create-admin.component.html' templateUrl: './create-admin.component.html',
providers: [AuthApiService, Location]
}) })
export class CreateAdminComponent { export class CreateAdminComponent {
protected createAdminForm!: FormGroup; protected createAdminForm!: FormGroup;
protected hidePass = true;
protected hideRetypePass = true; protected hideRetypePass = true;
protected activatedProviders: OAuthProvider[] = [];
constructor( constructor(private router: Router,
private location: Location,
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) { private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
this.createAdminForm = this.formBuilder.group({ this.createAdminForm = this.formBuilder.group({
user: ['', Validators.pattern(/^([A-Za-z0-9]){4,}$/)], user: ['', Validators.pattern(/^([A-Za-z0-9]){4,}$/)],
@ -41,12 +52,6 @@ export class CreateAdminComponent {
{validators: passwordMatchValidator('password', 'retype')} {validators: passwordMatchValidator('password', 'retype')}
); );
this.createAdminForm.get('password')?.setValidators([Validators.required,
Validators.pattern(/[A-Z]/),
Validators.pattern(/[!@#$%^&*]/),
Validators.minLength(8)
]);
this.navigationService.setNextButtonState(false); this.navigationService.setNextButtonState(false);
this.createAdminForm.valueChanges.subscribe(() => { this.createAdminForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.createAdminForm.valid); this.navigationService.setNextButtonState(this.createAdminForm.valid);
@ -60,15 +65,35 @@ export class CreateAdminComponent {
} }
); );
}; };
this.updateAdminData();
} }
protected togglePassword(event: MouseEvent) { private updateAdminData() {
this.hidePass = !this.hidePass; this.api.adminConfiguration().subscribe(configuration => {
event.stopPropagation(); 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) { protected toggleRetypePassword(event: MouseEvent) {
this.hideRetypePass = !this.hideRetypePass; this.hideRetypePass = !this.hideRetypePass;
event.stopPropagation(); event.stopPropagation();
} }
protected updateProviders() {
this.updateAdminData();
}
protected readonly OAuthAction = OAuthAction;
} }

View File

@ -17,9 +17,9 @@
</p> </p>
<mat-form-field color="accent"> <mat-form-field color="accent">
<mat-label>База данных</mat-label> <mat-label>База данных</mat-label>
<mat-select (valueChange)="onDatabaseChange($event)"> <mat-select (valueChange)="onDatabaseChange($event)" [value]="database">
<mat-option value="SetMysql">MySQL</mat-option> <mat-option value="mysql">MySQL</mat-option>
<mat-option value="SetPsql">PostgreSQL</mat-option> <mat-option value="psql">PostgreSQL</mat-option>
<mat-option value="sqlite">Sqlite</mat-option> <mat-option value="sqlite">Sqlite</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
@ -57,7 +57,8 @@
<input matInput <input matInput
matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6' matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6'
required required
formControlName="server"> formControlName="server"
focusNext="portNextFocus">
@if (databaseForm.get('server')?.hasError('required')) { @if (databaseForm.get('server')?.hasError('required')) {
<mat-error> <mat-error>
@ -77,7 +78,9 @@
<input matInput <input matInput
matTooltip="Укажите порт сервера" matTooltip="Укажите порт сервера"
required required
formControlName="port"> formControlName="port"
id="portNextFocus"
focusNext="databaseNextFocus">
@if (databaseForm.get('port')?.hasError('required')) { @if (databaseForm.get('port')?.hasError('required')) {
<mat-error> <mat-error>
@ -97,7 +100,9 @@
<input matInput <input matInput
matTooltip="Укажите название базы данных" matTooltip="Укажите название базы данных"
required required
formControlName="database_name"> formControlName="database_name"
id="databaseNextFocus"
focusNext="userNextFocus">
@if (databaseForm.get('database_name')?.hasError('required')) { @if (databaseForm.get('database_name')?.hasError('required')) {
<mat-error> <mat-error>
@ -117,7 +122,9 @@
<input matInput <input matInput
matTooltip="Укажите пользователя, который имеет доступ к базе данных" matTooltip="Укажите пользователя, который имеет доступ к базе данных"
required required
formControlName="user"> formControlName="user"
id="userNextFocus"
focusNext="passwordNextFocus">
@if (databaseForm.get('user')?.hasError('required')) { @if (databaseForm.get('user')?.hasError('required')) {
<mat-error> <mat-error>
@ -137,7 +144,9 @@
<input matInput <input matInput
matTooltip="Укажите пароль" matTooltip="Укажите пароль"
formControlName="password" formControlName="password"
[type]="hidePass ? 'password' : 'text'"> [type]="hidePass ? 'password' : 'text'"
id="passwordNextFocus"
focusNext="nextButtonFocus">
<button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'" <button mat-icon-button matSuffix (click)="togglePassword($event)" [attr.aria-label]="'Hide password'"
[attr.aria-pressed]="hidePass"> [attr.aria-pressed]="hidePass">

View File

@ -2,14 +2,17 @@ import {Component} from '@angular/core';
import {NavigationService} from "@service/navigation.service"; import {NavigationService} from "@service/navigation.service";
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import SetupService from "@api/v1/setup.service"; import SetupService from "@api/v1/setup.service";
import {DatabaseRequest} from "@api/v1/databaseRequest";
import {MatFormFieldModule} from "@angular/material/form-field"; import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select"; import {MatSelectModule} from "@angular/material/select";
import {MatInput} from "@angular/material/input"; import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip"; import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button"; import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon" import {MatIcon} from "@angular/material/icon";
import {MatCheckbox} from "@angular/material/checkbox"; 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({ @Component({
selector: 'app-database', selector: 'app-database',
@ -22,7 +25,8 @@ import {MatCheckbox} from "@angular/material/checkbox";
MatTooltip, MatTooltip,
MatIconButton, MatIconButton,
MatIcon, MatIcon,
MatCheckbox MatCheckbox,
FocusNextDirective
], ],
templateUrl: './database.component.html' templateUrl: './database.component.html'
}) })
@ -49,6 +53,42 @@ export class DatabaseComponent {
this.databaseForm.valueChanges.subscribe(() => { this.databaseForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.databaseForm.valid); 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) { private createForm(database: string) {

View File

@ -4,6 +4,11 @@
Настройте систему логирования как будет удобно для отображения. Настройте систему логирования как будет удобно для отображения.
Можно настроить путь к файлу, имена файлов или вовсе отключить логирование в файл. Можно настроить путь к файлу, имена файлов или вовсе отключить логирование в файл.
</p> </p>
<p class="mat-body-2 secondary">
Также вы можете настроить интеграцию с Seq.
Введите необходимые данные и мы отправим тестовый лог на сервер Seq. Его уровень будет Warning.
Если тестовый лог не появился вернитесь на данный шаг и перепроверьте данные.
</p>
<form [formGroup]="loggingSettings"> <form [formGroup]="loggingSettings">
<p> <p>
@ -31,9 +36,18 @@
matTooltip="Укажите название файла, в который будут записаны логи" matTooltip="Укажите название файла, в который будут записаны логи"
formControlName="logName"> formControlName="logName">
</mat-form-field> </mat-form-field>
<mat-form-field color="accent">
<mat-label>Сервер Seq</mat-label>
<input matInput
matTooltip="Укажите сервер Seq вначале указав схему (http/https)"
formControlName="seqServer">
</mat-form-field>
<mat-form-field color="accent">
<mat-label>Api ключ Seq</mat-label>
<input matInput
matTooltip="Укажите ключ API, который вы создали в Seq"
formControlName="seqKey">
</mat-form-field>
</div> </div>
</form> </form>
<div style="display: flex; justify-content: center;">
<button mat-flat-button color="accent" (click)="skipButton()">Пропустить</button>
</div>

View File

@ -6,8 +6,8 @@ import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select"; import {MatSelectModule} from "@angular/material/select";
import {MatInput} from "@angular/material/input"; import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip"; import {MatTooltip} from "@angular/material/tooltip";
import {MatButton, MatIconButton} from "@angular/material/button";
import {MatCheckbox} from "@angular/material/checkbox"; import {MatCheckbox} from "@angular/material/checkbox";
import {of} from "rxjs";
@Component({ @Component({
selector: 'app-logging', selector: 'app-logging',
@ -18,9 +18,7 @@ import {MatCheckbox} from "@angular/material/checkbox";
MatSelectModule, MatSelectModule,
MatInput, MatInput,
MatTooltip, MatTooltip,
MatIconButton, MatCheckbox
MatCheckbox,
MatButton
], ],
templateUrl: './logging.component.html' templateUrl: './logging.component.html'
@ -39,16 +37,17 @@ export class LoggingComponent {
} }
} }
protected skipButton() {
this.navigationService.skipNavigation.emit(() => this.api.setLogging(null));
}
constructor( constructor(
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) { private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => this.api.setLogging(null);
this.loggingSettings = this.formBuilder.group({ this.loggingSettings = this.formBuilder.group({
enabled: [true, Validators.required], enabled: [true, Validators.required],
logPath: [''], logPath: [''],
logName: [''] logName: [''],
seqServer: [''],
seqKey: ['']
} }
); );
@ -59,11 +58,27 @@ export class LoggingComponent {
this.navigationService.nextButtonAction = () => { this.navigationService.nextButtonAction = () => {
return this.api.setLogging({ return this.api.setLogging({
"enableLogToFile": this.loggingSettings.get('cron')?.value, enableLogToFile: this.loggingSettings.get('enabled')?.value,
"logFileName": this.loggingSettings.get('logName')?.value, logFileName: this.loggingSettings.get('logName')?.value,
"logFilePath": this.loggingSettings.get('logPath')?.value logFilePath: this.loggingSettings.get('logPath')?.value,
apiServerSeq: this.loggingSettings.get('seqServer')?.value,
apiKeySeq: this.loggingSettings.get('seqKey')?.value
} }
); );
}; };
api.loggingConfiguration().subscribe(x => {
if (!x)
return;
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
this.loggingSettings.get('enabled')?.setValue(x.enableLogToFile);
this.loggingSettings.get('logName')?.setValue(x.logFileName);
this.loggingSettings.get('logPath')?.setValue(x.logFilePath);
this.loggingSettings.get('seqServer')?.setValue(x.apiServerSeq);
this.loggingSettings.get('seqKey')?.setValue(x.apiKeySeq);
});
} }
} }

View File

@ -0,0 +1,48 @@
<h1>Настройка политики паролей</h1>
<hr/>
<p class="mat-body-2 secondary">
Задайте параметры для обеспечения безопасности паролей.
<br/>
Можно установить минимальную длину пароля и другие требования, чтобы усилить защиту учетных записей.
</p>
<form [formGroup]="policyForm">
<p>
Введите данные для настройки политики паролей:
</p>
<div style="display:flex; flex-direction: column;">
<mat-form-field color="accent">
<mat-label>Минимальная длина пароля</mat-label>
<input matInput
type="number"
matTooltip="Укажите минимальное количество длины пароля"
formControlName="minimumLength">
@if (policyForm.get('minimumLength')?.hasError('min')) {
<mat-error>
Пароль не может быть меньше 6 символов
</mat-error>
}
@if (policyForm.get('minimumLength')?.hasError('max')) {
<mat-error>
Пароль не может быть больше 12 символов
</mat-error>
}
</mat-form-field>
<mat-checkbox formControlName="requireLetter">
Требовать наличие букв в пароле
</mat-checkbox>
<mat-checkbox formControlName="requireLettersDifferentCase">
Требовать буквы разного регистра (заглавные и строчные)
</mat-checkbox>
<mat-checkbox formControlName="requireDigit">
Требовать наличие цифр в пароле
</mat-checkbox>
<mat-checkbox formControlName="requireSpecialCharacter">
Требовать наличие специальных символов (например, !, $, #)
</mat-checkbox>
</div>
</form>

View File

@ -0,0 +1,77 @@
import {Component} from '@angular/core';
import {MatCheckbox} from "@angular/material/checkbox";
import {MatError, MatFormField, MatLabel} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {NavigationService} from "@service/navigation.service";
import SetupService from "@api/v1/setup.service";
import {of} from "rxjs";
@Component({
selector: 'app-password-policy',
standalone: true,
imports: [
MatCheckbox,
MatFormField,
MatInput,
MatLabel,
MatTooltip,
ReactiveFormsModule,
MatError
],
templateUrl: './password-policy.component.html'
})
export class PasswordPolicyComponent {
protected policyForm!: FormGroup;
constructor(
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
this.policyForm = this.formBuilder.group({
minimumLength: ['', [
Validators.required,
Validators.min(6),
Validators.max(12)
]],
requireLetter: [false],
requireLettersDifferentCase: [false],
requireDigit: [false],
requireSpecialCharacter: [false]
});
this.api.passwordPolicyConfiguration().subscribe(response => {
if (!response)
return;
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
this.policyForm.patchValue({
minimumLength: response.minimumLength,
requireLetter: response.requireLetter,
requireLettersDifferentCase: response.requireLettersDifferentCase,
requireDigit: response.requireDigit,
requireSpecialCharacter: response.requireSpecialCharacter
});
});
this.navigationService.setNextButtonState(false);
this.policyForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.policyForm.valid);
});
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => this.api.setPasswordPolicy(null);
this.navigationService.nextButtonAction = () => {
return this.api.setPasswordPolicy(({
minimumLength: this.policyForm.get('minimumLength')?.value,
requireLetter: this.policyForm.get('requireLetter')?.value,
requireLettersDifferentCase: this.policyForm.get('requireLettersDifferentCase')?.value,
requireDigit: this.policyForm.get('requireDigit')?.value,
requireSpecialCharacter: this.policyForm.get('requireSpecialCharacter')?.value
}));
};
};
}

View File

@ -7,9 +7,9 @@ import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select"; import {MatSelectModule} from "@angular/material/select";
import {MatInput} from "@angular/material/input"; import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip"; import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import {MatDatepickerModule} from "@angular/material/datepicker"; import {MatDatepickerModule} from "@angular/material/datepicker";
import {DateOnly} from "@model/dateOnly";
import {of} from "rxjs";
@Component({ @Component({
selector: 'app-schedule-conf', selector: 'app-schedule-conf',
@ -20,22 +20,23 @@ import {MatDatepickerModule} from "@angular/material/datepicker";
MatSelectModule, MatSelectModule,
MatInput, MatInput,
MatTooltip, MatTooltip,
MatIconButton,
MatIcon,
MatDatepickerModule, MatDatepickerModule,
MatNativeDateModule MatNativeDateModule
], ],
templateUrl: './schedule.component.html' templateUrl: './schedule.component.html',
}) })
export class ScheduleComponent { export class ScheduleComponent {
protected scheduleSettings!: FormGroup; protected scheduleSettings!: FormGroup;
constructor( constructor(
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService, private _adapter: DateAdapter<any>) { private navigationService: NavigationService,
this._adapter.setLocale('ru'); formBuilder: FormBuilder,
this.scheduleSettings = this.formBuilder.group({ private api: SetupService,
cron: ['0 */6 * * *', Validators.pattern(/^([^\s]+\s){4}[^\s]{1}$/)], adapter: DateAdapter<any>) {
adapter.setLocale(navigator.language);
this.scheduleSettings = formBuilder.group({
cron: ['0 */6 * * *', Validators.pattern(/^(\S+\s){4}\S$/)],
startTerm: ['', Validators.required] startTerm: ['', Validators.required]
} }
); );
@ -52,5 +53,19 @@ export class ScheduleComponent {
} }
); );
}; };
api.scheduleConfiguration().subscribe(x => {
if (!x)
return;
this.scheduleSettings.get('startTerm')?.setValue(new DateOnly(x.startTerm).date);
this.scheduleSettings.get('cron')?.setValue(x.cronUpdateSchedule);
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
});
} }
} }

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