diff --git a/src/app/app-module.ts b/src/app/app-module.ts index 7b9b385..4003cf2 100644 --- a/src/app/app-module.ts +++ b/src/app/app-module.ts @@ -76,6 +76,7 @@ import { ConversionFailedComponent } from './conversion-failed/conversion-failed import { PayForOrderComponent } from './pay-for-order/pay-for-order.component'; import { CancelOrderDialog } from './cancel-order/cancel-order.dialog'; import { PricelistComponent } from './pricelist/pricelist.component'; +import { TheaterLayoutDesignerComponent } from './theater-layout-designer/theater-layout-designer.component'; @@ -129,6 +130,7 @@ import { PricelistComponent } from './pricelist/pricelist.component'; PayForOrderComponent, CancelOrderDialog, PricelistComponent, + TheaterLayoutDesignerComponent, ], imports: [ AppRoutingModule, diff --git a/src/app/app-routing-module.ts b/src/app/app-routing-module.ts index 9b8c10d..f775818 100644 --- a/src/app/app-routing-module.ts +++ b/src/app/app-routing-module.ts @@ -5,12 +5,13 @@ import { HomeComponent } from './home/home.component'; import { MainLayoutComponent } from './layouts/main-layout/main-layout.component'; import { MainComponent } from './main/main.component'; import { ScheduleComponent } from './schedule/schedule.component'; -import { TheaterOverlayComponent} from './theater-overlay/theater-overlay.component'; +import { TheaterOverlayComponent } from './theater-overlay/theater-overlay.component'; import { MovieImporterComponent } from './movie-importer/movie-importer.component'; import { AuthGuard } from './auth.guard'; import { PayForOrderComponent } from './pay-for-order/pay-for-order.component'; import { StatisticsComponent } from './statistics/statistics.component'; import { PricelistComponent } from './pricelist/pricelist.component'; +import { TheaterLayoutDesignerComponent } from './theater-layout-designer/theater-layout-designer.component'; const routes: Routes = [ // Seiten ohne Layout @@ -30,15 +31,27 @@ const routes: Routes = [ canActivate: [AuthGuard], data: { roles: ['admin'] }, // Array von erlaubten Rollen. Derzeit gäbe es 'admin' und 'employee' }, - { path: 'checkout/performance/:performanceId', component: TheaterOverlayComponent}, - { path: 'checkout/order/:orderId', component: TheaterOverlayComponent}, - { path: 'checkout/order', component: PayForOrderComponent}, + { path: 'checkout/performance/:performanceId', component: TheaterOverlayComponent }, + { path: 'checkout/order/:orderId', component: TheaterOverlayComponent }, + { path: 'checkout/order', component: PayForOrderComponent }, { path: 'admin/statistics', component: StatisticsComponent, canActivate: [AuthGuard], data: { roles: ['admin'] }, // Array von erlaubten Rollen. Derzeit gäbe es 'admin' und 'employee' }, + { + path: 'admin/designer', + component: TheaterLayoutDesignerComponent, + canActivate: [AuthGuard], + data: { roles: ['admin'] }, // Array von erlaubten Rollen. Derzeit gäbe es 'admin' und 'employee' + }, + { + path: 'admin/designer/:hallId', + component: TheaterLayoutDesignerComponent, + canActivate: [AuthGuard], + data: { roles: ['admin'] }, // Array von erlaubten Rollen. Derzeit gäbe es 'admin' und 'employee' + }, { path: 'prices', component: PricelistComponent }, ], }, diff --git a/src/app/http.service.ts b/src/app/http.service.ts index 60d0f7e..ff70221 100644 --- a/src/app/http.service.ts +++ b/src/app/http.service.ts @@ -6,21 +6,22 @@ import { OmdbSearch, Bestellung, Eintrittskarte, - StatisticsFilm, StatisticsVorstellung, - Sitzkategorie + StatisticsFilm, + StatisticsVorstellung, + Sitzkategorie, + Sitzreihe, } from '@infinimotion/model-frontend'; -import { HttpClient } from "@angular/common/http"; -import { inject, Injectable } from "@angular/core"; -import { Observable } from "rxjs"; +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class HttpService { private http = inject(HttpClient); private baseUrl = 'https://infinimotion.de/api/'; - /* Bestellung APIs */ /* POST /api/bestellung/filter */ @@ -39,11 +40,16 @@ export class HttpService { } /* POST /api/order-transaction/create */ - saveAddOrder(req: {order:Bestellung, tickets:Eintrittskarte[]}): Observable<{order:Bestellung, tickets:Eintrittskarte[]}> { - return this.http.post<{order: Bestellung, tickets: Eintrittskarte[]}>(`${this.baseUrl}order-transaction/create`, req); + saveAddOrder(req: { + order: Bestellung; + tickets: Eintrittskarte[]; + }): Observable<{ order: Bestellung; tickets: Eintrittskarte[] }> { + return this.http.post<{ order: Bestellung; tickets: Eintrittskarte[] }>( + `${this.baseUrl}order-transaction/create`, + req + ); } - /* Eintrittskarte APIs */ /* POST /api/eintrittskarte/filter */ @@ -51,7 +57,6 @@ export class HttpService { return this.http.post(`${this.baseUrl}eintrittskarte/filter`, filter); } - /* Kinosaal APIs */ /* GET /api/kinosaal */ @@ -64,7 +69,6 @@ export class HttpService { return this.http.post(`${this.baseUrl}kinosaal`, kinosaal); } - /* Vorstellung APIs */ /* GET /api/vorstellung/{id} */ @@ -77,7 +81,6 @@ export class HttpService { return this.http.post(`${this.baseUrl}vorstellung/filter`, filter); } - /* Film APIs */ /* GET /api/film */ @@ -90,51 +93,70 @@ export class HttpService { return this.http.post(`${this.baseUrl}film/filter`, filter); } - /* Show-Seats APIs */ /* GET /api/show-seats/{show} */ - getSeatsByShowId(show: number): Observable<{ seats: Sitzplatz[], reserved: Sitzplatz[], booked: Sitzplatz[] }> { + getSeatsByShowId( + show: number + ): Observable<{ seats: Sitzplatz[]; reserved: Sitzplatz[]; booked: Sitzplatz[] }> { return this.http.get<{ - seats: Sitzplatz[], - reserved: Sitzplatz[], - booked: Sitzplatz[] + seats: Sitzplatz[]; + reserved: Sitzplatz[]; + booked: Sitzplatz[]; }>(`${this.baseUrl}show-seats/${show}`); } + /* Sitzplatz APIs */ + + /* POST /api/seat/filter */ + getSeatsByHallId(hall: number): Observable { + return this.http.post(`${this.baseUrl}sitzplatz/filter`, [ + `eq;row.hall.id;int;${hall}`, + ]); + } + + /* POST /api/sitzplatz */ + createSeat(seat: Sitzplatz): Observable { + return this.http.post(`${this.baseUrl}sitzplatz`, seat); + } + + /* Sitzreihe APIs */ + + /* POST /api/sitzreihe */ + createSeatRow(row: Sitzreihe): Observable { + return this.http.post(`${this.baseUrl}sitzreihe`, row); + } /* Movie Importer APIs */ /* GET /api/importer/search */ searchMovie(query: string): Observable { return this.http.get(`${this.baseUrl}importer/search`, { - params: {title: query} + params: { title: query }, }); } /* POST /api/importer/import */ importMovie(imdbId: string): Observable { - return this.http.post(`${this.baseUrl}importer/import?id=${imdbId}`, {}) + return this.http.post(`${this.baseUrl}importer/import?id=${imdbId}`, {}); } - /* Statistics APIs */ /* GET /api/statistics/movies */ getMovieStatistics(): Observable { - return this.http.get(`${this.baseUrl}statistics/movies`) + return this.http.get(`${this.baseUrl}statistics/movies`); } /* GET /api/statistics/shows */ getShowStatistics(): Observable { - return this.http.get(`${this.baseUrl}statistics/shows`) + return this.http.get(`${this.baseUrl}statistics/shows`); } - /* Sitzkategorie APIs */ /* GET /api/sitzkategorie */ getSeatCategories(): Observable { - return this.http.get(`${this.baseUrl}sitzkategorie`) + return this.http.get(`${this.baseUrl}sitzkategorie`); } } diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index 1e38f32..007b279 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -5,18 +5,19 @@ import { Component, inject, computed, OnInit } from '@angular/core'; selector: 'app-navbar', standalone: false, templateUrl: './navbar.component.html', - styleUrl: './navbar.component.css' + styleUrl: './navbar.component.css', }) export class NavbarComponent { - navItems: { label:string, path:string }[] = [ - {label: 'Programm', path: '/schedule'}, - {label: 'Preise', path: '/prices'}, - {label: 'Bezahlen', path: '/checkout/order'}, - {label: 'Film importieren', path: '/admin/movie-importer'}, - {label: 'Statistiken', path: '/admin/statistics'}, - ] + navItems: { label: string; path: string }[] = [ + { label: 'Programm', path: '/schedule' }, + { label: 'Preise', path: '/prices' }, + { label: 'Bezahlen', path: '/checkout/order' }, + { label: 'Film importieren', path: '/admin/movie-importer' }, + { label: 'Statistiken', path: '/admin/statistics' }, + { label: 'Saal-Designer', path: '/admin/designer' }, + ]; - private auth = inject(AuthService) + private auth = inject(AuthService); currentUser = computed(() => this.auth.user()); diff --git a/src/app/selected-seats.service.ts b/src/app/selected-seats.service.ts index 9be1b06..2850ade 100644 --- a/src/app/selected-seats.service.ts +++ b/src/app/selected-seats.service.ts @@ -1,11 +1,12 @@ import { computed, Injectable, signal } from '@angular/core'; -import { Sitzplatz } from '@infinimotion/model-frontend'; +import { Sitzplatz, Sitzreihe } from '@infinimotion/model-frontend'; +import { TheaterLayoutDesignerComponent } from './theater-layout-designer/theater-layout-designer.component'; +import { TheaterSeatState } from './model/theater-seat-state.model'; @Injectable({ providedIn: 'root', }) export class SelectedSeatsService { - private selectedSeatsSignal = signal([]); private seatIsSelectableSignal = signal(true); private committedSignal = signal(false); @@ -23,20 +24,26 @@ export class SelectedSeatsService { readonly hadConflict = this.hadConflictSignal.asReadonly(); readonly totalSeats = computed(() => this.selectedSeats().length); - readonly totalPrice = computed(() => this.selectedSeats().reduce((sum, seat) => sum + seat.row.category.price, 0)); + readonly totalPrice = computed(() => + this.selectedSeats().reduce((sum, seat) => sum + seat.row.category.price, 0) + ); pushSelectedSeat(selectedSeat: Sitzplatz): void { - this.selectedSeatsSignal.update(seats => [...seats, selectedSeat]); + if (selectedSeat.id < 0) { + TheaterLayoutDesignerComponent.interceptSeatSelection(selectedSeat); + return; + } + this.selectedSeatsSignal.update((seats) => [...seats, selectedSeat]); this.hadConflictSignal.set(false); } removeSelectedSeat(selectedSeat: Sitzplatz): void { - this.selectedSeatsSignal.update(seats => seats.filter(seat => seat.id !== selectedSeat.id)); + this.selectedSeatsSignal.update((seats) => seats.filter((seat) => seat.id !== selectedSeat.id)); this.hadConflictSignal.set(false); } getSeatsByCategory(categoryId: number): Sitzplatz[] { - return this.selectedSeats().filter(seat => seat.row.category.id === categoryId); + return this.selectedSeats().filter((seat) => seat.row.category.id === categoryId); } clearSelection(): void { @@ -47,7 +54,7 @@ export class SelectedSeatsService { this.hadConflictSignal.set(false); } - getSeatIsSelectable(): boolean{ + getSeatIsSelectable(): boolean { return this.seatIsSelectable(); } @@ -75,11 +82,11 @@ export class SelectedSeatsService { } toggleDebug(): void { - this.debugSignal.update(debug => !debug); + this.debugSignal.update((debug) => !debug); } isSeatSelected(seatId: number): boolean { - return this.selectedSeats().some(seat => seat.id === seatId); + return this.selectedSeats().some((seat) => seat.id === seatId); } setConflict(value: boolean): void { diff --git a/src/app/theater-layout-designer/theater-layout-designer.component.css b/src/app/theater-layout-designer/theater-layout-designer.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/theater-layout-designer/theater-layout-designer.component.html b/src/app/theater-layout-designer/theater-layout-designer.component.html new file mode 100644 index 0000000..1c7600d --- /dev/null +++ b/src/app/theater-layout-designer/theater-layout-designer.component.html @@ -0,0 +1,89 @@ +@if(getHallId() >= 0) { +
+

Leinwand

+
+
+ @for (row of seatsPerRow(); track $index) { +
+ +
+ @if ($index % 4 === 0) { + + speaker + + } @if ($index % 4 === 2) { + + wall_lamp + + } +
+ + + + + +
+ @if ($index % 4 === 0) { + + speaker + + } @if ($index % 4 === 2) { + + wall_lamp + + } +
+
+ } + +
+
+ +
+
+} @else { +
+
+
+
+ + Kinosaal-Name + + +
+ +
+
+} diff --git a/src/app/theater-layout-designer/theater-layout-designer.component.ts b/src/app/theater-layout-designer/theater-layout-designer.component.ts new file mode 100644 index 0000000..288f868 --- /dev/null +++ b/src/app/theater-layout-designer/theater-layout-designer.component.ts @@ -0,0 +1,185 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { Kinosaal, Sitzkategorie, Sitzplatz, Sitzreihe } from '@infinimotion/model-frontend'; +import { TheaterSeatState } from '../model/theater-seat-state.model'; +import { SelectedSeatsService } from '../selected-seats.service'; +import { HttpService } from '../http.service'; +import { first, firstValueFrom } from 'rxjs'; +import { TheaterOverlayComponent } from '../theater-overlay/theater-overlay.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LoadingService } from '../loading.service'; + +@Component({ + selector: 'app-theater-layout-designer', + standalone: false, + templateUrl: './theater-layout-designer.component.html', + styleUrl: './theater-layout-designer.component.css', +}) +export class TheaterLayoutDesignerComponent implements OnInit { + public static seatsPerRow: { seat: Sitzplatz | null; state: TheaterSeatState | null }[][] = []; + addRow: { seat: Sitzplatz | null; state: TheaterSeatState | null }[] = []; + + protected selectedSeatsService = inject(SelectedSeatsService); + protected http = inject(HttpService); + protected route = inject(ActivatedRoute); + protected loading = inject(LoadingService); + protected router = inject(Router); + + getHallId() { + return parseInt(this.route.snapshot.paramMap.get('hallId') ?? '-1'); + } + + async ngOnInit() { + let hallId = this.getHallId(); + if (hallId == -1) { + return; + } + + let seats = await firstValueFrom(this.http.getSeatsByHallId(hallId)); + let rows: { seat: Sitzplatz | null; state: TheaterSeatState | null }[][] = []; + const categoryMap = new Map(); + + seats.forEach((seat) => { + if (!rows[seat.row.position]) { + rows[seat.row.position] = []; + } + rows[seat.row.position].push({ + seat: seat, + state: TheaterSeatState.RESERVED, + }); + + if (seat.row.category && !categoryMap.has(seat.row.category.id)) { + categoryMap.set(seat.row.category.id, seat.row.category); + } + }); + TheaterLayoutDesignerComponent.seatsPerRow = TheaterOverlayComponent.fillSeatsPerRow(rows); + let rowCounter = 0; + TheaterLayoutDesignerComponent.seatsPerRow.forEach( + (row) => (rowCounter = TheaterLayoutDesignerComponent.fillRowWithSettings(rowCounter, row)) + ); + + let realCategories = await firstValueFrom(this.http.getSeatCategories()); + this.addRow = realCategories.map((category) => { + return { + seat: { + id: -2, + position: 1, + row: { + id: 0, + hall: { id: hallId } as Kinosaal, + category: category, + position: -1, + }, + }, + state: TheaterSeatState.AVAILABLE, + }; + }); + } + + seatsPerRow() { + return TheaterLayoutDesignerComponent.seatsPerRow; + } + + public static fillRowWithSettings( + rowCounter: number, + row: { + seat: Sitzplatz | null; + state: TheaterSeatState | null; + }[] + ) { + if (!row[0].seat) { + return rowCounter + 1; + } + + let categories: Sitzkategorie[] = [ + { id: 0, name: '', price: -1, icon: 'check_box_outline_blank' }, + { id: 1, name: '', price: -1, icon: 'add' }, + ]; + categories.forEach((category) => { + row.push({ + seat: { + id: -1, + position: row.length, + row: { + id: -1, + hall: undefined as unknown as Kinosaal, + position: rowCounter, + category: category, + }, + }, + state: TheaterSeatState.AVAILABLE, + }); + }); + return rowCounter + 1; + } + + static addSeatDesigner(selectedSeat: Sitzplatz): void { + let row = TheaterLayoutDesignerComponent.seatsPerRow[selectedSeat.row.position]; + if (selectedSeat.row.category.id == 0) { + row.splice(row.length - 2, 0, { + seat: null, + state: null, + }); + } else { + row.splice(row.length - 2, 0, { + seat: { id: 0, position: row.length - 1, row: row[0].seat!.row }, + state: TheaterSeatState.RESERVED, + }); + } + } + + static addRowDesigner(selectedSeat: Sitzplatz): void { + let rows = TheaterLayoutDesignerComponent.seatsPerRow; + let firstSeat = { + seat: { id: 0, position: 1, row: { ...selectedSeat.row } }, + state: TheaterSeatState.RESERVED, + }; + firstSeat.seat!.row.position = rows.length + 1; + rows.push([firstSeat]); + TheaterLayoutDesignerComponent.fillRowWithSettings(rows.length - 1, rows[rows.length - 1]); + } + + public static interceptSeatSelection(selectedSeat: Sitzplatz) { + if (selectedSeat.id == -1) { + this.addSeatDesigner(selectedSeat); + } else if (selectedSeat.id == -2) { + this.addRowDesigner(selectedSeat); + } else { + console.log('Fehler: unerwartete Seat-ID: ' + selectedSeat.id); + } + } + + async save() { + this.loading.show(); + for (let row of TheaterLayoutDesignerComponent.seatsPerRow) { + let seatRow; + if (!row[0].seat) { + return; + } else if (row[0].seat.row.id == 0) { + seatRow = await firstValueFrom(this.http.createSeatRow(row[0].seat.row)); + } else { + seatRow = row[0].seat.row; + } + + for (const { seat, state } of row) { + if (seat != null && seat.id == 0) { + seat.row = seatRow; + let createdSeat = await firstValueFrom(this.http.createSeat(seat)); + seat.id = createdSeat.id; + } + } + } + this.loading.showError('Kinosaal erfolgreich aktualisiert'); + } + + hallName: string = ''; + + async navigate() { + let halls = await firstValueFrom(this.http.getAllKinosaal()); + let hall = halls.filter((hall) => hall.name == this.hallName); + if (hall.length == 0) { + hall[0] = await firstValueFrom(this.http.addKinosaal({ name: this.hallName })); + } + + this.router.navigate(['/admin/designer', hall[0].id]); + } +} diff --git a/src/app/theater-overlay/theater-overlay.component.ts b/src/app/theater-overlay/theater-overlay.component.ts index d85cb6d..94ba353 100644 --- a/src/app/theater-overlay/theater-overlay.component.ts +++ b/src/app/theater-overlay/theater-overlay.component.ts @@ -1,8 +1,27 @@ import { Component, DestroyRef, inject, OnDestroy, OnInit, signal } from '@angular/core'; import { HttpService } from '../http.service'; import { LoadingService } from '../loading.service'; -import { catchError, filter, finalize, forkJoin, from, fromEvent, interval, merge, of, startWith, switchMap, tap } from 'rxjs'; -import { Bestellung, Eintrittskarte, Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend'; +import { + catchError, + filter, + finalize, + forkJoin, + from, + fromEvent, + interval, + merge, + of, + startWith, + switchMap, + tap, +} from 'rxjs'; +import { + Bestellung, + Eintrittskarte, + Sitzkategorie, + Sitzplatz, + Vorstellung, +} from '@infinimotion/model-frontend'; import { TheaterSeatState } from '../model/theater-seat-state.model'; import { ActivatedRoute, Router } from '@angular/router'; import { SelectedSeatsService } from '../selected-seats.service'; @@ -16,9 +35,9 @@ const INACTIVITY_TIMEOUT_MS = 2 * 60 * 1000; selector: 'app-theater-overlay', standalone: false, templateUrl: './theater-overlay.component.html', - styleUrl: './theater-overlay.component.css' + styleUrl: './theater-overlay.component.css', }) -export class TheaterOverlayComponent implements OnInit, OnDestroy { +export class TheaterOverlayComponent implements OnInit, OnDestroy { private http = inject(HttpService); private route = inject(ActivatedRoute); private router = inject(Router); @@ -36,10 +55,10 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { order: Bestellung | undefined; blockedSeats: Sitzplatz[] | undefined; seatCategories: Sitzkategorie[] = []; - snackBarRef: MatSnackBarRef | undefined + snackBarRef: MatSnackBarRef | undefined; performance = signal(undefined); - seatsPerRow = signal<{ seat: Sitzplatz | null, state: TheaterSeatState | null }[][]>([]); + seatsPerRow = signal<{ seat: Sitzplatz | null; state: TheaterSeatState | null }[][]>([]); private isPollingEnabled = signal(true); private isInitialLoad = signal(true); @@ -60,7 +79,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { this.loadExistingOrder(this.orderCode); - this.route.queryParams.subscribe(params => { + this.route.queryParams.subscribe((params) => { if (params['action'] === 'cancel') { this.resumeWithCancel = true; } @@ -75,7 +94,6 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { this.setupActivityTracking(); this.startAutoRefresh(); this.startInactivityCheck(); - } else { // Fallback console.error('Ungültige Checkout-Route'); @@ -87,7 +105,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { this.isPollingEnabled.set(false); console.info('[TheaterOverlay] Stopped auto-refresh polling'); - if(this.snackBarRef) { + if (this.snackBarRef) { this.snackBar.dismiss(); } } @@ -102,44 +120,44 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { fromEvent(document, 'touchstart') ); - events$.pipe( - takeUntilDestroyed(this.destroyRef) - ).subscribe(() => { + events$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { this.lastActivityTimestamp.set(Date.now()); }); - fromEvent(document, 'visibilitychange').pipe( - takeUntilDestroyed(this.destroyRef) - ).subscribe(() => { - if (!this.isStepTwoOrHigher()) { - if (document.hidden) { - console.info('[TheaterOverlay] Tab hidden - pausing polling'); - this.pausePolling(); - } else { - // Nur in Schritt 1 reaktivieren - if (!this.inactivityTimeoutReached()) { - console.info('[TheaterOverlay] Tab visible - resumed polling'); - this.isPollingEnabled.set(true); - this.refreshSeats(); + fromEvent(document, 'visibilitychange') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + if (!this.isStepTwoOrHigher()) { + if (document.hidden) { + console.info('[TheaterOverlay] Tab hidden - pausing polling'); + this.pausePolling(); + } else { + // Nur in Schritt 1 reaktivieren + if (!this.inactivityTimeoutReached()) { + console.info('[TheaterOverlay] Tab visible - resumed polling'); + this.isPollingEnabled.set(true); + this.refreshSeats(); + } } } - } - }); + }); } private startInactivityCheck() { - interval(10000).pipe( - filter(() => this.isPollingEnabled()), - filter(() => !this.isStepTwoOrHigher()), // Kein Timeout ab Schritt 2 - takeUntilDestroyed(this.destroyRef) - ).subscribe(() => { - const inactiveDuration = Date.now() - this.lastActivityTimestamp(); + interval(10000) + .pipe( + filter(() => this.isPollingEnabled()), + filter(() => !this.isStepTwoOrHigher()), // Kein Timeout ab Schritt 2 + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + const inactiveDuration = Date.now() - this.lastActivityTimestamp(); - if (inactiveDuration >= INACTIVITY_TIMEOUT_MS && !this.inactivityTimeoutReached()) { - console.info('[TheaterOverlay] Inactivity timeout reached'); - this.handleInactivityTimeout(); - } - }); + if (inactiveDuration >= INACTIVITY_TIMEOUT_MS && !this.inactivityTimeoutReached()) { + console.info('[TheaterOverlay] Inactivity timeout reached'); + this.handleInactivityTimeout(); + } + }); } private handleInactivityTimeout() { @@ -149,7 +167,6 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { } private showInactivitySnackBar() { - this.snackBarRef = this.snackBar.open( 'Sitzplatzaktuallisierung wegen Inaktivität gestoppt.', 'Fortsetzen', @@ -157,7 +174,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { duration: 0, panelClass: ['timeout-snackbar'], horizontalPosition: 'center', - verticalPosition: 'bottom' + verticalPosition: 'bottom', } ); @@ -171,17 +188,19 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { private startAutoRefresh() { console.info('[TheaterOverlay] Starting auto-refresh polling'); - interval(POLLING_INTERVAL_MS).pipe( - startWith(POLLING_INTERVAL_MS), - filter(() => this.isPollingEnabled()), - filter(() => !this.selectedSeatService.committed()), - filter(() => !document.hidden), // Nicht pollen, wenn Tab nicht sichtbar - filter(() => !this.inactivityTimeoutReached()), // Nicht pollen nach Timeout - filter(() => !this.isRequestInProgress()), // Nicht pollen, wenn Request läuft - filter(() => !this.isStepTwoOrHigher()), // Nicht pollen ab Schritt 2 - switchMap(() => this.loadPerformanceAndSeats()), - takeUntilDestroyed(this.destroyRef) - ).subscribe(); + interval(POLLING_INTERVAL_MS) + .pipe( + startWith(POLLING_INTERVAL_MS), + filter(() => this.isPollingEnabled()), + filter(() => !this.selectedSeatService.committed()), + filter(() => !document.hidden), // Nicht pollen, wenn Tab nicht sichtbar + filter(() => !this.inactivityTimeoutReached()), // Nicht pollen nach Timeout + filter(() => !this.isRequestInProgress()), // Nicht pollen, wenn Request läuft + filter(() => !this.isStepTwoOrHigher()), // Nicht pollen ab Schritt 2 + switchMap(() => this.loadPerformanceAndSeats()), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } private loadPerformanceAndSeats() { @@ -195,7 +214,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { return forkJoin({ performance: this.http.getPerformaceById(this.showId!), - seats: this.http.getSeatsByShowId(this.showId!) + seats: this.http.getSeatsByShowId(this.showId!), }).pipe( tap(({ performance, seats }) => { this.performance.set(performance); @@ -206,11 +225,11 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { const conflicts = this.getConflictingSeats(seats.reserved); if (conflicts.length > 0) { console.info('[TheaterOverlay] Conflicts! Updating shopping cart.'); - conflicts.forEach(seat => this.selectedSeatService.removeSelectedSeat(seat)); + conflicts.forEach((seat) => this.selectedSeatService.removeSelectedSeat(seat)); this.selectedSeatService.setConflict(true); } - this.selectedSeatService.selectedSeats + this.selectedSeatService.selectedSeats; } this.blockedSeats = seats.reserved; @@ -220,7 +239,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { this.loading.hide(); } }), - catchError(err => { + catchError((err) => { if (isInitial) { this.loading.showError(err); } else { @@ -242,90 +261,97 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { this.loading.show(); const ticketFilter = [`eq;order.code;string;${orderCode}`]; - this.http.getTicketsByFilter(ticketFilter).pipe( - switchMap(tickets => { - this.tickets = tickets; + this.http + .getTicketsByFilter(ticketFilter) + .pipe( + switchMap((tickets) => { + this.tickets = tickets; - if (!tickets.length) { - return from(this.router.navigate( - ['/checkout/order'], - { queryParams: { error: 'invalid', code: orderCode } } - )); - } + if (!tickets.length) { + return from( + this.router.navigate(['/checkout/order'], { + queryParams: { error: 'invalid', code: orderCode }, + }) + ); + } - this.order = tickets[0].order; + this.order = tickets[0].order; - if (this.order.booked || this.order.cancelled) { - return from(this.router.navigate( - ['/checkout/order'], - { queryParams: { error: 'completed', code: orderCode } } - )); - } + if (this.order.booked || this.order.cancelled) { + return from( + this.router.navigate(['/checkout/order'], { + queryParams: { error: 'completed', code: orderCode }, + }) + ); + } - this.showId = this.tickets[0].show.id; + this.showId = this.tickets[0].show.id; - this.selectedSeatService.clearSelection(); - this.tickets.forEach(t => this.selectedSeatService.pushSelectedSeat(t.seat)); - this.selectedSeatService.setSeatSelectable(false); + this.selectedSeatService.clearSelection(); + this.tickets.forEach((t) => this.selectedSeatService.pushSelectedSeat(t.seat)); + this.selectedSeatService.setSeatSelectable(false); - return this.loadPerformanceAndSeats(); - }), - catchError(err => { - console.error('Fehler beim Laden der Bestellung', err); + return this.loadPerformanceAndSeats(); + }), + catchError((err) => { + console.error('Fehler beim Laden der Bestellung', err); - return from(this.router.navigate( - ['/checkout/order'], - { queryParams: { error: 'invalid', code: orderCode } } - )); - }), - finalize(() => { - this.loading.hide(); - }), - takeUntilDestroyed(this.destroyRef) - ).subscribe(); + return from( + this.router.navigate(['/checkout/order'], { + queryParams: { error: 'invalid', code: orderCode }, + }) + ); + }), + finalize(() => { + this.loading.hide(); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } - private equalSeats(a: Sitzplatz[], b: Sitzplatz[]): boolean { - if (a === b) return true; - if (a == null || b == null) return false; - if (a.length !== b.length) return false; + if (a === b) return true; + if (a == null || b == null) return false; + if (a.length !== b.length) return false; - // Arrays kopieren und sortieren - const sortedA = [...a].sort((a, b) => a.id - b.id); - const sortedB = [...b].sort((a, b) => a.id - b.id); + // Arrays kopieren und sortieren + const sortedA = [...a].sort((a, b) => a.id - b.id); + const sortedB = [...b].sort((a, b) => a.id - b.id); - for (let i = 0; i < sortedA.length; ++i) { - if (sortedA[i].id !== sortedB[i].id) return false; - } - return true; + for (let i = 0; i < sortedA.length; ++i) { + if (sortedA[i].id !== sortedB[i].id) return false; + } + return true; } private getConflictingSeats(blockedSeats: Sitzplatz[]): Sitzplatz[] { - const blockedIds = new Set(blockedSeats.map(bs => bs.id)); - return this.selectedSeatService.selectedSeats().filter( - selectedSeat => blockedIds.has(selectedSeat.id) - ); + const blockedIds = new Set(blockedSeats.map((bs) => bs.id)); + return this.selectedSeatService + .selectedSeats() + .filter((selectedSeat) => blockedIds.has(selectedSeat.id)); } - private converter(resp: { seats: Sitzplatz[], reserved: Sitzplatz[], booked: Sitzplatz[] }): { - seat: Sitzplatz | null, - state: TheaterSeatState | null + private converter(resp: { seats: Sitzplatz[]; reserved: Sitzplatz[]; booked: Sitzplatz[] }): { + seat: Sitzplatz | null; + state: TheaterSeatState | null; }[][] { - let rows: { seat: Sitzplatz | null, state: TheaterSeatState | null }[][] = []; + let rows: { seat: Sitzplatz | null; state: TheaterSeatState | null }[][] = []; const categoryMap = new Map(); // Sitzplätze sammeln - resp.seats.forEach(seat => { + resp.seats.forEach((seat) => { if (!rows[seat.row.position]) { rows[seat.row.position] = []; } - let state = resp.booked.find(other => other.id == seat.id) ? TheaterSeatState.BOOKED - : resp.reserved.find(other => other.id == seat.id) ? TheaterSeatState.RESERVED + let state = resp.booked.find((other) => other.id == seat.id) + ? TheaterSeatState.BOOKED + : resp.reserved.find((other) => other.id == seat.id) + ? TheaterSeatState.RESERVED : TheaterSeatState.AVAILABLE; - rows[seat.row.position].push({seat: seat, state: state}); + rows[seat.row.position].push({ seat: seat, state: state }); if (seat.row.category && !categoryMap.has(seat.row.category.id)) { categoryMap.set(seat.row.category.id, seat.row.category); @@ -334,24 +360,35 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { this.seatCategories = Array.from(categoryMap.values()).sort((a, b) => a.id - b.id); - rows = rows.filter(row => row && row.length > 0).sort((a, b) => a[0].seat!.row.position - b[0].seat!.row.position); + return TheaterOverlayComponent.fillSeatsPerRow(rows); + } + + public static fillSeatsPerRow( + rows: { + seat: Sitzplatz | null; + state: TheaterSeatState | null; + }[][] + ) { + rows = rows + .filter((row) => row && row.length > 0) + .sort((a, b) => a[0].seat!.row.position - b[0].seat!.row.position); if (rows.length === 0) { return []; } // Leere Plätze auffüllen - const filledSeats: { seat: Sitzplatz | null, state: TheaterSeatState | null }[][] = []; + const filledSeats: { seat: Sitzplatz | null; state: TheaterSeatState | null }[][] = []; - rows.forEach(row => { - row.sort((a, b) => a.seat!.position - b.seat!.position) + rows.forEach((row) => { + row.sort((a, b) => a.seat!.position - b.seat!.position); const minPos = row[0].seat!.position; const maxPos = row[row.length - 1].seat!.position; - const filledRow: { seat: Sitzplatz | null, state: TheaterSeatState | null }[] = []; + const filledRow: { seat: Sitzplatz | null; state: TheaterSeatState | null }[] = []; for (let pos = minPos; pos <= maxPos; pos++) { - const existingSeat = row.find(s => s.seat!.position === pos); + const existingSeat = row.find((s) => s.seat!.position === pos); if (existingSeat) { filledRow.push(existingSeat); } else { @@ -365,11 +402,14 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { // Leere Reihen auffüllen const minRowPos = rows[0][0].seat!.row.position; const maxRowPos = rows[rows.length - 1][0].seat!.row.position; - const filledRows: { seat: Sitzplatz | null, state: TheaterSeatState | null }[][] = []; + const filledRows: { seat: Sitzplatz | null; state: TheaterSeatState | null }[][] = []; let processedIndex = 0; for (let rowPos = minRowPos; rowPos <= maxRowPos; rowPos++) { - if (processedIndex < filledSeats.length && filledSeats[processedIndex][0].seat!.row.position === rowPos) { + if ( + processedIndex < filledSeats.length && + filledSeats[processedIndex][0].seat!.row.position === rowPos + ) { filledRows.push(filledSeats[processedIndex]); processedIndex++; } else { @@ -412,5 +452,3 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy { } } } - -