From 82b927c9a1fbc04d66d5b8e14fbca41b166b2afe Mon Sep 17 00:00:00 2001 From: Piet Ostendorp Date: Fri, 28 Nov 2025 17:34:48 +0100 Subject: [PATCH] Add performance scheduling feature for employees Introduces a new PerformanceSchedulingComponent with UI and logic for creating single performances and recurring performance plans. Updates routing, navigation, and service APIs to support scheduling by employees and admins. Also includes minor refactoring and integration of MatDatepickerModule. --- src/app/app-module.ts | 4 + src/app/app-routing-module.ts | 7 + src/app/http.service.ts | 20 +- src/app/navbar/navbar.component.ts | 1 + src/app/order/order.component.ts | 4 +- .../performance-scheduling.component.css | 6 + .../performance-scheduling.component.html | 195 ++++++++++++ .../performance-scheduling.component.ts | 294 ++++++++++++++++++ src/app/schedule/schedule.component.ts | 2 +- 9 files changed, 529 insertions(+), 4 deletions(-) create mode 100644 src/app/performance-scheduling/performance-scheduling.component.css create mode 100644 src/app/performance-scheduling/performance-scheduling.component.html create mode 100644 src/app/performance-scheduling/performance-scheduling.component.ts diff --git a/src/app/app-module.ts b/src/app/app-module.ts index e803c3e..64e9115 100644 --- a/src/app/app-module.ts +++ b/src/app/app-module.ts @@ -29,6 +29,7 @@ import { MatPaginatorModule } from '@angular/material/paginator'; import { MatTableModule } from '@angular/material/table'; import { MatSelectModule } from '@angular/material/select'; import { MatSortModule } from '@angular/material/sort'; +import { MatDatepickerModule } from '@angular/material/datepicker'; import { HeaderComponent } from './header/header.component'; import { HomeComponent } from './home/home.component'; @@ -81,6 +82,7 @@ import { PdfTicketComponent } from './pdf-ticket/pdf-ticket.component'; import { TestComponent } from './test/test.component'; import { TicketValidationComponent } from './ticket-validation/ticket-validation.component'; import { TicketValidationResultComponent } from './ticket-validation-result/ticket-validation-result.component'; +import { PerformanceSchedulingComponent } from './performance-scheduling/performance-scheduling.component'; @NgModule({ @@ -136,6 +138,7 @@ import { TicketValidationResultComponent } from './ticket-validation-result/tick TestComponent, TicketValidationComponent, TicketValidationResultComponent, + PerformanceSchedulingComponent, ], imports: [ AppRoutingModule, @@ -171,6 +174,7 @@ import { TicketValidationResultComponent } from './ticket-validation-result/tick MatSelectModule, MatSortModule, PdfTicketComponent, + MatDatepickerModule, ], providers: [ provideBrowserGlobalErrorListeners(), diff --git a/src/app/app-routing-module.ts b/src/app/app-routing-module.ts index 0ce4a72..c9fa9fe 100644 --- a/src/app/app-routing-module.ts +++ b/src/app/app-routing-module.ts @@ -1,3 +1,4 @@ +import { PerformanceSchedulingComponent } from './performance-scheduling/performance-scheduling.component'; import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { PocModelComponent } from './poc-model-component/poc-model-component'; @@ -68,6 +69,12 @@ const routes: Routes = [ canActivate: [AuthGuard], data: { roles: ['employee'] }, }, + { + path: 'employee/scheduling', + component: PerformanceSchedulingComponent, + canActivate: [AuthGuard], + data: { roles: ['employee', 'admin'] }, + }, ], }, diff --git a/src/app/http.service.ts b/src/app/http.service.ts index ff70221..5bbbc08 100644 --- a/src/app/http.service.ts +++ b/src/app/http.service.ts @@ -10,6 +10,7 @@ import { StatisticsVorstellung, Sitzkategorie, Sitzreihe, + Plan, } from '@infinimotion/model-frontend'; import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; @@ -81,6 +82,18 @@ export class HttpService { return this.http.post(`${this.baseUrl}vorstellung/filter`, filter); } + /* POST /api/vorstellung */ + createPerformance(performance: Vorstellung): Observable { + return this.http.post(`${this.baseUrl}vorstellung`, performance); + } + + /* Plan APIs */ + + /* POST /api/plan */ + createPerformanceSeries(series: Plan): Observable { + return this.http.post(`${this.baseUrl}plan`, series); + } + /* Film APIs */ /* GET /api/film */ @@ -108,7 +121,12 @@ export class HttpService { /* Sitzplatz APIs */ - /* POST /api/seat/filter */ + /* GET /api/sitzplatz */ + getAllSeats(): Observable { + return this.http.get(`${this.baseUrl}sitzplatz`); + } + + /* POST /api/sitzplatz/filter */ getSeatsByHallId(hall: number): Observable { return this.http.post(`${this.baseUrl}sitzplatz/filter`, [ `eq;row.hall.id;int;${hall}`, diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index 317c914..826f949 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -13,6 +13,7 @@ export class NavbarComponent { { label: 'Preise', path: '/prices', auth: null }, { label: 'Bezahlen', path: '/checkout/order', auth: null }, { label: 'Einlasskontrolle', path: '/employee/validation/ticket', auth: ['employee'] }, + { label: 'Filmplanung', path: '/employee/scheduling', auth: ['employee', 'admin'] }, { label: 'Statistiken', path: '/admin/statistics', auth: ['admin'] }, { label: 'Saal-Designer', path: '/admin/designer', auth: ['admin'] }, { label: 'Film-Importer', path: '/admin/movie-importer', auth: ['admin']}, diff --git a/src/app/order/order.component.ts b/src/app/order/order.component.ts index a35ad2c..5e817d9 100644 --- a/src/app/order/order.component.ts +++ b/src/app/order/order.component.ts @@ -1,7 +1,7 @@ import { SelectedSeatsService } from './../selected-seats.service'; import { LoadingService } from './../loading.service'; import { Bestellung, Eintrittskarte, Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend'; -import { Component, computed, DestroyRef, inject, input, output, signal } from '@angular/core'; +import { Component, computed, DestroyRef, inject, input, OnInit, output, signal } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { StepperSelectionEvent } from '@angular/cdk/stepper'; import { HttpService } from '../http.service'; @@ -30,7 +30,7 @@ type SubmissionMode = 'reservation' | 'purchase'; templateUrl: './order.component.html', styleUrl: './order.component.css' }) -export class OrderComponent { +export class OrderComponent implements OnInit { private fb = inject(FormBuilder); private httpService = inject(HttpService); private destroyRef = inject(DestroyRef); diff --git a/src/app/performance-scheduling/performance-scheduling.component.css b/src/app/performance-scheduling/performance-scheduling.component.css new file mode 100644 index 0000000..60459a7 --- /dev/null +++ b/src/app/performance-scheduling/performance-scheduling.component.css @@ -0,0 +1,6 @@ +.gradient-text { + background: linear-gradient(to right, #6366f1, #db2777); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} diff --git a/src/app/performance-scheduling/performance-scheduling.component.html b/src/app/performance-scheduling/performance-scheduling.component.html new file mode 100644 index 0000000..54249c6 --- /dev/null +++ b/src/app/performance-scheduling/performance-scheduling.component.html @@ -0,0 +1,195 @@ + + +
+ +
+

+ Einzelaufführung +

+ +
+ Einzelaufführung + + + + Film + + + @for (movie of filteredMoviesPerformance | async; track movie) { + + {{ movie.title }} + + } + + + + + + + Kinosaal + + + {{ hallName(performanceForm) }} + + @for (hall of halls; track hall) { + +
+

{{ hall.name }}

+

+ {{ hallSeatMap.get(hall.id)?.length }} + {{ hallSeatMap.get(hall.id)?.length === 1 ? 'Sitzplatz' : 'Sitzplätze' }} +

+
+
+ } +
+
+ + +
+ + + Datum + + + + @if (performanceForm.get('date')?.hasError('required')) { + Bitte eine gültiges Datum wählen + } + + + + + Uhrzeit + + schedule + @if (performanceForm.get('time')?.hasError('required')) { + Bitte eine gültige Uhrzeit wählen + } + +
+ + @if (hasDateTimeError(performanceForm)) { + + Datum und Uhrzeit müssen in der Zukunft liegen + + } + + +
+ +
+ +
+ +
+ +
+

+ Vorstellungsplan +

+ +
+ Vorstellungsplan + + + + Film + + + @for (movie of filteredMoviesPlan | async; track movie) { + + {{ movie.title }} + + } + + + + + + Kinosaal + + + {{ hallName(seriesForm) }} + + @for (hall of halls; track hall) { + +
+

{{ hall.name }}

+

+ {{ hallSeatMap.get(hall.id)?.length }} + {{ hallSeatMap.get(hall.id)?.length === 1 ? 'Sitzplatz' : 'Sitzplätze' }} +

+
+
+ } +
+
+ + + + Wochentag + + @for (day of weekdays; track day) { + + {{ day.label }} + + } + + + + +
+ + + Start-Datum + + + + + + @if (performanceForm.get('date')?.hasError('required')) { + Bitte eine gültiges Datum wählen + } + + + + + Uhrzeit + + schedule + @if (performanceForm.get('time')?.hasError('required')) { + Bitte eine gültige Uhrzeit wählen + } + +
+ + @if (hasDateTimeError(seriesForm)) { + + Datum und Uhrzeit müssen in der Zukunft liegen + + } + + +
+ +
+ +
+ +
+ +
diff --git a/src/app/performance-scheduling/performance-scheduling.component.ts b/src/app/performance-scheduling/performance-scheduling.component.ts new file mode 100644 index 0000000..855a9f4 --- /dev/null +++ b/src/app/performance-scheduling/performance-scheduling.component.ts @@ -0,0 +1,294 @@ +import { Component, inject, OnInit, DestroyRef, ViewChild } from '@angular/core'; +import { AbstractControl, FormBuilder, FormGroup, FormGroupDirective, ValidationErrors, Validators } from '@angular/forms'; +import { MAT_DATE_LOCALE } from '@angular/material/core'; +import { provideNativeDateAdapter } from '@angular/material/core'; +import { HttpService } from '../http.service'; +import { LoadingService } from '../loading.service'; +import { catchError, forkJoin, map, Observable, of, startWith } from 'rxjs'; +import { Film, Kinosaal, Plan, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +@Component({ + selector: 'app-performance-scheduling', + standalone: false, + templateUrl: './performance-scheduling.component.html', + styleUrl: './performance-scheduling.component.css', + providers: [ + provideNativeDateAdapter(), + { provide: MAT_DATE_LOCALE, useValue: 'de-DE' } + ] +}) +export class PerformanceSchedulingComponent implements OnInit { + + private fb = inject(FormBuilder); + private http = inject(HttpService); + private destroyRef = inject(DestroyRef); + + public loading = inject(LoadingService); + + constructor(private snackBar: MatSnackBar) {} + + @ViewChild('formDirectivePerformance') formDirectivePerformance!: FormGroupDirective; + @ViewChild('formDirectiveSeries') formDirectiveSeries!: FormGroupDirective; + + minDate = new Date(); + + movies!: Film[]; + filteredMoviesPerformance!: Observable; + filteredMoviesPlan!: Observable; + + halls: Kinosaal[] = []; + hallSeatMap = new Map(); + + weekdays = [ + { label: 'Montag', value: 1 }, + { label: 'Dienstag', value: 2 }, + { label: 'Mittwoch', value: 3 }, + { label: 'Donnerstag', value: 4 }, + { label: 'Freitag', value: 5 }, + { label: 'Samstag', value: 6 }, + { label: 'Sonntag', value: 7 } + ]; + + weekdayFilter = (date: Date | null): boolean => { + if (!date) return false; + const selectedWeekday = this.seriesForm.get('weekday')?.value; + if (!selectedWeekday) return true; + const weekday = date.getDay() === 0 ? 7 : date.getDay(); + return weekday === selectedWeekday; + }; + + performanceForm!: FormGroup; + seriesForm!: FormGroup; + + ngOnInit() { + this.performanceForm = this.fb.group({ + movie: ['', [Validators.required, Validators.minLength(3)]], + hall: ['', Validators.required], + date: [null, Validators.required], + time: ['', Validators.required] + }, { + validators: this.dateTimeInFutureValidator() + }); + this.seriesForm = this.fb.group({ + movie: ['', [Validators.required, Validators.minLength(3)]], + hall: ['', Validators.required], + weekday: ['', Validators.required], + first: [null, Validators.required], + time: ['', Validators.required] + }, { + validators: this.dateTimeInFutureValidator() + }); + + // Wochentagüberprüfung + const weekdayControl = this.seriesForm.get('weekday'); + const dateControl = this.seriesForm.get('first'); + + weekdayControl?.valueChanges.subscribe(() => { + + const currentDate = dateControl?.value; + if (currentDate && !this.weekdayFilter(currentDate)) { + dateControl?.setValue(null); + } + dateControl?.updateValueAndValidity(); + }); + + dateControl?.valueChanges.subscribe((date: Date | null) => { + if (!date) return; + + const weekday = date.getDay() === 0 ? 7 : date.getDay(); + weekdayControl?.setValue(weekday, { emitEvent: false }); + }); + + this.loadData(); + } + + dateTimeInFutureValidator() { + return (formGroup: AbstractControl): ValidationErrors | null => { + const dateControl = formGroup.get('date'); + const timeControl = formGroup.get('time'); + + if (!dateControl?.value || !timeControl?.value) { + return null; + } + + const selectedDateTime = this.combineDateAndTime(dateControl.value, timeControl.value); + const now = new Date(); + + if (selectedDateTime <= now) { + return { dateTimeInPast: true }; + } + + return null; + }; + } + + hasDateTimeError(formGroup: AbstractControl): boolean { + return formGroup.hasError('dateTimeInPast') && + formGroup.get('date')?.touched === true && + formGroup.get('time')?.touched === true; + } + + hallName(formGroup: AbstractControl): string | undefined { + const hall: Kinosaal | null = formGroup.get('hall')?.value ?? null; + return hall?.name; + } + + displayMovie(movie: Film | null): string { + return movie ? movie.title : ''; + } + + loadData() { + const moviesRequest = this.http.getMovies(); + const seatsRequest = this.http.getAllSeats(); + + this.loading.show(); + this.hallSeatMap.clear(); + this.halls = []; + + forkJoin([moviesRequest, seatsRequest]).subscribe({ + next: ([movies, seats]) => { + this.movies = movies.sort((a, b) => a.title.localeCompare(b.title)); + + seats.forEach((seat) => { + const hallId = seat.row.hall.id; + if (!this.hallSeatMap.has(hallId)) { + this.halls.push(seat.row.hall); + this.hallSeatMap.set(hallId, []); + } + this.hallSeatMap.get(hallId)?.push(seat); + }); + + this.filteredMoviesPerformance = this.performanceForm.get('movie')!.valueChanges.pipe( + startWith(''), + map(value => this._filterMovies(value)) + ); + + this.filteredMoviesPlan = this.seriesForm.get('movie')!.valueChanges.pipe( + startWith(''), + map(value => this._filterMovies(value)) + ); + + this.loading.hide() + }, + error: (err) => { + console.error('Fehler beim Laden der Filme/Kinosäle', err); + this.loading.showError(err); + }, + }); + } + + private _filterMovies(value: string | Film): Film[] { + const filterValue = typeof value === 'string' ? value.toLowerCase() : value?.title.toLowerCase() ?? ''; + return this.movies.filter(movie => movie.title.toLowerCase().includes(filterValue)); + } + + onPerformanceSubmit() { + if (!this.performanceForm.valid) { + return; + } + this.loading.show(); + + const formValue = this.performanceForm.value; + const datetime = this.combineDateAndTime(formValue.date, formValue.time); + + const performance = this.generateNewPerformanceObject(datetime, formValue.hall, formValue.movie) + + this.http.createPerformance(performance).pipe( + map(performance => { + this.loading.hide(); + + this.formDirectivePerformance.resetForm(); + + this.snackBar.open( + `Vorstellung wurde erfolgreich unter der ID ${performance.id} angelegt.`, + 'Schließen', + { + duration: 5000, + panelClass: ['success-snackbar'], + horizontalPosition: 'right', + verticalPosition: 'top' + } + ); + }), + catchError(err => { + this.loading.showError(err); + this.performanceForm.setErrors({ serverError: true }); + console.log('Fehler beim Anlegen der Vorstellung', err); + return of(null); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(); + } + + private generateNewPerformanceObject(start: Date, hall: Kinosaal, movie: Film): Vorstellung { + return { + id: 0, // Wird durch das Backend gesetzt + start: start, + hall: hall, + movie: movie + }; + } + + private combineDateAndTime(date: Date, time: string): Date { + const [hours, minutes] = time.split(':').map(Number); + const combined = new Date(date); + combined.setHours(hours, minutes, 0, 0); + return combined; + } + + onPlanSubmit() { + if (!this.seriesForm.valid) { + return; + } + this.loading.show(); + + const formValue = this.seriesForm.value; + const series = this.generateNewSeriesObject(formValue.weekday, formValue.time, formValue.first, formValue.hall, formValue.movie) + + this.http.createPerformanceSeries(series).pipe( + map(performance => { + this.loading.hide(); + + this.formDirectiveSeries.resetForm(); + this.seriesForm.reset({ + movie: '', + hall: '', + weekday: '', + first: null, + time: '' + }); + + this.snackBar.open( + `Vorstellungsplan wurde erfolgreich unter der ID ${performance.id} angelegt.`, + 'Schließen', + { + duration: 5000, + panelClass: ['success-snackbar'], + horizontalPosition: 'right', + verticalPosition: 'top' + } + ); + }), + catchError(err => { + this.loading.showError(err); + this.seriesForm.setErrors({ serverError: true }); + console.log('Fehler beim Anlegen des Plans', err); + return of(null); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(); + } + + private generateNewSeriesObject(weekday: number, time: Date, first: Date, hall: Kinosaal, movie: Film): Plan { + return { + id: 0, // Wird durch das Backend gesetzt + weekday: weekday, + time: time, + first: first, + hall: hall, + movie: movie + }; + } +} diff --git a/src/app/schedule/schedule.component.ts b/src/app/schedule/schedule.component.ts index cd58a67..6d8c7eb 100644 --- a/src/app/schedule/schedule.component.ts +++ b/src/app/schedule/schedule.component.ts @@ -110,7 +110,7 @@ export class ScheduleComponent implements OnInit { id: perf.id, hall: perf.hall.name, start: new Date(perf.start), - // utilisation: 0 // TODO: perf.utilisation einrichten + // utilisation: 0 // TODO: perf.utilisation einrichten // Zu teuer: wont-fix }; movieMap.get(movieId)!.performances.push(performance); }