kinosaal designer

This commit is contained in:
2025-11-23 16:43:22 +01:00
parent 2ecaf2d526
commit 3a8fc2cc63
9 changed files with 526 additions and 169 deletions

View File

@@ -76,6 +76,7 @@ import { ConversionFailedComponent } from './conversion-failed/conversion-failed
import { PayForOrderComponent } from './pay-for-order/pay-for-order.component'; import { PayForOrderComponent } from './pay-for-order/pay-for-order.component';
import { CancelOrderDialog } from './cancel-order/cancel-order.dialog'; import { CancelOrderDialog } from './cancel-order/cancel-order.dialog';
import { PricelistComponent } from './pricelist/pricelist.component'; 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, PayForOrderComponent,
CancelOrderDialog, CancelOrderDialog,
PricelistComponent, PricelistComponent,
TheaterLayoutDesignerComponent,
], ],
imports: [ imports: [
AppRoutingModule, AppRoutingModule,

View File

@@ -5,12 +5,13 @@ import { HomeComponent } from './home/home.component';
import { MainLayoutComponent } from './layouts/main-layout/main-layout.component'; import { MainLayoutComponent } from './layouts/main-layout/main-layout.component';
import { MainComponent } from './main/main.component'; import { MainComponent } from './main/main.component';
import { ScheduleComponent } from './schedule/schedule.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 { MovieImporterComponent } from './movie-importer/movie-importer.component';
import { AuthGuard } from './auth.guard'; import { AuthGuard } from './auth.guard';
import { PayForOrderComponent } from './pay-for-order/pay-for-order.component'; import { PayForOrderComponent } from './pay-for-order/pay-for-order.component';
import { StatisticsComponent } from './statistics/statistics.component'; import { StatisticsComponent } from './statistics/statistics.component';
import { PricelistComponent } from './pricelist/pricelist.component'; import { PricelistComponent } from './pricelist/pricelist.component';
import { TheaterLayoutDesignerComponent } from './theater-layout-designer/theater-layout-designer.component';
const routes: Routes = [ const routes: Routes = [
// Seiten ohne Layout // Seiten ohne Layout
@@ -30,15 +31,27 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: { roles: ['admin'] }, // Array von erlaubten Rollen. Derzeit gäbe es 'admin' und 'employee' data: { roles: ['admin'] }, // Array von erlaubten Rollen. Derzeit gäbe es 'admin' und 'employee'
}, },
{ path: 'checkout/performance/:performanceId', component: TheaterOverlayComponent}, { path: 'checkout/performance/:performanceId', component: TheaterOverlayComponent },
{ path: 'checkout/order/:orderId', component: TheaterOverlayComponent}, { path: 'checkout/order/:orderId', component: TheaterOverlayComponent },
{ path: 'checkout/order', component: PayForOrderComponent}, { path: 'checkout/order', component: PayForOrderComponent },
{ {
path: 'admin/statistics', path: 'admin/statistics',
component: StatisticsComponent, component: StatisticsComponent,
canActivate: [AuthGuard], canActivate: [AuthGuard],
data: { roles: ['admin'] }, // Array von erlaubten Rollen. Derzeit gäbe es 'admin' und 'employee' 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 }, { path: 'prices', component: PricelistComponent },
], ],
}, },

View File

@@ -6,21 +6,22 @@ import {
OmdbSearch, OmdbSearch,
Bestellung, Bestellung,
Eintrittskarte, Eintrittskarte,
StatisticsFilm, StatisticsVorstellung, StatisticsFilm,
Sitzkategorie StatisticsVorstellung,
Sitzkategorie,
Sitzreihe,
} from '@infinimotion/model-frontend'; } from '@infinimotion/model-frontend';
import { HttpClient } from "@angular/common/http"; import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from "@angular/core"; import { inject, Injectable } from '@angular/core';
import { Observable } from "rxjs"; import { Observable } from 'rxjs';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class HttpService { export class HttpService {
private http = inject(HttpClient); private http = inject(HttpClient);
private baseUrl = 'https://infinimotion.de/api/'; private baseUrl = 'https://infinimotion.de/api/';
/* Bestellung APIs */ /* Bestellung APIs */
/* POST /api/bestellung/filter */ /* POST /api/bestellung/filter */
@@ -39,11 +40,16 @@ export class HttpService {
} }
/* POST /api/order-transaction/create */ /* POST /api/order-transaction/create */
saveAddOrder(req: {order:Bestellung, tickets:Eintrittskarte[]}): Observable<{order:Bestellung, tickets:Eintrittskarte[]}> { saveAddOrder(req: {
return this.http.post<{order: Bestellung, tickets: Eintrittskarte[]}>(`${this.baseUrl}order-transaction/create`, 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 */ /* Eintrittskarte APIs */
/* POST /api/eintrittskarte/filter */ /* POST /api/eintrittskarte/filter */
@@ -51,7 +57,6 @@ export class HttpService {
return this.http.post<Eintrittskarte[]>(`${this.baseUrl}eintrittskarte/filter`, filter); return this.http.post<Eintrittskarte[]>(`${this.baseUrl}eintrittskarte/filter`, filter);
} }
/* Kinosaal APIs */ /* Kinosaal APIs */
/* GET /api/kinosaal */ /* GET /api/kinosaal */
@@ -64,7 +69,6 @@ export class HttpService {
return this.http.post<Kinosaal>(`${this.baseUrl}kinosaal`, kinosaal); return this.http.post<Kinosaal>(`${this.baseUrl}kinosaal`, kinosaal);
} }
/* Vorstellung APIs */ /* Vorstellung APIs */
/* GET /api/vorstellung/{id} */ /* GET /api/vorstellung/{id} */
@@ -77,7 +81,6 @@ export class HttpService {
return this.http.post<Vorstellung[]>(`${this.baseUrl}vorstellung/filter`, filter); return this.http.post<Vorstellung[]>(`${this.baseUrl}vorstellung/filter`, filter);
} }
/* Film APIs */ /* Film APIs */
/* GET /api/film */ /* GET /api/film */
@@ -90,51 +93,70 @@ export class HttpService {
return this.http.post<Film[]>(`${this.baseUrl}film/filter`, filter); return this.http.post<Film[]>(`${this.baseUrl}film/filter`, filter);
} }
/* Show-Seats APIs */ /* Show-Seats APIs */
/* GET /api/show-seats/{show} */ /* 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<{ return this.http.get<{
seats: Sitzplatz[], seats: Sitzplatz[];
reserved: Sitzplatz[], reserved: Sitzplatz[];
booked: Sitzplatz[] booked: Sitzplatz[];
}>(`${this.baseUrl}show-seats/${show}`); }>(`${this.baseUrl}show-seats/${show}`);
} }
/* Sitzplatz APIs */
/* POST /api/seat/filter */
getSeatsByHallId(hall: number): Observable<Sitzplatz[]> {
return this.http.post<Sitzplatz[]>(`${this.baseUrl}sitzplatz/filter`, [
`eq;row.hall.id;int;${hall}`,
]);
}
/* POST /api/sitzplatz */
createSeat(seat: Sitzplatz): Observable<Sitzplatz> {
return this.http.post<Sitzplatz>(`${this.baseUrl}sitzplatz`, seat);
}
/* Sitzreihe APIs */
/* POST /api/sitzreihe */
createSeatRow(row: Sitzreihe): Observable<Sitzreihe> {
return this.http.post<Sitzreihe>(`${this.baseUrl}sitzreihe`, row);
}
/* Movie Importer APIs */ /* Movie Importer APIs */
/* GET /api/importer/search */ /* GET /api/importer/search */
searchMovie(query: string): Observable<OmdbSearch> { searchMovie(query: string): Observable<OmdbSearch> {
return this.http.get<OmdbSearch>(`${this.baseUrl}importer/search`, { return this.http.get<OmdbSearch>(`${this.baseUrl}importer/search`, {
params: {title: query} params: { title: query },
}); });
} }
/* POST /api/importer/import */ /* POST /api/importer/import */
importMovie(imdbId: string): Observable<Film> { importMovie(imdbId: string): Observable<Film> {
return this.http.post<Film>(`${this.baseUrl}importer/import?id=${imdbId}`, {}) return this.http.post<Film>(`${this.baseUrl}importer/import?id=${imdbId}`, {});
} }
/* Statistics APIs */ /* Statistics APIs */
/* GET /api/statistics/movies */ /* GET /api/statistics/movies */
getMovieStatistics(): Observable<StatisticsFilm[]> { getMovieStatistics(): Observable<StatisticsFilm[]> {
return this.http.get<StatisticsFilm[]>(`${this.baseUrl}statistics/movies`) return this.http.get<StatisticsFilm[]>(`${this.baseUrl}statistics/movies`);
} }
/* GET /api/statistics/shows */ /* GET /api/statistics/shows */
getShowStatistics(): Observable<StatisticsVorstellung[]> { getShowStatistics(): Observable<StatisticsVorstellung[]> {
return this.http.get<StatisticsVorstellung[]>(`${this.baseUrl}statistics/shows`) return this.http.get<StatisticsVorstellung[]>(`${this.baseUrl}statistics/shows`);
} }
/* Sitzkategorie APIs */ /* Sitzkategorie APIs */
/* GET /api/sitzkategorie */ /* GET /api/sitzkategorie */
getSeatCategories(): Observable<Sitzkategorie[]> { getSeatCategories(): Observable<Sitzkategorie[]> {
return this.http.get<Sitzkategorie[]>(`${this.baseUrl}sitzkategorie`) return this.http.get<Sitzkategorie[]>(`${this.baseUrl}sitzkategorie`);
} }
} }

View File

@@ -5,18 +5,19 @@ import { Component, inject, computed, OnInit } from '@angular/core';
selector: 'app-navbar', selector: 'app-navbar',
standalone: false, standalone: false,
templateUrl: './navbar.component.html', templateUrl: './navbar.component.html',
styleUrl: './navbar.component.css' styleUrl: './navbar.component.css',
}) })
export class NavbarComponent { export class NavbarComponent {
navItems: { label:string, path:string }[] = [ navItems: { label: string; path: string }[] = [
{label: 'Programm', path: '/schedule'}, { label: 'Programm', path: '/schedule' },
{label: 'Preise', path: '/prices'}, { label: 'Preise', path: '/prices' },
{label: 'Bezahlen', path: '/checkout/order'}, { label: 'Bezahlen', path: '/checkout/order' },
{label: 'Film importieren', path: '/admin/movie-importer'}, { label: 'Film importieren', path: '/admin/movie-importer' },
{label: 'Statistiken', path: '/admin/statistics'}, { label: 'Statistiken', path: '/admin/statistics' },
] { label: 'Saal-Designer', path: '/admin/designer' },
];
private auth = inject(AuthService) private auth = inject(AuthService);
currentUser = computed(() => this.auth.user()); currentUser = computed(() => this.auth.user());

View File

@@ -1,11 +1,12 @@
import { computed, Injectable, signal } from '@angular/core'; 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({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class SelectedSeatsService { export class SelectedSeatsService {
private selectedSeatsSignal = signal<Sitzplatz[]>([]); private selectedSeatsSignal = signal<Sitzplatz[]>([]);
private seatIsSelectableSignal = signal(true); private seatIsSelectableSignal = signal(true);
private committedSignal = signal(false); private committedSignal = signal(false);
@@ -23,20 +24,26 @@ export class SelectedSeatsService {
readonly hadConflict = this.hadConflictSignal.asReadonly(); readonly hadConflict = this.hadConflictSignal.asReadonly();
readonly totalSeats = computed(() => this.selectedSeats().length); 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 { 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); this.hadConflictSignal.set(false);
} }
removeSelectedSeat(selectedSeat: Sitzplatz): void { 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); this.hadConflictSignal.set(false);
} }
getSeatsByCategory(categoryId: number): Sitzplatz[] { 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 { clearSelection(): void {
@@ -47,7 +54,7 @@ export class SelectedSeatsService {
this.hadConflictSignal.set(false); this.hadConflictSignal.set(false);
} }
getSeatIsSelectable(): boolean{ getSeatIsSelectable(): boolean {
return this.seatIsSelectable(); return this.seatIsSelectable();
} }
@@ -75,11 +82,11 @@ export class SelectedSeatsService {
} }
toggleDebug(): void { toggleDebug(): void {
this.debugSignal.update(debug => !debug); this.debugSignal.update((debug) => !debug);
} }
isSeatSelected(seatId: number): boolean { isSeatSelected(seatId: number): boolean {
return this.selectedSeats().some(seat => seat.id === seatId); return this.selectedSeats().some((seat) => seat.id === seatId);
} }
setConflict(value: boolean): void { setConflict(value: boolean): void {

View File

@@ -0,0 +1,89 @@
@if(getHallId() >= 0) {
<div
class="m-auto w-200 h-10 bg-gray-200 mb-22 cursor-pointer"
style="clip-path: polygon(0% 0%, 100% 0%, 90% 100%, 10% 100%)"
>
<p class="flex justify-center text-lg font-bold p-1.5">Leinwand</p>
</div>
<div class="mb-5">
@for (row of seatsPerRow(); track $index) {
<div class="flex items-center justify-between">
<!-- Speaker -->
<div class="shrink-0 pl-20">
@if ($index % 4 === 0) {
<mat-icon
class="material-symbols-outlined opacity-25"
style="font-size: 30px; width: 30px; height: 30px"
>
speaker
</mat-icon>
} @if ($index % 4 === 2) {
<mat-icon
class="material-symbols-outlined opacity-25"
style="font-size: 30px; width: 30px; height: 30px"
>
wall_lamp
</mat-icon>
}
</div>
<!-- Sitzreihe -->
<app-seat-row class="flex justify-center" [rowSeatList]="row"></app-seat-row>
<!-- Speaker -->
<div class="shrink-0 pr-20">
@if ($index % 4 === 0) {
<mat-icon
class="material-symbols-outlined opacity-25 mirrored"
style="font-size: 30px; width: 30px; height: 30px"
>
speaker
</mat-icon>
} @if ($index % 4 === 2) {
<mat-icon
class="material-symbols-outlined opacity-25 mirrored"
style="font-size: 30px; width: 30px; height: 30px"
>
wall_lamp
</mat-icon>
}
</div>
</div>
}
<app-seat-row class="flex justify-center" [rowSeatList]="addRow"></app-seat-row>
<br />
<div class="flex justify-center">
<button mat-button matButton="filled" class="w-1/4" (click)="save()">Speichern</button>
</div>
</div>
} @else {
<br />
<div class="w-100 m-auto middle">
<form class="order-search-form w-full" (ngSubmit)="navigate()">
<div class="flex items-center space-x-4">
<mat-form-field class="w-full" subscriptSizing="dynamic">
<mat-label>Kinosaal-Name</mat-label>
<input
class="w-full"
type="text"
matInput
placeholder="Galactus"
autocomplete="off"
name="hallName"
[(ngModel)]="hallName"
/>
</mat-form-field>
</div>
<button
mat-button
class="w-100 mt-2"
matButton="filled"
color="accent"
[disabled]="hallName.length == 0"
type="submit"
>
Bearbeiten oder Erstellen
</button>
</form>
</div>
}

View File

@@ -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<number, Sitzkategorie>();
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]);
}
}

View File

@@ -1,8 +1,27 @@
import { Component, DestroyRef, inject, OnDestroy, OnInit, signal } from '@angular/core'; import { Component, DestroyRef, inject, OnDestroy, OnInit, signal } from '@angular/core';
import { HttpService } from '../http.service'; import { HttpService } from '../http.service';
import { LoadingService } from '../loading.service'; import { LoadingService } from '../loading.service';
import { catchError, filter, finalize, forkJoin, from, fromEvent, interval, merge, of, startWith, switchMap, tap } from 'rxjs'; import {
import { Bestellung, Eintrittskarte, Sitzkategorie, Sitzplatz, Vorstellung } from '@infinimotion/model-frontend'; 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 { TheaterSeatState } from '../model/theater-seat-state.model';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { SelectedSeatsService } from '../selected-seats.service'; import { SelectedSeatsService } from '../selected-seats.service';
@@ -16,7 +35,7 @@ const INACTIVITY_TIMEOUT_MS = 2 * 60 * 1000;
selector: 'app-theater-overlay', selector: 'app-theater-overlay',
standalone: false, standalone: false,
templateUrl: './theater-overlay.component.html', 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 http = inject(HttpService);
@@ -36,10 +55,10 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
order: Bestellung | undefined; order: Bestellung | undefined;
blockedSeats: Sitzplatz[] | undefined; blockedSeats: Sitzplatz[] | undefined;
seatCategories: Sitzkategorie[] = []; seatCategories: Sitzkategorie[] = [];
snackBarRef: MatSnackBarRef<TextOnlySnackBar> | undefined snackBarRef: MatSnackBarRef<TextOnlySnackBar> | undefined;
performance = signal<Vorstellung | undefined>(undefined); performance = signal<Vorstellung | undefined>(undefined);
seatsPerRow = signal<{ seat: Sitzplatz | null, state: TheaterSeatState | null }[][]>([]); seatsPerRow = signal<{ seat: Sitzplatz | null; state: TheaterSeatState | null }[][]>([]);
private isPollingEnabled = signal(true); private isPollingEnabled = signal(true);
private isInitialLoad = signal(true); private isInitialLoad = signal(true);
@@ -60,7 +79,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
this.loadExistingOrder(this.orderCode); this.loadExistingOrder(this.orderCode);
this.route.queryParams.subscribe(params => { this.route.queryParams.subscribe((params) => {
if (params['action'] === 'cancel') { if (params['action'] === 'cancel') {
this.resumeWithCancel = true; this.resumeWithCancel = true;
} }
@@ -75,7 +94,6 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
this.setupActivityTracking(); this.setupActivityTracking();
this.startAutoRefresh(); this.startAutoRefresh();
this.startInactivityCheck(); this.startInactivityCheck();
} else { } else {
// Fallback // Fallback
console.error('Ungültige Checkout-Route'); console.error('Ungültige Checkout-Route');
@@ -87,7 +105,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
this.isPollingEnabled.set(false); this.isPollingEnabled.set(false);
console.info('[TheaterOverlay] Stopped auto-refresh polling'); console.info('[TheaterOverlay] Stopped auto-refresh polling');
if(this.snackBarRef) { if (this.snackBarRef) {
this.snackBar.dismiss(); this.snackBar.dismiss();
} }
} }
@@ -102,15 +120,13 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
fromEvent(document, 'touchstart') fromEvent(document, 'touchstart')
); );
events$.pipe( events$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {
this.lastActivityTimestamp.set(Date.now()); this.lastActivityTimestamp.set(Date.now());
}); });
fromEvent(document, 'visibilitychange').pipe( fromEvent(document, 'visibilitychange')
takeUntilDestroyed(this.destroyRef) .pipe(takeUntilDestroyed(this.destroyRef))
).subscribe(() => { .subscribe(() => {
if (!this.isStepTwoOrHigher()) { if (!this.isStepTwoOrHigher()) {
if (document.hidden) { if (document.hidden) {
console.info('[TheaterOverlay] Tab hidden - pausing polling'); console.info('[TheaterOverlay] Tab hidden - pausing polling');
@@ -128,11 +144,13 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
} }
private startInactivityCheck() { private startInactivityCheck() {
interval(10000).pipe( interval(10000)
.pipe(
filter(() => this.isPollingEnabled()), filter(() => this.isPollingEnabled()),
filter(() => !this.isStepTwoOrHigher()), // Kein Timeout ab Schritt 2 filter(() => !this.isStepTwoOrHigher()), // Kein Timeout ab Schritt 2
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe(() => { )
.subscribe(() => {
const inactiveDuration = Date.now() - this.lastActivityTimestamp(); const inactiveDuration = Date.now() - this.lastActivityTimestamp();
if (inactiveDuration >= INACTIVITY_TIMEOUT_MS && !this.inactivityTimeoutReached()) { if (inactiveDuration >= INACTIVITY_TIMEOUT_MS && !this.inactivityTimeoutReached()) {
@@ -149,7 +167,6 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
} }
private showInactivitySnackBar() { private showInactivitySnackBar() {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
'Sitzplatzaktuallisierung wegen Inaktivität gestoppt.', 'Sitzplatzaktuallisierung wegen Inaktivität gestoppt.',
'Fortsetzen', 'Fortsetzen',
@@ -157,7 +174,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
duration: 0, duration: 0,
panelClass: ['timeout-snackbar'], panelClass: ['timeout-snackbar'],
horizontalPosition: 'center', horizontalPosition: 'center',
verticalPosition: 'bottom' verticalPosition: 'bottom',
} }
); );
@@ -171,7 +188,8 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
private startAutoRefresh() { private startAutoRefresh() {
console.info('[TheaterOverlay] Starting auto-refresh polling'); console.info('[TheaterOverlay] Starting auto-refresh polling');
interval(POLLING_INTERVAL_MS).pipe( interval(POLLING_INTERVAL_MS)
.pipe(
startWith(POLLING_INTERVAL_MS), startWith(POLLING_INTERVAL_MS),
filter(() => this.isPollingEnabled()), filter(() => this.isPollingEnabled()),
filter(() => !this.selectedSeatService.committed()), filter(() => !this.selectedSeatService.committed()),
@@ -181,7 +199,8 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
filter(() => !this.isStepTwoOrHigher()), // Nicht pollen ab Schritt 2 filter(() => !this.isStepTwoOrHigher()), // Nicht pollen ab Schritt 2
switchMap(() => this.loadPerformanceAndSeats()), switchMap(() => this.loadPerformanceAndSeats()),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe(); )
.subscribe();
} }
private loadPerformanceAndSeats() { private loadPerformanceAndSeats() {
@@ -195,7 +214,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
return forkJoin({ return forkJoin({
performance: this.http.getPerformaceById(this.showId!), performance: this.http.getPerformaceById(this.showId!),
seats: this.http.getSeatsByShowId(this.showId!) seats: this.http.getSeatsByShowId(this.showId!),
}).pipe( }).pipe(
tap(({ performance, seats }) => { tap(({ performance, seats }) => {
this.performance.set(performance); this.performance.set(performance);
@@ -206,11 +225,11 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
const conflicts = this.getConflictingSeats(seats.reserved); const conflicts = this.getConflictingSeats(seats.reserved);
if (conflicts.length > 0) { if (conflicts.length > 0) {
console.info('[TheaterOverlay] Conflicts! Updating shopping cart.'); 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.setConflict(true);
} }
this.selectedSeatService.selectedSeats this.selectedSeatService.selectedSeats;
} }
this.blockedSeats = seats.reserved; this.blockedSeats = seats.reserved;
@@ -220,7 +239,7 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
this.loading.hide(); this.loading.hide();
} }
}), }),
catchError(err => { catchError((err) => {
if (isInitial) { if (isInitial) {
this.loading.showError(err); this.loading.showError(err);
} else { } else {
@@ -242,50 +261,55 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
this.loading.show(); this.loading.show();
const ticketFilter = [`eq;order.code;string;${orderCode}`]; const ticketFilter = [`eq;order.code;string;${orderCode}`];
this.http.getTicketsByFilter(ticketFilter).pipe( this.http
switchMap(tickets => { .getTicketsByFilter(ticketFilter)
.pipe(
switchMap((tickets) => {
this.tickets = tickets; this.tickets = tickets;
if (!tickets.length) { if (!tickets.length) {
return from(this.router.navigate( return from(
['/checkout/order'], this.router.navigate(['/checkout/order'], {
{ queryParams: { error: 'invalid', code: orderCode } } queryParams: { error: 'invalid', code: orderCode },
)); })
);
} }
this.order = tickets[0].order; this.order = tickets[0].order;
if (this.order.booked || this.order.cancelled) { if (this.order.booked || this.order.cancelled) {
return from(this.router.navigate( return from(
['/checkout/order'], this.router.navigate(['/checkout/order'], {
{ queryParams: { error: 'completed', code: orderCode } } queryParams: { error: 'completed', code: orderCode },
)); })
);
} }
this.showId = this.tickets[0].show.id; this.showId = this.tickets[0].show.id;
this.selectedSeatService.clearSelection(); this.selectedSeatService.clearSelection();
this.tickets.forEach(t => this.selectedSeatService.pushSelectedSeat(t.seat)); this.tickets.forEach((t) => this.selectedSeatService.pushSelectedSeat(t.seat));
this.selectedSeatService.setSeatSelectable(false); this.selectedSeatService.setSeatSelectable(false);
return this.loadPerformanceAndSeats(); return this.loadPerformanceAndSeats();
}), }),
catchError(err => { catchError((err) => {
console.error('Fehler beim Laden der Bestellung', err); console.error('Fehler beim Laden der Bestellung', err);
return from(this.router.navigate( return from(
['/checkout/order'], this.router.navigate(['/checkout/order'], {
{ queryParams: { error: 'invalid', code: orderCode } } queryParams: { error: 'invalid', code: orderCode },
)); })
);
}), }),
finalize(() => { finalize(() => {
this.loading.hide(); this.loading.hide();
}), }),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
).subscribe(); )
.subscribe();
} }
private equalSeats(a: Sitzplatz[], b: Sitzplatz[]): boolean { private equalSeats(a: Sitzplatz[], b: Sitzplatz[]): boolean {
if (a === b) return true; if (a === b) return true;
if (a == null || b == null) return false; if (a == null || b == null) return false;
@@ -302,30 +326,32 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
} }
private getConflictingSeats(blockedSeats: Sitzplatz[]): Sitzplatz[] { private getConflictingSeats(blockedSeats: Sitzplatz[]): Sitzplatz[] {
const blockedIds = new Set(blockedSeats.map(bs => bs.id)); const blockedIds = new Set(blockedSeats.map((bs) => bs.id));
return this.selectedSeatService.selectedSeats().filter( return this.selectedSeatService
selectedSeat => blockedIds.has(selectedSeat.id) .selectedSeats()
); .filter((selectedSeat) => blockedIds.has(selectedSeat.id));
} }
private converter(resp: { seats: Sitzplatz[], reserved: Sitzplatz[], booked: Sitzplatz[] }): { private converter(resp: { seats: Sitzplatz[]; reserved: Sitzplatz[]; booked: Sitzplatz[] }): {
seat: Sitzplatz | null, seat: Sitzplatz | null;
state: TheaterSeatState | null state: TheaterSeatState | null;
}[][] { }[][] {
let rows: { seat: Sitzplatz | null, state: TheaterSeatState | null }[][] = []; let rows: { seat: Sitzplatz | null; state: TheaterSeatState | null }[][] = [];
const categoryMap = new Map<number, Sitzkategorie>(); const categoryMap = new Map<number, Sitzkategorie>();
// Sitzplätze sammeln // Sitzplätze sammeln
resp.seats.forEach(seat => { resp.seats.forEach((seat) => {
if (!rows[seat.row.position]) { if (!rows[seat.row.position]) {
rows[seat.row.position] = []; rows[seat.row.position] = [];
} }
let state = resp.booked.find(other => other.id == seat.id) ? TheaterSeatState.BOOKED let state = resp.booked.find((other) => other.id == seat.id)
: resp.reserved.find(other => other.id == seat.id) ? TheaterSeatState.RESERVED ? TheaterSeatState.BOOKED
: resp.reserved.find((other) => other.id == seat.id)
? TheaterSeatState.RESERVED
: TheaterSeatState.AVAILABLE; : 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)) { if (seat.row.category && !categoryMap.has(seat.row.category.id)) {
categoryMap.set(seat.row.category.id, seat.row.category); 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); 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) { if (rows.length === 0) {
return []; return [];
} }
// Leere Plätze auffüllen // Leere Plätze auffüllen
const filledSeats: { seat: Sitzplatz | null, state: TheaterSeatState | null }[][] = []; const filledSeats: { seat: Sitzplatz | null; state: TheaterSeatState | null }[][] = [];
rows.forEach(row => { rows.forEach((row) => {
row.sort((a, b) => a.seat!.position - b.seat!.position) row.sort((a, b) => a.seat!.position - b.seat!.position);
const minPos = row[0].seat!.position; const minPos = row[0].seat!.position;
const maxPos = row[row.length - 1].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++) { 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) { if (existingSeat) {
filledRow.push(existingSeat); filledRow.push(existingSeat);
} else { } else {
@@ -365,11 +402,14 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
// Leere Reihen auffüllen // Leere Reihen auffüllen
const minRowPos = rows[0][0].seat!.row.position; const minRowPos = rows[0][0].seat!.row.position;
const maxRowPos = rows[rows.length - 1][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; let processedIndex = 0;
for (let rowPos = minRowPos; rowPos <= maxRowPos; rowPos++) { 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]); filledRows.push(filledSeats[processedIndex]);
processedIndex++; processedIndex++;
} else { } else {
@@ -412,5 +452,3 @@ export class TheaterOverlayComponent implements OnInit, OnDestroy {
} }
} }
} }