Compare commits

..

42 Commits

Author SHA1 Message Date
87d9291bfb Use substitutions for disable button test
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-11-06 23:19:00 +01:00
c646ae15ee Optionally disable starting a match and show reason
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-11-06 23:00:00 +01:00
Michel ten Voorde
8128ba4744 Cleanup
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-11-06 16:42:48 +01:00
Michel ten Voorde
19e4372006 Show substitutions in standings 2025-11-06 16:42:22 +01:00
Michel ten Voorde
7fd84005f9 Show 100 players by default 2025-11-06 16:41:03 +01:00
Michel ten Voorde
a56317fecd Update counter 2025-11-06 16:40:20 +01:00
Michel ten Voorde
ecbb16776c Don't show teams inline when entering match result
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-11-03 14:32:41 +01:00
Michel ten Voorde
3008f45dfa Use autocompletion for partner search
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-10-31 16:02:15 +01:00
Michel ten Voorde
5baf1228f7 Use autocompletion for partner search, WIP 2025-10-31 15:13:22 +01:00
Michel ten Voorde
aacf06e203 Made tournament-players more standalone, now works from tournament screen 2025-10-29 17:27:32 +01:00
368aa53b9d match-result simplification
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-10-29 00:12:11 +01:00
335577fa4d match-result simplification 2025-10-29 00:09:14 +01:00
1f55acecd0 Use snackbars instead of alerts
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-10-28 09:26:26 +01:00
c1fedd728b Use inline on round overview 2025-10-28 09:22:36 +01:00
0e9e8d1e0f Put event on top on match sheets 2025-10-28 09:22:21 +01:00
Michel ten Voorde
0e1e1932a1 Use localStorage
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-10-27 14:25:26 +01:00
fb36ee1a05 Always show substitutes if applicable
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-10-09 16:24:55 +02:00
9cb3568770 Update route config
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-10-08 22:36:02 +02:00
d387452044 Cleanup
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-10-08 22:18:04 +02:00
2cdccc4dd8 Cleanup
Some checks failed
Gitea/swiss-client/pipeline/head There was a failure building this commit
2025-10-08 22:15:41 +02:00
8083e7fc5f Updated auth config
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-10-08 21:36:13 +02:00
0fd693aa42 Layout fixes
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-10-04 22:40:51 +02:00
edaffc82e4 Updated angular-material
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-09-30 23:21:53 +02:00
1dbc2f1ca9 Updated angular-core 2025-09-30 23:01:46 +02:00
21fa706b45 Updated angular-cli 2025-09-30 23:00:57 +02:00
f2ee2b467d Fixed authguard 2025-09-30 22:59:13 +02:00
cdf27f1948 Moved logic to backend
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-09-25 23:14:32 +02:00
2c33c9a68d Invallers
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-09-23 22:45:44 +02:00
181aebf16a Invallers 2025-09-23 20:47:11 +02:00
6a9ea43e23 Invallers
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-09-18 23:28:02 +02:00
02b884bbcf Invallers
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-09-17 08:59:02 +02:00
5c846a6351 Invallers
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-09-11 23:43:28 +02:00
929fd6b581 WIP: substitutions
Some checks failed
Gitea/swiss-client/pipeline/head There was a failure building this commit
2025-09-10 10:35:15 +02:00
33bfde27d4 Spelerslijst op elke pagina
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-09-01 22:59:33 +02:00
564340fec4 Finals round
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-08-28 23:22:09 +02:00
Michel ten Voorde
d1338bf524 Disable positioning fix
All checks were successful
Gitea/swiss-client/pipeline/head This commit looks good
2025-08-26 16:06:45 +02:00
Michel ten Voorde
f858020330 Upgrade angular/material 2025-08-26 15:59:42 +02:00
Michel ten Voorde
cf86a449e2 Upgrade angular/material 2025-08-26 15:59:28 +02:00
Michel ten Voorde
3dd4746304 Upgrade angular/core 2025-08-26 15:58:27 +02:00
Michel ten Voorde
1e45a12392 Upgrade angular/cli 2025-08-26 15:56:50 +02:00
Michel ten Voorde
8082a72d4d Fix position for menu 2025-08-26 15:54:52 +02:00
Michel ten Voorde
27e90ceb17 Make components standalone 2025-08-26 15:54:40 +02:00
64 changed files with 3730 additions and 4973 deletions

View File

@@ -32,11 +32,11 @@
Player-registrations: oude toernooien verbergen via knop Player-registrations: oude toernooien verbergen via knop
Onderdeel afsluiten Onderdeel afsluiten
Wedstrijd opgave Wedstrijd opgave
Blessures Invallers
Titels pagina's Titels pagina's
Authenticatie Authenticatie
Progress indicator tijdens communicatie backend Progress indicator tijdens communicatie backend
Bij tellers rekening houden met invallers
https://blog.shhdharmen.me/browser-storage-in-angular-ssr https://blog.shhdharmen.me/browser-storage-in-angular-ssr
Won't do / later: Won't do / later:

4431
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,23 +11,24 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^20.1.6", "@angular/animations": "^20.3.2",
"@angular/cdk": "^20.1.5", "@angular/cdk": "^20.2.5",
"@angular/common": "^20.1.6", "@angular/common": "^20.3.2",
"@angular/compiler": "^20.1.6", "@angular/compiler": "^20.3.2",
"@angular/core": "^20.1.6", "@angular/core": "^20.3.2",
"@angular/forms": "^20.1.6", "@angular/forms": "^20.3.2",
"@angular/material": "^20.1.5", "@angular/material": "^20.2.5",
"@angular/material-moment-adapter": "^20.1.5", "@angular/material-moment-adapter": "^20.2.5",
"@angular/platform-browser": "^20.1.6", "@angular/platform-browser": "^20.3.2",
"@angular/platform-browser-dynamic": "^20.1.6", "@angular/platform-browser-dynamic": "^20.3.2",
"@angular/platform-server": "^20.1.6", "@angular/platform-server": "^20.3.2",
"@angular/router": "^20.1.6", "@angular/router": "^20.3.2",
"@angular/ssr": "^20.1.5", "@angular/ssr": "^20.3.3",
"@ng-bootstrap/ng-bootstrap": "^19.0.1", "@ng-bootstrap/ng-bootstrap": "^19.0.1",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.7", "bootstrap": "^5.3.7",
"express": "^4.21.2", "express": "^4.21.2",
"jwt-decode": "^4.0.0",
"moment": "2.30.1", "moment": "2.30.1",
"ngx-cookie-service-ssr": "^20.1.0", "ngx-cookie-service-ssr": "^20.1.0",
"ngx-mask": "^20.0.3", "ngx-mask": "^20.0.3",
@@ -37,10 +38,10 @@
"zone.js": "~0.15.1" "zone.js": "~0.15.1"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^20.1.5", "@angular/build": "^20.3.3",
"@angular/cli": "^20.1.5", "@angular/cli": "^20.3.3",
"@angular/compiler-cli": "^20.1.6", "@angular/compiler-cli": "^20.3.2",
"@angular/localize": "^20.1.6", "@angular/localize": "^20.3.2",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"@types/node": "^24.2.1", "@types/node": "^24.2.1",

View File

@@ -17,10 +17,10 @@
<mat-icon>group</mat-icon> <mat-icon>group</mat-icon>
Spelers Spelers
</a> </a>
@if (this.userService.isLoggedIn()) { @if (this.authService.isLoggedIn()) {
<button mat-flat-button [matMenuTriggerFor]="accountMenu"> <button mat-flat-button [matMenuTriggerFor]="accountMenu">
<mat-icon>person</mat-icon> <mat-icon>person</mat-icon>
{{ user }} {{ this.authService.getUsername() }}
</button> </button>
<mat-menu #accountMenu="matMenu"> <mat-menu #accountMenu="matMenu">
<button mat-menu-item (click)="logOut()"> <button mat-menu-item (click)="logOut()">

View File

@@ -6,9 +6,10 @@ import {MatIcon} from "@angular/material/icon";
import {MatToolbar} from "@angular/material/toolbar"; import {MatToolbar} from "@angular/material/toolbar";
import {filter, map, Subscription} from "rxjs"; import {filter, map, Subscription} from "rxjs";
import {MatMenu, MatMenuItem, MatMenuTrigger} from "@angular/material/menu"; import {MatMenu, MatMenuItem, MatMenuTrigger} from "@angular/material/menu";
import {UserService} from "./authentication/user.service"; // import {UserService} from "./authentication/user.service";
import {TournamentService} from "./service/tournament.service"; import {TournamentService} from "./service/tournament.service";
import {HeaderService} from "./service/header.service"; import {HeaderService} from "./service/header.service";
import {AuthService} from "./auth/auth.service";
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -19,11 +20,11 @@ import {HeaderService} from "./service/header.service";
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
header: string; header: string;
user: string; // user: string;
userSubscription: Subscription; // userSubscription: Subscription;
constructor( constructor(
protected userService: UserService, protected authService: AuthService,
private router: Router, private router: Router,
protected activatedRoute: ActivatedRoute, protected activatedRoute: ActivatedRoute,
private tournamentService: TournamentService, private tournamentService: TournamentService,
@@ -32,7 +33,7 @@ export class AppComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.userSubscription = this.userService.currentUser.subscribe(newUser => this.user = newUser); // this.userSubscription = this.userService.currentUser.subscribe(newUser => this.user = newUser);
this.router.events.pipe( this.router.events.pipe(
filter(event => event instanceof NavigationEnd), filter(event => event instanceof NavigationEnd),
@@ -55,8 +56,8 @@ export class AppComponent implements OnInit {
} }
logOut() { logOut() {
this.userService.removeUser(); this.authService.logout();
this.router.navigate(['/auth/login']); this.router.navigate(['/login']);
} }
addTestData() { addTestData() {

View File

@@ -1,4 +1,4 @@
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core'; import {ApplicationConfig, inject, provideAppInitializer, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router'; import {provideRouter} from '@angular/router';
import {routes} from './app.routes'; import {routes} from './app.routes';
@@ -7,10 +7,9 @@ import {HTTP_INTERCEPTORS, provideHttpClient, withFetch, withInterceptorsFromDi}
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async'; import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import {provideMomentDateAdapter} from "@angular/material-moment-adapter"; import {provideMomentDateAdapter} from "@angular/material-moment-adapter";
import {MAT_SNACK_BAR_DEFAULT_OPTIONS} from "@angular/material/snack-bar"; import {MAT_SNACK_BAR_DEFAULT_OPTIONS} from "@angular/material/snack-bar";
import {AuthGuard} from "./authentication/authguard"; import {provideNgxMask} from "ngx-mask";
import {TokenInterceptor} from "./authentication/tokenInterceptor"; import {provideAnimations} from "@angular/platform-browser/animations";
import {ErrorInterceptor} from "./authentication/errorInterceptor"; import {AuthInterceptor} from "./auth/auth.interceptor";
import {provideEnvironmentNgxMask, provideNgxMask} from "ngx-mask";
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@@ -18,12 +17,11 @@ export const appConfig: ApplicationConfig = {
provideRouter(routes), provideRouter(routes),
provideClientHydration(), provideClientHydration(),
provideHttpClient(withFetch(), withInterceptorsFromDi()), provideHttpClient(withFetch(), withInterceptorsFromDi()),
provideAnimationsAsync(), provideAnimations(),
provideMomentDateAdapter(undefined, {useUtc: false}), provideMomentDateAdapter(undefined, {useUtc: false}),
{ provide: MAT_SNACK_BAR_DEFAULT_OPTIONS, useValue: { duration: 2500}}, { provide: MAT_SNACK_BAR_DEFAULT_OPTIONS, useValue: { duration: 2500}},
AuthGuard, { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
provideNgxMask(), provideNgxMask(),
] ]
}; };

View File

@@ -8,24 +8,24 @@ import {TournamentDrawComponent} from "./components/tournament-draw/tournament-d
import {TournamentManageComponent} from "./components/tournament-manage/tournament-manage.component"; import {TournamentManageComponent} from "./components/tournament-manage/tournament-manage.component";
import {MatchSheetsComponent} from "./components/match-sheets/match-sheets.component"; import {MatchSheetsComponent} from "./components/match-sheets/match-sheets.component";
import {RoundOverviewComponent} from "./components/round-overview/round-overview.component"; import {RoundOverviewComponent} from "./components/round-overview/round-overview.component";
import {AuthGuard} from "./authentication/authguard";
import {LoginComponent} from "./components/login/login.component"; import {LoginComponent} from "./components/login/login.component";
import {TournamentPlayersComponent} from "./components/tournament-players/tournament-players.component"; import {TournamentPlayersComponent} from "./components/tournament-players/tournament-players.component";
import {AuthGuard} from "./auth/auth-guard.service";
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: TournamentListComponent, canActivate: [AuthGuard], data: { header: 'Toernooien' }}, { path: '', component: TournamentListComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER', header: 'Toernooien' }},
{ path: 'tournaments', component: TournamentListComponent, canActivate: [AuthGuard], data: { header: 'Toernooien' }}, { path: 'tournaments', component: TournamentListComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER', header: 'Toernooien' }},
{ path: 'tournaments/add', component: TournamentEditComponent, canActivate: [AuthGuard], data: { header: 'Nieuw Toernooi' }}, { path: 'tournaments/add', component: TournamentEditComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER', header: 'Nieuw Toernooi' }},
{ path: 'tournaments/:id/edit', component: TournamentEditComponent, canActivate: [AuthGuard], data: { header: 'Bewerk Toernooi' }}, { path: 'tournaments/:id/edit', component: TournamentEditComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER', header: 'Bewerk Toernooi' }},
{ path: 'tournaments/:id/registrations', component: TournamentPlayersComponent, canActivate: [AuthGuard], data: { header: 'Inschrijvingen' }}, { path: 'tournaments/:id/registrations', component: TournamentPlayersComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER', header: 'Inschrijvingen' }},
{ path: 'tournaments/:id/draw', component: TournamentDrawComponent, canActivate: [AuthGuard], data: { header: 'Toernooi loten' }}, { path: 'tournaments/:id/draw', component: TournamentDrawComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER', header: 'Toernooi loten' }},
{ path: 'tournaments/:id/manage', component: TournamentManageComponent, canActivate: [AuthGuard]}, { path: 'tournaments/:id/manage', component: TournamentManageComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER' }},
{ path: 'tournaments/:id/manage/:tab', component: TournamentManageComponent, canActivate: [AuthGuard], data: { header: 'Toernooien' }}, { path: 'tournaments/:id/manage/:tab', component: TournamentManageComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER', header: 'Toernooien' }},
{ path: 'players', component: PlayerListComponent, canActivate: [AuthGuard], data: { header: 'Spelers' }}, { path: 'players', component: PlayerListComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER', header: 'Spelers' }},
{ path: 'players/add', component: PlayerEditComponent, canActivate: [AuthGuard], data: { header: 'Nieuwe Speler' }}, { path: 'players/add', component: PlayerEditComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER', header: 'Nieuwe Speler' }},
{ path: 'players/:id/edit', component: PlayerEditComponent, canActivate: [AuthGuard], data: { header: 'Bewerk Speler' }}, { path: 'players/:id/edit', component: PlayerEditComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER', header: 'Bewerk Speler' }},
{ path: 'players/:id/registrations', component: PlayerRegistrationsComponent, canActivate: [AuthGuard]}, { path: 'players/:id/registrations', component: PlayerRegistrationsComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER' }},
{ path: 'tournaments/:id/rounds/:roundId/matchsheets', component: MatchSheetsComponent, canActivate: [AuthGuard]}, { path: 'tournaments/:id/rounds/:roundId/matchsheets', component: MatchSheetsComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER' }},
{ path: 'tournaments/:id/rounds/:roundId/overview', component: RoundOverviewComponent, canActivate: [AuthGuard], data: { header: 'Rondeoverzicht' }}, { path: 'tournaments/:id/rounds/:roundId/overview', component: RoundOverviewComponent, canActivate: [AuthGuard], data: { role: 'ROLE_USER', header: 'Rondeoverzicht' }},
{ path: 'auth/login', component: LoginComponent, data: { header: 'Inloggen'}} { path: 'login', component: LoginComponent, data: { header: 'Inloggen'}}
]; ];

View File

@@ -0,0 +1,22 @@
import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from '@angular/router';
import {AuthService} from './auth.service';
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {
}
public canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
if (this.authService.isLoggedIn() && this.authService.isUserInRole(next.routeConfig?.data?.['role'])) {
return true;
} else {
// this.router.navigateByUrl("/login");
this.router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
return false;
}
}
}

View File

@@ -0,0 +1,35 @@
import {Injectable} from '@angular/core';
import {HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http';
import {catchError, Observable, throwError} from 'rxjs';
import {Router} from '@angular/router';
@Injectable({
providedIn: 'root'
})
export class AuthInterceptor implements HttpInterceptor {
constructor(private router: Router) {
}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
let token = localStorage.getItem("app.token");
if (token) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
},
});
}
return next.handle(request).pipe(
catchError((error: HttpErrorResponse) => this.handleErrorRes(error))
);
}
private handleErrorRes(error: HttpErrorResponse): Observable<never> {
if (error.status === 401) {
this.router.navigateByUrl("/login", {replaceUrl: true});
}
return throwError(() => error);
}
}

View File

@@ -0,0 +1,85 @@
import {Inject, Injectable, PLATFORM_ID} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {environment} from "../../environments/environment";
import {isPlatformBrowser} from "@angular/common";
import {jwtDecode, JwtPayload} from "jwt-decode";
@Injectable({
providedIn: 'root'
})
export class AuthService {
private readonly authUrl: string
constructor(private http: HttpClient,
@Inject(PLATFORM_ID) private platformId: Object) {
this.authUrl = `${environment.backendUrl}/api/auth`;
}
private get isBrowser(): boolean {
return isPlatformBrowser(this.platformId);
}
isLoggedIn(): boolean {
if (!this.isBrowser) return false;
return localStorage.getItem("app.token") != null;
}
login(username: string, password: string): Observable<string> {
if (!this.isBrowser) {
throw new Error('Login can only be performed in browser');
}
const credentials = btoa(`${username}:${password}`);
const httpOptions = {
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/json'
},
responseType: 'text' as 'text',
};
return this.http.post(this.authUrl, null, httpOptions);
}
logout() {
if (!this.isBrowser) return;
localStorage.removeItem("app.token");
localStorage.removeItem("app.roles");
}
isUserInRole(roleFromRoute: string) {
if (!this.isBrowser) return false;
const roles = localStorage.getItem("app.roles");
if (roles!.includes(",")) {
if (roles === roleFromRoute) {
return true;
}
} else {
const roleArray = roles!.split(",");
for (let role of roleArray) {
if (role === roleFromRoute) {
return true;
}
}
}
return false;
}
getUsername(): string | null {
if (!this.isBrowser) return null;
const token = localStorage.getItem("app.token");
if (!token) return null;
try {
const decodedToken = jwtDecode<JwtPayload>(token);
return decodedToken.sub || null; // 'sub' is the standard JWT claim for subject/username
} catch (error) {
return null;
}
}
}

View File

@@ -1,35 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import {environment} from "../../environments/environment";
import {LoginCredentials} from "./loginCredentials";
import {TokenModel} from "./tokenModel";
@Injectable({
providedIn: 'root'
})
export class AuthenticationService {
private readonly authUrl: string
constructor(private http: HttpClient) {
this.authUrl = `${environment.backendUrl}`
}
public login(loginCredentials: LoginCredentials) {
return this.http.post<any>(`${this.authUrl}/authenticate`, loginCredentials);
}
public logout(tokenModel: TokenModel) {
return this.http.post<string>(
`${this.authUrl}/logout`,
tokenModel
);
}
public logoutEverywhere() {
return this.http.post<string>(
`${this.authUrl}/logout-everywhere`,
undefined
);
}
}

View File

@@ -1,23 +0,0 @@
import {Injectable} from "@angular/core";
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from "@angular/router";
import {UserService} from "./user.service";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private router: Router,
private userService: UserService
) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
if (this.userService.getUser()) {
return true;
}
this.router.navigate(['/auth/login'], {
queryParams: { returnUrl: state.url },
});
return false;
}
}

View File

@@ -1,31 +0,0 @@
import {Injectable} from "@angular/core";
import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from "@angular/common/http";
import {catchError, Observable, throwError} from "rxjs";
import {UserService} from "./user.service";
@Injectable({
providedIn: 'root'
})
export class ErrorInterceptor implements HttpInterceptor {
constructor(private userService: UserService) { }
intercept(
request: HttpRequest<any>,
next: HttpHandler
): Observable<HttpEvent<any>> {
return next.handle(request).pipe(catchError(error=>{
if((error.status == 401 || error.status == 403) && !this.isLoginPage(request)){
this.userService.removeUser();
}
// const errMsg = error.error.message || error.statusText;
return throwError(() => error);
// return throwError(()=> errMsg);
}));
}
private isLoginPage(request: HttpRequest<any>){
return request.url.includes("/authenticate")
}
}

View File

@@ -1,14 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class IpService {
constructor(private http: HttpClient) { }
public getIPAddress() {
return this.http.get("http://api.ipify.org/?format=json");
}
}

View File

@@ -1,6 +0,0 @@
export class LoginCredentials {
username: string;
password: string;
// ipAddress: string;
// recaptcha: string;
}

View File

@@ -1,27 +0,0 @@
import {Injectable} from "@angular/core";
import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from "@angular/common/http";
import {Observable} from "rxjs";
import {UserService} from "./user.service";
@Injectable({
providedIn: 'root'
})
export class TokenInterceptor implements HttpInterceptor {
constructor(private userService: UserService) { }
intercept(
request: HttpRequest<any>,
next: HttpHandler
): Observable<HttpEvent<any>> {
if (this.userService.isLoggedIn()) {
let newRequest = request.clone({
setHeaders: {
Authorization: `Bearer ${this.userService.getUser()?.accessToken}`,
},
});
return next.handle(newRequest);
}
return next.handle(request);
}
}

View File

@@ -1,11 +0,0 @@
export class TokenModel {
constructor(token: string, refreshToken: string, ipAddress: string) {
this.token = token;
this.refreshToken = refreshToken;
this.ipAddress = ipAddress;
}
token: string;
refreshToken: string;
ipAddress: string;
}

View File

@@ -1,54 +0,0 @@
import {EventEmitter, Injectable, Output} from "@angular/core";
import {SsrCookieService} from "ngx-cookie-service-ssr";
import {Router} from "@angular/router";
import {User} from "./user";
import {BehaviorSubject} from "rxjs";
@Injectable({
providedIn: 'root'
})
export class UserService {
user?: User;
private userEmitter = new BehaviorSubject('');
currentUser = this.userEmitter.asObservable();
constructor(
private cookieService: SsrCookieService,
private router: Router,
) {}
public getUser(): User | undefined {
const user = this.cookieService.get('swissuser');
if (user) {
this.user = JSON.parse(user);
this.userEmitter.next(JSON.parse(user).username);
} else {
this.user = undefined;
}
return this.user;
}
public isLoggedIn(): boolean {
const user = this.cookieService.get('swissuser');
return !(user == undefined || false || user == "");
}
public setUser(user: User) {
this.cookieService.set('swissuser', JSON.stringify(user));
this.user = user;
this.userEmitter.next(this.user.username);
}
public removeUser() {
this.cookieService.delete('swissuser');
this.user = undefined;
this.router.navigate(['/tournaments']);
this.userEmitter.next('');
}
}

View File

@@ -1,8 +0,0 @@
export class User {
accessToken: string;
username: string;
// passwordHash: string;
// email: string;
// token: string;
// refreshToken: string;
}

View File

@@ -0,0 +1,22 @@
<mat-dialog-content>
<h3>Kies een teller:</h3>
<div class="col-md-6">
<mat-form-field appearance="fill">
<mat-label>Teller</mat-label>
<mat-select [(ngModel)]="counter">
<mat-option>Geen</mat-option>
@for (player of data.availableCounters; track player.playerId) {
<mat-option [value]="player">
{{ player.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</mat-dialog-content>
<mat-dialog-actions>
@if (counter) {
<button mat-button [mat-dialog-close]="{counter: counter}">Opslaan</button>
}
<button mat-button (click)="onAnnulerenClick()">Annuleren</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,4 @@
button:disabled {
cursor: not-allowed;
pointer-events: all !important;
}

View File

@@ -0,0 +1,49 @@
import {Component, inject, Inject} from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef
} from "@angular/material/dialog";
import {Match} from "../../model/match";
import {MatButton} from "@angular/material/button";
import {TournamentPlayer} from "../../model/tournamentPlayer";
import {MatFormField, MatLabel} from "@angular/material/form-field";
import {MatOption, MatSelect} from "@angular/material/select";
import {FormsModule} from "@angular/forms";
@Component({
selector: 'app-counter-selection',
imports: [
MatDialogContent,
MatButton,
MatDialogClose,
MatDialogActions,
MatFormField,
MatLabel,
MatOption,
MatSelect,
FormsModule,
],
templateUrl: './counter-selection.component.html',
standalone: true,
styleUrl: './counter-selection.component.scss'
})
export class CounterSelectionComponent {
counter: TournamentPlayer;
readonly dialogRef = inject(MatDialogRef<CounterSelectionComponent>);
constructor(@Inject(MAT_DIALOG_DATA) public data: {
match: Match,
availableCounters: TournamentPlayer[]
}) {}
onAnnulerenClick() {
this.dialogRef.close();
}
}

View File

@@ -14,7 +14,7 @@ import {MatOption, MatSelect} from "@angular/material/select";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
@Component({ @Component({
selector: 'app-court-selection', selector: 'app-court-selection',
imports: [ imports: [
MatDialogContent, MatDialogContent,
MatButton, MatButton,
@@ -27,8 +27,9 @@ import {FormsModule} from "@angular/forms";
FormsModule, FormsModule,
], ],
templateUrl: './court-selection.component.html', templateUrl: './court-selection.component.html',
styleUrl: './court-selection.component.scss' standalone: true,
styleUrl: './court-selection.component.scss'
}) })
export class CourtSelectionComponent { export class CourtSelectionComponent {

View File

@@ -1,22 +1,23 @@
<mat-card> <mat-card>
<mat-card-content> <mat-card-content>
<form class="form-horizontal" [formGroup]="form"> <form class="form-horizontal" (ngSubmit)="login()">
<div class="row"> <div class="row">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Gebruikersnaam</mat-label> <mat-label>Gebruikersnaam</mat-label>
<input matInput placeholder="Gebruikersnaam" formControlName="username" required> <input matInput name="username" placeholder="Gebruikersnaam" required [(ngModel)]="username">
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
<mat-label>Wachtwoord</mat-label> <mat-label>Wachtwoord</mat-label>
<input matInput placeholder="Wachtwoord" type="password" formControlName="password" required> <input matInput name="password" placeholder="Wachtwoord" type="password" required [(ngModel)]="password">
</mat-form-field> </mat-form-field>
</div> </div>
<button class="w-100 mt-2" mat-button mat-flat-button color="primary" <button class="w-100 mt-2" mat-button mat-flat-button color="primary"
(click)="login()" [disabled]="form.invalid"> (click)="login()">
Inloggen Inloggen
</button> </button>
</form> </form>
<p>{{message}}</p>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

View File

@@ -1,78 +1,72 @@
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {Component, OnInit} from '@angular/core'; import {Component, OnInit} from '@angular/core';
import {ActivatedRoute, Router, RouterLink} from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from "@angular/material/card"; import {MatCard, MatCardContent} from "@angular/material/card";
import {MatError, MatFormField, MatLabel} from "@angular/material/form-field"; import {MatFormField, MatLabel} from "@angular/material/form-field";
import {MatButton} from "@angular/material/button"; import {MatButton} from "@angular/material/button";
import {MatInput} from "@angular/material/input"; import {MatInput} from "@angular/material/input";
import {NgIf} from "@angular/common"; import {jwtDecode, JwtPayload} from "jwt-decode";
import {AuthenticationService} from "../../authentication/authentication.service"; import {AuthService} from "../../auth/auth.service";
import {UserService} from "../../authentication/user.service"; import {MatSnackBar} from "@angular/material/snack-bar";
import {LoginCredentials} from "../../authentication/loginCredentials";
import {User} from "../../authentication/user";
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
templateUrl: './login.component.html', templateUrl: './login.component.html',
imports: [ imports: [
MatCardContent, MatCardContent,
ReactiveFormsModule, ReactiveFormsModule,
MatFormField, MatFormField,
MatButton, MatButton,
MatInput, MatInput,
MatLabel, MatLabel,
MatCard, MatCard,
], FormsModule,
styleUrls: ['./login.component.scss'] ],
standalone: true,
styleUrls: ['./login.component.scss']
}) })
export class LoginComponent implements OnInit { export class LoginComponent implements OnInit {
public form: FormGroup; public form: FormGroup;
private returnUrl: string; private returnUrl: string;
private ipAddress: string; private ipAddress: string;
constructor( username: string = "";
private route: ActivatedRoute, password: string = "";
private fb: FormBuilder, message: string = "";
private authenticationService: AuthenticationService,
private router: Router, constructor(private authService: AuthService,
private userPersistenceService: UserService, private route: ActivatedRoute,
) { private router: Router,
this.initializeForm(); private snackBar: MatSnackBar) {
} }
private initializeForm() {
this.form = this.fb.group({
username: ['', [Validators.required]],
password: ['', [Validators.required]],
});
}
ngOnInit() { ngOnInit() {
this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/'; this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
} }
login() { public login(): void {
if (this.form.invalid) { localStorage.removeItem("app.token");
return;
}
let loginCredentials = new LoginCredentials(); this.authService.login(this.username, this.password)
loginCredentials = { .subscribe({
...loginCredentials, next: (token) => {
...this.form.value, localStorage.setItem("app.token", token);
};
this.authenticationService const decodedToken = jwtDecode<JwtPayload>(token);
.login(loginCredentials) // @ts-ignore
.subscribe( localStorage.setItem("app.roles", decodedToken.scope);
{
next: (user: User) => { // this.router.navigateByUrl("/persons");
this.userPersistenceService.setUser(user);
this.router.navigate([this.returnUrl]); // this.router.navigate([this.returnUrl]);
} this.router.navigate([this.route.snapshot.queryParams['returnUrl'] || '/']);
},
error: (error) => {
this.snackBar.open(`Login failed: ${error.status}`, "OK")
} }
); });
} }
} }

View File

@@ -7,76 +7,76 @@
<b>{{ data.group.name }} {{ data.round.name }}</b> <b>{{ data.group.name }} {{ data.round.name }}</b>
</div> </div>
</mat-grid-tile> </mat-grid-tile>
<mat-grid-tile>
<button type="button" class="btn btn-primary btn-lg" (click)="set21(1, 1)">21</button>
</mat-grid-tile>
<mat-grid-tile>
<button type="button" class="btn btn-primary btn-lg" (click)="set21(1, 2)">21</button>
</mat-grid-tile>
<mat-grid-tile>
<button type="button" class="btn btn-primary btn-lg" (click)="set21(1, 3)">21</button>
</mat-grid-tile>
@for (gameNum of [1, 2, 3]; track gameNum) {
<mat-grid-tile>
<button type="button" class="btn btn-primary btn-lg" (click)="set21(1, gameNum)">21</button>
</mat-grid-tile>
}
<mat-grid-tile colspan="2"> <mat-grid-tile colspan="2">
<div class="w-100" [ngClass]="{'winner': validateResult() == 1}"> <div class="w-100" [ngClass]="{'winner': isValidResult == 1}">
{{ data.match.team1 | teamText }} <app-team-display
[team]="data.match.team1"
[event]="data.event"
[tournament]="data.tournament"
[inline]="false">
</app-team-display>
</div> </div>
</mat-grid-tile> </mat-grid-tile>
<mat-grid-tile> @for (game of result.games; track $index; let i = $index) {
<mat-form-field appearance="outline" (change)="validateResult()"> <mat-grid-tile>
<input matInput type="number" min="0" max="30" [(ngModel)]="result.games[0].score1"> <mat-form-field appearance="outline">
</mat-form-field> <input matInput
</mat-grid-tile> type="number"
<mat-grid-tile> min="0"
<mat-form-field appearance="outline" (change)="validateResult()"> max="30"
<input matInput type="number" min="0" max="30" [(ngModel)]="result.games[1].score1"> [(ngModel)]="game.score1"
</mat-form-field> (blur)="complementScores()"
</mat-grid-tile> (ngModelChange)="validateResult()"
<mat-grid-tile> [tabindex]="i * 2 + 1">
<mat-form-field appearance="outline" (change)="validateResult()"> </mat-form-field>
<input matInput type="number" min="0" max="30" [(ngModel)]="result.games[2].score1"> </mat-grid-tile>
</mat-form-field> }
</mat-grid-tile>
<mat-grid-tile colspan="2"> <mat-grid-tile colspan="2">
<div class="w-100" [ngClass]="{'winner': validateResult() == -1}"> <div class="w-100" [ngClass]="{'winner': isValidResult == -1}">
{{ data.match.team2 | teamText }} <app-team-display
[team]="data.match.team2"
[event]="data.event"
[tournament]="data.tournament"
[inline]="false">
</app-team-display>
</div> </div>
</mat-grid-tile> </mat-grid-tile>
<mat-grid-tile>
<mat-form-field appearance="outline" (change)="validateResult()">
<input matInput type="number" min="0" max="30" [(ngModel)]="result.games[0].score2">
</mat-form-field>
</mat-grid-tile>
<mat-grid-tile>
<mat-form-field appearance="outline" (change)="validateResult()">
<input matInput type="number" min="0" max="30" [(ngModel)]="result.games[1].score2">
</mat-form-field>
</mat-grid-tile>
<mat-grid-tile>
<mat-form-field appearance="outline" (change)="validateResult()">
<input matInput type="number" min="0" max="30" [(ngModel)]="result.games[2].score2">
</mat-form-field>
</mat-grid-tile>
<mat-grid-tile> @for (game of result.games; track $index; let i = $index) {
<mat-grid-tile>
<mat-form-field appearance="outline">
<input matInput
type="number"
min="0"
max="30"
[(ngModel)]="game.score2"
(blur)="complementScores()"
(ngModelChange)="validateResult()"
[tabindex]="i * 2 + 2">
</mat-form-field>
</mat-grid-tile>
}
<mat-grid-tile></mat-grid-tile>
<mat-grid-tile></mat-grid-tile>
@for (gameNum of [1, 2, 3]; track gameNum) {
<mat-grid-tile>
<button type="button" class="btn btn-primary btn-lg" (click)="set21(2, gameNum)">21</button>
</mat-grid-tile>
}
</mat-grid-tile>
<mat-grid-tile>
</mat-grid-tile>
<mat-grid-tile>
<button type="button" class="btn btn-primary btn-lg" (click)="set21(2, 1)">21</button>
</mat-grid-tile>
<mat-grid-tile>
<button type="button" class="btn btn-primary btn-lg" (click)="set21(2, 2)">21</button>
</mat-grid-tile>
<mat-grid-tile>
<button type="button" class="btn btn-primary btn-lg" (click)="set21(2, 3)">21</button>
</mat-grid-tile>
</mat-grid-list> </mat-grid-list>
</mat-dialog-content> </mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>
<button mat-button (click)="onAnnulerenClick()">Annuleren</button> <button mat-button (click)="onAnnulerenClick()">Annuleren</button>
<button mat-button [disabled]="validateResult() == 0" [mat-dialog-close]="result">Opslaan</button> <button mat-button [disabled]="isValidResult == 0" [mat-dialog-close]="result">Opslaan</button>
</mat-dialog-actions> </mat-dialog-actions>

View File

@@ -19,36 +19,41 @@ import {MatGridList, MatGridTile} from "@angular/material/grid-list";
import {Round} from "../../model/round"; import {Round} from "../../model/round";
import {Group} from "../../model/group"; import {Group} from "../../model/group";
import {Game} from "../../model/game"; import {Game} from "../../model/game";
import {TeamDisplayComponent} from "../team-display/team-display.component";
import {Event} from "../../model/event";
import {Tournament} from "../../model/tournament";
@Component({ @Component({
selector: 'app-match-result', selector: 'app-match-result',
imports: [ imports: [
MatDialogContent, MatDialogContent,
MatDialogActions, MatDialogActions,
MatButton, MatButton,
MatDialogClose, MatDialogClose,
MatDialogTitle, MatDialogTitle,
TeamPipe, MatInput,
MatInput, ReactiveFormsModule,
ReactiveFormsModule, FormsModule,
FormsModule, MatFormField,
MatFormField, MatGridList,
MatGridList, MatGridTile,
MatGridTile, NgClass,
NgClass TeamDisplayComponent
], ],
providers: [ providers: [
FullNamePipe, FullNamePipe,
TeamPipe TeamPipe
], ],
templateUrl: './match-result.component.html', templateUrl: './match-result.component.html',
styleUrl: './match-result.component.scss' standalone: true,
styleUrl: './match-result.component.scss'
}) })
export class MatchResultComponent { export class MatchResultComponent {
result: Result = new Result(); result: Result = new Result();
isValidResult: number = 0;
constructor(@Inject(MAT_DIALOG_DATA) public data: {match: Match, group: Group, round: Round}) { constructor(@Inject(MAT_DIALOG_DATA) public data: {match: Match, tournament: Tournament, event: Event, group: Group, round: Round}) {
this.result.matchId = this.data.match.id; this.result.matchId = this.data.match.id;
if (data.match.games.length == 0) { if (data.match.games.length == 0) {
@@ -63,6 +68,8 @@ export class MatchResultComponent {
this.result.games.push(new Game()); this.result.games.push(new Game());
} }
} }
this.validateResult();
} }
readonly dialogRef = inject(MatDialogRef<MatchResultComponent>); readonly dialogRef = inject(MatDialogRef<MatchResultComponent>);
@@ -75,13 +82,18 @@ export class MatchResultComponent {
this.result.games[game - 1].score2 = 21; this.result.games[game - 1].score2 = 21;
} }
this.validateResult();
} }
onAnnulerenClick() { onAnnulerenClick() {
this.dialogRef.close(); this.dialogRef.close();
} }
validateResult(): number { complementScores() {
// console.log("in complementScores");
}
validateResult(): void {
let valid : boolean = true; let valid : boolean = true;
valid &&= this.gameValid(this.result.games[0].score1, this.result.games[0].score2); valid &&= this.gameValid(this.result.games[0].score1, this.result.games[0].score2);
valid &&= this.gameValid(this.result.games[1].score1, this.result.games[1].score2); valid &&= this.gameValid(this.result.games[1].score1, this.result.games[1].score2);
@@ -90,7 +102,7 @@ export class MatchResultComponent {
valid &&= this.gameValid(this.result.games[2].score1, this.result.games[2].score2); valid &&= this.gameValid(this.result.games[2].score1, this.result.games[2].score2);
} }
return valid ? this.matchResult(this.result) : 0; this.isValidResult = valid ? this.matchResult(this.result) : 0;
} }
gameValid(score1: number, score2: number): boolean { gameValid(score1: number, score2: number): boolean {
@@ -113,29 +125,31 @@ export class MatchResultComponent {
} }
matchResult(result: Result): number { matchResult(result: Result): number {
let gameBalance = 0; let team1Wins = 0;
if (result.games[0].score1 < result.games[0].score2) { let team2Wins = 0;
gameBalance--;
} else {
gameBalance++;
}
if (result.games[1].score1 < result.games[1].score2) {
gameBalance--;
} else {
gameBalance++;
}
if (Math.abs(gameBalance) == 2 && (result.games[2].score1 != undefined || result.games[2].score2 != undefined)) { // Count wins for games 1 and 2
return 0; if (result.games[0].score1 > result.games[0].score2) team1Wins++;
} else team2Wins++;
if (result.games[2].score1 != undefined && result.games[2].score2 != undefined) { if (result.games[1].score1 > result.games[1].score2) team1Wins++;
if (result.games[2].score1 < result.games[2].score2) { else team2Wins++;
gameBalance--;
} else { // If match is already decided (2-0), game 3 shouldn't have scores
gameBalance++; if (Math.max(team1Wins, team2Wins) == 2) {
if (result.games[2].score1 != undefined || result.games[2].score2 != undefined) {
return 0; // Invalid
} }
return team1Wins > team2Wins ? 1 : -1;
} }
return Math.sign(gameBalance);
// Match is 1-1, check game 3
if (result.games[2].score1 != undefined && result.games[2].score2 != undefined) {
if (result.games[2].score1 > result.games[2].score2) team1Wins++;
else team2Wins++;
return team1Wins > team2Wins ? 1 : -1;
}
return 0; // Incomplete
} }
} }

View File

@@ -3,12 +3,26 @@
<div class="nobreak"> <div class="nobreak">
<mat-card appearance="outlined"> <mat-card appearance="outlined">
<mat-card-content> <mat-card-content>
<h6>{{ tournament.name }}</h6>
<br>
<br>
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-6">
<b>{{ match.team1 | teamText }}</b> <h5>{{ tournament.name }}</h5>
</div>
<div class="col-6 text-end">
{{ group.name }} {{ round.name }}
</div>
</div>
<br>
<br>
<div class="row align-middle">
<div class="col-6">
<b>
<app-team-display
[team]="match.team1"
[event]="this.event"
[tournament]="this.tournament"
[inline]="true">
</app-team-display>
</b>
</div> </div>
<div class="col-2"> <div class="col-2">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
@@ -28,7 +42,14 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-6"> <div class="col-6">
<b>{{ match.team2 | teamText }}</b> <b>
<app-team-display
[team]="match.team2"
[event]="this.event"
[tournament]="this.tournament"
[inline]="true">
</app-team-display>
</b>
</div> </div>
<div class="col-2"> <div class="col-2">
<mat-form-field appearance="outline"> <mat-form-field appearance="outline">
@@ -50,9 +71,6 @@
<div class="col-6"> <div class="col-6">
<u>Graag de winnaar omcirkelen</u> <u>Graag de winnaar omcirkelen</u>
</div> </div>
<div class="col-6 text-end">
{{ group.name }} {{ round.name }}
</div>
</div> </div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

View File

@@ -11,27 +11,31 @@ import {MatFormField} from "@angular/material/form-field";
import {MatInput} from "@angular/material/input"; import {MatInput} from "@angular/material/input";
import {ReactiveFormsModule} from "@angular/forms"; import {ReactiveFormsModule} from "@angular/forms";
import {HeaderService} from "../../service/header.service"; import {HeaderService} from "../../service/header.service";
import {TeamDisplayComponent} from "../team-display/team-display.component";
import {Event} from "../../model/event";
@Component({ @Component({
selector: 'app-match-sheets', selector: 'app-match-sheets',
imports: [ imports: [
MatCard, MatCard,
MatCardContent, MatCardContent,
TeamPipe,
MatFormField, MatFormField,
MatInput, MatInput,
ReactiveFormsModule ReactiveFormsModule,
TeamDisplayComponent
], ],
providers: [ providers: [
TeamPipe, TeamPipe,
FullNamePipe FullNamePipe
], ],
templateUrl: './match-sheets.component.html', templateUrl: './match-sheets.component.html',
styleUrl: './match-sheets.component.scss' standalone: true,
styleUrl: './match-sheets.component.scss'
}) })
export class MatchSheetsComponent implements OnInit, OnDestroy { export class MatchSheetsComponent implements OnInit, OnDestroy {
tournament: Tournament; tournament: Tournament;
event: Event;
group: Group; group: Group;
round: Round; round: Round;
@@ -53,6 +57,7 @@ export class MatchSheetsComponent implements OnInit, OnDestroy {
for (let group of event.groups) { for (let group of event.groups) {
for (let round of group.rounds) { for (let round of group.rounds) {
if (round.id == roundId) { if (round.id == roundId) {
this.event = event;
this.group = group; this.group = group;
this.round = round; this.round = round;
this.headerService.setTitle(`Wedstrijdbriefjes ${this.group.name} ${this.round.name}`); this.headerService.setTitle(`Wedstrijdbriefjes ${this.group.name} ${this.round.name}`);

View File

@@ -0,0 +1,4 @@
.has-substitute {
text-decoration-line: underline;
text-decoration-style: dotted;
}

View File

@@ -0,0 +1,49 @@
import {Component, Input} from '@angular/core';
import {Player} from '../../model/player';
import {Event} from '../../model/event';
import {Tournament} from '../../model/tournament';
import {FullNamePipe} from '../../pipes/fullname-pipe';
import {MatTooltip} from '@angular/material/tooltip';
@Component({
selector: 'app-player-display',
standalone: true,
imports: [FullNamePipe, MatTooltip],
styleUrls: ['./player-display.component.scss'],
template: `
@let substitute = getSubstituteForEvent(player, event);
@if (exlicitSubstitute) {
@if (substitute) {
{{ substitute }} (valt in voor {{ player | fullName }})
} @else {
{{ player | fullName }}
}
} @else {
<span [class.has-substitute]="substitute"
[matTooltip]="substitute ? 'Valt in voor ' + (player | fullName) : ''"
matTooltipPosition="below">{{ substitute || (player | fullName) }}</span>
}
`
})
export class PlayerDisplayComponent {
@Input({ required: true }) player!: Player;
@Input({ required: true }) event!: Event;
@Input({ required: true }) tournament!: Tournament;
@Input({ required: false }) exlicitSubstitute: boolean = false;
getSubstituteForEvent(player: Player, event: Event): string | undefined {
const tournamentPlayer = this.tournament.tournamentPlayers.find(
tp => tp.playerId === player.id
);
if (!tournamentPlayer) return undefined;
const substitution = tournamentPlayer.substitutions.find(
s => s.event === event.type
);
if (!substitution) return undefined;
return this.tournament.tournamentPlayers.find(
p => p.id === substitution.substitute
)?.name;
}
}

View File

@@ -15,7 +15,7 @@ import {MatSnackBar} from "@angular/material/snack-bar";
import {NgxMaskDirective} from "ngx-mask"; import {NgxMaskDirective} from "ngx-mask";
@Component({ @Component({
selector: 'app-player-edit', selector: 'app-player-edit',
imports: [ imports: [
FormsModule, FormsModule,
RouterLink, RouterLink,
@@ -37,14 +37,14 @@ import {NgxMaskDirective} from "ngx-mask";
ReactiveFormsModule, ReactiveFormsModule,
NgxMaskDirective NgxMaskDirective
], ],
templateUrl: './player-edit.component.html', templateUrl: './player-edit.component.html',
styleUrl: './player-edit.component.scss' standalone: true,
styleUrl: './player-edit.component.scss'
}) })
export class PlayerEditComponent implements OnInit { export class PlayerEditComponent implements OnInit {
player: Player; player: Player;
isEditMode: boolean = false; isEditMode: boolean = false;
constructor( constructor(
private playerService: PlayerService, private playerService: PlayerService,
private route: ActivatedRoute, private route: ActivatedRoute,

View File

@@ -3,13 +3,13 @@ import {Component, Input, OnInit} from '@angular/core';
import {RouterLink} from "@angular/router"; import {RouterLink} from "@angular/router";
@Component({ @Component({
selector: 'player-link', selector: 'player-link',
imports: [ imports: [
// MatAnchor, RouterLink
RouterLink ],
], templateUrl: './player-link.component.html',
templateUrl: './player-link.component.html', standalone: true,
styleUrl: './player-link.component.scss' styleUrl: './player-link.component.scss'
}) })
export class PlayerLinkComponent implements OnInit { export class PlayerLinkComponent implements OnInit {

View File

@@ -39,7 +39,8 @@
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table> </table>
<mat-paginator [pageSizeOptions]="[10, 20, 50]" <mat-paginator [pageSizeOptions]="[10, 25, 100]"
[pageSize]="100"
showFirstLastButtons showFirstLastButtons
aria-label="Select page of periodic elements"> aria-label="Select page of periodic elements">
</mat-paginator> </mat-paginator>

View File

@@ -5,3 +5,7 @@ a {
td, th { td, th {
background-color: transparent !important; background-color: transparent !important;
} }
.mat-mdc-row:hover {
background-color: rgba(0, 0, 0, 0.075);
}

View File

@@ -25,11 +25,12 @@ import {MatPaginator} from "@angular/material/paginator";
import {MatSort, MatSortHeader} from "@angular/material/sort"; import {MatSort, MatSortHeader} from "@angular/material/sort";
@Component({ @Component({
selector: 'app-player-list', selector: 'app-player-list',
imports: [RouterLink, MatAnchor, MatIcon, MatCard, MatCardContent, FullNamePipe, MatTable, MatColumnDef, MatHeaderCell, MatHeaderCellDef, MatCell, MatCellDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, MatFormField, MatInput, MatFormFieldModule, MatPaginator, MatSortHeader, MatSort], imports: [RouterLink, MatAnchor, MatIcon, MatCard, MatCardContent, FullNamePipe, MatTable, MatColumnDef, MatHeaderCell, MatHeaderCellDef, MatCell, MatCellDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, MatFormField, MatInput, MatFormFieldModule, MatPaginator, MatSortHeader, MatSort],
providers: [FullNamePipe], providers: [FullNamePipe],
templateUrl: './player-list.component.html', templateUrl: './player-list.component.html',
styleUrl: './player-list.component.scss' standalone: true,
styleUrl: './player-list.component.scss'
}) })
export class PlayerListComponent implements AfterViewInit { export class PlayerListComponent implements AfterViewInit {

View File

@@ -18,14 +18,23 @@
@if (eventRegistration.doublesEvent) { @if (eventRegistration.doublesEvent) {
<mat-form-field appearance="fill"> <mat-form-field appearance="fill">
<mat-label>Partner</mat-label> <mat-label>Partner</mat-label>
<mat-select [value]="eventRegistration.partner" [disabled]="!tournamentRegistration.editable || !eventRegistration.registered" [(ngModel)]="eventRegistration.partner"> <input type="text"
<mat-option>Geen</mat-option> matInput
@for (player of getRelevantPlayers(eventRegistration.type); track player.id) { [matAutocomplete]="auto"
[disabled]="!tournamentRegistration.editable || !eventRegistration.registered"
[(ngModel)]="eventRegistration.partner"
(input)="onPartnerSearch($any($event.target).value, eventRegistration)"
[name]="'partner-' + eventRegistration.id">
<mat-autocomplete #auto="matAutocomplete"
[displayWith]="displayPartnerName.bind(this)"
(optionSelected)="onPartnerSelected($event, eventRegistration)">
<mat-option [value]="null">Geen</mat-option>
@for (player of getFilteredPlayers(eventRegistration); track player.id) {
<mat-option [value]="player.id"> <mat-option [value]="player.id">
{{ player | fullName }} {{ player | fullName }}
</mat-option> </mat-option>
} }
</mat-select> </mat-autocomplete>
</mat-form-field> </mat-form-field>
} }
</div> </div>

View File

@@ -9,15 +9,16 @@ import {RegistrationService} from "../../service/registration.service";
import {MatCheckbox, MatCheckboxChange} from "@angular/material/checkbox"; import {MatCheckbox, MatCheckboxChange} from "@angular/material/checkbox";
import {EventRegistration, TournamentRegistration} from "../../model/tournamentRegistration"; import {EventRegistration, TournamentRegistration} from "../../model/tournamentRegistration";
import {MatOption} from "@angular/material/core"; import {MatOption} from "@angular/material/core";
import {MatSelect} from "@angular/material/select";
import {MatIcon} from "@angular/material/icon"; import {MatIcon} from "@angular/material/icon";
import {MatAnchor, MatButton} from "@angular/material/button"; import {MatAnchor, MatButton} from "@angular/material/button";
import {MatSnackBar} from "@angular/material/snack-bar"; import {MatSnackBar} from "@angular/material/snack-bar";
import {FullNamePipe} from "../../pipes/fullname-pipe"; import {FullNamePipe} from "../../pipes/fullname-pipe";
import {HeaderService} from "../../service/header.service"; import {HeaderService} from "../../service/header.service";
import {MatAutocomplete, MatAutocompleteTrigger} from "@angular/material/autocomplete";
import {MatInput} from "@angular/material/input";
@Component({ @Component({
selector: 'app-player-registrations', selector: 'app-player-registrations',
imports: [ imports: [
MatCard, MatCard,
MatCardContent, MatCardContent,
@@ -30,17 +31,21 @@ import {HeaderService} from "../../service/header.service";
MatCardActions, MatCardActions,
RouterLink, RouterLink,
MatOption, MatOption,
MatSelect, // MatSelect,
MatIcon, MatIcon,
MatButton, MatButton,
MatAnchor, MatAnchor,
FullNamePipe,
MatAutocomplete,
MatAutocompleteTrigger,
MatInput,
],
providers: [
FullNamePipe FullNamePipe
], ],
providers: [ templateUrl: './player-registrations.component.html',
FullNamePipe standalone: true,
], styleUrl: './player-registrations.component.scss'
templateUrl: './player-registrations.component.html',
styleUrl: './player-registrations.component.scss'
}) })
export class PlayerRegistrationsComponent implements OnInit { export class PlayerRegistrationsComponent implements OnInit {
@@ -51,6 +56,8 @@ export class PlayerRegistrationsComponent implements OnInit {
waitingForBackend: boolean = false; waitingForBackend: boolean = false;
private partnerSearchTerms: Map<number, string> = new Map();
constructor( constructor(
private _snackBar: MatSnackBar, private _snackBar: MatSnackBar,
private playerService: PlayerService, private playerService: PlayerService,
@@ -75,6 +82,40 @@ export class PlayerRegistrationsComponent implements OnInit {
}); });
} }
onPartnerSearch(searchTerm: any, eventRegistration: EventRegistration) {
// Only treat as search if it's a string (typed input)
if (typeof searchTerm === 'string') {
this.partnerSearchTerms.set(eventRegistration.id, searchTerm?.toLowerCase() || '');
}
}
getFilteredPlayers(eventRegistration: EventRegistration): Player[] {
const allRelevant = this.getRelevantPlayers(eventRegistration.type);
const searchTerm = this.partnerSearchTerms.get(eventRegistration.id);
if (!searchTerm) {
return allRelevant;
}
return allRelevant.filter(player => {
const fullName = this.fullNamePipe.transform(player).toLowerCase();
return fullName.includes(searchTerm);
});
}
displayPartnerName(playerId: number | null): string {
if (!playerId || !this.allPlayers) return '';
const player = this.allPlayers.find(p => p.id === playerId);
return player ? this.fullNamePipe.transform(player) : '';
}
onPartnerSelected(event: any, eventRegistration: EventRegistration) {
eventRegistration.partner = event.option.value;
// Clear the search term when a partner is selected
this.partnerSearchTerms.delete(eventRegistration.id);
}
saveRegistration(tournamentRegistration: TournamentRegistration, event: MouseEvent) { saveRegistration(tournamentRegistration: TournamentRegistration, event: MouseEvent) {
this.waitingForBackend = true; this.waitingForBackend = true;
this.registrationService.saveTournamentRegistrations(tournamentRegistration, this.player.id).subscribe(data => { this.registrationService.saveTournamentRegistrations(tournamentRegistration, this.player.id).subscribe(data => {

View File

@@ -5,9 +5,23 @@
<tbody> <tbody>
@for (match of round.matches; track match.id) { @for (match of round.matches; track match.id) {
<tr> <tr>
<td class="align-middle" style="width: 45%;">{{ match.team1 | teamText }}</td> <td class="align-middle" style="width: 45%;">
<app-team-display
[team]="match.team1"
[event]="this.event"
[tournament]="this.tournament"
[inline]="true">
</app-team-display>
</td>
<td class="align-middle w-sep">-</td> <td class="align-middle w-sep">-</td>
<td class="align-middle" style="width: 45%;">{{ match.team2 | teamText }}</td> <td class="align-middle" style="width: 45%;">
<app-team-display
[team]="match.team2"
[event]="this.event"
[tournament]="this.tournament"
[inline]="true">
</app-team-display>
</td>
<td class="align-middle w-sep"></td> <td class="align-middle w-sep"></td>
</tr> </tr>
} }
@@ -24,18 +38,20 @@
@for (match of round.matches; track match.id) { @for (match of round.matches; track match.id) {
<tr> <tr>
<td class="align-middle" style="width: 30%;"> <td class="align-middle" style="width: 30%;">
@if (event.doublesEvent) { <app-team-display
{{ match.team1.player1 | fullName }} /<br>{{ match.team1.player2 | fullName }} [team]="match.team1"
} @else { [event]="this.event"
{{ match.team1.player1 | fullName }} [tournament]="this.tournament"
} [inline]="true">
</app-team-display>
<td class="align-middle w-sep">-</td> <td class="align-middle w-sep">-</td>
<td class="align-middle" style="width: 30%;"> <td class="align-middle" style="width: 30%;">
@if (event.doublesEvent) { <app-team-display
{{ match.team2.player1 | fullName }} /<br>{{ match.team2.player2 | fullName }} [team]="match.team2"
} @else { [event]="this.event"
{{ match.team2.player1 | fullName }} [tournament]="this.tournament"
} [inline]="true">
</app-team-display>
</td> </td>
<td class="align-middle" style="width: 35%;"> <td class="align-middle" style="width: 35%;">
<div class="row result align-items-center"> <div class="row result align-items-center">
@@ -87,7 +103,15 @@
@for (entry of round.standings.entries; track entry.position) { @for (entry of round.standings.entries; track entry.position) {
<tr> <tr>
<td class="align-middle">{{ entry.position }}</td> <td class="align-middle">{{ entry.position }}</td>
<td class="align-middle">{{ entry.team | teamText }}</td> <td class="align-middle">
<app-team-display
[team]="entry.team"
[event]="this.event"
[tournament]="this.tournament"
[inline]="true"
[explicitSubstitute]="true">
</app-team-display>
</td>
<td class="align-middle">{{ entry.played }}</td> <td class="align-middle">{{ entry.played }}</td>
<td class="align-middle">{{ entry.points / entry.played | number: '1.0-2' }}</td> <td class="align-middle">{{ entry.points / entry.played | number: '1.0-2' }}</td>
<td class="align-middle">{{ (entry.gamesWon - entry.gamesLost) / entry.played | number: '1.0-2' }}</td> <td class="align-middle">{{ (entry.gamesWon - entry.gamesLost) / entry.played | number: '1.0-2' }}</td>

View File

@@ -8,20 +8,22 @@ import {ActivatedRoute, Router} from "@angular/router";
import {DecimalPipe} from "@angular/common"; import {DecimalPipe} from "@angular/common";
import {TeamPipe} from "../../pipes/team-pipe"; import {TeamPipe} from "../../pipes/team-pipe";
import {FullNamePipe} from "../../pipes/fullname-pipe"; import {FullNamePipe} from "../../pipes/fullname-pipe";
import {TeamDisplayComponent} from "../team-display/team-display.component";
@Component({ @Component({
selector: 'app-round-overview', selector: 'app-round-overview',
imports: [ imports: [
TeamPipe, TeamPipe,
DecimalPipe, DecimalPipe,
TeamDisplayComponent
],
providers: [
TeamPipe,
FullNamePipe FullNamePipe
], ],
providers: [ templateUrl: './round-overview.component.html',
TeamPipe, standalone: true,
FullNamePipe styleUrl: './round-overview.component.scss'
],
templateUrl: './round-overview.component.html',
styleUrl: './round-overview.component.scss'
}) })
export class RoundOverviewComponent implements OnInit { export class RoundOverviewComponent implements OnInit {

View File

@@ -0,0 +1,29 @@
<h2 mat-dialog-title>Kies een invaller voor {{ data.player.name }}:</h2>
<mat-dialog-content>
@for (substitution of data.player.substitutions; track $index) {
<div class="row mt-3">
<div class="col-md-3">
<h5>{{ Event.getType(substitution.event) }}</h5>
</div>
<div class="col-md-6">
<mat-form-field appearance="fill">
<mat-label>Invaller</mat-label>
<mat-select [(ngModel)]="substitution.substitute">
<mat-option [value]="-1">Geen</mat-option>
@for (player of data.availablePlayers; track player.id) {
<mat-option [value]="player.id">
{{ player.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
</mat-dialog-content>
<mat-dialog-actions>
<!-- @if (substitute) {-->
<button mat-button [mat-dialog-close]="{substitutions: data.player.substitutions}">Opslaan</button>
<!-- }-->
<button mat-button (click)="onAnnulerenClick()">Annuleren</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,51 @@
import {Component, inject, Inject} from '@angular/core';
import {MatButton} from "@angular/material/button";
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from "@angular/material/dialog";
import {MatFormField, MatLabel} from "@angular/material/form-field";
import {MatOption, MatSelect} from "@angular/material/select";
import {TournamentPlayer} from "../../model/tournamentPlayer";
import {FormsModule} from "@angular/forms";
import {Event} from "../../model/event";
@Component({
selector: 'app-substitute-selection',
imports: [
MatButton,
MatDialogActions,
MatDialogTitle,
MatDialogContent,
MatFormField,
MatLabel,
MatOption,
MatSelect,
FormsModule,
MatDialogClose,
],
templateUrl: './substitute-selection.component.html',
styleUrl: './substitute-selection.component.scss',
standalone: true
})
export class SubstituteSelectionComponent {
substitute: TournamentPlayer[];
readonly dialogRef = inject(MatDialogRef<SubstituteSelectionComponent>);
constructor(@Inject(MAT_DIALOG_DATA) public data: {
player: TournamentPlayer,
availablePlayers: TournamentPlayer[]
}) {}
onAnnulerenClick() {
this.dialogRef.close();
}
protected readonly Event = Event;
}

View File

@@ -0,0 +1,39 @@
import {Component, Input} from '@angular/core';
import {Event} from '../../model/event';
import {Tournament} from '../../model/tournament';
import {PlayerDisplayComponent} from '../player-display/player-display.component';
import {Team} from "../../model/team";
@Component({
selector: 'app-team-display',
standalone: true,
imports: [PlayerDisplayComponent],
template: `
<app-player-display
[player]="team.player1"
[event]="event"
[tournament]="tournament"
[exlicitSubstitute]="explicitSubstitute">
</app-player-display>
@if (event.doublesEvent && team.player2) {
@if (this.inline) {
/
} @else {
<br />
}
<app-player-display
[player]="team.player2"
[event]="event"
[tournament]="tournament">
</app-player-display>
}
`
})
export class TeamDisplayComponent {
@Input({ required: true }) team!: Team;
@Input({ required: true }) event!: Event;
@Input({ required: true }) tournament!: Tournament;
@Input({ required: false }) inline: boolean = true;
@Input({ required: false }) explicitSubstitute: boolean = false;
}

View File

@@ -14,7 +14,7 @@ import {TeamPipe} from "../../pipes/team-pipe";
import {FullNamePipe} from "../../pipes/fullname-pipe"; import {FullNamePipe} from "../../pipes/fullname-pipe";
@Component({ @Component({
selector: 'app-tournament-draw', selector: 'app-tournament-draw',
imports: [ imports: [
MatCard, MatCard,
MatCardContent, MatCardContent,
@@ -25,12 +25,13 @@ import {FullNamePipe} from "../../pipes/fullname-pipe";
TeamPipe, TeamPipe,
MatAccordion MatAccordion
], ],
providers: [ providers: [
FullNamePipe, FullNamePipe,
TeamPipe TeamPipe
], ],
templateUrl: './tournament-draw.component.html', templateUrl: './tournament-draw.component.html',
styleUrl: './tournament-draw.component.scss' standalone: true,
styleUrl: './tournament-draw.component.scss'
}) })
export class TournamentDrawComponent implements OnInit { export class TournamentDrawComponent implements OnInit {

View File

@@ -18,7 +18,7 @@ import {MatCheckbox} from "@angular/material/checkbox";
registerLocaleData(nl); registerLocaleData(nl);
@Component({ @Component({
selector: 'app-tournament-edit', selector: 'app-tournament-edit',
imports: [ imports: [
FormsModule, FormsModule,
RouterLink, RouterLink,
@@ -37,11 +37,12 @@ registerLocaleData(nl);
NgxMaskDirective, NgxMaskDirective,
MatCheckbox MatCheckbox
], ],
providers: [ providers: [
CurrencyPipe CurrencyPipe
], ],
templateUrl: './tournament-edit.component.html', templateUrl: './tournament-edit.component.html',
styleUrl: './tournament-edit.component.scss' standalone: true,
styleUrl: './tournament-edit.component.scss'
}) })
export class TournamentEditComponent implements OnInit { export class TournamentEditComponent implements OnInit {

View File

@@ -9,12 +9,13 @@ import {MatTableModule} from "@angular/material/table";
import {HeaderService} from "../../service/header.service"; import {HeaderService} from "../../service/header.service";
@Component({ @Component({
selector: 'app-tournament-list', selector: 'app-tournament-list',
imports: [ imports: [
RouterLink, MatAnchor, MatIcon, MatCard, MatCardContent, MatButton, MatTableModule RouterLink, MatAnchor, MatIcon, MatCard, MatCardContent, MatButton, MatTableModule
], ],
templateUrl: './tournament-list.component.html', templateUrl: './tournament-list.component.html',
styleUrl: './tournament-list.component.scss' standalone: true,
styleUrl: './tournament-list.component.scss'
}) })
export class TournamentListComponent implements OnInit { export class TournamentListComponent implements OnInit {

View File

@@ -20,6 +20,13 @@
</ng-template> </ng-template>
<app-tournament-validate></app-tournament-validate> <app-tournament-validate></app-tournament-validate>
</mat-tab> </mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon>settings</mat-icon>
&nbsp;Beheer
</ng-template>
<app-tournament-players [tournament]="tournament"></app-tournament-players>
</mat-tab>
} }
@if (tournament.status == 'DIVIDED') { @if (tournament.status == 'DIVIDED') {
@@ -97,6 +104,13 @@
} }
} }
</mat-tab> </mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon>settings</mat-icon>
&nbsp;Beheer
</ng-template>
<app-tournament-players [tournament]="tournament"></app-tournament-players>
</mat-tab>
} }
@if (tournament.status == 'ONGOING') { @if (tournament.status == 'ONGOING') {
@@ -108,41 +122,61 @@
<span class="badge text-bg-success">{{ this.activeMatches().length }}</span> <span class="badge text-bg-success">{{ this.activeMatches().length }}</span>
} }
</ng-template> </ng-template>
<div class="tab-content-wrapper">
@if (this.activeMatches().length > 0) {
<h6 class="mt-3"></h6>
@if (this.activeMatches().length > 0) { @for (activeMatch of this.activeMatches(); track activeMatch.match.id) {
<h6 class="mt-3">Actieve wedstrijden</h6> <mat-expansion-panel>
<mat-expansion-panel-header>
@for (activeMatch of this.activeMatches(); track activeMatch.match.id) { <div class="col-md-2 d-flex align-items-center">Baan {{ activeMatch.match.court }}</div>
<mat-expansion-panel> <div class="col-md-3">
<mat-expansion-panel-header> <app-team-display
<div class="col-md-2">Baan {{ activeMatch.match.court }}</div> [team]="activeMatch.match.team1"
<div class="col-md-3">{{ activeMatch.match.team1 | teamText }}</div> [event]="activeMatch.event"
<div class="col-md-1">-</div> [tournament]="this.tournament"
<div class="col-md-3">{{ activeMatch.match.team2 | teamText }}</div> [inline]="false">
<div class="col-md-3">{{ activeMatch.group.name }} {{ activeMatch.round.name }}</div> </app-team-display>
</mat-expansion-panel-header> </div>
<div class="row"> <div class="col-md-1 d-flex align-items-center">-</div>
<div class="col-md-3">Teller: {{ activeMatch.match.counter | fullName }}</div> <div class="col-md-3">
<div class="col-md-5"></div> <app-team-display
<div class="col-md-2">Starttijd: {{ activeMatch.match.startTime | date: 'HH:mm' }}</div> [team]="activeMatch.match.team2"
<div class="col-md-2">Duur: {{ getDuration(activeMatch.match.startTime) | date: 'mm:ss' }}</div> [event]="activeMatch.event"
</div> [tournament]="this.tournament"
<mat-action-row> [inline]="false">
<button class="align-baseline" mat-button (click)="editResult(activeMatch.match, activeMatch.group, activeMatch.round)"> </app-team-display>
<mat-icon>edit</mat-icon> </div>
Uitslag invoeren <div class="col-md-3 d-flex align-items-center">{{ activeMatch.group.name }} {{ activeMatch.round.name }}</div>
</button> </mat-expansion-panel-header>
<button mat-button (click)="stopMatch(activeMatch.match)"> <div class="row">
<mat-icon>stop</mat-icon> <hr/>
Wedstrijd stoppen <div class="col-md-3">Teller: {{ activeMatch.match.counter | fullName }}</div>
</button> <div class="col-md-5"></div>
</mat-action-row> <div class="col-md-2">Starttijd: {{ activeMatch.match.startTime | date: 'HH:mm' }}</div>
</mat-expansion-panel> <div class="col-md-2">Duur: {{ getDuration(activeMatch.match.startTime) | date: 'mm:ss' }}</div>
<br> </div>
<mat-action-row>
<button class="align-baseline" mat-button (click)="editResult(activeMatch.match, activeMatch.event, activeMatch.group, activeMatch.round)">
<mat-icon>leaderboard</mat-icon>
Uitslag invoeren
</button>
<button mat-button (click)="changeCounter(activeMatch.match)">
<mat-icon>person</mat-icon>
Teller wijzigen
</button>
<button mat-button (click)="stopMatch(activeMatch.match)">
<mat-icon>stop</mat-icon>
Wedstrijd stoppen
</button>
</mat-action-row>
</mat-expansion-panel>
<br>
}
} @else {
<h6 class="mt-3">Geen actieve wedstrijden</h6>
} }
} @else { </div>
<h6 class="mt-3">Geen actieve wedstrijden</h6>
}
</mat-tab> </mat-tab>
} }
@if (tournament.status == 'ONGOING' || tournament.status == 'DRAWN') { @if (tournament.status == 'ONGOING' || tournament.status == 'DRAWN') {
@@ -212,17 +246,19 @@
<mat-icon>print</mat-icon> <mat-icon>print</mat-icon>
Wedstrijdbriefjes printen Wedstrijdbriefjes printen
</button> </button>
<button mat-menu-item (click)="printRoundOverview(round)"> @if (!round.isFinalsRound) {
<mat-icon>print</mat-icon> <button mat-menu-item (click)="printRoundOverview(round)">
Rondeoverzicht printen <mat-icon>print</mat-icon>
</button> Rondeoverzicht printen
</button>
}
@if (round.status == 'IN_PROGRESS' && checkRoundComplete(round)) { @if (round.status == 'IN_PROGRESS' && checkRoundComplete(round)) {
<button mat-menu-item (click)="finishRound(round)"> <button mat-menu-item (click)="finishRound(round)">
<mat-icon>check</mat-icon> <mat-icon>check</mat-icon>
Ronde afsluiten Ronde afsluiten
</button> </button>
} }
@if (group.status != 'FINISHED' && round.status == 'FINISHED' && (roundIndex + 1) == group.rounds.length) { @if (group.status != 'FINISHED' && round.status == 'FINISHED' && (roundIndex + 1) == group.rounds.length && !round.isFinalsRound) {
<button mat-menu-item (click)="newRound(group)"> <button mat-menu-item (click)="newRound(group)">
<mat-icon>playlist_add</mat-icon> <mat-icon>playlist_add</mat-icon>
Nieuwe ronde Nieuwe ronde
@@ -238,15 +274,29 @@
<tbody> <tbody>
@for (match of round.matches; track match.id) { @for (match of round.matches; track match.id) {
<tr> <tr>
<td class="align-middle w-team">{{ match.team1 | teamText }}</td> <td class="align-middle w-team">
<app-team-display
[team]="match.team1"
[event]="event"
[tournament]="tournament">
</app-team-display>
</td>
<td class="align-middle w-sep">-</td> <td class="align-middle w-sep">-</td>
<td class="align-middle w-team">{{ match.team2 | teamText }}</td> <td class="align-middle w-team">
<app-team-display
[team]="match.team2"
[event]="event"
[tournament]="tournament">
</app-team-display>
</td>
<td class="align-middle w-fill"></td> <td class="align-middle w-fill"></td>
</tr> </tr>
} }
@if (round.drawnOut) { @if (round.drawnOut) {
<tr> <tr>
<td class="align-middle w-100" colspan="4"><b>Deze ronde uitgeloot:</b> {{ round.drawnOut | teamText }}</td> <td class="align-middle w-100" colspan="4">
<b>Deze ronde uitgeloot:</b> {{ round.drawnOut | teamText }}
</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -256,23 +306,48 @@
<tbody> <tbody>
@for (match of round.matches; track match.id) { @for (match of round.matches; track match.id) {
<tr> <tr>
<td class="align-middle w-team" [ngClass]="{'winner': checkWinner(match) == 1}">{{ match.team1 | teamText }}</td> <td class="align-middle w-team" [ngClass]="{'winner': checkWinner(match) == 1}">
<app-team-display
[team]="match.team1"
[event]="event"
[tournament]="tournament">
</app-team-display>
</td>
<td class="align-middle w-sep">-</td> <td class="align-middle w-sep">-</td>
<td class="align-middle w-team" [ngClass]="{'winner': checkWinner(match) == 2}">{{ match.team2 | teamText }}</td> <td class="align-middle w-team" [ngClass]="{'winner': checkWinner(match) == 2}">
<app-team-display
[team]="match.team2"
[event]="event"
[tournament]="tournament">
</app-team-display>
</td>
<td class="align-middle w-fill"> <td class="align-middle w-fill">
@if (match.status == 'NOT_STARTED') { @if (match.status == 'NOT_STARTED') {
<button mat-button (click)="startMatch(match)"> @if (match.canStart) {
<mat-icon>play_arrow</mat-icon> <button mat-button (click)="startMatch(match)">
Wedstrijd starten <mat-icon>play_arrow</mat-icon>
</button> Wedstrijd starten
</button>
} @else {
<span [matTooltip]="match.cantStartReason" style="cursor: not-allowed;">
<button mat-button disabled>
<mat-icon>play_arrow</mat-icon>
Wedstrijd starten
</button>
</span>
}
} @else if (match.status == 'IN_PROGRESS') { } @else if (match.status == 'IN_PROGRESS') {
<button mat-button (click)="editResult(match, group, round)"> <button mat-button (click)="editResult(match, event, group, round)">
<mat-icon>edit</mat-icon> <mat-icon>leaderboard</mat-icon>
Uitslag invoeren Uitslag
</button>
<button mat-button (click)="changeCounter(match)">
<mat-icon>person</mat-icon>
Teller
</button> </button>
<button mat-button (click)="stopMatch(match)"> <button mat-button (click)="stopMatch(match)">
<mat-icon>stop</mat-icon> <mat-icon>stop</mat-icon>
Wedstrijd stoppen Stoppen
</button> </button>
} @else if (match.status == 'FINISHED') { } @else if (match.status == 'FINISHED') {
<div class="row result align-items-center"> <div class="row result align-items-center">
@@ -287,8 +362,8 @@
<mat-icon>more_vert</mat-icon> <mat-icon>more_vert</mat-icon>
</button> </button>
<mat-menu #finishedMatchMenu="matMenu"> <mat-menu #finishedMatchMenu="matMenu">
<button mat-menu-item (click)="editResult(match, group, round)"> <button mat-menu-item (click)="editResult(match, event, group, round)">
<mat-icon>edit</mat-icon> <mat-icon>leaderboard</mat-icon>
Uitslag bewerken Uitslag bewerken
</button> </button>
</mat-menu> </mat-menu>
@@ -309,9 +384,21 @@
<tbody> <tbody>
@for (match of round.matches; track match.id) { @for (match of round.matches; track match.id) {
<tr> <tr>
<td class="align-middle w-team" [ngClass]="{'winner': checkWinner(match) == 1}">{{ match.team1 | teamText }}</td> <td class="align-middle w-team" [ngClass]="{'winner': checkWinner(match) == 1}">
<app-team-display
[team]="match.team1"
[event]="event"
[tournament]="tournament">
</app-team-display>
</td>
<td class="align-middle w-sep">-</td> <td class="align-middle w-sep">-</td>
<td class="align-middle w-team" [ngClass]="{'winner': checkWinner(match) == 2}">{{ match.team2 | teamText }}</td> <td class="align-middle w-team" [ngClass]="{'winner': checkWinner(match) == 2}">
<app-team-display
[team]="match.team2"
[event]="event"
[tournament]="tournament">
</app-team-display>
</td>
<td class="align-middle w-fill"> <td class="align-middle w-fill">
<div class="row result align-items-center"> <div class="row result align-items-center">
@for (game of match.games; track game.id) { @for (game of match.games; track game.id) {
@@ -358,7 +445,14 @@
@for (entry of getStandingsForRound(round, group).entries; track entry.position) { @for (entry of getStandingsForRound(round, group).entries; track entry.position) {
<tr> <tr>
<td class="align-middle">{{ entry.position }}</td> <td class="align-middle">{{ entry.position }}</td>
<td class="align-middle">{{ entry.team | teamText }}</td> <td class="align-middle">
<app-team-display
[team]="entry.team"
[event]="event"
[tournament]="this.tournament"
[inline]="true">
</app-team-display>
</td>
<td class="align-middle">{{ entry.played }}</td> <td class="align-middle">{{ entry.played }}</td>
<td class="align-middle"> <td class="align-middle">
@if (entry.played > 0 ) { @if (entry.played > 0 ) {
@@ -379,6 +473,25 @@
} }
</tbody> </tbody>
</table> </table>
} @else if (round.isFinalsRound && round.status == 'FINISHED') {
<h6 class="mt-3">Uitslag</h6>
<table class="table w-50 m-4">
<tbody>
<tr>
<td>1e Plaats</td>
<td>{{ (checkWinner(round.matches[0]) == 1 ? round.matches[0].team1 : round.matches[0].team2) | teamText }}</td>
</tr>
<tr>
<td>2e Plaats</td>
<td>{{ (checkWinner(round.matches[0]) == 1 ? round.matches[0].team2 : round.matches[0].team1) | teamText }}</td>
</tr>
<tr>
<td>3e Plaats</td>
<td>{{ (checkWinner(round.matches[1]) == 1 ? round.matches[1].team1 : round.matches[1].team2) | teamText }}</td>
</tr>
</tbody>
</table>
} }
</mat-tab> </mat-tab>
} }
@@ -394,15 +507,7 @@
<mat-icon>settings</mat-icon> <mat-icon>settings</mat-icon>
&nbsp;Beheer &nbsp;Beheer
</ng-template> </ng-template>
<mat-tab-group animationDuration="0ms" disableRipple="true"> <app-tournament-players [tournament]="tournament"></app-tournament-players>
<mat-tab>
<ng-template mat-tab-label>
<mat-icon>group</mat-icon>
&nbsp;Spelerslijst
</ng-template>
<app-tournament-players [tournament]="tournament"></app-tournament-players>
</mat-tab>
</mat-tab-group>
</mat-tab> </mat-tab>
} }
</mat-tab-group> </mat-tab-group>

View File

@@ -1,6 +1,7 @@
td { td {
vertical-align: middle; vertical-align: middle;
} }
td, th { td, th {
background-color: transparent !important; background-color: transparent !important;
} }
@@ -8,6 +9,7 @@ td, th {
table.wide td, table.wide th { table.wide td, table.wide th {
height: 4em; height: 4em;
} }
.winner { .winner {
color: green; color: green;
font-weight: bold; font-weight: bold;
@@ -33,6 +35,10 @@ td.w-fill {
width: 95% !important; width: 95% !important;
} }
.material-tooltip { .mat-menu-panel {
white-space: pre-line; z-index: 1000 !important;
}
.tab-content-wrapper {
margin: 0 4px 0 4px;
} }

View File

@@ -1,7 +1,8 @@
import {Component, inject, Input, OnDestroy, OnInit} from '@angular/core'; import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import { import {
MatAccordion, MatAccordion,
MatExpansionPanel, MatExpansionPanelActionRow, MatExpansionPanel,
MatExpansionPanelActionRow,
MatExpansionPanelHeader, MatExpansionPanelHeader,
MatExpansionPanelTitle MatExpansionPanelTitle
} from "@angular/material/expansion"; } from "@angular/material/expansion";
@@ -16,7 +17,7 @@ import {MatButton, MatIconButton} from "@angular/material/button";
import {MatIcon} from "@angular/material/icon"; import {MatIcon} from "@angular/material/icon";
import {Group} from "../../model/group"; import {Group} from "../../model/group";
import {Round} from "../../model/round"; import {Round} from "../../model/round";
import {MatMenu, MatMenuContent, MatMenuItem, MatMenuTrigger} from "@angular/material/menu"; import {MatMenu, MatMenuItem, MatMenuTrigger} from "@angular/material/menu";
import {Match} from "../../model/match"; import {Match} from "../../model/match";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {MatTab, MatTabChangeEvent, MatTabGroup, MatTabLabel} from "@angular/material/tabs"; import {MatTab, MatTabChangeEvent, MatTabGroup, MatTabLabel} from "@angular/material/tabs";
@@ -26,17 +27,19 @@ import {MatchResultPipe} from "../../pipes/match-result-pipe";
import {Event} from "../../model/event"; import {Event} from "../../model/event";
import {TournamentValidateComponent} from "../tournament-validate/tournament-validate.component"; import {TournamentValidateComponent} from "../tournament-validate/tournament-validate.component";
import {Player, Strength} from "../../model/player"; import {Player, Strength} from "../../model/player";
import {MatSlideToggleChange} from "@angular/material/slide-toggle";
import {MatSnackBar} from "@angular/material/snack-bar"; import {MatSnackBar} from "@angular/material/snack-bar";
import {CourtSelectionComponent} from "../court-selection/court-selection.component"; import {CourtSelectionComponent} from "../court-selection/court-selection.component";
import {Standings} from "../../model/standings"; import {Standings} from "../../model/standings";
import {HeaderService} from "../../service/header.service"; import {HeaderService} from "../../service/header.service";
import {TournamentPlayersComponent} from "../tournament-players/tournament-players.component"; import {TournamentPlayersComponent} from "../tournament-players/tournament-players.component";
import {TournamentPlayer} from "../../model/tournamentPlayer"; import {TournamentPlayer} from "../../model/tournamentPlayer";
import {MatTooltip} from "@angular/material/tooltip"; import {TeamDisplayComponent} from "../team-display/team-display.component";
import {CounterSelectionComponent} from "../counter-selection/counter-selection.component";
import {Observable, Subscription, tap} from "rxjs";
import {MatTooltip} from '@angular/material/tooltip';
@Component({ @Component({
selector: 'app-tournament-manage', selector: 'app-tournament-manage',
imports: [ imports: [
FullNamePipe, FullNamePipe,
MatAccordion, MatAccordion,
@@ -63,14 +66,17 @@ import {MatTooltip} from "@angular/material/tooltip";
TournamentValidateComponent, TournamentValidateComponent,
TournamentPlayersComponent, TournamentPlayersComponent,
MatExpansionPanelActionRow, MatExpansionPanelActionRow,
TeamDisplayComponent,
MatTooltip,
], ],
providers: [ providers: [
FullNamePipe, FullNamePipe,
TeamPipe, TeamPipe,
MatchResultPipe MatchResultPipe
], ],
templateUrl: './tournament-manage.component.html', templateUrl: './tournament-manage.component.html',
styleUrl: './tournament-manage.component.scss' standalone: true,
styleUrl: './tournament-manage.component.scss'
}) })
export class TournamentManageComponent implements OnInit, OnDestroy { export class TournamentManageComponent implements OnInit, OnDestroy {
@@ -94,10 +100,10 @@ export class TournamentManageComponent implements OnInit, OnDestroy {
this.activeRoundTab = params['tab']; this.activeRoundTab = params['tab'];
} }
}) })
this.tournamentService.getById(Number(id)).subscribe(data => { this.loadTournament(
this.tournament = data; this.tournamentService.getById(Number(id)),
this.headerService.setTitle(this.tournament.name); (tournament) => this.headerService.setTitle(tournament.name)
}); );
} }
ngOnDestroy() { ngOnDestroy() {
@@ -148,56 +154,42 @@ export class TournamentManageComponent implements OnInit, OnDestroy {
} }
startRound(round: Round) { startRound(round: Round) {
this.tournamentService.startRound(this.tournament.id, round.id).subscribe(data => { this.loadTournament(this.tournamentService.startRound(this.tournament.id, round.id));
this.tournament = data;
});
} }
finishRound(round: Round) { finishRound(round: Round) {
this.tournamentService.finishRound(this.tournament.id, round.id).subscribe(data => { this.loadTournament(this.tournamentService.finishRound(this.tournament.id, round.id));
this.tournament = data;
});
} }
finishGroup(group: Group) { finishGroup(group: Group) {
this.tournamentService.finishGroup(this.tournament.id, group.id).subscribe(data => { this.loadTournament(this.tournamentService.finishGroup(this.tournament.id, group.id));
this.tournament = data;
});
} }
reopenGroup(group: Group) { reopenGroup(group: Group) {
this.tournamentService.reopenGroup(this.tournament.id, group.id).subscribe(data => { this.loadTournament(this.tournamentService.reopenGroup(this.tournament.id, group.id));
this.tournament = data;
});
} }
divideTournament() { divideTournament() {
this.tournamentService.divide(this.tournament.id).subscribe(data => { this.loadTournament(this.tournamentService.divide(this.tournament.id));
this.tournament = data;
});
} }
clearDivision() { clearDivision() {
this.tournamentService.clearDivision(this.tournament.id).subscribe(data => { this.loadTournament(this.tournamentService.clearDivision(this.tournament.id));
this.tournament = data;
});
} }
drawTournament() { drawTournament() {
this.tournamentService.draw(this.tournament.id).subscribe(data => { this.loadTournament(this.tournamentService.draw(this.tournament.id));
this.tournament = data;
});
} }
startMatch(match: Match) { startMatch(match: Match) {
const availableCourts = this.getAvailableCourts(); const availableCourts = this.getAvailableCourts();
if (availableCourts.length == 0) { if (availableCourts.length == 0) {
alert('Geen banen beschikbaar!'); this._snackBar.open('Geen banen beschikbaar.')
} else if (this.matchContainsPlayersThatArePlaying(match)) { } else if (this.matchContainsPlayersThatArePlaying(match)) {
alert('Deze wedstrijd bevat spelers die al aan het spelen zijn!'); this._snackBar.open('Deze wedstrijd bevat spelers die al aan het spelen zijn.')
} else if (this.matchContainsPlayersThatAreCounting(match)) { } else if (this.matchContainsPlayersThatAreCounting(match)) {
alert('Deze wedstrijd bevat spelers die aan het tellen zijn!'); this._snackBar.open('Deze wedstrijd bevat spelers die aan het tellen zijn.')
} else { } else {
this.courtSelectionDialog.open(CourtSelectionComponent, { this.courtSelectionDialog.open(CourtSelectionComponent, {
data: { data: {
@@ -210,46 +202,72 @@ export class TournamentManageComponent implements OnInit, OnDestroy {
minHeight: '250px' minHeight: '250px'
}).afterClosed().subscribe(result => { }).afterClosed().subscribe(result => {
if (result != undefined) { if (result != undefined) {
console.log('Start match on court ' + result.court + ' with counter ' + result.counter.name); this.loadTournament(this.tournamentService.startMatch(this.tournament.id, match.id, result.court, result.counter.playerId));
this.tournamentService.startMatch(this.tournament.id, match.id, result.court, result.counter.playerId).subscribe(data => {
this.tournament = data;
});
} }
}); });
} }
} }
matchContainsPlayersThatArePlaying(match: Match): boolean { matchContainsPlayersThatArePlaying(match: Match): boolean {
let activePlayers: number[] = [];
for (let activeMatch of this.activeMatches()) {
activePlayers.push(activeMatch.match.team1.player1.id);
if (activeMatch.match.team1.player2) activePlayers.push(activeMatch.match.team1.player2.id);
activePlayers.push(activeMatch.match.team2.player1.id);
if (activeMatch.match.team2.player2) activePlayers.push(activeMatch.match.team2.player2.id);
}
let matchPlayers: number[] = []; let matchPlayers: number[] = [];
matchPlayers.push(match.team1.player1.id); matchPlayers.push(this.getPlayerOrSubstitute(match.team1.player1, match.type).id);
if (match.team1.player2) matchPlayers.push(match.team1.player2.id); if (match.team1.player2) matchPlayers.push(this.getPlayerOrSubstitute(match.team1.player2, match.type).id);
matchPlayers.push(match.team2.player1.id); matchPlayers.push(this.getPlayerOrSubstitute(match.team2.player1, match.type).id);
if (match.team2.player2) matchPlayers.push(match.team2.player2.id); if (match.team2.player2) matchPlayers.push(this.getPlayerOrSubstitute(match.team2.player2, match.type).id);
let playersThatArePlaying = activePlayers.filter(Set.prototype.has, new Set(matchPlayers));
return playersThatArePlaying.length > 0; let matchPlayersThatArePlaying = this.tournament.playersPlaying.filter(Set.prototype.has, new Set(matchPlayers));
return matchPlayersThatArePlaying.length > 0;
}
matchPlayersThatArePlaying(match: Match): number[] {
let matchPlayers: number[] = [];
matchPlayers.push(this.getPlayerOrSubstitute(match.team1.player1, match.type).id);
if (match.team1.player2) matchPlayers.push(this.getPlayerOrSubstitute(match.team1.player2, match.type).id);
matchPlayers.push(this.getPlayerOrSubstitute(match.team2.player1, match.type).id);
if (match.team2.player2) matchPlayers.push(this.getPlayerOrSubstitute(match.team2.player2, match.type).id);
return this.tournament.playersPlaying.filter(Set.prototype.has, new Set(matchPlayers));
} }
matchContainsPlayersThatAreCounting(match: Match): boolean { matchContainsPlayersThatAreCounting(match: Match): boolean {
let currentCounters: number[] = [];
for (let activeMatch of this.activeMatches()) {
currentCounters.push(activeMatch.match.counter.id);
}
let matchPlayers: number[] = []; let matchPlayers: number[] = [];
matchPlayers.push(match.team1.player1.id); matchPlayers.push(this.getPlayerOrSubstitute(match.team1.player1, match.type).id);
if (match.team1.player2) matchPlayers.push(match.team1.player2.id); if (match.team1.player2) matchPlayers.push(this.getPlayerOrSubstitute(match.team1.player2, match.type).id);
matchPlayers.push(match.team2.player1.id); matchPlayers.push(this.getPlayerOrSubstitute(match.team2.player1, match.type).id);
if (match.team2.player2) matchPlayers.push(match.team2.player2.id); if (match.team2.player2) matchPlayers.push(this.getPlayerOrSubstitute(match.team2.player2, match.type).id);
let playersThatAreCounting = currentCounters.filter(Set.prototype.has, new Set(matchPlayers)); let matchPlayersThatAreCounting = this.tournament.playersCounting.filter(Set.prototype.has, new Set(matchPlayers));
return playersThatAreCounting.length > 0; return matchPlayersThatAreCounting.length > 0;
}
matchPlayersThatAreCounting(match: Match): number[] {
let matchPlayers: number[] = [];
matchPlayers.push(this.getPlayerOrSubstitute(match.team1.player1, match.type).id);
if (match.team1.player2) matchPlayers.push(this.getPlayerOrSubstitute(match.team1.player2, match.type).id);
matchPlayers.push(this.getPlayerOrSubstitute(match.team2.player1, match.type).id);
if (match.team2.player2) matchPlayers.push(this.getPlayerOrSubstitute(match.team2.player2, match.type).id);
return this.tournament.playersCounting.filter(Set.prototype.has, new Set(matchPlayers));
}
getTournamentPlayerFromPlayer(player: Player): TournamentPlayer {
for (let tournamentPlayer of this.tournament.tournamentPlayers) {
if (tournamentPlayer.playerId == player.id) {
return tournamentPlayer;
}
}
return null!;
}
getTournamentPlayerFromId(tournamentPlayerId: number): TournamentPlayer {
for (let tournamentPlayer of this.tournament.tournamentPlayers) {
if (tournamentPlayer.id == tournamentPlayerId) {
return tournamentPlayer;
}
}
return null!;
} }
getAvailableCourts(): number[] { getAvailableCourts(): number[] {
@@ -263,40 +281,41 @@ export class TournamentManageComponent implements OnInit, OnDestroy {
} }
getAvailableCounters(match: Match): TournamentPlayer[] { getAvailableCounters(match: Match): TournamentPlayer[] {
const activePlayerIds = new Set( const matchPlayers: number[] = [];
this.activeMatches().flatMap(activeMatch => [ matchPlayers.push(this.getPlayerOrSubstitute(match.team1.player1, match.type).id);
activeMatch.match.team1.player1.id, if (match.team1.player2) matchPlayers.push(this.getPlayerOrSubstitute(match.team1.player2, match.type).id);
activeMatch.match.team1.player2?.id, matchPlayers.push(this.getPlayerOrSubstitute(match.team2.player1, match.type).id);
activeMatch.match.team2.player1.id, if (match.team2.player2) matchPlayers.push(this.getPlayerOrSubstitute(match.team2.player2, match.type).id);
activeMatch.match.team2.player2?.id
].filter(id => id !== undefined))
);
const playerIdsInMatchToBeStarted = new Set([ const counterIds = this.tournament.playersAvailable.filter(player => !matchPlayers.includes(player));
match.team1.player1.id,
match.team1.player2?.id,
match.team2.player1.id,
match.team2.player2?.id
].filter(id => id !== undefined));
return this.tournament.tournamentPlayers.filter( return counterIds.map(id => this.getTournamentPlayerFromId(id));
player => }
!player.counting
&& !activePlayerIds.has(player.playerId) getPlayerOrSubstitute(player: Player, type: String): TournamentPlayer {
&& !playerIdsInMatchToBeStarted.has(player.playerId) return this.getSubstituteForEvent(player, type) || this.getTournamentPlayerFromPlayer(player);
}
getSubstituteForEvent(player: Player, type: String): TournamentPlayer | undefined {
const tournamentPlayer = this.tournament.tournamentPlayers.find(
tp => tp.playerId === player.id
); );
if (!tournamentPlayer) return undefined;
const substitution = tournamentPlayer.substitutions.find(
s => s.event === type
);
if (!substitution) return undefined;
return this.getTournamentPlayerFromId(substitution.substitute);
} }
stopMatch(match: Match) { stopMatch(match: Match) {
this.tournamentService.stopMatch(this.tournament.id, match.id).subscribe(data => { this.loadTournament(this.tournamentService.stopMatch(this.tournament.id, match.id));
this.tournament = data;
})
} }
newRound(group: Group) { newRound(group: Group) {
this.tournamentService.newRound(this.tournament.id, group.id).subscribe(data => { this.loadTournament(this.tournamentService.newRound(this.tournament.id, group.id));
this.tournament = data;
})
} }
getStrength(strength: string | undefined) { getStrength(strength: string | undefined) {
@@ -322,7 +341,7 @@ export class TournamentManageComponent implements OnInit, OnDestroy {
for (const round of group.rounds) { for (const round of group.rounds) {
for (const match of round.matches) { for (const match of round.matches) {
if (match.status == 'IN_PROGRESS') { if (match.status == 'IN_PROGRESS') {
matches.push(new ActiveMatch(match, round, group)); matches.push(new ActiveMatch(match, round, group, event));
} }
} }
} }
@@ -353,16 +372,15 @@ export class TournamentManageComponent implements OnInit, OnDestroy {
matchResultDialog = inject(MatDialog); matchResultDialog = inject(MatDialog);
courtSelectionDialog = inject(MatDialog); courtSelectionDialog = inject(MatDialog);
counterSelectionDialog = inject(MatDialog);
editResult(match: Match, group: Group, round: Round) { editResult(match: Match, event: Event, group: Group, round: Round) {
this.matchResultDialog.open(MatchResultComponent, { this.matchResultDialog.open(MatchResultComponent, {
data: {match: match, group: group, round: round}, data: {match: match, tournament: this.tournament, event: event, group: group, round: round},
minWidth: '800px' minWidth: '800px'
}).afterClosed().subscribe(result => { }).afterClosed().subscribe(result => {
if (result != undefined) { if (result != undefined) {
this.tournamentService.saveResult(this.tournament.id, result.matchId, result).subscribe(data => { this.loadTournament(this.tournamentService.saveResult(this.tournament.id, result.matchId, result));
this.tournament = data;
})
} }
}); });
} }
@@ -412,16 +430,97 @@ export class TournamentManageComponent implements OnInit, OnDestroy {
} }
return count; return count;
} }
changeCounter(match: Match) {
this.counterSelectionDialog.open(CounterSelectionComponent, {
data: {
match: match,
availableCounters: this.getAvailableCounters(match)
},
minWidth: '800px',
minHeight: '250px'
}).afterClosed().subscribe(result => {
if (result != undefined) {
this.loadTournament(this.tournamentService.updateCounter(this.tournament.id, match.id, result.counter.playerId));
}
});
}
private loadTournament(
observable: Observable<Tournament>,
afterLoad?: (tournament: Tournament) => void
): Subscription {
return observable.subscribe(data => {
this.tournament = data;
this.updateMatchAvailability();
afterLoad?.(data); // Optional chaining
});
}
private updateMatchAvailability() {
for (const event of this.tournament.events) {
for (const group of event.groups) {
for (const round of group.rounds) {
for (const match of round.matches) {
let canStart = true;
let matchPlayersThatArePlaying = this.matchPlayersThatArePlaying(match);
let matchPlayersThatAreCounting = this.matchPlayersThatAreCounting(match);
let cantStartReason = "";
if (matchPlayersThatArePlaying.length > 0) {
canStart = false;
cantStartReason = this.joinWithEn(matchPlayersThatArePlaying.map(m => this.getTournamentPlayerFromId(m).name));
if (matchPlayersThatArePlaying.length == 1) {
cantStartReason += " is al aan het spelen";
} else {
cantStartReason += " zijn al aan het spelen";
}
}
if (matchPlayersThatAreCounting.length > 0) {
canStart = false;
if (cantStartReason.length > 0) {
cantStartReason += " en ";
}
cantStartReason += this.joinWithEn(matchPlayersThatAreCounting.map(m => this.getTournamentPlayerFromId(m).name));
if (matchPlayersThatAreCounting.length == 1) {
cantStartReason += " is aan het tellen";
} else {
cantStartReason += " zijn aan het tellen";
}
}
match.canStart = canStart;
match.cantStartReason = cantStartReason;
}
}
}
}
}
joinWithEn(items: string[], separator = ", "): string {
const len = items.length;
if (len === 0) return "";
if (len === 1) return items[0];
if (len === 2) return items.join(" en ");
return items.slice(0, -1).join(separator) + `${separator}and ${items[len - 1]}`;
}
} }
class ActiveMatch { class ActiveMatch {
constructor(match: Match, round: Round, group: Group) { constructor(match: Match, round: Round, group: Group, event: Event) {
this.match = match; this.match = match;
this.round = round; this.round = round;
this.group = group; this.group = group;
this.event = event;
} }
match: Match; match: Match;
round: Round; round: Round;
group: Group; group: Group;
event: Event;
} }

View File

@@ -1,48 +1,108 @@
@if (tournament) { @if (tournament) {
<table class="table table-hover w-75 m-4"> <mat-tab-group animationDuration="0ms" disableRipple="true">
<thead> <mat-tab>
<tr> <ng-template mat-tab-label>
<th>Naam</th> <mat-icon>euro</mat-icon>
<th>Wedstrijden geteld</th> &nbsp;Administratie
<th>Onderdelen</th> </ng-template>
<th>Kosten</th>
<th>Betaald</th> <table class="table table-hover w-75 m-4">
<th>Aanwezig</th> <thead>
</tr>
</thead>
<tbody>
@for (tournamentPlayer of tournament.tournamentPlayers; track tournamentPlayer.playerId) {
<tr> <tr>
<td>{{ tournamentPlayer.name }}</td> <th>Naam</th>
<td>{{ tournamentPlayer.counts }}</td> <th>Onderdelen</th>
<td> <th>Kosten</th>
@for (event of tournamentPlayer.events; track event) { <th>Betaald</th>
{{ event }}&nbsp; @if (tournament.status == 'ONGOING') {
} <th>Aanwezig</th>
</td> }
<td>
{{ tournament.costsPerEvent[tournamentPlayer.events.length - 1] | currency:'EUR':'symbol':'1.2-2':'nl' }}
</td>
<td>
<mat-slide-toggle [(ngModel)]="tournamentPlayer.paid" (change)="playerPaid($event, tournamentPlayer.playerId)">
@if (tournamentPlayer.paid) {
Betaald
} @else {
Nog niet betaald
}
</mat-slide-toggle>
</td>
<td>
<mat-slide-toggle [(ngModel)]="tournamentPlayer.present" (change)="playerPresent($event, tournamentPlayer.playerId)">
@if (tournamentPlayer.present) {
Aanwezig
} @else {
Nog niet aanwezig
}
</mat-slide-toggle>
</td>
</tr> </tr>
} </thead>
</tbody> <tbody>
</table> @for (tournamentPlayer of tournament.tournamentPlayers; track tournamentPlayer.playerId) {
<tr>
<td>{{ tournamentPlayer.name }}</td>
<td>
@for (event of tournamentPlayer.events; track event) {
{{ event }}&nbsp;
}
</td>
<td>
{{ tournament.costsPerEvent[tournamentPlayer.events.length - 1] | currency:'EUR':'symbol':'1.2-2':'nl' }}
</td>
<td>
<mat-slide-toggle [(ngModel)]="tournamentPlayer.paid" (change)="playerPaid($event, tournamentPlayer.playerId)">
@if (tournamentPlayer.paid) {
Betaald
} @else {
Nog niet betaald
}
</mat-slide-toggle>
</td>
@if (tournament.status == 'ONGOING') {
<td>
<mat-slide-toggle [(ngModel)]="tournamentPlayer.present" (change)="playerPresent($event, tournamentPlayer.playerId)">
@if (tournamentPlayer.present) {
Aanwezig
} @else {
Nog niet aanwezig
}
</mat-slide-toggle>
</td>
}
</tr>
}
</tbody>
</table>
</mat-tab>
@if (tournament.status == 'ONGOING') {
<mat-tab>
<ng-template mat-tab-label>
<mat-icon>group</mat-icon>
&nbsp;Tellers en invallers
</ng-template>
<table class="table table-hover w-75 m-4">
<thead>
<tr>
<th>Naam</th>
<th>Wedstrijden geteld</th>
<th>Onderdelen</th>
<th>Invallers</th>
<th></th>
</tr>
</thead>
<tbody>
@for (tournamentPlayer of tournament.tournamentPlayers; track tournamentPlayer.playerId) {
<tr>
<td>{{ tournamentPlayer.name }}</td>
<td>{{ tournamentPlayer.counts }}</td>
<td>
@for (event of tournamentPlayer.events; track event) {
{{ event }}&nbsp;
}
</td>
<td>{{ hasSubstitutes(tournamentPlayer) ? 'Ja' : 'Nee' }}</td>
<td>
<button mat-icon-button [matMenuTriggerFor]="dividedTournamentMenu" class="menu-button">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #dividedTournamentMenu="matMenu">
<button mat-menu-item (click)="findSubstitute(tournamentPlayer)">
<mat-icon>autorenew</mat-icon>
Invallers
</button>
<button mat-menu-item> <!--(click)="drawTournament()"-->
<mat-icon>do_not_disturb_on</mat-icon>
Deelname stoppen
</button>
</mat-menu>
</td>
</tr>
}
</tbody>
</table>
</mat-tab>
}
</mat-tab-group>
} }

View File

@@ -1,3 +1,7 @@
td, th { td, th {
background-color: transparent !important; background-color: transparent !important;
} }
td {
vertical-align: middle;
}

View File

@@ -1,20 +1,36 @@
import {Component, Input, OnInit} from '@angular/core'; import {Component, inject, Input, OnInit} from '@angular/core';
import {CurrencyPipe} from "@angular/common"; import {CurrencyPipe} from "@angular/common";
import {MatSlideToggle, MatSlideToggleChange} from "@angular/material/slide-toggle"; import {MatSlideToggle, MatSlideToggleChange} from "@angular/material/slide-toggle";
import {TournamentService} from "../../service/tournament.service"; import {TournamentService} from "../../service/tournament.service";
import {ActivatedRoute, Router} from "@angular/router"; import {ActivatedRoute} from "@angular/router";
import {Tournament} from "../../model/tournament"; import {Tournament} from "../../model/tournament";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {MatSnackBar} from "@angular/material/snack-bar"; import {MatSnackBar} from "@angular/material/snack-bar";
import {MatIcon} from "@angular/material/icon";
import {MatIconButton} from "@angular/material/button";
import {MatMenu, MatMenuItem, MatMenuTrigger} from "@angular/material/menu";
import {MatDialog} from "@angular/material/dialog";
import {SubstituteSelectionComponent} from "../substitute-selection/substitute-selection.component";
import {TournamentPlayer} from "../../model/tournamentPlayer";
import {MatTab, MatTabGroup, MatTabLabel} from "@angular/material/tabs";
@Component({ @Component({
selector: 'app-tournament-players', selector: 'app-tournament-players',
imports: [ imports: [
CurrencyPipe, CurrencyPipe,
MatSlideToggle, MatSlideToggle,
FormsModule FormsModule,
MatIcon,
MatIconButton,
MatMenu,
MatMenuItem,
MatMenuTrigger,
MatTab,
MatTabGroup,
MatTabLabel
], ],
templateUrl: './tournament-players.component.html', templateUrl: './tournament-players.component.html',
standalone: true,
styleUrl: './tournament-players.component.scss' styleUrl: './tournament-players.component.scss'
}) })
export class TournamentPlayersComponent implements OnInit { export class TournamentPlayersComponent implements OnInit {
@@ -23,17 +39,19 @@ export class TournamentPlayersComponent implements OnInit {
constructor( constructor(
private tournamentService: TournamentService, private tournamentService: TournamentService,
private _snackBar: MatSnackBar,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private _snackBar: MatSnackBar,
) {} ) {}
ngOnInit() { ngOnInit() {
console.log('Tournament received from parent:', this.tournament); if (!this.tournament) {
// const id = this.route.snapshot.paramMap.get('id'); const id = Number(this.route.snapshot.paramMap.get('id'));
// this.tournamentService.getById(Number(id)).subscribe(data => { if (id) {
// this.tournament = data; this.tournamentService.getById(Number(id)).subscribe(data => {
// }); this.tournament = data;
});
}
}
} }
playerPaid($event: MatSlideToggleChange, playerId: number) { playerPaid($event: MatSlideToggleChange, playerId: number) {
@@ -48,4 +66,27 @@ export class TournamentPlayersComponent implements OnInit {
}); });
} }
substituteSelectionDialog = inject(MatDialog);
findSubstitute(player: TournamentPlayer) {
this.substituteSelectionDialog.open(SubstituteSelectionComponent, {
data: {
player: player,
availablePlayers: this.tournament.tournamentPlayers
},
minWidth: '800px',
minHeight: '250px'
}).afterClosed().subscribe(result => {
if (result != undefined) {
this.tournamentService.playerSubstitute(this.tournament.id, player.playerId, result.substitutions).subscribe(data => {
this.tournament = data;
});
}
});
}
hasSubstitutes(tournamentPlayer: TournamentPlayer) {
return tournamentPlayer.substitutions.filter(s => s.substitute != null && s.substitute >= 0).length > 0;
}
} }

View File

@@ -7,15 +7,16 @@ import {Event} from "../../model/event";
import {FullNamePipe} from "../../pipes/fullname-pipe"; import {FullNamePipe} from "../../pipes/fullname-pipe";
@Component({ @Component({
selector: 'app-tournament-registrations', selector: 'app-tournament-registrations',
imports: [ imports: [
MatCard, MatCard,
MatCardHeader, MatCardHeader,
MatCardContent, MatCardContent,
FullNamePipe FullNamePipe
], ],
templateUrl: './tournament-registrations.component.html', templateUrl: './tournament-registrations.component.html',
styleUrl: './tournament-registrations.component.scss' standalone: true,
styleUrl: './tournament-registrations.component.scss'
}) })
export class TournamentRegistrationsComponent implements OnInit { export class TournamentRegistrationsComponent implements OnInit {

View File

@@ -18,21 +18,22 @@ import {PlayerService} from "../../service/player.service";
import {PlayerLinkComponent} from "../player-link/player-link.component"; import {PlayerLinkComponent} from "../player-link/player-link.component";
@Component({ @Component({
selector: 'app-tournament-validate', selector: 'app-tournament-validate',
imports: [ imports: [
MatCard, MatCard,
MatCardHeader, MatCardHeader,
MatCardContent, MatCardContent,
MatExpansionPanel, MatExpansionPanel,
MatExpansionPanelTitle, MatExpansionPanelTitle,
MatExpansionPanelHeader, MatExpansionPanelHeader,
MatAccordion, MatAccordion,
MatIcon, MatIcon,
FullNamePipe, FullNamePipe,
PlayerLinkComponent PlayerLinkComponent
], ],
templateUrl: './tournament-validate.component.html', templateUrl: './tournament-validate.component.html',
styleUrl: './tournament-validate.component.scss' standalone: true,
styleUrl: './tournament-validate.component.scss'
}) })
export class TournamentValidateComponent implements OnInit { export class TournamentValidateComponent implements OnInit {
tournament: Tournament; tournament: Tournament;

View File

@@ -13,4 +13,6 @@ export class Match {
games: Game[]; games: Game[];
court: number; court: number;
counter: Player; counter: Player;
canStart?: boolean;
cantStartReason?: string;
} }

View File

@@ -12,6 +12,9 @@ export class Tournament {
costsPerEvent: number[] = [10, 20, 0]; costsPerEvent: number[] = [10, 20, 0];
courts: number; courts: number;
active: boolean; active: boolean;
playersPlaying: number[];
playersCounting: number[];
playersAvailable: number[];
static getStatus(tournament: Tournament): string { static getStatus(tournament: Tournament): string {
if (tournament.status == "CLOSED") return "Afgerond"; if (tournament.status == "CLOSED") return "Afgerond";

View File

@@ -1,5 +1,6 @@
export class TournamentPlayer { export class TournamentPlayer {
id: number;
playerId: number; playerId: number;
name: string; name: string;
events: string[]; events: string[];
@@ -7,4 +8,11 @@ export class TournamentPlayer {
present: boolean; present: boolean;
counting: boolean; counting: boolean;
counts: number; counts: number;
substitutions: TournamentPlayerSubstitution[];
}
export class TournamentPlayerSubstitution {
substitutionId: number;
event: string;
substitute: number = -1;
} }

View File

@@ -1,5 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
import {FullNamePipe} from "../pipes/fullname-pipe"; import {FullNamePipe} from "./fullname-pipe";
@Pipe({ @Pipe({
name: 'teamText', name: 'teamText',
standalone: true standalone: true

View File

@@ -1,4 +1,3 @@
// header.service.ts
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';

View File

@@ -5,6 +5,7 @@ import {Tournament} from "../model/tournament";
import {TournamentValidation} from "../model/tournamentValidation"; import {TournamentValidation} from "../model/tournamentValidation";
import {Result} from "../model/result"; import {Result} from "../model/result";
import { environment } from "../../environments/environment" import { environment } from "../../environments/environment"
import {TournamentPlayerSubstitution} from "../model/tournamentPlayer";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -92,6 +93,14 @@ export class TournamentService {
return this.http.patch<void>(`${this.tournamentsUrl}/${tournamentId}/players/${playerId}/present/${paid}`, null); return this.http.patch<void>(`${this.tournamentsUrl}/${tournamentId}/players/${playerId}/present/${paid}`, null);
} }
public playerSubstitute(tournamentId: number, playerId: number, substitutions: TournamentPlayerSubstitution[]): Observable<Tournament> {
return this.http.post<Tournament>(`${this.tournamentsUrl}/${tournamentId}/players/${playerId}/substitutions`, substitutions)
}
public updateCounter(tournamentId: number, matchId: number, counter: number): Observable<Tournament> {
return this.http.patch<Tournament>(`${this.tournamentsUrl}/${tournamentId}/matches/${matchId}/update?counter=${counter}`, null);
}
public addTestData(): Observable<void> { public addTestData(): Observable<void> {
return this.http.get<void>(`${environment.backendUrl}/testdata`); return this.http.get<void>(`${environment.backendUrl}/testdata`);
} }

View File

@@ -1,7 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser';
import { AppComponent } from './app/app.component'; import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server'; import { config } from './app/app.config.server';
const bootstrap = () => bootstrapApplication(AppComponent, config); const bootstrap = (context: BootstrapContext) => bootstrapApplication(AppComponent, config, context);
export default bootstrap; export default bootstrap;

View File

@@ -2,3 +2,11 @@
html, body { height: 100%; } html, body { height: 100%; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
/*
.cdk-overlay-container {
z-index: 2000 !important;
}
*/
.mat-tooltip {
white-space: pre-line !important;
}

2117
yarn.lock

File diff suppressed because it is too large Load Diff