Compare commits

...

23 Commits

Author SHA1 Message Date
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
43 changed files with 2528 additions and 476 deletions

View File

@ -1,6 +1,6 @@
# MIREA schedule by Winsomnia
[![Angular Release](https://img.shields.io/badge/v19.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.

2071
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,17 +10,17 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.0.5",
"@angular/cdk": "~19.0.4",
"@angular/cdk-experimental": "^19.0.4",
"@angular/common": "^19.0.5",
"@angular/compiler": "^19.0.5",
"@angular/core": "^19.0.5",
"@angular/forms": "^19.0.5",
"@angular/material": "~19.0.4",
"@angular/platform-browser": "^19.0.5",
"@angular/platform-browser-dynamic": "^19.0.5",
"@angular/router": "^19.0.5",
"@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",
@ -28,9 +28,9 @@
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.6",
"@angular/cli": "^19.0.6",
"@angular/compiler-cli": "^19.0.5",
"@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",
@ -38,6 +38,6 @@
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "^5.6.3"
"typescript": "^5.7.3"
}
}

View File

@ -11,57 +11,44 @@ export interface RequestData {
}
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 result: RequestData = Object.create({});
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 get build(): RequestData {
return {
endpoint: this.endpoint,
queryParams: this.queryParams,
httpHeaders: this.httpHeaders,
data: this.data,
silenceMode: this.silenceMode,
withCredentials: this.withCredentials,
needAuth: false
};
return this.result;
}
}

View File

@ -52,7 +52,7 @@ export default abstract class ApiService {
}
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 combinedUrl(request: RequestData) {
@ -65,7 +65,7 @@ export default abstract class ApiService {
return this.http.request<Type>(method, doneEndpoint, {
withCredentials: request.withCredentials,
headers: request.httpHeaders,
body: request.data,
body: request.data
}).pipe(
catchError(error => {
if (request.needAuth && !secondTry && error.status === 401)
@ -158,7 +158,7 @@ export default abstract class ApiService {
private handleError(error: HttpErrorResponse): void {
// todo: change to Retry-After condition
if (error.error && error.error.detail.includes("setup")) {
if (error.error && error.error.detail && error.error.detail.includes("setup")) {
this.router.navigate(['/setup/']).then();
return;
}

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[], 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})
.build;
return this.addAuth(request).post(request);
}
}

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

@ -1,10 +1,10 @@
import {ApplicationConfig} from '@angular/core';
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: [
@ -22,5 +22,8 @@ export const appConfig: ApplicationConfig = {
disableTimeOut: false,
autoDismiss: true,
maxOpened: 5
})]
}),
provideNativeDateAdapter(),
{ provide: LOCALE_ID, useValue: 'ru' },
{ provide: MAT_DATE_LOCALE, useValue: 'ru' }]
};

View File

@ -11,6 +11,9 @@ 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},
@ -29,6 +32,14 @@ export const routes: Routes = [
]
},
{path: 'login', title: 'Вход', component: LoginComponent},
/*{path: 'not-found', title: '404 страница не найдена'},
{path: '**', redirectTo: '/not-found'}*/
{
path: 'admin', title: 'Админ панель', component: AdminComponent, children: [
{path: 'schedule', component: ScheduleConfigurationComponent},
{path: 'institute', component: UnderConstructionComponent},
{path: 'account', component: UnderConstructionComponent},
{path: 'server', component: UnderConstructionComponent},
{path: '', redirectTo: 'schedule', pathMatch: 'full'},
{path: '**', redirectTo: 'schedule', pathMatch: 'full'}
]
}
];

View File

@ -1,5 +1,5 @@
import {Component, EventEmitter, Inject, Input, OnInit, Output} from '@angular/core';
import AuthApiService, {OAuthProviderData} from "@api/v1/authApiService";
import AuthApiService, {OAuthProviderData} from "@api/v1/authApi.service";
import {OAuthProvider} from "@model/oAuthProvider";
import {ToastrService} from "ngx-toastr";
import {

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,28 @@
<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>
<ul>
@for (file of selectedFiles; track $index) {
<li>{{ file.name }}</li>
}
</ul>
</div>
}
</app-configuration-card>

View File

@ -0,0 +1,57 @@
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";
@Component({
selector: 'app-schedule-file-upload',
imports: [
ConfigurationCardComponent,
MatButtonModule,
MatIcon,
DataSpinnerComponent
],
templateUrl: './schedule-file-upload.component.html',
providers: [ScheduleService]
})
export class ScheduleFileUploadComponent {
selectedFiles: File[] = [];
fileLoading: boolean = false;
@ViewChild('fileInput') input!: ElementRef;
constructor(private dialog: MatDialog, private api: ScheduleService) {
}
protected saveFunction() {
return () => {
const dialogRef = this.dialog.open(ConfirmDeleteScheduleDialogComponent);
return dialogRef.afterClosed().pipe(switchMap(result => {
return this.api.uploadScheduleFile(this.selectedFiles, result);
}));
};
}
onFileChooseClick() {
this.fileLoading = true;
this.input.nativeElement.click();
}
onFileSelected(event: any): void {
this.fileLoading = false;
this.selectedFiles = Array.from(event.target.files);
}
onUpload(data: Observable<any>): void {
data.subscribe(_ => {
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.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

@ -23,6 +23,7 @@
<app-other idButton="lecture-button" textButton="Кабинеты" #lecture (retryLoadData)="loadLectureHalls()"/>
<app-other idButton="group-button" textButton="Группы" #group (retryLoadData)="loadGroups()"/>
<app-other idButton="professor-button" textButton="Профессоры" #professor (retryLoadData)="loadProfessors()"/>
<app-other idButton="lesson-type-button" textButton="Тип занятия" #lesson_type (retryLoadData)="loadLessonType()"/>
<section>
<button mat-flat-button (click)="otherFilter()">Отфильтровать</button>
</section>

View File

@ -18,6 +18,8 @@ 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,
@ -43,7 +45,15 @@ export enum TabsSelect {
],
templateUrl: './tabs.component.html',
styleUrl: './tabs.component.css',
providers: [ScheduleService, DisciplineService, LectureHallService, GroupService, ProfessorService, TabStorageService]
providers: [
ScheduleService,
DisciplineService,
LectureHallService,
GroupService,
ProfessorService,
TabStorageService,
CampusService,
LessonTypeService]
})
export class TabsComponent implements AfterViewInit {
@ -55,7 +65,9 @@ export class TabsComponent implements AfterViewInit {
private lectureApi: LectureHallService,
private groupApi: GroupService,
private professorApi: ProfessorService,
private tabStorage: TabStorageService) {
private tabStorage: TabStorageService,
private campusApi: CampusService,
private lessonTypeApi: LessonTypeService) {
}
ngAfterViewInit(): void {
@ -142,6 +154,7 @@ export class TabsComponent implements AfterViewInit {
await this.loadLectureHalls();
await this.loadGroups();
await this.loadProfessors();
await this.loadLessonType();
break;
default:
await this.chooseTabs(0);
@ -159,11 +172,13 @@ export class TabsComponent implements AfterViewInit {
}
protected async loadLectureHalls() {
this.lectureApi.getLectureHalls().subscribe(data => {
this.lectureHallEx.Data = data.map(x => ({
id: x.id,
name: x.name
}) as SelectData);
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);
});
});
}
@ -185,6 +200,15 @@ export class TabsComponent implements AfterViewInit {
});
}
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;
@ -193,6 +217,7 @@ export class TabsComponent implements AfterViewInit {
@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;
@ -202,7 +227,8 @@ export class TabsComponent implements AfterViewInit {
groups: this.groupEx.selectedIds,
disciplines: this.disciplineEx.selectedIds,
professors: this.professorEx.selectedIds,
lectureHalls: this.lectureHallEx.selectedIds
lectureHalls: this.lectureHallEx.selectedIds,
lessonType: this.lessonTypeEx.selectedIds
});
this.eventResult.emit(

View File

@ -1,5 +1,5 @@
import {Directive, Input, TemplateRef, ViewContainerRef} from '@angular/core';
import AuthApiService from "@api/v1/authApiService";
import AuthApiService from "@api/v1/authApi.service";
import {AuthRoles} from "@model/authRoles";
import {catchError, of} from "rxjs";

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

@ -8,7 +8,7 @@ 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/authApiService";
import AuthApiService from "@api/v1/authApi.service";
import {Router} from "@angular/router";
import {catchError} from "rxjs";
import {TwoFactorAuthentication} from "@model/twoFactorAuthentication";

View File

@ -1,4 +1,4 @@
import {Component, LOCALE_ID, ViewChild} from '@angular/core';
import {Component, ViewChild} from '@angular/core';
import {AdditionalText, TableHeaderComponent} from "@component/schedule/table-header/table-header.component";
import {addDays, weekInYear} from "@progress/kendo-date-math";
import {TabsComponent, TabsSelect} from "@component/schedule/tabs/tabs.component";
@ -38,8 +38,7 @@ import {HasRoleDirective} from "@/directives/has-role.directive";
styleUrl: './schedule.component.css',
providers: [
ScheduleService,
ImportService,
{provide: LOCALE_ID, useValue: 'ru-RU'}
ImportService
]
})
@ -56,7 +55,11 @@ export class ScheduleComponent {
@ViewChild('tableHeader') childComponent!: TableHeaderComponent;
constructor(api: ScheduleService, route: ActivatedRoute, private importApi: ImportService, private notify: ToastrService, public dialog: MatDialog) {
constructor(api: ScheduleService,
route: ActivatedRoute,
private importApi: ImportService,
private notify: ToastrService,
public dialog: MatDialog) {
route.queryParams.subscribe(params => {
TabStorageService.selectDataFromQuery(params);
});

View File

@ -10,7 +10,7 @@ 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/authApiService";
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";

View File

@ -4,4 +4,5 @@ export interface ScheduleRequest {
disciplines?: Array<number>;
professors?: Array<number>;
lectureHalls?: Array<number>;
lessonType?: Array<number>;
}

View File

@ -0,0 +1,4 @@
export interface CronUpdateScheduleResponse {
cron: string;
nextStart?: Date[];
}

View File

@ -0,0 +1,4 @@
export interface LessonTypeResponse {
id: number;
name: string;
}

View File

@ -0,0 +1,7 @@
import {DateOnly} from "@model/dateOnly";
export default interface CronUpdateSkip {
start?: DateOnly;
end?: DateOnly;
date?: DateOnly;
}