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); }