From 499242601e1f857d7dec100fea8bfb40c5f4f659 Mon Sep 17 00:00:00 2001 From: Mathis Hofer Date: Wed, 29 Apr 2020 13:16:37 +0200 Subject: [PATCH] Display open absences hint in presence control #149 #146 --- .../edit-absences-list.component.scss | 14 +++ .../services/open-absences.service.spec.ts | 29 ++--- .../services/open-absences.service.ts | 22 +--- .../presence-control-entry.component.html | 16 ++- .../presence-control-entry.component.scss | 73 +++++++---- .../presence-control-entry.component.ts | 1 + .../presence-control-list.component.html | 6 + .../presence-control-list.component.spec.ts | 1 + .../presence-control-state.service.ts | 31 +++++ src/app/settings.ts | 3 + .../lesson-presences-rest.service.spec.ts | 113 ++++++++++++++---- .../services/lesson-presences-rest.service.ts | 84 ++++++++----- src/app/shared/styles/table.scss | 12 -- src/assets/locales/de-CH.json | 3 +- src/settings.example.js | 8 ++ src/spec-helpers.ts | 1 + 16 files changed, 289 insertions(+), 128 deletions(-) diff --git a/src/app/edit-absences/components/edit-absences-list/edit-absences-list.component.scss b/src/app/edit-absences/components/edit-absences-list/edit-absences-list.component.scss index 527abbf20..dd51ea0db 100644 --- a/src/app/edit-absences/components/edit-absences-list/edit-absences-list.component.scss +++ b/src/app/edit-absences/components/edit-absences-list/edit-absences-list.component.scss @@ -27,3 +27,17 @@ .student { color: $body-color; } + +@media screen and (max-width: 820px) { + .edit-absences-checkbox { + text-align: left; + } + + .presence-category { + text-align: right; + } + + .designation-comment { + max-width: initial; + } +} diff --git a/src/app/open-absences/services/open-absences.service.spec.ts b/src/app/open-absences/services/open-absences.service.spec.ts index 9fb9a3549..0d9604ddf 100644 --- a/src/app/open-absences/services/open-absences.service.spec.ts +++ b/src/app/open-absences/services/open-absences.service.spec.ts @@ -1,29 +1,30 @@ import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; import { buildTestModuleMetadata } from 'src/spec-helpers'; import { OpenAbsencesService } from './open-absences.service'; +import { LessonPresencesRestService } from 'src/app/shared/services/lesson-presences-rest.service'; describe('OpenAbsencesService', () => { - let storeMock: any; beforeEach(() => { - storeMock = {}; - spyOn(localStorage, 'getItem').and.callFake( - (key: string) => storeMock[key] || null - ); - spyOn(localStorage, 'setItem').and.callFake( - (key: string) => storeMock[key] || null - ); - storeMock['CLX.LoginToken'] = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJvYXV0aCIsImF1ZCI6Imh0dHBzOi8vZGV2NDIwMC8iLCJuYmYiOjE1NjkzOTM5NDMsImV4cCI6MTU2OTQwODM0MywidG9rZW5fcHVycG9zZSI6IlVzZXIiLCJzY29wZSI6IlR1dG9yaW5nIiwiY29uc3VtZXJfaWQiOiJkZXY0MjAwIiwidXNlcm5hbWUiOiJMMjQzMSIsImluc3RhbmNlX2lkIjoiR1ltVEVTVCIsImN1bHR1cmVfaW5mbyI6ImRlLUNIIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL2xvY2FsaG9zdDo0MjAwIiwiaWRfbWFuZGFudCI6IjIxMCIsImlkX3BlcnNvbiI6IjI0MzEiLCJmdWxsbmFtZSI6IlRlc3QgUnVkeSIsInJvbGVzIjoiTGVzc29uVGVhY2hlclJvbGU7Q2xhc3NUZWFjaGVyUm9sZSIsInRva2VuX2lkIjoiMzc0OSJ9.9lDju5CIIUaISRSz0x8k-kcF7Q6IhN_6HEMOlnsiDRA'; - TestBed.configureTestingModule( - buildTestModuleMetadata({ providers: [OpenAbsencesService] }) + buildTestModuleMetadata({ + providers: [ + OpenAbsencesService, + { + provide: LessonPresencesRestService, + useValue: { + getListOfUnconfirmed(): any { + return of([]); + }, + }, + }, + ], + }) ); }); it('should be created', () => { - storeMock['CLX.LoginToken'] = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJvYXV0aCIsImF1ZCI6Imh0dHBzOi8vZGV2NDIwMC8iLCJuYmYiOjE1NjkzOTM5NDMsImV4cCI6MTU2OTQwODM0MywidG9rZW5fcHVycG9zZSI6IlVzZXIiLCJzY29wZSI6IlR1dG9yaW5nIiwiY29uc3VtZXJfaWQiOiJkZXY0MjAwIiwidXNlcm5hbWUiOiJMMjQzMSIsImluc3RhbmNlX2lkIjoiR1ltVEVTVCIsImN1bHR1cmVfaW5mbyI6ImRlLUNIIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL2xvY2FsaG9zdDo0MjAwIiwiaWRfbWFuZGFudCI6IjIxMCIsImlkX3BlcnNvbiI6IjI0MzEiLCJmdWxsbmFtZSI6IlRlc3QgUnVkeSIsInJvbGVzIjoiTGVzc29uVGVhY2hlclJvbGU7Q2xhc3NUZWFjaGVyUm9sZSIsInRva2VuX2lkIjoiMzc0OSJ9.9lDju5CIIUaISRSz0x8k-kcF7Q6IhN_6HEMOlnsiDRA'; const service: OpenAbsencesService = TestBed.inject(OpenAbsencesService); expect(service).toBeTruthy(); }); diff --git a/src/app/open-absences/services/open-absences.service.ts b/src/app/open-absences/services/open-absences.service.ts index 70ddc0312..2eeabdedf 100644 --- a/src/app/open-absences/services/open-absences.service.ts +++ b/src/app/open-absences/services/open-absences.service.ts @@ -5,7 +5,6 @@ import { Observable, Subject, merge, - forkJoin, } from 'rxjs'; import { map, shareReplay, take } from 'rxjs/operators'; import { LessonPresence } from 'src/app/shared/models/lesson-presence.model'; @@ -17,9 +16,7 @@ import { buildOpenAbsencesEntries, sortOpenAbsencesEntries, removeOpenAbsences, - mergeUniqueLessonPresences, } from '../utils/open-absences-entries'; -import { StorageService } from 'src/app/shared/services/storage.service'; export type PrimarySortKey = 'date' | 'name'; @@ -33,7 +30,6 @@ export class OpenAbsencesService { loading$ = this.loadingService.loading$; search$ = new BehaviorSubject(''); - private storage = new StorageService(); private updateUnconfirmedAbsences$ = new Subject< ReadonlyArray >(); @@ -128,20 +124,8 @@ export class OpenAbsencesService { } private loadUnconfirmedAbsences(): Observable> { - const tokenPayload = this.storage.getPayload(); - const roles = tokenPayload ? tokenPayload.roles : ''; - const classTeacher = roles.indexOf('ClassTeacherRole') > 0 ? true : false; - if (classTeacher) { - return this.loadingService.load( - forkJoin([ - this.lessonPresencesService.getListOfUnconfirmedLessonTeacher(), - this.lessonPresencesService.getListOfUnconfirmedClassTeacher(), - ]).pipe(map(spreadTuple(mergeUniqueLessonPresences))) - ); - } else { - return this.loadingService.load( - this.lessonPresencesService.getListOfUnconfirmedLessonTeacher().pipe() - ); - } + return this.loadingService.load( + this.lessonPresencesService.getListOfUnconfirmed() + ); } } diff --git a/src/app/presence-control/components/presence-control-entry/presence-control-entry.component.html b/src/app/presence-control/components/presence-control-entry/presence-control-entry.component.html index 11b4ca18d..8ae90feea 100644 --- a/src/app/presence-control/components/presence-control-entry/presence-control-entry.component.html +++ b/src/app/presence-control/components/presence-control-entry/presence-control-entry.component.html @@ -2,13 +2,13 @@ *ngIf="!isListViewMode" [studentId]="studentId$ | async" [link]="['/presence-control/student', entry.lessonPresence.StudentRef.Id]" - class="avatar mr-3 large" + class="avatar large" > @@ -25,8 +25,18 @@ entry.lessonPresence.StudentRef.Id ]" class="student-name" - >{{ entry.lessonPresence.StudentFullName }} + + {{ entry.lessonPresence.StudentFullName }} + + + {{ + hasUnconfirmedAbsences + ? ('presence-control.entry.unconfirmed-absences' | translate) + : '' + }} + + * { @@ -49,23 +49,44 @@ a.comment, .avatar { grid-area: avatar; -} - -.designation { - padding-top: 0; - padding-bottom: 0; - grid-area: designation; + margin-right: 1.5 * $spacer; } .status { - padding-left: 0; grid-area: status; } +.status .material-icons { + font-size: 2rem; +} + +.designation { + grid-area: designation; + text-align: left; + line-height: 2rem + $btn-padding-y; +} .student-name { color: $body-color; grid-area: student-name; display: flex; + flex-direction: column; + + &:hover, + &:active { + text-decoration: none; + } +} +a:hover, +a:active { + .student-name-inner { + text-decoration: underline; + } +} + +.unconfirmed-absences { + color: $absent-color; + font-size: 0.8rem; + line-height: 1; } .comment { @@ -79,7 +100,21 @@ a.comment, margin-right: 0.3em; } -// List layout +// Grid layout specifics +:host.grid { + .status, + .designation { + align-self: start; + margin-left: -$btn-padding-x; + margin-top: -$btn-padding-y; + } + + .unconfirmed-absences { + height: 0.8rem; // Always occupy space, event if hint is not present + } +} + +// List layout overrides :host.list { grid-template-areas: "student-name commentordesignation status"; grid-template-columns: 1fr 2fr min-content; @@ -89,6 +124,9 @@ a.comment, .designation { grid-area: commentordesignation; } + .designation { + text-align: right; + } @media (max-width: 750px) { grid-template-areas: @@ -97,18 +135,3 @@ a.comment, grid-template-columns: 1fr min-content; } } - -:host-context(.ie11) { - // To force generation of missing -ms-grid-column-span property - .comment, - .designation { - grid-column: span 1; - } - - @media (max-width: 750px) { - .comment, - .designation { - grid-column: span 2; - } - } -} diff --git a/src/app/presence-control/components/presence-control-entry/presence-control-entry.component.ts b/src/app/presence-control/components/presence-control-entry/presence-control-entry.component.ts index 76c0337e4..0aaab33d7 100644 --- a/src/app/presence-control/components/presence-control-entry/presence-control-entry.component.ts +++ b/src/app/presence-control/components/presence-control-entry/presence-control-entry.component.ts @@ -22,6 +22,7 @@ import { ViewMode } from '../../services/presence-control-state.service'; }) export class PresenceControlEntryComponent implements OnInit, OnChanges { @Input() entry: PresenceControlEntry; + @Input() hasUnconfirmedAbsences = false; @Input() viewMode: ViewMode; @Output() togglePresenceType = new EventEmitter(); diff --git a/src/app/presence-control/components/presence-control-list/presence-control-list.component.html b/src/app/presence-control/components/presence-control-list/presence-control-list.component.html index 007b99c3a..ad836097c 100644 --- a/src/app/presence-control/components/presence-control-list/presence-control-list.component.html +++ b/src/app/presence-control/components/presence-control-list/presence-control-list.component.html @@ -37,6 +37,9 @@ @@ -50,6 +53,9 @@ diff --git a/src/app/presence-control/components/presence-control-list/presence-control-list.component.spec.ts b/src/app/presence-control/components/presence-control-list/presence-control-list.component.spec.ts index acd66be0f..c67347914 100644 --- a/src/app/presence-control/components/presence-control-list/presence-control-list.component.spec.ts +++ b/src/app/presence-control/components/presence-control-list/presence-control-list.component.spec.ts @@ -65,6 +65,7 @@ describe('PresenceControlListComponent', () => { getBlockLessonPresences: jasmine .createSpy('getBlockLessonPresences') .and.callFake(() => of(blockLessons)), + hasUnconfirmedAbsences: () => of(false), } as unknown) as PresenceControlStateService; lessonPresencesUpdateServiceMock = ({ diff --git a/src/app/presence-control/services/presence-control-state.service.ts b/src/app/presence-control/services/presence-control-state.service.ts index 92712b889..339cea9b9 100644 --- a/src/app/presence-control/services/presence-control-state.service.ts +++ b/src/app/presence-control/services/presence-control-state.service.ts @@ -5,6 +5,7 @@ import { merge, Observable, Subject, + timer, } from 'rxjs'; import { map, @@ -14,6 +15,8 @@ import { withLatestFrom, distinctUntilChanged, } from 'rxjs/operators'; +import { uniq } from 'lodash-es'; + import { LessonPresence } from '../../shared/models/lesson-presence.model'; import { Lesson } from '../../shared/models/lesson.model'; import { PresenceType } from '../../shared/models/presence-type.model'; @@ -73,6 +76,11 @@ export class PresenceControlStateService { distinctUntilChanged(lessonsEqual) ); + studentIdsWithUnconfirmedAbsences$ = this.selectedDateSubject$.pipe( + switchMap(() => this.loadStudentIdsWithUnconfirmedAbsences()), + shareReplay(1) + ); + loading$ = this.loadingService.loading$; selectedLesson$ = merge(this.currentLesson$, this.selectLesson$).pipe( @@ -216,6 +224,12 @@ export class PresenceControlStateService { ); } + hasUnconfirmedAbsences(entry: PresenceControlEntry): Observable { + return this.studentIdsWithUnconfirmedAbsences$.pipe( + map((ids) => ids.indexOf(entry.lessonPresence.StudentRef.Id) > 0) + ); + } + /** * Lesson presences for which the presence type cannot be updated are filtered from the list of block lesson presences */ @@ -258,4 +272,21 @@ export class PresenceControlStateService { private loadPresenceTypes(): Observable> { return this.loadingService.load(this.presenceTypesService.getList()); } + + /** + * Starts polling the unconfirmed absences, returns an array with + * student ids that have unconfirmed absences. + */ + private loadStudentIdsWithUnconfirmedAbsences(): Observable< + ReadonlyArray + > { + return timer( + 0, + // Only start polling if a refresh time is defined + this.settings.unconfirmedAbsencesRefreshTime || undefined + ).pipe( + switchMap(() => this.lessonPresencesService.getListOfUnconfirmed()), + map((unconfirmed) => uniq(unconfirmed.map((p) => p.StudentRef.Id))) + ); + } } diff --git a/src/app/settings.ts b/src/app/settings.ts index b8a77fa9e..def4c68ca 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -1,6 +1,8 @@ import { InjectionToken } from '@angular/core'; import * as t from 'io-ts'; +import { Option } from './shared/models/common-types'; + const Settings = t.type({ apiUrl: t.string, scriptsAndAssetsPath: t.string, @@ -12,6 +14,7 @@ const Settings = t.type({ unconfirmedAbsenceStateId: t.number, unexcusedAbsenceStateId: t.number, excusedAbsenceStateId: t.number, + unconfirmedAbsencesRefreshTime: Option(t.number), }); type Settings = t.TypeOf; diff --git a/src/app/shared/services/lesson-presences-rest.service.spec.ts b/src/app/shared/services/lesson-presences-rest.service.spec.ts index d671525dc..027813ef7 100644 --- a/src/app/shared/services/lesson-presences-rest.service.spec.ts +++ b/src/app/shared/services/lesson-presences-rest.service.spec.ts @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { HttpTestingController } from '@angular/common/http/testing'; +import * as t from 'io-ts/lib/index'; import { buildTestModuleMetadata } from 'src/spec-helpers'; import { LessonPresencesRestService } from './lesson-presences-rest.service'; @@ -7,6 +8,14 @@ import { EvaluateAbsencesFilter } from 'src/app/evaluate-absences/services/evalu import { EditAbsencesFilter } from 'src/app/edit-absences/services/edit-absences-state.service'; import { Sorting } from './paginated-entries.service'; import { LessonPresenceStatistic } from '../models/lesson-presence-statistic'; +import { LessonPresence } from '../models/lesson-presence.model'; +import { buildLessonPresence } from 'src/spec-builders'; + +const CLASS_TEACHER_TOKEN = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJvYXV0aCIsImF1ZCI6Imh0dHBzOi8vZGV2NDIwMC8iLCJuYmYiOjE1NjkzOTM5NDMsImV4cCI6MTU2OTQwODM0MywidG9rZW5fcHVycG9zZSI6IlVzZXIiLCJzY29wZSI6IlRlc3QiLCJjb25zdW1lcl9pZCI6ImRldiIsInVzZXJuYW1lIjoiam9obiIsImluc3RhbmNlX2lkIjoiVEVTVCIsImN1bHR1cmVfaW5mbyI6ImRlLUNIIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL2xvY2FsaG9zdDo0MjAwIiwiaWRfbWFuZGFudCI6IjEyMyIsImlkX3BlcnNvbiI6IjQ1NiIsImZ1bGxuYW1lIjoiSm9obiBEb2UiLCJyb2xlcyI6Ikxlc3NvblRlYWNoZXJSb2xlO0NsYXNzVGVhY2hlclJvbGUiLCJ0b2tlbl9pZCI6IjEyMzQ1NiJ9.erGO0ORYWA7LAjuWSrz924rkgC2Gqg6_Wu3GUZiMOyI'; + +const LESSON_TEACHER_TOKEN = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJvYXV0aCIsImF1ZCI6Imh0dHBzOi8vZGV2NDIwMC8iLCJuYmYiOjE1NjkzOTM5NDMsImV4cCI6MTU2OTQwODM0MywidG9rZW5fcHVycG9zZSI6IlVzZXIiLCJzY29wZSI6IlRlc3QiLCJjb25zdW1lcl9pZCI6ImRldiIsInVzZXJuYW1lIjoiam9obiIsImluc3RhbmNlX2lkIjoiVEVTVCIsImN1bHR1cmVfaW5mbyI6ImRlLUNIIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL2xvY2FsaG9zdDo0MjAwIiwiaWRfbWFuZGFudCI6IjEyMyIsImlkX3BlcnNvbiI6IjQ1NiIsImZ1bGxuYW1lIjoiSm9obiBEb2UiLCJyb2xlcyI6Ikxlc3NvblRlYWNoZXJSb2xlIiwidG9rZW5faWQiOiIxMjM0NTYifQ.w2j7_k48rm1gY6RAieS0KG8-wFvK9T-y731w8Lun5Nk'; describe('LessonPresencesRestService', () => { let service: LessonPresencesRestService; @@ -102,35 +111,89 @@ describe('LessonPresencesRestService', () => { }); }); - describe('.getListOfUnconfirmedLessonTeacher', () => { - it('fetches list for lesson teacher filtered by unconfirmed state from settings', () => { - const data: any[] = []; - service - .getListOfUnconfirmedLessonTeacher() - .subscribe((result) => expect(result).toBe(data)); + describe('.getListOfUnconfirmed', () => { + const classTeacherRequestUrl = + 'https://eventotest.api/LessonPresences/?filter.TypeRef==11&filter.ConfirmationStateId==219&filter.HasStudyCourseConfirmationCode==true'; + const lessonTeacherRequestUrl = + 'https://eventotest.api/LessonPresences/?filter.TypeRef==11&filter.ConfirmationStateId==219&filter.HasStudyCourseConfirmationCode==false'; - const url = - 'https://eventotest.api/LessonPresences/?filter.TypeRef==11&filter.ConfirmationStateId==219&filter.HasStudyCourseConfirmationCode==false'; - httpTestingController - .expectOne((req) => req.urlWithParams === url, url) - .flush(data); + let presence1: LessonPresence; + let presence2: LessonPresence; + let presence3: LessonPresence; + + beforeEach(() => { + presence1 = buildLessonPresence(1, new Date(), new Date(), 'Mathematik'); + presence1.Id = '1'; + presence2 = buildLessonPresence(2, new Date(), new Date(), 'Französisch'); + presence2.Id = '2'; + presence3 = buildLessonPresence(3, new Date(), new Date(), 'Turnen'); + presence3.Id = '3'; }); - }); - describe('.getListOfUnconfirmedClassTeacher', () => { - it('fetches list for class teacher filtered by unconfirmed state from settings', () => { - storeMock['CLX.LoginToken'] = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJvYXV0aCIsImF1ZCI6Imh0dHBzOi8vZGV2NDIwMC8iLCJuYmYiOjE1NjkzOTM5NDMsImV4cCI6MTU2OTQwODM0MywidG9rZW5fcHVycG9zZSI6IlVzZXIiLCJzY29wZSI6IlR1dG9yaW5nIiwiY29uc3VtZXJfaWQiOiJkZXY0MjAwIiwidXNlcm5hbWUiOiJMMjQzMSIsImluc3RhbmNlX2lkIjoiR1ltVEVTVCIsImN1bHR1cmVfaW5mbyI6ImRlLUNIIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL2xvY2FsaG9zdDo0MjAwIiwiaWRfbWFuZGFudCI6IjIxMCIsImlkX3BlcnNvbiI6IjI0MzEiLCJmdWxsbmFtZSI6IlRlc3QgUnVkeSIsInJvbGVzIjoiTGVzc29uVGVhY2hlclJvbGU7Q2xhc3NUZWFjaGVyUm9sZSIsInRva2VuX2lkIjoiMzc0OSJ9.9lDju5CIIUaISRSz0x8k-kcF7Q6IhN_6HEMOlnsiDRA'; - const data: any[] = []; - service - .getListOfUnconfirmedClassTeacher() - .subscribe((result) => expect(result).toBe(data)); + describe('for lesson teacher', () => { + beforeEach(() => { + storeMock['CLX.LoginToken'] = LESSON_TEACHER_TOKEN; + }); - const url = - 'https://eventotest.api/LessonPresences/?filter.TypeRef==11&filter.ConfirmationStateId==219&filter.HasStudyCourseConfirmationCode==true'; - httpTestingController - .expectOne((req) => req.urlWithParams === url, url) - .flush(data); + it('return unconfirmed absences for lesson teacher role', () => { + service.getListOfUnconfirmed().subscribe((result) => { + expect(result.map((p) => p.Id)).toEqual([presence1.Id]); + }); + + httpTestingController + .expectOne( + (req) => + req.urlWithParams === lessonTeacherRequestUrl && + req.headers.get('X-Role-Restriction') === 'LessonTeacherRole', + lessonTeacherRequestUrl + ) + .flush(t.array(LessonPresence).encode([presence1])); + }); + }); + + describe('for class teacher', () => { + beforeEach(() => { + storeMock['CLX.LoginToken'] = CLASS_TEACHER_TOKEN; + }); + + it('returns unconfirmed absences for both class teacher and lesson teacher roles', () => { + service.getListOfUnconfirmed().subscribe((result) => { + expect(result.map((p) => p.Id)).toEqual([ + presence1.Id, + presence2.Id, + presence3.Id, + ]); + }); + + const calls = httpTestingController.match( + (request) => + request.urlWithParams === classTeacherRequestUrl || + request.urlWithParams === lessonTeacherRequestUrl + ); + expect(calls.length).toBe(2); + + const classTeacherRequest = calls.find( + (r) => r.request.urlWithParams === classTeacherRequestUrl + ); + expect(classTeacherRequest).toBeDefined(); + expect( + classTeacherRequest?.request.headers.get('X-Role-Restriction') + ).toBe('ClassTeacherRole'); + classTeacherRequest?.flush( + t.array(LessonPresence).encode([presence1, presence2]) + ); + + const lessonTeacherRequest = calls.find( + (r) => r.request.urlWithParams === lessonTeacherRequestUrl + ); + expect(lessonTeacherRequest).toBeDefined(); + expect( + lessonTeacherRequest?.request.headers.get('X-Role-Restriction') + ).toBe('LessonTeacherRole'); + lessonTeacherRequest?.flush( + t.array(LessonPresence).encode([presence2, presence3]) + ); + }); }); }); diff --git a/src/app/shared/services/lesson-presences-rest.service.ts b/src/app/shared/services/lesson-presences-rest.service.ts index 81671e335..b10081e57 100644 --- a/src/app/shared/services/lesson-presences-rest.service.ts +++ b/src/app/shared/services/lesson-presences-rest.service.ts @@ -1,8 +1,8 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Inject, Injectable } from '@angular/core'; import { format, isSameDay, addDays, subDays } from 'date-fns'; -import { Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { Observable, forkJoin } from 'rxjs'; +import { switchMap, map } from 'rxjs/operators'; import { SETTINGS, Settings } from '../../settings'; import { LessonPresence } from '../models/lesson-presence.model'; @@ -18,6 +18,10 @@ import { paginatedHeaders, } from '../utils/pagination'; import { Sorting } from './paginated-entries.service'; +import { spreadTuple } from '../utils/function'; +import { mergeUniqueLessonPresences } from 'src/app/open-absences/utils/open-absences-entries'; +import { StorageService } from './storage.service'; +import { log } from '../utils/observable'; @Injectable({ providedIn: 'root', @@ -25,7 +29,11 @@ import { Sorting } from './paginated-entries.service'; export class LessonPresencesRestService extends RestService< typeof LessonPresence > { - constructor(http: HttpClient, @Inject(SETTINGS) settings: Settings) { + constructor( + http: HttpClient, + @Inject(SETTINGS) settings: Settings, + private storage: StorageService + ) { super(http, settings, LessonPresence, 'LessonPresences'); } @@ -46,32 +54,22 @@ export class LessonPresencesRestService extends RestService< .pipe(switchMap(decodeArray(this.codec))); } - getListOfUnconfirmedLessonTeacher(): Observable< - ReadonlyArray - > { - return this.getList({ - headers: { 'X-Role-Restriction': 'LessonTeacherRole' }, - params: { - 'filter.TypeRef': `=${this.settings.absencePresenceTypeId}`, - 'filter.ConfirmationStateId': `=${this.settings.unconfirmedAbsenceStateId}`, - 'filter.HasStudyCourseConfirmationCode': '=false', - }, - }); - } - - getListOfUnconfirmedClassTeacher(): Observable< - ReadonlyArray - > { - return this.getList({ - headers: { - 'X-Role-Restriction': 'ClassTeacherRole', - }, - params: { - 'filter.TypeRef': `=${this.settings.absencePresenceTypeId}`, - 'filter.ConfirmationStateId': `=${this.settings.unconfirmedAbsenceStateId}`, - 'filter.HasStudyCourseConfirmationCode': '=true', - }, - }); + /** + * Returns the list of unconfirmed absences, considering the user's + * role (merges the presences from two requests for class teachers + * or uses a single request for lesson teachers). + */ + getListOfUnconfirmed(): Observable> { + const tokenPayload = this.storage.getPayload(); + const roles = tokenPayload ? tokenPayload.roles : ''; + const classTeacher = roles.indexOf('ClassTeacherRole') > 0; + if (classTeacher) { + return forkJoin([ + this.getListOfUnconfirmedClassTeacher(), + this.getListOfUnconfirmedLessonTeacher(), + ]).pipe(map(spreadTuple(mergeUniqueLessonPresences))); + } + return this.getListOfUnconfirmedLessonTeacher(); } getStatistics( @@ -140,6 +138,34 @@ export class LessonPresencesRestService extends RestService< }) .pipe(decodePaginatedResponse(LessonPresence)); } + + private getListOfUnconfirmedLessonTeacher(): Observable< + ReadonlyArray + > { + return this.getList({ + headers: { 'X-Role-Restriction': 'LessonTeacherRole' }, + params: { + 'filter.TypeRef': `=${this.settings.absencePresenceTypeId}`, + 'filter.ConfirmationStateId': `=${this.settings.unconfirmedAbsenceStateId}`, + 'filter.HasStudyCourseConfirmationCode': '=false', + }, + }); + } + + private getListOfUnconfirmedClassTeacher(): Observable< + ReadonlyArray + > { + return this.getList({ + headers: { + 'X-Role-Restriction': 'ClassTeacherRole', + }, + params: { + 'filter.TypeRef': `=${this.settings.absencePresenceTypeId}`, + 'filter.ConfirmationStateId': `=${this.settings.unconfirmedAbsenceStateId}`, + 'filter.HasStudyCourseConfirmationCode': '=true', + }, + }); + } } /** diff --git a/src/app/shared/styles/table.scss b/src/app/shared/styles/table.scss index 2c5b3bd58..f7c1f4a36 100644 --- a/src/app/shared/styles/table.scss +++ b/src/app/shared/styles/table.scss @@ -9,18 +9,6 @@ } @media screen and (max-width: 820px) { - .presence-category { - text-align: right !important; - } - - .designation-comment { - max-width: initial !important; - } - - .edit-absences-checkbox { - text-align: left; - } - erz-evaluate-absences-list > div > table thead, erz-edit-absences-list > div > table thead { border: none; diff --git a/src/assets/locales/de-CH.json b/src/assets/locales/de-CH.json index 2ee1a0918..3273bfee3 100644 --- a/src/assets/locales/de-CH.json +++ b/src/assets/locales/de-CH.json @@ -43,7 +43,8 @@ }, "entry": { "comment": "Kommentar...", - "update-warning": "Statuswechsel nicht möglich" + "update-warning": "Statuswechsel nicht möglich", + "unconfirmed-absences": "Offene Absenzen" }, "comment": { "placeholder": "Kommentar...", diff --git a/src/settings.example.js b/src/settings.example.js index c6fbcc240..061e6ddd5 100644 --- a/src/settings.example.js +++ b/src/settings.example.js @@ -37,4 +37,12 @@ window.absenzenmanagement.settings = { // Id of the confirmation state for absences with valid excuse excusedAbsenceStateId: 220, + + // In presence control, a hint is shown if the student has + // unconfirmed absences (in any lesson). These unconfirmed absences + // are refreshed each time the use changes the date and in fixed + // intervals afterwards (polling). Refresh time is in seconds and + // may be set to `null` to disable polling (5 * 60 * 1000 = refresh + // every 5 minutes). + unconfirmedAbsencesRefreshTime: 5 * 60 * 1000, }; diff --git a/src/spec-helpers.ts b/src/spec-helpers.ts index 6741bd138..98e2e557c 100644 --- a/src/spec-helpers.ts +++ b/src/spec-helpers.ts @@ -29,6 +29,7 @@ export const settings: Settings = { unconfirmedAbsenceStateId: 219, unexcusedAbsenceStateId: 225, excusedAbsenceStateId: 220, + unconfirmedAbsencesRefreshTime: null, }; const baseTestModuleMetadata: TestModuleMetadata = {