From 12e6d9cf11265a513a223f5f06918683c856c051 Mon Sep 17 00:00:00 2001 From: Mathis Hofer Date: Wed, 9 Sep 2020 13:58:09 +0200 Subject: [PATCH] Add my absences #185 --- src/app/app-routing.module.ts | 8 + src/app/home.component.spec.ts | 1 + src/app/home.component.ts | 1 + .../my-absences-confirm.component.html | 75 +++++++ .../my-absences-confirm.component.scss | 0 .../my-absences-confirm.component.spec.ts | 45 ++++ .../my-absences-confirm.component.ts | 128 +++++++++++ .../my-absences-report-link.component.html | 8 + .../my-absences-report-link.component.scss | 14 ++ .../my-absences-report-link.component.spec.ts | 27 +++ .../my-absences-report-link.component.ts | 27 +++ .../my-absences-report.component.html | 1 + .../my-absences-report.component.scss | 0 .../my-absences-report.component.spec.ts | 27 +++ .../my-absences-report.component.ts | 13 ++ .../my-absences-show.component.html | 98 +++++++++ .../my-absences-show.component.scss | 0 .../my-absences-show.component.spec.ts | 49 +++++ .../my-absences-show.component.ts | 80 +++++++ .../my-absences/my-absences.component.html | 1 + .../my-absences/my-absences.component.scss | 0 .../my-absences/my-absences.component.spec.ts | 27 +++ .../my-absences/my-absences.component.ts | 15 ++ .../my-absences/my-absences-routing.module.ts | 24 +++ src/app/my-absences/my-absences.module.ts | 20 ++ .../services/my-absences.service.spec.ts | 32 +++ .../services/my-absences.service.ts | 200 ++++++++++++++++++ .../my-profile-address.component.ts | 8 +- .../my-profile-edit.component.ts | 3 +- .../my-profile-entry.component.ts | 8 +- .../my-profile-header.component.html | 18 +- .../my-profile-header.component.ts | 24 ++- .../my-profile-show.component.ts | 3 +- .../my-profile/my-profile.component.ts | 3 +- src/app/settings.ts | 1 + .../student-profile-absences.component.html | 53 ++++- .../student-profile-absences.component.scss | 37 ++-- .../student-profile-absences.component.ts | 57 ++++- .../student-profile.component.html | 2 + src/app/shared/models/lesson-absence.model.ts | 4 + .../shared/models/lesson-presence.model.ts | 2 +- .../shared/models/timetable-entry.model.ts | 26 +++ .../confirm-absences-selection.service.ts | 20 +- .../shared/services/presence-types.service.ts | 29 +++ .../shared/services/reports.service.spec.ts | 95 ++++++++- src/app/shared/services/reports.service.ts | 155 +++++++++++--- .../student-profile-absences.service.ts | 32 +-- .../shared/services/students-rest.service.ts | 40 +++- src/app/shared/shared.module.ts | 6 +- src/app/shared/utils/lesson-presences.spec.ts | 16 ++ src/app/shared/utils/lesson-presences.ts | 10 + src/assets/locales/de-CH.json | 17 ++ src/settings.example.js | 4 + src/spec-builders.ts | 2 +- src/spec-helpers.ts | 1 + 55 files changed, 1491 insertions(+), 106 deletions(-) create mode 100644 src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.html create mode 100644 src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.scss create mode 100644 src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.spec.ts create mode 100644 src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.ts create mode 100644 src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.html create mode 100644 src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.scss create mode 100644 src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.spec.ts create mode 100644 src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.ts create mode 100644 src/app/my-absences/components/my-absences-report/my-absences-report.component.html create mode 100644 src/app/my-absences/components/my-absences-report/my-absences-report.component.scss create mode 100644 src/app/my-absences/components/my-absences-report/my-absences-report.component.spec.ts create mode 100644 src/app/my-absences/components/my-absences-report/my-absences-report.component.ts create mode 100644 src/app/my-absences/components/my-absences-show/my-absences-show.component.html create mode 100644 src/app/my-absences/components/my-absences-show/my-absences-show.component.scss create mode 100644 src/app/my-absences/components/my-absences-show/my-absences-show.component.spec.ts create mode 100644 src/app/my-absences/components/my-absences-show/my-absences-show.component.ts create mode 100644 src/app/my-absences/components/my-absences/my-absences.component.html create mode 100644 src/app/my-absences/components/my-absences/my-absences.component.scss create mode 100644 src/app/my-absences/components/my-absences/my-absences.component.spec.ts create mode 100644 src/app/my-absences/components/my-absences/my-absences.component.ts create mode 100644 src/app/my-absences/my-absences-routing.module.ts create mode 100644 src/app/my-absences/my-absences.module.ts create mode 100644 src/app/my-absences/services/my-absences.service.spec.ts create mode 100644 src/app/my-absences/services/my-absences.service.ts create mode 100644 src/app/shared/models/timetable-entry.model.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 6e0d218a3..5a5d29936 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -37,6 +37,14 @@ const routes: Routes = [ (m) => m.EvaluateAbsencesModule ), }, + { + path: 'my-absences', + canActivate: [AuthGuard], + loadChildren: () => + import('./my-absences/my-absences.module').then( + (m) => m.MyAbsencesModule + ), + }, { path: 'my-profile', canActivate: [AuthGuard], diff --git a/src/app/home.component.spec.ts b/src/app/home.component.spec.ts index faf0d01d3..169f36b07 100644 --- a/src/app/home.component.spec.ts +++ b/src/app/home.component.spec.ts @@ -34,6 +34,7 @@ describe('HomeComponent', () => { '/open-absences', '/edit-absences', '/evaluate-absences', + '/my-absences', '/my-profile', ]); }); diff --git a/src/app/home.component.ts b/src/app/home.component.ts index 6bd29a741..b8b0c3d7d 100644 --- a/src/app/home.component.ts +++ b/src/app/home.component.ts @@ -22,6 +22,7 @@ export class HomeComponent { 'open-absences', 'edit-absences', 'evaluate-absences', + 'my-absences', 'my-profile', ]; } diff --git a/src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.html b/src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.html new file mode 100644 index 000000000..328b8cdbd --- /dev/null +++ b/src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.html @@ -0,0 +1,75 @@ +
+
+ {{ + (selectedCount === 1 + ? 'my-absences.confirm.lesson-selected' + : 'my-absences.confirm.lessons-selected' + ) | translate: { count: selectedCount } + }} +
+
+
+ +
+ + + +
+ {{ + 'global.validation-errors.' + error.error + | translate: error.params + }} +
+
+
+
+
+ {{ 'my-absences.confirm.remark' | translate }} +
+ +
+ + +
+
+
diff --git a/src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.scss b/src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.spec.ts b/src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.spec.ts new file mode 100644 index 000000000..8f8d6d991 --- /dev/null +++ b/src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.spec.ts @@ -0,0 +1,45 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; + +import { MyAbsencesConfirmComponent } from './my-absences-confirm.component'; +import { buildTestModuleMetadata } from 'src/spec-helpers'; +import { MyAbsencesService } from '../../services/my-absences.service'; +import { ConfirmAbsencesSelectionService } from 'src/app/shared/services/confirm-absences-selection.service'; + +describe('MyAbsencesConfirmComponent', () => { + let component: MyAbsencesConfirmComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule( + buildTestModuleMetadata({ + declarations: [MyAbsencesConfirmComponent], + providers: [ + { + provide: MyAbsencesService, + useValue: { + openAbsences$: of([]), + counts$: of({}), + }, + }, + { + provide: ConfirmAbsencesSelectionService, + useValue: { + selectedIds$: of([{ lessonIds: [1], personIds: [1] }]), + }, + }, + ], + }) + ).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyAbsencesConfirmComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.ts b/src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.ts new file mode 100644 index 000000000..5120d801d --- /dev/null +++ b/src/app/my-absences/components/my-absences-confirm/my-absences-confirm.component.ts @@ -0,0 +1,128 @@ +import { + Component, + OnInit, + ChangeDetectionStrategy, + Inject, +} from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, combineLatest } from 'rxjs'; +import { + take, + switchMap, + finalize, + map, + filter, + startWith, +} from 'rxjs/operators'; + +import { ConfirmAbsencesSelectionService } from 'src/app/shared/services/confirm-absences-selection.service'; +import { LessonPresencesUpdateRestService } from 'src/app/shared/services/lesson-presences-update-rest.service'; +import { PresenceTypesService } from 'src/app/shared/services/presence-types.service'; +import { getValidationErrors } from 'src/app/shared/utils/form'; +import { SETTINGS, Settings } from 'src/app/settings'; +import { MyAbsencesService } from '../../services/my-absences.service'; + +@Component({ + selector: 'erz-my-absences-confirm', + templateUrl: './my-absences-confirm.component.html', + styleUrls: ['./my-absences-confirm.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyAbsencesConfirmComponent implements OnInit { + formGroup = this.createFormGroup(); + + saving$ = new BehaviorSubject(false); + private submitted$ = new BehaviorSubject(false); + + absenceTypes$ = this.presenceTypesService.confirmationTypes$.pipe( + map((types) => types.filter((t) => t.IsAbsence && !t.IsHalfDay)) + ); + + absenceTypeIdErrors$ = combineLatest([ + getValidationErrors(this.formGroup.get('absenceTypeId')), + this.submitted$, + ]).pipe( + filter((v) => v[1]), + map((v) => v[0]), + startWith([]) + ); + + constructor( + private fb: FormBuilder, + private router: Router, + private toastr: ToastrService, + private translate: TranslateService, + private presenceTypesService: PresenceTypesService, + private updateService: LessonPresencesUpdateRestService, + private myAbsencesService: MyAbsencesService, + @Inject(SETTINGS) private settings: Settings, + public selectionService: ConfirmAbsencesSelectionService + ) {} + + ngOnInit(): void { + this.selectionService.selectedIds$ + .pipe(take(1)) + .subscribe((selectedIds) => { + if (selectedIds.length === 0) { + // Nothing to confirm if no entries are selected + this.navigateBack(); + } + }); + } + + onSubmit(): void { + this.submitted$.next(true); + if (this.formGroup.valid) { + const { absenceTypeId } = this.formGroup.value; + this.save(absenceTypeId); + } + } + + cancel(): void { + this.navigateBack(); + } + + private createFormGroup(): FormGroup { + return this.fb.group({ + absenceTypeId: [null, Validators.required], + }); + } + + private save(absenceTypeId: number): void { + this.saving$.next(true); + + this.selectionService.selectedIds$ + .pipe( + take(1), + switchMap((selectedIds) => + combineLatest( + selectedIds.map(({ lessonIds, personIds }) => + this.updateService.editLessonPresences( + lessonIds, + personIds, + absenceTypeId, + this.settings.unconfirmedAbsenceStateId + ) + ) + ) + ), + finalize(() => this.saving$.next(false)) + ) + .subscribe(this.onSaveSuccess.bind(this)); + } + + private onSaveSuccess(): void { + this.myAbsencesService.reset(); + this.toastr.success( + this.translate.instant('my-absences.confirm.save-success') + ); + this.navigateBack(); + } + + private navigateBack(): void { + this.router.navigate(['/my-absences']); + } +} diff --git a/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.html b/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.html new file mode 100644 index 000000000..b710f193f --- /dev/null +++ b/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.html @@ -0,0 +1,8 @@ +
+ {{ 'my-absences.show.report-absence-link' | translate }} +
+ +
+ keyboard_arrow_right +
+
diff --git a/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.scss b/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.scss new file mode 100644 index 000000000..9cc6bff33 --- /dev/null +++ b/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.scss @@ -0,0 +1,14 @@ +@import "../../../../bootstrap-variables"; + +:host { + display: flex; + align-items: center; + justify-content: space-between; + padding: $spacer; + cursor: pointer; +} + +.btn { + color: $body-color; + text-decoration: none; +} diff --git a/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.spec.ts b/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.spec.ts new file mode 100644 index 000000000..98c496461 --- /dev/null +++ b/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MyAbsencesReportLinkComponent } from '../my-absences-report-link/my-absences-report-link.component'; +import { buildTestModuleMetadata } from 'src/spec-helpers'; + +describe('MyAbsencesEditLinkComponent', () => { + let component: MyAbsencesReportLinkComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule( + buildTestModuleMetadata({ + declarations: [MyAbsencesReportLinkComponent], + }) + ).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyAbsencesReportLinkComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.ts b/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.ts new file mode 100644 index 000000000..7c60d8691 --- /dev/null +++ b/src/app/my-absences/components/my-absences-report-link/my-absences-report-link.component.ts @@ -0,0 +1,27 @@ +import { + Component, + OnInit, + ChangeDetectionStrategy, + HostListener, + ViewChild, + ElementRef, +} from '@angular/core'; + +@Component({ + selector: 'erz-my-absences-report-link', + templateUrl: './my-absences-report-link.component.html', + styleUrls: ['./my-absences-report-link.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyAbsencesReportLinkComponent implements OnInit { + @ViewChild('link') link: ElementRef; + + @HostListener('click', ['$event']) + onClick(): void { + this.link.nativeElement.click(); + } + + constructor() {} + + ngOnInit(): void {} +} diff --git a/src/app/my-absences/components/my-absences-report/my-absences-report.component.html b/src/app/my-absences/components/my-absences-report/my-absences-report.component.html new file mode 100644 index 000000000..dd1ebce50 --- /dev/null +++ b/src/app/my-absences/components/my-absences-report/my-absences-report.component.html @@ -0,0 +1 @@ +

my-absences-edit works!

diff --git a/src/app/my-absences/components/my-absences-report/my-absences-report.component.scss b/src/app/my-absences/components/my-absences-report/my-absences-report.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/my-absences/components/my-absences-report/my-absences-report.component.spec.ts b/src/app/my-absences/components/my-absences-report/my-absences-report.component.spec.ts new file mode 100644 index 000000000..a5c3e2113 --- /dev/null +++ b/src/app/my-absences/components/my-absences-report/my-absences-report.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MyAbsencesReportComponent } from './my-absences-report.component'; +import { buildTestModuleMetadata } from 'src/spec-helpers'; + +describe('MyAbsencesReportComponent', () => { + let component: MyAbsencesReportComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule( + buildTestModuleMetadata({ + declarations: [MyAbsencesReportComponent], + }) + ).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyAbsencesReportComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/my-absences/components/my-absences-report/my-absences-report.component.ts b/src/app/my-absences/components/my-absences-report/my-absences-report.component.ts new file mode 100644 index 000000000..7730d8d37 --- /dev/null +++ b/src/app/my-absences/components/my-absences-report/my-absences-report.component.ts @@ -0,0 +1,13 @@ +import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'erz-my-absences-report', + templateUrl: './my-absences-report.component.html', + styleUrls: ['./my-absences-report.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyAbsencesReportComponent implements OnInit { + constructor() {} + + ngOnInit(): void {} +} diff --git a/src/app/my-absences/components/my-absences-show/my-absences-show.component.html b/src/app/my-absences/components/my-absences-show/my-absences-show.component.html new file mode 100644 index 000000000..850f8be8b --- /dev/null +++ b/src/app/my-absences/components/my-absences-show/my-absences-show.component.html @@ -0,0 +1,98 @@ +
+ + + + + + + + + + + + {{ 'shared.profile.open-absences' | translate }} + ({{ data.absenceCounts.openAbsences }}) + + + + + + + + + + + {{ 'shared.profile.excused-absences' | translate }} + ({{ data.absenceCounts.excusedAbsences }}) + + + + + + + + + + + {{ 'shared.profile.unexcused-absences' | translate }} + ({{ data.absenceCounts.unexcusedAbsences }}) + + + + + + + + + + + {{ 'shared.profile.incidents' | translate }} + ({{ data.absenceCounts.incidents }}) + + + + + + + +
diff --git a/src/app/my-absences/components/my-absences-show/my-absences-show.component.scss b/src/app/my-absences/components/my-absences-show/my-absences-show.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/my-absences/components/my-absences-show/my-absences-show.component.spec.ts b/src/app/my-absences/components/my-absences-show/my-absences-show.component.spec.ts new file mode 100644 index 000000000..9b4fef9e8 --- /dev/null +++ b/src/app/my-absences/components/my-absences-show/my-absences-show.component.spec.ts @@ -0,0 +1,49 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; + +import { MyAbsencesShowComponent } from './my-absences-show.component'; +import { buildTestModuleMetadata } from 'src/spec-helpers'; +import { MyAbsencesService } from '../../services/my-absences.service'; +import { MyAbsencesReportLinkComponent } from '../my-absences-report-link/my-absences-report-link.component'; +import { StorageService } from 'src/app/shared/services/storage.service'; + +describe('MyAbsencesShowComponent', () => { + let component: MyAbsencesShowComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule( + buildTestModuleMetadata({ + declarations: [MyAbsencesShowComponent, MyAbsencesReportLinkComponent], + providers: [ + { + provide: MyAbsencesService, + useValue: { + openAbsences$: of([]), + lessonAbsences$: of([]), + counts$: of({}), + }, + }, + { + provide: StorageService, + useValue: { + getPayload(): Option { + return { id_person: 42 }; + }, + }, + }, + ], + }) + ).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyAbsencesShowComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/my-absences/components/my-absences-show/my-absences-show.component.ts b/src/app/my-absences/components/my-absences-show/my-absences-show.component.ts new file mode 100644 index 000000000..887e0101c --- /dev/null +++ b/src/app/my-absences/components/my-absences-show/my-absences-show.component.ts @@ -0,0 +1,80 @@ +import { + Component, + OnInit, + ChangeDetectionStrategy, + OnDestroy, +} from '@angular/core'; +import { Observable, combineLatest, Subject, of } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; + +import { MyAbsencesService } from '../../services/my-absences.service'; +import { ConfirmAbsencesSelectionService } from 'src/app/shared/services/confirm-absences-selection.service'; +import { ReportsService } from 'src/app/shared/services/reports.service'; + +@Component({ + selector: 'erz-my-absences-show', + templateUrl: './my-absences-show.component.html', + styleUrls: ['./my-absences-show.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyAbsencesShowComponent implements OnInit, OnDestroy { + reportUrl$ = this.loadReportUrl(); + reportAvailable$ = this.reportsService.studentConfirmationAvailability$; + + private destroy$ = new Subject(); + + constructor( + private reportsService: ReportsService, + public myAbsencesService: MyAbsencesService, + public absencesSelectionService: ConfirmAbsencesSelectionService + ) {} + + ngOnInit(): void { + // When the absences have been loaded, set the record ID to + // initiate the loading of the report's availability state + this.myAbsencesService.lessonAbsences$ + .pipe(take(1)) + .subscribe((absences) => { + if (absences.length > 0) { + const absence = absences[0]; + this.reportsService.setStudentConfirmationAvailabilityRecordId( + `${absence.LessonRef.Id}_${absence.RegistrationId}` + ); + } + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + } + + private loadReportUrl(): Observable> { + return combineLatest([ + this.absencesSelectionService.selectedWithoutPresenceType$, + this.absencesSelectionService.selectedIds$, + ]).pipe( + switchMap(([selectedWithout, selectedIds]) => + selectedWithout.length === 0 && selectedIds.length > 0 + ? this.getReportRecordIds(selectedIds[0].lessonIds) + : of(null) + ), + map((recordIds) => + recordIds + ? this.reportsService.getStudentConfirmationUrl(recordIds) + : null + ) + ); + } + + private getReportRecordIds( + lessonIds: ReadonlyArray + ): Observable> { + return this.myAbsencesService.lessonAbsences$.pipe( + map((absences) => + absences + .filter((a) => lessonIds.includes(a.LessonRef.Id)) + .map((a) => `${a.LessonRef.Id}_${a.RegistrationId}`) + ) + ); + } +} diff --git a/src/app/my-absences/components/my-absences/my-absences.component.html b/src/app/my-absences/components/my-absences/my-absences.component.html new file mode 100644 index 000000000..0680b43f9 --- /dev/null +++ b/src/app/my-absences/components/my-absences/my-absences.component.html @@ -0,0 +1 @@ + diff --git a/src/app/my-absences/components/my-absences/my-absences.component.scss b/src/app/my-absences/components/my-absences/my-absences.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/my-absences/components/my-absences/my-absences.component.spec.ts b/src/app/my-absences/components/my-absences/my-absences.component.spec.ts new file mode 100644 index 000000000..859b3ccb0 --- /dev/null +++ b/src/app/my-absences/components/my-absences/my-absences.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MyAbsencesComponent } from './my-absences.component'; +import { buildTestModuleMetadata } from 'src/spec-helpers'; + +describe('MyAbsencesComponent', () => { + let component: MyAbsencesComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule( + buildTestModuleMetadata({ + declarations: [MyAbsencesComponent], + }) + ).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MyAbsencesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/my-absences/components/my-absences/my-absences.component.ts b/src/app/my-absences/components/my-absences/my-absences.component.ts new file mode 100644 index 000000000..4828cb787 --- /dev/null +++ b/src/app/my-absences/components/my-absences/my-absences.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { MyAbsencesService } from '../../services/my-absences.service'; + +@Component({ + selector: 'erz-my-absences', + templateUrl: './my-absences.component.html', + styleUrls: ['./my-absences.component.scss'], + providers: [MyAbsencesService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MyAbsencesComponent implements OnInit { + constructor() {} + + ngOnInit(): void {} +} diff --git a/src/app/my-absences/my-absences-routing.module.ts b/src/app/my-absences/my-absences-routing.module.ts new file mode 100644 index 000000000..5536b2003 --- /dev/null +++ b/src/app/my-absences/my-absences-routing.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { MyAbsencesComponent } from './components/my-absences/my-absences.component'; +import { MyAbsencesShowComponent } from './components/my-absences-show/my-absences-show.component'; +import { MyAbsencesConfirmComponent } from './components/my-absences-confirm/my-absences-confirm.component'; +import { MyAbsencesReportComponent } from './components/my-absences-report/my-absences-report.component'; + +const routes: Routes = [ + { + path: '', + component: MyAbsencesComponent, + children: [ + { path: '', component: MyAbsencesShowComponent }, + { path: 'confirm', component: MyAbsencesConfirmComponent }, + { path: 'report', component: MyAbsencesReportComponent }, + ], + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class MyAbsencesRoutingModule {} diff --git a/src/app/my-absences/my-absences.module.ts b/src/app/my-absences/my-absences.module.ts new file mode 100644 index 000000000..b400a9348 --- /dev/null +++ b/src/app/my-absences/my-absences.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { SharedModule } from '../shared/shared.module'; +import { MyAbsencesRoutingModule } from './my-absences-routing.module'; +import { MyAbsencesComponent } from './components/my-absences/my-absences.component'; +import { MyAbsencesShowComponent } from './components/my-absences-show/my-absences-show.component'; +import { MyAbsencesReportComponent } from './components/my-absences-report/my-absences-report.component'; +import { MyAbsencesReportLinkComponent } from './components/my-absences-report-link/my-absences-report-link.component'; +import { MyAbsencesConfirmComponent } from './components/my-absences-confirm/my-absences-confirm.component'; + +@NgModule({ + declarations: [ + MyAbsencesComponent, + MyAbsencesShowComponent, + MyAbsencesReportComponent, + MyAbsencesReportLinkComponent, + MyAbsencesConfirmComponent, + ], + imports: [SharedModule, MyAbsencesRoutingModule], +}) +export class MyAbsencesModule {} diff --git a/src/app/my-absences/services/my-absences.service.spec.ts b/src/app/my-absences/services/my-absences.service.spec.ts new file mode 100644 index 000000000..ad07b86e8 --- /dev/null +++ b/src/app/my-absences/services/my-absences.service.spec.ts @@ -0,0 +1,32 @@ +import { TestBed } from '@angular/core/testing'; + +import { MyAbsencesService } from './my-absences.service'; +import { buildTestModuleMetadata } from 'src/spec-helpers'; +import { StorageService } from 'src/app/shared/services/storage.service'; + +describe('MyAbsencesService', () => { + let service: MyAbsencesService; + + beforeEach(() => { + TestBed.configureTestingModule( + buildTestModuleMetadata({ + providers: [ + MyAbsencesService, + { + provide: StorageService, + useValue: { + getPayload(): any { + return { id_person: 123 }; + }, + }, + }, + ], + }) + ); + service = TestBed.inject(MyAbsencesService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/my-absences/services/my-absences.service.ts b/src/app/my-absences/services/my-absences.service.ts new file mode 100644 index 000000000..1f9d7bf10 --- /dev/null +++ b/src/app/my-absences/services/my-absences.service.ts @@ -0,0 +1,200 @@ +import { Injectable, Inject } from '@angular/core'; +import { Observable, ReplaySubject, combineLatest } from 'rxjs'; +import { + switchMap, + startWith, + multicast, + refCount, + map, + take, + shareReplay, +} from 'rxjs/operators'; + +import { SETTINGS, Settings } from 'src/app/settings'; +import { StudentProfileAbsencesCounts } from 'src/app/shared/services/student-profile-absences.service'; +import { StorageService } from 'src/app/shared/services/storage.service'; +import { LessonPresence } from 'src/app/shared/models/lesson-presence.model'; +import { LessonAbsence } from 'src/app/shared/models/lesson-absence.model'; +import { LessonIncident } from 'src/app/shared/models/lesson-incident.model'; +import { StudentsRestService } from 'src/app/shared/services/students-rest.service'; +import { TimetableEntry } from 'src/app/shared/models/timetable-entry.model'; +import { sortLessonPresencesByDate } from 'src/app/shared/utils/lesson-presences'; + +@Injectable() +export class MyAbsencesService { + private studentId$ = new ReplaySubject(1); + lessonAbsences$ = this.studentId$.pipe( + switchMap(this.loadLessonAbsences.bind(this)), + shareReplay(1) + ); + private lessonIncidents$ = this.studentId$.pipe( + switchMap(this.loadLessonIncidents.bind(this)), + shareReplay(1) + ); + private lessonPresences$ = this.getLessonPresences(); + + openAbsences$ = this.getAbsences(this.settings.unconfirmedAbsenceStateId); + excusedAbsences$ = this.getAbsences(this.settings.excusedAbsenceStateId); + unexcusedAbsences$ = this.getAbsences(this.settings.unexcusedAbsenceStateId); + incidents$ = this.getAbsences(null); + + // The halfDays are not provided by intention, since there is no + // reliable method for counting them. + + counts$ = this.getCounts(); + + constructor( + @Inject(SETTINGS) private settings: Settings, + private storageService: StorageService, + private studentsService: StudentsRestService + ) { + const studentId = this.storageService.getPayload()?.id_person || null; + if (studentId) { + this.studentId$.next(studentId); + } + } + + reset(): void { + this.studentId$ + .pipe(take(1)) + .subscribe((studentId) => this.studentId$.next(studentId)); + } + + private getLessonPresences(): Observable< + Option> + > { + return this.getCached( + combineLatest([ + this.studentId$, + this.lessonAbsences$, + this.lessonIncidents$, + ]).pipe( + switchMap(([studentId, absences, incidents]) => + this.loadTimetableEntries(studentId, absences, incidents).pipe( + map((timetableEntries) => + this.buildLessonPresences(absences, incidents, timetableEntries) + ) + ) + ), + map(sortLessonPresencesByDate) + ) + ); + } + + private getAbsences( + confirmationStateId: Option + ): Observable>> { + return this.getCached( + this.lessonPresences$.pipe( + map( + (presences) => + presences?.filter( + (p) => p.ConfirmationStateId === confirmationStateId + ) || null + ) + ) + ); + } + + private getCounts(): Observable { + return combineLatest([ + this.getCount(this.openAbsences$), + this.getCount(this.excusedAbsences$), + this.getCount(this.unexcusedAbsences$), + this.getCount(this.incidents$), + ]).pipe( + map(([openAbsences, excusedAbsences, unexcusedAbsences, incidents]) => ({ + openAbsences, + excusedAbsences, + unexcusedAbsences, + incidents, + halfDays: null, + })) + ); + } + + private getCached(source$: Observable): Observable> { + return source$.pipe( + startWith(null), + // Clear the cache if all subscribers disconnect (don't replay the previous value) + multicast(() => new ReplaySubject>(1)), + refCount() + ); + } + + private getCount( + source$: Observable>> + ): Observable> { + return source$.pipe(map((absences) => absences?.length ?? null)); + } + + private loadLessonAbsences( + studentId: number + ): Observable> { + return this.studentsService.getLessonAbsences(studentId); + } + + private loadLessonIncidents( + studentId: number + ): Observable> { + return this.studentsService.getLessonIncidents(studentId); + } + + private loadTimetableEntries( + studentId: number, + absences: ReadonlyArray, + incidents: ReadonlyArray + ): Observable> { + return this.studentsService.getTimetableEntries(studentId, { + 'filter.Id': `;${[...absences, ...incidents] + .map((e) => e.LessonRef.Id) + .join(';')}`, + }); + } + + private buildLessonPresences( + absences: ReadonlyArray, + incidents: ReadonlyArray, + timetableEntries: ReadonlyArray + ): ReadonlyArray { + return [...absences, ...incidents].map((absence) => + this.buildLessonPresence(absence, timetableEntries) + ); + } + + /** + * Construct a lesson presence object for the given absence/incident + * using the data from the corresponding timetable entry. + */ + private buildLessonPresence( + absence: LessonAbsence | LessonIncident, + timetableEntries: ReadonlyArray + ): LessonPresence { + // There is always a timetable entry for the given lesson + // absence/incident, handle it gracefully if not (should not + // happen in real life) + const entry = timetableEntries.find((e) => e.Id === absence.LessonRef.Id); + return { + Id: '', + LessonRef: absence.LessonRef, + StudentRef: absence.StudentRef, + EventRef: { Id: entry?.EventId || 0, HRef: null }, + TypeRef: absence.TypeRef, + ConfirmationStateId: + 'ConfirmationStateId' in absence ? absence.ConfirmationStateId : null, + EventDesignation: entry?.EventDesignation || '', + HasStudyCourseConfirmationCode: false, + LessonDateTimeFrom: entry?.From || new Date(), + LessonDateTimeTo: entry?.To || new Date(), + Comment: null, + Date: entry?.From || new Date(), + Type: absence.Type, + StudentFullName: absence.StudentFullName, + StudyClassNumber: '', // Currently not available on timetable entry + TeacherInformation: entry + ? `${entry.LessonTeacherLastname} ${entry.LessonTeacherFirstname}` + : '', + WasAbsentInPrecedingLesson: false, + }; + } +} diff --git a/src/app/my-profile/components/my-profile-address/my-profile-address.component.ts b/src/app/my-profile/components/my-profile-address/my-profile-address.component.ts index 5bb7094fe..9c13d2a35 100644 --- a/src/app/my-profile/components/my-profile-address/my-profile-address.component.ts +++ b/src/app/my-profile/components/my-profile-address/my-profile-address.component.ts @@ -1,9 +1,15 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { + Component, + Input, + OnInit, + ChangeDetectionStrategy, +} from '@angular/core'; @Component({ selector: 'erz-my-profile-address', templateUrl: './my-profile-address.component.html', styleUrls: ['./my-profile-address.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class MyProfileAddressComponent implements OnInit { @Input() address: string; diff --git a/src/app/my-profile/components/my-profile-edit/my-profile-edit.component.ts b/src/app/my-profile/components/my-profile-edit/my-profile-edit.component.ts index 55a39a310..8352dd2cf 100644 --- a/src/app/my-profile/components/my-profile-edit/my-profile-edit.component.ts +++ b/src/app/my-profile/components/my-profile-edit/my-profile-edit.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; @@ -24,6 +24,7 @@ import { getControlValidationErrors } from 'src/app/shared/utils/form'; selector: 'erz-my-profile-edit', templateUrl: './my-profile-edit.component.html', styleUrls: ['./my-profile-edit.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class MyProfileEditComponent implements OnInit { student$ = this.profileService.profile$.pipe(pluck('student')); diff --git a/src/app/my-profile/components/my-profile-entry/my-profile-entry.component.ts b/src/app/my-profile/components/my-profile-entry/my-profile-entry.component.ts index 9f27dbd8d..9a8061a5d 100644 --- a/src/app/my-profile/components/my-profile-entry/my-profile-entry.component.ts +++ b/src/app/my-profile/components/my-profile-entry/my-profile-entry.component.ts @@ -1,9 +1,15 @@ -import { Component, OnInit, Input } from '@angular/core'; +import { + Component, + OnInit, + Input, + ChangeDetectionStrategy, +} from '@angular/core'; @Component({ selector: 'erz-my-profile-entry', templateUrl: './my-profile-entry.component.html', styleUrls: ['./my-profile-entry.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class MyProfileEntryComponent implements OnInit { @Input() label: string; diff --git a/src/app/my-profile/components/my-profile-header/my-profile-header.component.html b/src/app/my-profile/components/my-profile-header/my-profile-header.component.html index 23f9c346d..230080b95 100644 --- a/src/app/my-profile/components/my-profile-header/my-profile-header.component.html +++ b/src/app/my-profile/components/my-profile-header/my-profile-header.component.html @@ -7,13 +7,15 @@
{{ student.Birthdate | date: 'dd.MM.yyyy' }}
- - description - + + + description + + diff --git a/src/app/my-profile/components/my-profile-header/my-profile-header.component.ts b/src/app/my-profile/components/my-profile-header/my-profile-header.component.ts index fe0bb9eb4..1cad3ad8e 100644 --- a/src/app/my-profile/components/my-profile-header/my-profile-header.component.ts +++ b/src/app/my-profile/components/my-profile-header/my-profile-header.component.ts @@ -4,7 +4,11 @@ import { OnInit, SimpleChanges, OnChanges, + ChangeDetectionStrategy, } from '@angular/core'; +import { ReplaySubject, combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; + import { Person } from '../../../shared/models/person.model'; import { ReportsService } from '../../../shared/services/reports.service'; @@ -12,21 +16,31 @@ import { ReportsService } from '../../../shared/services/reports.service'; selector: 'erz-my-profile-header', templateUrl: './my-profile-header.component.html', styleUrls: ['./my-profile-header.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class MyProfileHeaderComponent implements OnInit, OnChanges { @Input() student?: Person; - public reportUrl: Option = null; + private studentId$ = new ReplaySubject>(1); + + reportUrl$ = combineLatest([ + this.reportsService.personMasterDataAvailability$, + this.studentId$, + ]).pipe( + map(([available, studentId]) => + available && studentId + ? this.reportsService.getPersonMasterDataUrl(studentId) + : null + ) + ); constructor(private reportsService: ReportsService) {} ngOnInit(): void {} ngOnChanges(changes: SimpleChanges): void { - if (changes.student && this.student) { - this.reportUrl = this.reportsService.getPersonMasterDataReportUrl( - this.student.Id - ); + if (changes.student) { + this.studentId$.next(changes.student.currentValue?.Id || null); } } } diff --git a/src/app/my-profile/components/my-profile-show/my-profile-show.component.ts b/src/app/my-profile/components/my-profile-show/my-profile-show.component.ts index b942f8ed7..2113f4286 100644 --- a/src/app/my-profile/components/my-profile-show/my-profile-show.component.ts +++ b/src/app/my-profile/components/my-profile-show/my-profile-show.component.ts @@ -1,10 +1,11 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { MyProfileService } from '../../services/my-profile.service'; @Component({ selector: 'erz-my-profile-show', templateUrl: './my-profile-show.component.html', styleUrls: ['./my-profile-show.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class MyProfileShowComponent implements OnInit { constructor(public profileService: MyProfileService) {} diff --git a/src/app/my-profile/components/my-profile/my-profile.component.ts b/src/app/my-profile/components/my-profile/my-profile.component.ts index ec3ae21a7..21086d5be 100644 --- a/src/app/my-profile/components/my-profile/my-profile.component.ts +++ b/src/app/my-profile/components/my-profile/my-profile.component.ts @@ -1,10 +1,11 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { MyProfileService } from '../../services/my-profile.service'; @Component({ selector: 'erz-my-profile', templateUrl: './my-profile.component.html', styleUrls: ['./my-profile.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, providers: [MyProfileService], }) export class MyProfileComponent implements OnInit { diff --git a/src/app/settings.ts b/src/app/settings.ts index eb8acccf9..dc25b1924 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -16,6 +16,7 @@ const Settings = t.type({ excusedAbsenceStateId: t.number, unconfirmedAbsencesRefreshTime: Option(t.number), personMasterDataReportId: t.number, + studentConfirmationReportId: t.number, }); type Settings = t.TypeOf; diff --git a/src/app/shared/components/student-profile-absences/student-profile-absences.component.html b/src/app/shared/components/student-profile-absences/student-profile-absences.component.html index 9cc891d65..0179636c3 100644 --- a/src/app/shared/components/student-profile-absences/student-profile-absences.component.html +++ b/src/app/shared/components/student-profile-absences/student-profile-absences.component.html @@ -18,12 +18,31 @@ + +
+ {{ defaultAbsenceSelectionMessage }}
@@ -42,7 +61,10 @@ />
- {{ absence.EventDesignation }}, {{ absence.StudyClassNumber }} + {{ absence.EventDesignation + }}, {{ absence.StudyClassNumber }}
{{ absence.LessonDateTimeFrom | date: 'HH:mm' }}–{{ @@ -52,14 +74,25 @@
{{ absence.TeacherInformation }}
-
- {{ absence.LessonDateTimeFrom | date: 'HH:mm' }}–{{ - absence.LessonDateTimeTo | date: 'HH:mm' - }}, {{ absence.TeacherInformation }} -
-
- {{ absence.Comment }} -
+ +
+ {{ presenceTypeDesignation }} +
+
+ {{ 'my-absences.show.confirm-presence-type' | translate }} +
+
{{ absence.LessonDateTimeFrom | date: 'dd.MM.yyyy' }}
diff --git a/src/app/shared/components/student-profile-absences/student-profile-absences.component.scss b/src/app/shared/components/student-profile-absences/student-profile-absences.component.scss index a324de94f..0e8482ec3 100644 --- a/src/app/shared/components/student-profile-absences/student-profile-absences.component.scss +++ b/src/app/shared/components/student-profile-absences/student-profile-absences.component.scss @@ -3,10 +3,12 @@ /* autoprefixer grid: on */ .absence-all { - padding: 0 $spacer (0.5 * $spacer) $spacer; + padding: 0 0 (0.5 * $spacer) $spacer; border-bottom: 1px solid $gray-200; display: grid; - grid-template-areas: "checkbox all buttons"; + grid-template-areas: + "checkbox all buttons" + "validation validation validation"; grid-template-columns: min-content 1fr min-content; } @@ -21,7 +23,7 @@ display: grid; grid-template-areas: "checkbox lesson-class time teacher" - "checkbox comment date days-ago"; + "checkbox presence-type date days-ago"; grid-template-columns: min-content 3fr 1fr 1fr; } @@ -55,7 +57,7 @@ .buttons { grid-area: buttons; - margin-right: $spacer; + display: flex; .btn { line-height: 1; @@ -63,6 +65,14 @@ } } +.validation { + grid-area: validation; +} +.validation, +.presence-type.confirm { + color: $form-feedback-invalid-color; +} + .lesson-class { grid-area: lesson-class; overflow: hidden; @@ -78,14 +88,9 @@ grid-area: teacher; } -.time-teacher { - grid-area: time-teacher; - display: none; -} - -.comment { +.presence-type { color: $gray-500; - grid-area: comment; + grid-area: presence-type; } .date { @@ -93,7 +98,7 @@ } .days-ago { - color: $gray-600; + color: $gray-500; grid-area: days-ago; } @@ -106,19 +111,17 @@ .absence-entry { grid-template-areas: "checkbox lesson-class" - "checkbox time-teacher" + "checkbox teacher" "checkbox date-days-ago" - "checkbox comment"; + "checkbox time" + "checkbox presence-type"; grid-template-columns: min-content 1fr; } - .time-teacher, .date-days-ago { display: block; } - .time, - .teacher, .date, .days-ago { display: none; diff --git a/src/app/shared/components/student-profile-absences/student-profile-absences.component.ts b/src/app/shared/components/student-profile-absences/student-profile-absences.component.ts index f529f67d7..492b5c8eb 100644 --- a/src/app/shared/components/student-profile-absences/student-profile-absences.component.ts +++ b/src/app/shared/components/student-profile-absences/student-profile-absences.component.ts @@ -9,7 +9,13 @@ import { QueryList, ElementRef, } from '@angular/core'; -import { Observable, combineLatest, ReplaySubject } from 'rxjs'; +import { + Observable, + combineLatest, + ReplaySubject, + BehaviorSubject, + of, +} from 'rxjs'; import { switchMap, filter, @@ -25,6 +31,7 @@ import { LessonPresence } from '../../models/lesson-presence.model'; import { notNull, not } from '../../utils/filter'; import { isArray } from '../../utils/array'; import { ConfirmAbsencesSelectionService } from '../../services/confirm-absences-selection.service'; +import { PresenceTypesService } from '../../services/presence-types.service'; @Component({ selector: 'erz-student-profile-absences', @@ -36,6 +43,27 @@ export class StudentProfileAbsencesComponent implements OnInit, OnChanges { @Input() absences$?: Observable>; @Input() selectionService: Option = null; + /** + * Whether display the presence type's designation (but only if is + * not the default absence type). + */ + @Input() displayPresenceType = true; + + /** + * If set to a string, this message will be displayed, if the + * selection contains absences that have no absence type (i.e. the + * default absence type). Also, entries without absence type will be + * annotated. + */ + @Input() defaultAbsenceSelectionMessage: Option = null; + + /** + * The report button be shown disabled if `reportAvailable` is true + * but no `reportUrl` is given. + */ + @Input() reportUrl: Option = null; + @Input() reportAvailable = false; + @ViewChildren('checkbox') checkboxes: QueryList>; lessonPresences$$ = new ReplaySubject< @@ -51,6 +79,8 @@ export class StudentProfileAbsencesComponent implements OnInit, OnChanges { selectionService$ = new ReplaySubject(1); editable$ = this.selectionService$.pipe(mapTo(true), startWith(false)); + private displayPresenceType$ = new BehaviorSubject(true); + allSelected$ = combineLatest([ this.lessonPresences$.pipe(filter(notNull)), this.selectionService$.pipe(switchMap((service) => service.selection$)), @@ -61,7 +91,7 @@ export class StudentProfileAbsencesComponent implements OnInit, OnChanges { ) ); - constructor() {} + constructor(private presenceTypesService: PresenceTypesService) {} ngOnInit(): void {} @@ -73,6 +103,9 @@ export class StudentProfileAbsencesComponent implements OnInit, OnChanges { changes.selectionService.currentValue.clear(); this.selectionService$.next(changes.selectionService.currentValue); } + if (changes.displayPresenceType) { + this.displayPresenceType$.next(changes.displayPresenceType.currentValue); + } } toggleAll(checked: boolean): void { @@ -99,8 +132,26 @@ export class StudentProfileAbsencesComponent implements OnInit, OnChanges { } else { checkbox = indexOrCheckbox; } - if (event.target !== checkbox) { + if ( + event.target !== checkbox && + !Boolean((event.target as HTMLElement).closest('.buttons')) + ) { checkbox.click(); } } + + getPresenceTypeDesignation( + absence: LessonPresence + ): Observable> { + return this.displayPresenceType$.pipe( + switchMap((display) => + display ? this.presenceTypesService.displayedTypes$ : of([]) + ), + map((types) => + absence.TypeRef.Id + ? types.find((t) => t.Id === absence.TypeRef.Id)?.Designation || null + : null + ) + ); + } } diff --git a/src/app/shared/components/student-profile/student-profile.component.html b/src/app/shared/components/student-profile/student-profile.component.html index e10a87039..16658eadb 100644 --- a/src/app/shared/components/student-profile/student-profile.component.html +++ b/src/app/shared/components/student-profile/student-profile.component.html @@ -138,6 +138,7 @@ @@ -176,6 +177,7 @@ diff --git a/src/app/shared/models/lesson-absence.model.ts b/src/app/shared/models/lesson-absence.model.ts index 586faf29b..25400757d 100644 --- a/src/app/shared/models/lesson-absence.model.ts +++ b/src/app/shared/models/lesson-absence.model.ts @@ -2,6 +2,7 @@ import * as t from 'io-ts'; import { Reference, Option } from './common-types'; const LessonAbsence = t.type({ + Id: t.string, LessonRef: Reference, StudentRef: Reference, TypeRef: Reference, @@ -10,6 +11,9 @@ const LessonAbsence = t.type({ Comment: Option(t.string), StudentFullName: t.string, Type: Option(t.string), + // IsHalfDayAbsence: t.boolean, + // HalfDayPoint: Option(/* number maybe? */), + RegistrationId: t.number, HRef: t.string, }); type LessonAbsence = t.TypeOf; diff --git a/src/app/shared/models/lesson-presence.model.ts b/src/app/shared/models/lesson-presence.model.ts index 166801c83..68ae256df 100644 --- a/src/app/shared/models/lesson-presence.model.ts +++ b/src/app/shared/models/lesson-presence.model.ts @@ -30,7 +30,7 @@ const LessonPresence = t.type({ StudentFullName: t.string, // StudyClassDesignation: t.string, StudyClassNumber: t.string, - // TeacherInformation: t.string, + TeacherInformation: t.string, WasAbsentInPrecedingLesson: Maybe(t.boolean), }); type LessonPresence = t.TypeOf; diff --git a/src/app/shared/models/timetable-entry.model.ts b/src/app/shared/models/timetable-entry.model.ts new file mode 100644 index 000000000..79dacaad2 --- /dev/null +++ b/src/app/shared/models/timetable-entry.model.ts @@ -0,0 +1,26 @@ +import * as t from 'io-ts'; +import { LocalDateTimeFromString } from './common-types'; + +const TimetableEntry = t.type({ + Id: t.number, + EventId: t.number, + From: LocalDateTimeFromString, + To: LocalDateTimeFromString, + // Comment: Option(t.string), + EventNumber: t.string, + EventDesignation: t.string, + // EventColor: t.number, + // EventLocation: t.number, + // EventManagerInformation: t.string, + // EventManagerId: t.number, + // EventManagerLastname: Option(t.string), + // EventManagerFirstname: Option(t.string), + // LessonTeacherId: t.number, + LessonTeacherLastname: t.string, + LessonTeacherFirstname: t.string, + // RoomId: t.number, + // Room: t.string, + // HRef: Option(t.string), +}); +type TimetableEntry = t.TypeOf; +export { TimetableEntry }; diff --git a/src/app/shared/services/confirm-absences-selection.service.ts b/src/app/shared/services/confirm-absences-selection.service.ts index ef41ca1b6..8309f0ddb 100644 --- a/src/app/shared/services/confirm-absences-selection.service.ts +++ b/src/app/shared/services/confirm-absences-selection.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Inject } from '@angular/core'; import { map, shareReplay, take } from 'rxjs/operators'; import { not } from 'fp-ts/lib/function'; @@ -8,6 +8,7 @@ import { getIdsGroupedByPerson } from 'src/app/shared/utils/lesson-presences'; import { isInstanceOf } from 'src/app/shared/utils/filter'; import { OpenAbsencesEntry } from 'src/app/open-absences/models/open-absences-entry.model'; import { flattenOpenAbsencesEntries } from 'src/app/open-absences/utils/open-absences-entries'; +import { SETTINGS, Settings } from 'src/app/settings'; @Injectable({ providedIn: 'any', // Every module should have its own instance @@ -26,6 +27,23 @@ export class ConfirmAbsencesSelectionService extends SelectionService< shareReplay(1) ); + /** + * Selected lesson presences that have no absence type (i.e. the + * default absence type). + */ + selectedWithoutPresenceType$ = this.selection$.pipe( + map(getEntriesByType), + map(({ lessonPresences }) => + lessonPresences.filter( + (p) => p.TypeRef.Id === this.settings.absencePresenceTypeId + ) + ) + ); + + constructor(@Inject(SETTINGS) private settings: Settings) { + super(); + } + clearNonOpenAbsencesEntries(): void { this.selection$ .pipe(take(1), map(getEntriesByType)) diff --git a/src/app/shared/services/presence-types.service.ts b/src/app/shared/services/presence-types.service.ts index efc12ebc7..4b51f07bf 100644 --- a/src/app/shared/services/presence-types.service.ts +++ b/src/app/shared/services/presence-types.service.ts @@ -23,21 +23,42 @@ import { sortPresenceTypes } from '../utils/presence-types'; export class PresenceTypesService { presenceTypes$ = this.loadPresenceTypes().pipe(shareReplay(1)); + /** + * All currently active presence types + */ activePresenceTypes$ = this.presenceTypes$.pipe( map(this.filterActiveTypes.bind(this)), shareReplay(1) ); + /** + * Currently active presence types that need confirmation + */ confirmationTypes$ = this.presenceTypes$.pipe( map(this.filterConfirmationTypes.bind(this)), shareReplay(1) ); + /** + * Presence types that represent an incident + */ incidentTypes$ = this.presenceTypes$.pipe( map(this.filterIncidentTypes.bind(this)), shareReplay(1) ); + /** + * Presence types that should be displayed in profile and in my + * absences + */ + displayedTypes$ = this.presenceTypes$.pipe( + map(this.filterDisplayedTypes.bind(this)), + shareReplay(1) + ); + + /** + * Boolean whether half day type is active for current tenant + */ halfDayActive$ = this.presenceTypes$.pipe( map(this.isHalfDayActive.bind(this)), startWith(false), @@ -77,6 +98,14 @@ export class PresenceTypesService { return presenceTypes.filter((t) => t.IsIncident && t.Active); } + private filterDisplayedTypes( + presenceTypes: ReadonlyArray + ): ReadonlyArray { + return presenceTypes.filter( + (t) => t.Id !== this.settings.absencePresenceTypeId + ); + } + private isHalfDayActive(presenceTypes: ReadonlyArray): boolean { return Boolean( presenceTypes.find((t) => t.Id === this.settings.halfDayPresenceTypeId) diff --git a/src/app/shared/services/reports.service.spec.ts b/src/app/shared/services/reports.service.spec.ts index 6aeb51038..4ff18f2ca 100644 --- a/src/app/shared/services/reports.service.spec.ts +++ b/src/app/shared/services/reports.service.spec.ts @@ -3,9 +3,11 @@ import { TestBed } from '@angular/core/testing'; import { ReportsService } from './reports.service'; import { buildTestModuleMetadata } from 'src/spec-helpers'; import { StorageService } from './storage.service'; +import { HttpTestingController } from '@angular/common/http/testing'; describe('ReportsService', () => { let service: ReportsService; + let httpTestingController: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule( @@ -17,19 +19,102 @@ describe('ReportsService', () => { getAccessToken(): Option { return 'SOMETOKEN'; }, + getPayload(): Option { + return { id_person: 42 }; + }, }, }, ], }) ); service = TestBed.inject(ReportsService); + httpTestingController = TestBed.inject(HttpTestingController); }); - describe('personMasterDataReportUrl', () => { - it('returns the report url', () => { - expect(service.getPersonMasterDataReportUrl(123)).toBe( - 'https://eventotest.api/Files/CrystalReports/Person/290026?ids=123&token=SOMETOKEN' - ); + afterEach(() => httpTestingController.verify()); + + describe('Stammblatt', () => { + describe('personMasterDataAvailability$', () => { + let callback: jasmine.Spy; + beforeEach(() => { + callback = jasmine.createSpy('callback'); + service.personMasterDataAvailability$.subscribe(callback); + }); + + it('emits true if the report is available', () => { + httpTestingController + .expectOne( + (req) => + req.urlWithParams === + 'https://eventotest.api/CrystalReports/AvailableReports/Person?ids=290026&keys=42' + ) + .flush({ Id: 290026 }); + + expect(callback.calls.allArgs()).toEqual([[false], [true]]); + }); + + it('emits false if the report is unavailable', () => { + httpTestingController + .expectOne( + (req) => + req.urlWithParams === + 'https://eventotest.api/CrystalReports/AvailableReports/Person?ids=290026&keys=42' + ) + .flush(null); + + expect(callback.calls.allArgs()).toEqual([[false]]); + }); + }); + + describe('getPersonMasterDataUrl', () => { + it('returns the report url', () => { + expect(service.getPersonMasterDataUrl(123)).toBe( + 'https://eventotest.api/Files/CrystalReports/Person/290026?ids=123&token=SOMETOKEN' + ); + }); + }); + }); + + describe('Lektionsbuchungen', () => { + describe('studentConfirmationAvailability$', () => { + let callback: jasmine.Spy; + beforeEach(() => { + callback = jasmine.createSpy('callback'); + service.setStudentConfirmationAvailabilityRecordId('123_456'); + service.studentConfirmationAvailability$.subscribe(callback); + }); + + it('emits true if the report is available', () => { + httpTestingController + .expectOne( + (req) => + req.urlWithParams === + 'https://eventotest.api/CrystalReports/AvailableReports/Praesenzinformation?ids=30&keys=123_456' + ) + .flush({ Id: 30 }); + + expect(callback.calls.allArgs()).toEqual([[false], [true]]); + }); + + it('emits false if the report is unavailable', () => { + httpTestingController + .expectOne( + (req) => + req.urlWithParams === + 'https://eventotest.api/CrystalReports/AvailableReports/Praesenzinformation?ids=30&keys=123_456' + ) + .flush(null); + + expect(callback.calls.allArgs()).toEqual([[false]]); + }); + }); + + describe('getStudentConfirmationUrl', () => { + it('returns the report url', () => { + expect(service.getStudentConfirmationUrl(['123_456', '789_012'])).toBe( + 'https://eventotest.api/Files/CrystalReports/Praesenzinformation/30?ids=123_456,789_012&token=SOMETOKEN' + ); + }); }); }); }); diff --git a/src/app/shared/services/reports.service.ts b/src/app/shared/services/reports.service.ts index 45e504a79..a20eaa0ae 100644 --- a/src/app/shared/services/reports.service.ts +++ b/src/app/shared/services/reports.service.ts @@ -1,51 +1,150 @@ -import { Injectable, Inject } from '@angular/core'; +import { Injectable, Inject, OnDestroy } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { + Observable, + Subject, + ReplaySubject, + ConnectableObservable, + Subscription, +} from 'rxjs'; +import { + shareReplay, + map, + switchMap, + startWith, + multicast, + filter, + distinctUntilChanged, +} from 'rxjs/operators'; import { SETTINGS, Settings } from 'src/app/settings'; import { StorageService } from './storage.service'; +import { notNull } from '../utils/filter'; +/** + * Reports are PDFs that are served under a special URL. They can be + * generated for a given set of records (the type of the records is + * depending on the report). In the UI, we simply create a link to the + * report URL, since the access token can be provided as query param. + * + * Every report has an availability state (whether it's active for the + * current tenant/user or not), that can be requested via an API + * endpoint. The availability request must contain at least one record + * ID. Since we only link to reports containing records that are + * already loaded (hence the user must have access to them), we can + * request the availability state once, which whatever record ID. + * + * The report URL looks like this (where the report can be downloaded): + * /Files/CrystalReports/{report context}/{report id} + * ?ids={comma separated record ids to be included in the report} + * &token={access token} + * + * And the availability of the report can be requested via this enpoint: + * /CrystalReports/AvailableReports/{report context} + * ?ids=${report id} + * &keys={comma separated record ids (same as ?ids in the report url)} + */ @Injectable({ providedIn: 'root', }) -export class ReportsService { - private baseUrl = `${this.settings.apiUrl}/Files/CrystalReports`; +export class ReportsService implements OnDestroy { + studentConfirmationAvailabilityRecordId$ = new Subject(); + + personMasterDataAvailability$ = this.loadReportAvailability( + 'Person', + this.settings.personMasterDataReportId, + [Number(this.storageService.getPayload()?.id_person)] + ).pipe(shareReplay(1)); + + studentConfirmationAvailability$ = this.loadReportAvailabilityByAsyncRecordId( + 'Praesenzinformation', + this.settings.studentConfirmationReportId, + this.studentConfirmationAvailabilityRecordId$ + ); + + private studentConfirmationAvailabilitySub: Subscription; constructor( @Inject(SETTINGS) private settings: Settings, - private storageService: StorageService - ) {} + private storageService: StorageService, + private http: HttpClient + ) { + this.studentConfirmationAvailabilitySub = (this + .studentConfirmationAvailability$ as ConnectableObservable< + boolean + >).connect(); + } - getPersonMasterDataReportUrl(personId: number): string { + ngOnDestroy(): void { + this.studentConfirmationAvailabilitySub.unsubscribe(); + } + + /** + * Report: Stammblatt + */ + getPersonMasterDataUrl(personId: number): string { return this.getReportUrl('Person', this.settings.personMasterDataReportId, [ personId, ]); } + /** + * Report: Lektionsbuchungen + * + * The record IDs are the string + * {LessonAbsences.LessonRef.Id}_{LessonAbsences.RegistrationId} + */ + getStudentConfirmationUrl(recordIds: ReadonlyArray): string { + return this.getReportUrl( + 'Praesenzinformation', + this.settings.studentConfirmationReportId, + recordIds + ); + } + + setStudentConfirmationAvailabilityRecordId(recordId: string): void { + this.studentConfirmationAvailabilityRecordId$.next(recordId); + } + private getReportUrl( context: string, reportId: number, - recordIds: ReadonlyArray + recordIds: ReadonlyArray ): string { - return `${this.baseUrl}/${context}/${reportId}?ids=${recordIds.join( + return `${ + this.settings.apiUrl + }/Files/CrystalReports/${context}/${reportId}?ids=${recordIds.join( ',' - )}&token=${this.accessToken}`; - } - - // private isReportAvailable( - // context: string, - // reportId: number - // ): Observable { - // return this.http - // .get( - // `${this.baseUrl}/AvailableReports/${context}?ids=${reportId}&keys=0` - // ) - // .pipe(log('availableReports')); - // } - - private get accessToken(): string { - const token = this.storageService.getAccessToken(); - if (!token) { - throw new Error('No access token available'); - } - return token; + )}&token=${this.storageService.getAccessToken()}`; + } + + private loadReportAvailability( + context: string, + reportId: number, + recordIds: ReadonlyArray + ): Observable { + return this.http + .get( + `${ + this.settings.apiUrl + }/CrystalReports/AvailableReports/${context}?ids=${reportId}&keys=${recordIds.join( + ',' + )}` + ) + .pipe(map(notNull), startWith(false), distinctUntilChanged()); + } + + private loadReportAvailabilityByAsyncRecordId( + context: string, + reportId: number, + recordId$: Observable + ): Observable { + return recordId$.pipe( + filter((_, i) => i === 0), // Fetch the availability only once and cache it afterwards (but don't complete) + switchMap((recordId) => + this.loadReportAvailability(context, reportId, [recordId]) + ), + multicast(() => new ReplaySubject(1)) + ); } } diff --git a/src/app/shared/services/student-profile-absences.service.ts b/src/app/shared/services/student-profile-absences.service.ts index e31a76e7f..cbdb8d8fb 100644 --- a/src/app/shared/services/student-profile-absences.service.ts +++ b/src/app/shared/services/student-profile-absences.service.ts @@ -42,6 +42,22 @@ export class StudentProfileAbsencesService { this.studentId$.next(id); } + private getAbsences( + loadFn: ( + studentId: number + ) => Observable>> + ): Observable>> { + return this.studentId$.pipe( + switchMap(loadFn), + startWith(null), + // Clear the cache if all subscribers disconnect (don't replay the previous value) + multicast( + () => new ReplaySubject>>(1) + ), + refCount() + ); + } + private getCounts(): Observable { return this.studentId$.pipe( switchMap((studentId) => { @@ -60,22 +76,6 @@ export class StudentProfileAbsencesService { ); } - private getAbsences( - loadFn: ( - studentId: number - ) => Observable>> - ): Observable>> { - return this.studentId$.pipe( - switchMap(loadFn), - startWith(null), - // Clear the cache if all subscribers disconnect (don't replay the previous value) - multicast( - () => new ReplaySubject>>(1) - ), - refCount() - ); - } - private loadStatistics( studentId: number ): Observable { diff --git a/src/app/shared/services/students-rest.service.ts b/src/app/shared/services/students-rest.service.ts index 17b3fa68d..9cf0eb7c7 100644 --- a/src/app/shared/services/students-rest.service.ts +++ b/src/app/shared/services/students-rest.service.ts @@ -9,6 +9,9 @@ import { decodeArray } from '../utils/decode'; import { Student } from '../models/student.model'; import { LegalRepresentative } from '../models/legal-representative.model'; import { ApprenticeshipContract } from '../models/apprenticeship-contract.model'; +import { TimetableEntry } from '../models/timetable-entry.model'; +import { LessonAbsence } from '../models/lesson-absence.model'; +import { LessonIncident } from '../models/lesson-incident.model'; @Injectable({ providedIn: 'root', @@ -23,7 +26,7 @@ export class StudentsRestService extends TypeaheadRestService { params?: HttpParams | Dict ): Observable> { return this.http - .get(`${this.baseUrl}/${studentId}/LegalRepresentatives`, { + .get(`${this.baseUrl}/${studentId}/LegalRepresentatives`, { params, }) .pipe(switchMap(decodeArray(LegalRepresentative))); @@ -34,7 +37,7 @@ export class StudentsRestService extends TypeaheadRestService { params?: HttpParams | Dict ): Observable> { return this.http - .get( + .get( `${this.baseUrl}/${studentId}/ApprenticeshipContracts/Current`, { params, @@ -42,4 +45,37 @@ export class StudentsRestService extends TypeaheadRestService { ) .pipe(switchMap(decodeArray(ApprenticeshipContract))); } + + getLessonAbsences( + studentId: number, + params?: HttpParams | Dict + ): Observable> { + return this.http + .get(`${this.baseUrl}/${studentId}/LessonAbsences`, { + params, + }) + .pipe(switchMap(decodeArray(LessonAbsence))); + } + + getLessonIncidents( + studentId: number, + params?: HttpParams | Dict + ): Observable> { + return this.http + .get(`${this.baseUrl}/${studentId}/LessonIncidents`, { + params, + }) + .pipe(switchMap(decodeArray(LessonIncident))); + } + + getTimetableEntries( + studentId: number, + params?: HttpParams | Dict + ): Observable> { + return this.http + .get(`${this.baseUrl}/${studentId}/TimetableEntries`, { + params, + }) + .pipe(switchMap(decodeArray(TimetableEntry))); + } } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index ed71a7aa1..c9a664f1c 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -39,16 +39,14 @@ const components = [ StudentProfileAddressComponent, StudentProfileLegalRepresentativeComponent, StudentProfileApprenticeshipCompanyComponent, + StudentProfileAbsencesComponent, ConfirmAbsencesComponent, PersonEmailPipe, DaysDifferencePipe, ]; -// Components only used within the shared module -const internalComponents = [StudentProfileAbsencesComponent]; - @NgModule({ - declarations: [...components, internalComponents], + declarations: [...components], providers: [ { provide: HTTP_INTERCEPTORS, useClass: RestErrorInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: RestAuthInterceptor, multi: true }, diff --git a/src/app/shared/utils/lesson-presences.spec.ts b/src/app/shared/utils/lesson-presences.spec.ts index 1ff07a3c4..ff3121d76 100644 --- a/src/app/shared/utils/lesson-presences.spec.ts +++ b/src/app/shared/utils/lesson-presences.spec.ts @@ -3,6 +3,7 @@ import { LessonPresence } from '../models/lesson-presence.model'; import { getIdsGroupedByPerson, getIdsGroupedByLesson, + sortLessonPresencesByDate, } from './lesson-presences'; describe('lesson presences utils', () => { @@ -35,4 +36,19 @@ describe('lesson presences utils', () => { ]); }); }); + + describe('sortLessonPresencesByDate', () => { + it('sorts lesson presences by LessonDateTimeFrom attribute', () => { + presenceA.LessonDateTimeFrom = new Date(2000, 1, 23, 12, 30); + presenceB.LessonDateTimeFrom = new Date(2000, 1, 23, 9, 0); + presenceC.LessonDateTimeFrom = new Date(2000, 1, 23, 12, 0); + + const result = sortLessonPresencesByDate([ + presenceA, + presenceB, + presenceC, + ]); + expect(result).toEqual([presenceB, presenceC, presenceA]); + }); + }); }); diff --git a/src/app/shared/utils/lesson-presences.ts b/src/app/shared/utils/lesson-presences.ts index ba984e7e9..9a58ddcf2 100644 --- a/src/app/shared/utils/lesson-presences.ts +++ b/src/app/shared/utils/lesson-presences.ts @@ -41,3 +41,13 @@ export function getIdsGroupedByLesson( }; }); } + +export function sortLessonPresencesByDate( + lessonPresences: ReadonlyArray +): ReadonlyArray { + return lessonPresences + .slice() + .sort( + (a, b) => a.LessonDateTimeFrom.getTime() - b.LessonDateTimeFrom.getTime() + ); +} diff --git a/src/assets/locales/de-CH.json b/src/assets/locales/de-CH.json index e6614ea1b..62fa32984 100644 --- a/src/assets/locales/de-CH.json +++ b/src/assets/locales/de-CH.json @@ -153,6 +153,23 @@ } } }, + "my-absences": { + "title": "Meine Absenzen", + "show": { + "report-absence-link": "Absenz melden", + "default-absence-selection-message": "Sie müssen bei allen offenen Absenzen einen Grund erfassen, bevor Sie das Entschuldigungsformular herunterladen können.", + "confirm-presence-type": "Grund erfassen" + }, + "confirm": { + "lesson-selected": "{{count}} Lektion ausgewählt", + "lessons-selected": "{{count}} Lektionen ausgewählt", + "choose-presence-type": "Absenzgrund auswählen", + "remark": "Bereits erfasste Absenzgründe werden überschrieben.", + "cancel": "Abbrechen", + "save": "Übernehmen", + "save-success": "Die ausgewählten Einträge wurden erfolgreich geändert." + } + }, "my-profile": { "title": "Mein Profil", "show": { diff --git a/src/settings.example.js b/src/settings.example.js index a22a84717..470bd5165 100644 --- a/src/settings.example.js +++ b/src/settings.example.js @@ -60,4 +60,8 @@ window.absenzenmanagement.settings = { */ // Id of the report that contains a user's master data (used in my profile) personMasterDataReportId: 290026, + + // Id of the report that contains the open absences with + // confirmation values to sign (used in my absences) + studentConfirmationReportId: 30, }; diff --git a/src/spec-builders.ts b/src/spec-builders.ts index 60cab14ac..2bdea4126 100644 --- a/src/spec-builders.ts +++ b/src/spec-builders.ts @@ -53,7 +53,7 @@ export function buildLessonPresence( StudentFullName: studentName, // StudyClassDesignation: '', StudyClassNumber: '9a', - // TeacherInformation: '', + TeacherInformation: '', WasAbsentInPrecedingLesson: null, }; } diff --git a/src/spec-helpers.ts b/src/spec-helpers.ts index 907c6354c..be1ccf274 100644 --- a/src/spec-helpers.ts +++ b/src/spec-helpers.ts @@ -31,6 +31,7 @@ export const settings: Settings = { excusedAbsenceStateId: 220, unconfirmedAbsencesRefreshTime: null, personMasterDataReportId: 290026, + studentConfirmationReportId: 30, }; const baseTestModuleMetadata: TestModuleMetadata = {