Compare commits

...

148 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
156 changed files with 9772 additions and 6626 deletions

View File

@ -23,14 +23,13 @@ jobs:
run: npm install
- name: Build Angular app
run: npm run build -- --prod
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 }}
ssh-passphrase: ${{ secrets.SSH_PASSPHRASE }}
- name: Deploy to Server
env:
@ -40,5 +39,7 @@ jobs:
run: |
mkdir -p ~/.ssh
ssh-keyscan $SSH_HOST >> ~/.ssh/known_hosts
scp -r ./dist/frontend/* $SSH_USER@$SSH_HOST:$TARGET_DIR
ssh $SSH_USER@$SSH_HOST "sudo chown -R www-data:www-data $TARGET_DIR"
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"

View File

@ -1,6 +1,6 @@
# MIREA schedule by Winsomnia
[![Angular Release](https://img.shields.io/badge/v18.0-8?style=flat-square&label=Angular&labelColor=512BD4&color=606060)](https://github.com/angular/angular-cli)
[![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)
This project provides a Web interface for working with the MIREA schedule.

10208
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,9 +1,5 @@
import {HttpHeaders} from "@angular/common/http";
export interface SetRequestBuilderAfterBuild {
setRequestBuilder(request: RequestData): void;
}
export interface RequestData {
endpoint: string;
queryParams: Record<string, string | number | boolean | Array<any> | null> | null;
@ -11,102 +7,48 @@ export interface RequestData {
data: any;
silenceMode: boolean;
withCredentials: boolean;
needAuth: boolean;
}
export class RequestBuilder {
private endpoint: string = '';
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 withCredentials: boolean = false;
private readonly object: any;
private result: RequestData = Object.create({});
constructor(obj: any) {
this.object = obj;
constructor() {
}
public setEndpoint(endpoint: string): this {
this.endpoint = endpoint;
this.result.endpoint = endpoint;
return this;
}
public setQueryParams(queryParams: Record<string, string | number | boolean | Array<any> | null>): RequestBuilder {
this.queryParams = queryParams;
this.result.queryParams = queryParams;
return this;
}
public addHeaders(headers: Record<string, string>): RequestBuilder {
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;
}
public setData(data: any): RequestBuilder {
this.data = data;
this.result.data = data;
return this;
}
public setSilenceMode(silence: boolean = true): RequestBuilder {
this.silenceMode = silence;
this.result.silenceMode = silence;
return this;
}
public setWithCredentials(credentials: boolean = true): RequestBuilder {
this.withCredentials = credentials;
this.result.withCredentials = credentials;
return this;
}
public build<Type>(): Type {
(this.object as SetRequestBuilderAfterBuild).setRequestBuilder({
endpoint: this.endpoint,
queryParams: this.queryParams,
httpHeaders: this.httpHeaders,
data: this.data,
silenceMode: this.silenceMode,
withCredentials: this.withCredentials
});
return this.object as Type;
}
public getEndpoint(): string {
return this.endpoint;
}
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,
withCredentials: false
}
}
public reset(): void {
this.endpoint = '';
this.queryParams = null;
this.httpHeaders = new HttpHeaders();
this.data = null;
this.silenceMode = false;
this.withCredentials = false;
public get build(): RequestData {
return this.result;
}
}

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

View File

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

View File

@ -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 getDisciplines(page: number | null = null, pageSize: number | null = null) {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize})
.build<ApiService>()
.get<DisciplineResponse[]>();
.build;
return this.get<DisciplineResponse[]>(request);
}
public getById(id: number) {

View File

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

View File

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

View File

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

View File

@ -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 getProfessors(page: number | null = null, pageSize: number | null = null) {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize})
.build<ApiService>()
.get<ProfessorResponse[]>();
.build;
return this.get<ProfessorResponse[]>(request);
}
public getById(id: number) {

View File

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

View File

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

View File

@ -1,12 +1,17 @@
import {Injectable} from "@angular/core";
import ApiService, {AvailableVersion} from "@api/api.service";
import {DatabaseRequest} from "@api/v1/databaseRequest";
import {CacheRequest} from "@api/v1/cacheRequest";
import {catchError, of} from "rxjs";
import {DatabaseResponse} from "@api/v1/configuration/databaseResponse";
import {DatabaseRequest} from "@api/v1/configuration/databaseRequest";
import {CacheRequest} from "@api/v1/configuration/cacheRequest";
import {CreateUserRequest} from "@api/v1/createUserRequest";
import {LoggingRequest} from "@api/v1/loggingRequest";
import {EmailRequest} from "@api/v1/emailRequest";
import {ScheduleConfigurationRequest} from "@api/v1/scheduleConfigurationRequest";
import {DateOnly} from "@model/DateOnly";
import {LoggingRequest} from "@api/v1/configuration/loggingRequest";
import {ScheduleConfigurationRequest} from "@api/v1/configuration/scheduleConfigurationRequest";
import {EmailRequest} from "@api/v1/configuration/emailRequest";
import {DateOnly} from "@model/dateOnly";
import {CacheResponse} from "@api/v1/configuration/cacheResponse";
import {PasswordPolicy} from "@model/passwordPolicy";
import {UserResponse} from "@api/v1/userResponse";
@Injectable()
export default class SetupService extends ApiService {
@ -14,101 +19,217 @@ export default class SetupService extends ApiService {
public readonly version = AvailableVersion.v1;
public checkToken(token: string) {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setEndpoint('CheckToken')
.setQueryParams({token: token})
.build<ApiService>()
.get<boolean>();
.setWithCredentials()
.build;
return this.get<boolean>(request);
}
public isConfiguredToken() {
let request = this.createRequestBuilder()
.setEndpoint('IsConfiguredToken')
.setWithCredentials()
.build;
return this.get<boolean>(request).pipe(catchError(_ => {
return of(false);
}));
}
public setPsql(data: DatabaseRequest) {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setEndpoint('SetPsql')
.setData(data)
.setWithCredentials()
.build<ApiService>()
.post<boolean>();
.build;
return this.post<boolean>(request);
}
public setMysql(data: DatabaseRequest) {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setEndpoint('SetMysql')
.setData(data)
.setWithCredentials()
.build<ApiService>()
.post<boolean>();
.build;
return this.post<boolean>(request);
}
public setSqlite(path: string | null = null) {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setEndpoint('SetSqlite')
.setQueryParams({path: path})
.setWithCredentials()
.build<ApiService>()
.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) {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setEndpoint('SetRedis')
.setData(data)
.setWithCredentials()
.build<ApiService>()
.post<boolean>();
.build;
return this.post<boolean>(request);
}
public setMemcached() {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setEndpoint('SetMemcached')
.setWithCredentials()
.build<ApiService>()
.post<boolean>();
.build;
return this.post<boolean>(request);
}
public cacheConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('CacheConfiguration')
.setWithCredentials()
.build;
return this.get<CacheResponse>(request);
}
public setPasswordPolicy(data: PasswordPolicy | null) {
let request = this.createRequestBuilder()
.setEndpoint('SetPasswordPolicy')
.setData(data)
.setWithCredentials()
.build;
return this.post<boolean>(request);
}
public passwordPolicyConfiguration() {
let request = this.createRequestBuilder()
.setEndpoint('PasswordPolicyConfiguration')
.setWithCredentials()
.build;
return this.get<PasswordPolicy>(request);
}
public createAdmin(data: CreateUserRequest) {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setEndpoint('CreateAdmin')
.setData(data)
.setWithCredentials()
.build<ApiService>()
.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) {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setEndpoint('SetLogging')
.setData(data)
.setWithCredentials()
.build<ApiService>()
.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) {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setEndpoint('SetEmail')
.setData(data)
.setWithCredentials()
.build<ApiService>()
.post<boolean>();
.build;
return this.post<boolean>(request);
}
public setSchedule(data: ScheduleConfigurationRequest) {
data.startTerm = new DateOnly(data.startTerm).toString();
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setEndpoint('SetSchedule')
.setData(data)
.setWithCredentials()
.build<ApiService>()
.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() {
return this.createRequestBuilder()
let request = this.createRequestBuilder()
.setEndpoint('Submit')
.setWithCredentials()
.build<ApiService>()
.post<boolean>();
.build;
return this.post<boolean>(request);
}
public isConfigured() {

View File

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

View File

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

View File

@ -8,6 +8,12 @@ import {ScheduleComponent as SetupScheduleComponent} from "@page/setup/schedule/
import {SetupComponent} from "@page/setup/setup.component";
import {CreateAdminComponent} from "@page/setup/create-admin/create-admin.component";
import {SummaryComponent} from "@page/setup/summary/summary.component";
import {LoginComponent} from "@page/login/login.component";
import {PasswordPolicyComponent} from "@page/setup/password-policy/password-policy.component";
import {TwoFactorComponent} from "@page/setup/two-factor/two-factor.component";
import {AdminComponent} from "@page/admin/admin.component";
import {UnderConstructionComponent} from "@page/admin/under-construction/under-construction.component";
import {ScheduleConfigurationComponent} from "@page/admin/schedule-configuration/schedule-configuration.component";
export const routes: Routes = [
{path: '', title: 'Расписание', pathMatch: 'full', component: ScheduleComponent},
@ -20,9 +26,20 @@ export const routes: Routes = [
{path: 'schedule', component: SetupScheduleComponent},
{path: 'logging', component: LoggingComponent},
{path: 'summary', component: SummaryComponent},
{path: 'password-policy', component: PasswordPolicyComponent},
{path: 'two-factor', component: TwoFactorComponent},
{path: '', redirectTo: 'welcome', pathMatch: 'full'}
]
},
{path: 'login', title: 'Вход', component: LoginComponent},
{
path: 'admin', title: 'Админ панель', component: AdminComponent, children: [
{path: 'schedule', component: ScheduleConfigurationComponent},
{path: 'institute', component: UnderConstructionComponent},
{path: 'account', component: UnderConstructionComponent},
{path: 'server', component: UnderConstructionComponent},
{path: '', redirectTo: 'schedule', pathMatch: 'full'},
{path: '**', redirectTo: 'schedule', pathMatch: 'full'}
]
}
/*{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 {MatProgressSpinner} from "@angular/material/progress-spinner";
import {NgStyle} from "@angular/common";
@Component({
selector: 'app-data-spinner',
standalone: true,
imports: [
MatProgressSpinner,
NgStyle
],
templateUrl: './data-spinner.component.html'
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,25 @@
import {Component, EventEmitter, Output} from '@angular/core';
import {OtherComponent} from "@component/schedule/tabs/other/other.component";
import {MatTab, MatTabChangeEvent, MatTabGroup} from "@angular/material/tabs";
import {map, Observable} from "rxjs";
import {ReactiveFormsModule} from "@angular/forms";
import {AfterViewInit, Component, EventEmitter, Output, ViewChild} from '@angular/core';
import {OtherComponent, SelectData} from "@component/schedule/tabs/other/other.component";
import {MatTab, MatTabGroup} from "@angular/material/tabs";
import {Observable} from "rxjs";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatButton} from "@angular/material/button";
import {DataSpinnerComponent} from "@component/common/data-spinner/data-spinner.component";
import {GroupComponent} from "@component/schedule/tabs/group/group.component";
import {ProfessorComponent} from "@component/schedule/tabs/professor/professor.component";
import {LectureHallComponent} from "@component/schedule/tabs/lecture-hall/lecture-hall.component";
import {ScheduleService} from "@api/v1/schedule.service";
import {ScheduleResponse} from "@api/v1/scheduleResponse";
import {IScheduleTab} from "@component/schedule/tabs/ischedule-tab";
import {DisciplineService} from "@api/v1/discipline.service";
import {LectureHallService} from "@api/v1/lectureHall.service";
import {GroupService} from "@api/v1/group.service";
import {ProfessorService} from "@api/v1/professor.service";
import {AuthRoles} from "@model/authRoles";
import {HasRoleDirective} from "@/directives/has-role.directive";
import {TabSelectType, TabStorageService} from "@service/tab-storage.service";
import {ScheduleRequest} from "@api/v1/scheduleRequest";
import {CampusService} from "@api/v1/campus.service";
import {LessonTypeService} from "@api/v1/lessonType.service";
export enum TabsSelect {
Group,
@ -27,133 +37,207 @@ export enum TabsSelect {
MatTab,
ReactiveFormsModule,
MatButton,
DataSpinnerComponent,
GroupComponent,
ProfessorComponent,
LectureHallComponent
LectureHallComponent,
FormsModule,
HasRoleDirective
],
templateUrl: './tabs.component.html',
styleUrl: './tabs.component.css',
providers: [ScheduleService]
providers: [
ScheduleService,
DisciplineService,
LectureHallService,
GroupService,
ProfessorService,
TabStorageService,
CampusService,
LessonTypeService]
})
export class TabsComponent {
@Output() eventResult = new EventEmitter<[TabsSelect, number, Observable<ScheduleResponse[]>]>();
export class TabsComponent implements AfterViewInit {
@Output() eventResult = new EventEmitter<[TabsSelect, number, Observable<ScheduleResponse[]>, ScheduleRequest]>();
private currentTab: number = -1;
constructor(private scheduleApi: ScheduleService) {
constructor(private scheduleApi: ScheduleService,
private disciplineApi: DisciplineService,
private lectureApi: LectureHallService,
private groupApi: GroupService,
private professorApi: ProfessorService,
private tabStorage: TabStorageService,
private campusApi: CampusService,
private lessonTypeApi: LessonTypeService) {
}
protected groupSelected(id: number) {
this.eventResult.emit(
ngAfterViewInit(): void {
this.groupTab.selectChangeEvent.subscribe(event => this.tabStorage.select(TabSelectType.group, event));
this.professorTab.selectChangeEvent.subscribe(event => this.tabStorage.select(TabSelectType.professor, event));
this.lectureHallTab.selectChangeEvent.subscribe(event => this.tabStorage.select(TabSelectType.lecture, event));
this.groupTab.eventResult.subscribe(event => this.eventResult.emit(
[
TabsSelect.Group,
id,
this.scheduleApi.getByGroup(id)
.pipe(
map(g =>
g.map(data =>
({
dayOfWeek: data.dayOfWeek,
pairNumber: data.pairNumber,
isEven: data.isEven,
discipline: data.discipline,
disciplineId: data.disciplineId,
isExcludedWeeks: data.isExcludedWeeks,
weeks: data.weeks,
typeOfOccupations: data.typeOfOccupations,
group: data.group,
groupId: data.groupId,
lectureHalls: data.lectureHalls,
lectureHallsId: data.lectureHallsId,
professors: data.professors,
professorsId: data.professorsId,
campus: data.campus,
campusId: data.campusId,
linkToMeet: data.linkToMeet
}))
)
)
event,
this.scheduleApi.getByGroup(event),
{groups: [event]}
]
);
}
));
protected professorSelected(id: number) {
this.eventResult.emit(
this.professorTab.eventResult.subscribe(event => this.eventResult.emit(
[
TabsSelect.Professor,
id,
this.scheduleApi.getByProfessor(id)
.pipe(
map(p =>
p.map(data =>
({
dayOfWeek: data.dayOfWeek,
pairNumber: data.pairNumber,
isEven: data.isEven,
discipline: data.discipline,
disciplineId: data.disciplineId,
isExcludedWeeks: data.isExcludedWeeks,
weeks: data.weeks,
typeOfOccupations: data.typeOfOccupations,
group: data.group,
groupId: data.groupId,
lectureHalls: data.lectureHalls,
lectureHallsId: data.lectureHallsId,
professors: data.professors,
professorsId: data.professorsId,
campus: data.campus,
campusId: data.campusId,
linkToMeet: data.linkToMeet
}))
)
)
event,
this.scheduleApi.getByProfessor(event),
{professors: [event]}
]
);
}
));
protected lectureHallSelected(id: number) {
this.eventResult.emit(
this.lectureHallTab.eventResult.subscribe(event => this.eventResult.emit(
[
TabsSelect.LectureHall,
id,
this.scheduleApi.getByLectureHall(id)
.pipe(
map(lh =>
lh.map(data =>
({
dayOfWeek: data.dayOfWeek,
pairNumber: data.pairNumber,
isEven: data.isEven,
discipline: data.discipline,
disciplineId: data.disciplineId,
isExcludedWeeks: data.isExcludedWeeks,
weeks: data.weeks,
typeOfOccupations: data.typeOfOccupations,
group: data.group,
groupId: data.groupId,
lectureHalls: data.lectureHalls,
lectureHallsId: data.lectureHallsId,
professors: data.professors,
professorsId: data.professorsId,
campus: data.campus,
campusId: data.campusId,
linkToMeet: data.linkToMeet
}))
)
)
event,
this.scheduleApi.getByLectureHall(event),
{lectureHalls: [event]}
]
);
));
let selected = TabStorageService.selected;
let index = 0;
if (selected !== null) {
const selectedKeys = Object.keys(selected?.selected);
if (selected.type === null) {
if (this.groupTab.getEnclosureList().every((value, index) => value === selectedKeys[index]))
index = 0;
else if (this.professorTab.getEnclosureList().every((value, index) => value === selectedKeys[index]))
index = 1;
else if (this.lectureHallTab.getEnclosureList().every((value, index) => value === selectedKeys[index]))
index = 2;
} else
index = selected.type;
}
if (index === 0)
this.chooseTabs(0).then();
else
this.tabs.selectedIndex = index;
}
protected async chooseTabs(event: MatTabChangeEvent) {
switch (event.index) {
protected async chooseTabs(index: number) {
let needGetEnclosure = false;
if (this.currentTab !== index) {
this.currentTab = index;
needGetEnclosure = true;
}
switch (index) {
case 0:
this.groupTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.groupTab.getEnclosureList();
break;
case 1:
this.professorTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.professorTab.getEnclosureList();
break;
case 2:
this.lectureHallTab.load();
if (needGetEnclosure)
this.tabStorage.enclosure = this.lectureHallTab.getEnclosureList();
break;
case 3:
await this.loadDisciplines();
await this.loadLectureHalls();
await this.loadGroups();
await this.loadProfessors();
await this.loadLessonType();
break;
default:
await this.chooseTabs(0);
break;
}
}
/*
protected async loadDisciplines() {
this.disciplineApi.getDisciplines().subscribe(data => {
this.disciplineEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
});
}
protected async loadLectureHalls() {
this.campusApi.getCampus().subscribe(campus => {
this.lectureApi.getLectureHalls().subscribe(data => {
this.lectureHallEx.Data = data.map(x => ({
id: x.id,
name: x.name + ` (${campus.find(c => c.id == x.campusId)?.codeName})`
}) as SelectData);
});
});
}
protected async loadGroups() {
this.groupApi.getGroups().subscribe(data => {
this.groupEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
});
}
protected async loadProfessors() {
this.professorApi.getProfessors().subscribe(data => {
this.professorEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
});
}
protected async loadLessonType() {
this.lessonTypeApi.getLessonTypes().subscribe(data => {
this.lessonTypeEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
});
}
@ViewChild('groupTab') groupTab!: IScheduleTab;
@ViewChild('professorTab') professorTab!: IScheduleTab;
@ViewChild('lectureHallTab') lectureHallTab!: IScheduleTab;
@ViewChild('discipline') disciplineEx!: OtherComponent;
@ViewChild('lecture') lectureHallEx!: OtherComponent;
@ViewChild('group') groupEx!: OtherComponent;
@ViewChild('professor') professorEx!: OtherComponent;
*/
@ViewChild('lesson_type') lessonTypeEx!: OtherComponent;
@ViewChild('tabGroup') tabs!: MatTabGroup;
protected readonly AuthRoles = AuthRoles;
protected otherFilter() {
const data: ScheduleRequest = ({
groups: this.groupEx.selectedIds,
disciplines: this.disciplineEx.selectedIds,
professors: this.professorEx.selectedIds,
lectureHalls: this.lectureHallEx.selectedIds,
lessonType: this.lessonTypeEx.selectedIds
});
this.eventResult.emit(
[
TabsSelect.Other,
0,
this.scheduleApi.postSchedule(data),
data
]
);
}
}

View File

@ -0,0 +1,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 = {
apiUrl: 'http://localhost:5269/api/',
apiUrl: 'http://localhost:8080/api/',
maxRetry: 5,
retryDelay: 1500
};

View File

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

View File

@ -6,10 +6,14 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
<app-root></app-root>
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@
<mat-form-field color="accent">
<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="memcached">Memcached</mat-option>
</mat-select>
@ -29,7 +29,8 @@
<input matInput
matTooltip='Укажите сервер в формате: "winsomnia.net" или ip адреса формата IPv4 или IPv6'
required
formControlName="server">
formControlName="server"
focusNext="serverNextFocus">
@if (databaseForm.get('server')?.hasError('required')) {
<mat-error>
@ -49,7 +50,9 @@
<input matInput
matTooltip="Укажите порт сервера"
required
formControlName="port">
formControlName="port"
id="serverNextFocus"
focusNext="passwordNextFocus">
@if (databaseForm.get('port')?.hasError('required')) {
<mat-error>
@ -69,7 +72,9 @@
<input matInput
matTooltip="Укажите пароль"
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'"
[attr.aria-pressed]="hidePass">

View File

@ -8,6 +8,9 @@ import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import {of} from "rxjs";
import {CacheType} from "@model/cacheType";
import {FocusNextDirective} from "@/directives/focus-next.directive";
@Component({
selector: 'app-cache',
@ -19,7 +22,8 @@ import {MatIcon} from "@angular/material/icon";
MatInput,
MatTooltip,
MatIconButton,
MatIcon
MatIcon,
FocusNextDirective
],
templateUrl: './cache.component.html'
})
@ -40,6 +44,35 @@ export class CacheComponent {
this.databaseForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.databaseForm.valid);
});
this.api.cacheConfiguration().subscribe(response => {
if (!response)
return;
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
this.databaseForm.patchValue({
server: response.server,
port: response.port,
password: response.password,
});
let type: string;
switch (response.type) {
case CacheType.Redis:
type = "redis";
break;
case CacheType.Memcached:
type = "memcached";
break;
}
this.database = type;
this.onDatabaseChange(type);
});
}
onDatabaseChange(selectedDatabase: string) {

View File

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

View File

@ -1,4 +1,5 @@
import {Component} from '@angular/core';
import {Location} from '@angular/common';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {NavigationService} from "@service/navigation.service";
import {passwordMatchValidator} from '@service/password-match.validator';
@ -9,6 +10,12 @@ import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon";
import AuthApiService from "@api/v1/authApi.service";
import {OAuthProviders} from "@component/OAuthProviders/OAuthProviders";
import {OAuthProvider} from "@model/oAuthProvider";
import {PasswordInputComponent} from "@component/common/password-input/password-input.component";
import {OAuthAction} from "@model/oAuthAction";
import {Router} from "@angular/router";
@Component({
selector: 'app-create-admin',
@ -20,18 +27,22 @@ import {MatIcon} from "@angular/material/icon";
MatInput,
MatTooltip,
MatIconButton,
MatIcon
MatIcon,
OAuthProviders,
PasswordInputComponent
],
templateUrl: './create-admin.component.html'
templateUrl: './create-admin.component.html',
providers: [AuthApiService, Location]
})
export class CreateAdminComponent {
protected createAdminForm!: FormGroup;
protected hidePass = true;
protected hideRetypePass = true;
protected activatedProviders: OAuthProvider[] = [];
constructor(
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
constructor(private router: Router,
private location: Location,
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService) {
this.createAdminForm = this.formBuilder.group({
user: ['', Validators.pattern(/^([A-Za-z0-9]){4,}$/)],
email: ['', Validators.email],
@ -41,12 +52,6 @@ export class CreateAdminComponent {
{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.createAdminForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.createAdminForm.valid);
@ -60,15 +65,35 @@ export class CreateAdminComponent {
}
);
};
this.updateAdminData();
}
protected togglePassword(event: MouseEvent) {
this.hidePass = !this.hidePass;
event.stopPropagation();
private updateAdminData() {
this.api.adminConfiguration().subscribe(configuration => {
if (configuration) {
if (this.createAdminForm.get('email')?.value == 0)
this.createAdminForm.get('email')?.setValue(configuration.email);
if (this.createAdminForm.get('user')?.value == 0)
this.createAdminForm.get('user')?.setValue(configuration.username);
this.activatedProviders = configuration.usedOAuthProviders;
}
const currentPath = this.router.url.split('?')[0];
this.location.replaceState(currentPath);
});
}
protected toggleRetypePassword(event: MouseEvent) {
this.hideRetypePass = !this.hideRetypePass;
event.stopPropagation();
}
protected updateProviders() {
this.updateAdminData();
}
protected readonly OAuthAction = OAuthAction;
}

View File

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

View File

@ -2,14 +2,17 @@ import {Component} from '@angular/core';
import {NavigationService} from "@service/navigation.service";
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import SetupService from "@api/v1/setup.service";
import {DatabaseRequest} from "@api/v1/databaseRequest";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select";
import {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon"
import {MatIcon} from "@angular/material/icon";
import {MatCheckbox} from "@angular/material/checkbox";
import {DatabaseRequest} from "@api/v1/configuration/databaseRequest";
import {of} from "rxjs";
import {DatabaseType} from "@model/databaseType";
import {FocusNextDirective} from "@/directives/focus-next.directive";
@Component({
selector: 'app-database',
@ -22,7 +25,8 @@ import {MatCheckbox} from "@angular/material/checkbox";
MatTooltip,
MatIconButton,
MatIcon,
MatCheckbox
MatCheckbox,
FocusNextDirective
],
templateUrl: './database.component.html'
})
@ -49,6 +53,42 @@ export class DatabaseComponent {
this.databaseForm.valueChanges.subscribe(() => {
this.navigationService.setNextButtonState(this.databaseForm.valid);
});
this.api.databaseConfiguration().subscribe(response => {
if (!response)
return;
this.navigationService.setSkipButtonState(true);
this.navigationService.skipButtonAction = () => of(true);
this.navigationService.triggerAutoSkip(this.navigationService.skipButtonAction);
this.databaseForm.patchValue({
server: response.server,
port: response.port,
database_name: response.database,
user: response.user,
ssl: response.ssl,
password: response.password,
folder: response.pathToDatabase
});
let type: string;
switch (response.type) {
case DatabaseType.Mysql:
type = "mysql";
break;
case DatabaseType.PostgresSql:
type = "psql";
break;
case DatabaseType.Sqlite:
type = "sqlite";
break;
}
this.database = type;
this.onDatabaseChange(type);
});
}
private createForm(database: string) {

View File

@ -4,6 +4,11 @@
Настройте систему логирования как будет удобно для отображения.
Можно настроить путь к файлу, имена файлов или вовсе отключить логирование в файл.
</p>
<p class="mat-body-2 secondary">
Также вы можете настроить интеграцию с Seq.
Введите необходимые данные и мы отправим тестовый лог на сервер Seq. Его уровень будет Warning.
Если тестовый лог не появился вернитесь на данный шаг и перепроверьте данные.
</p>
<form [formGroup]="loggingSettings">
<p>
@ -31,9 +36,18 @@
matTooltip="Укажите название файла, в который будут записаны логи"
formControlName="logName">
</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>
</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 {MatInput} from "@angular/material/input";
import {MatTooltip} from "@angular/material/tooltip";
import {MatButton, MatIconButton} from "@angular/material/button";
import {MatCheckbox} from "@angular/material/checkbox";
import {of} from "rxjs";
@Component({
selector: 'app-logging',
@ -18,9 +18,7 @@ import {MatCheckbox} from "@angular/material/checkbox";
MatSelectModule,
MatInput,
MatTooltip,
MatIconButton,
MatCheckbox,
MatButton
MatCheckbox
],
templateUrl: './logging.component.html'
@ -39,16 +37,17 @@ export class LoggingComponent {
}
}
protected skipButton() {
this.navigationService.skipNavigation.emit(() => this.api.setLogging(null));
}
constructor(
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({
enabled: [true, Validators.required],
logPath: [''],
logName: ['']
logName: [''],
seqServer: [''],
seqKey: ['']
}
);
@ -59,11 +58,27 @@ export class LoggingComponent {
this.navigationService.nextButtonAction = () => {
return this.api.setLogging({
"enableLogToFile": this.loggingSettings.get('cron')?.value,
"logFileName": this.loggingSettings.get('logName')?.value,
"logFilePath": this.loggingSettings.get('logPath')?.value
enableLogToFile: this.loggingSettings.get('enabled')?.value,
logFileName: this.loggingSettings.get('logName')?.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 {MatInput} from "@angular/material/input";
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 {DateOnly} from "@model/dateOnly";
import {of} from "rxjs";
@Component({
selector: 'app-schedule-conf',
@ -20,22 +20,23 @@ import {MatDatepickerModule} from "@angular/material/datepicker";
MatSelectModule,
MatInput,
MatTooltip,
MatIconButton,
MatIcon,
MatDatepickerModule,
MatNativeDateModule
],
templateUrl: './schedule.component.html'
templateUrl: './schedule.component.html',
})
export class ScheduleComponent {
protected scheduleSettings!: FormGroup;
constructor(
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService, private _adapter: DateAdapter<any>) {
this._adapter.setLocale('ru');
this.scheduleSettings = this.formBuilder.group({
cron: ['0 */6 * * *', Validators.pattern(/^([^\s]+\s){4}[^\s]{1}$/)],
private navigationService: NavigationService,
formBuilder: FormBuilder,
private api: SetupService,
adapter: DateAdapter<any>) {
adapter.setLocale(navigator.language);
this.scheduleSettings = formBuilder.group({
cron: ['0 */6 * * *', Validators.pattern(/^(\S+\s){4}\S$/)],
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