first commit

This commit is contained in:
Bastian Wagner
2026-07-31 21:02:47 +02:00
commit 6bea4f766a
512 changed files with 64459 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
<div class="app_version">{{version}}</div>
<router-outlet></router-outlet>

View File

@@ -0,0 +1,9 @@
.app_version{
position: absolute;
left: 0px;
bottom: 0px;
font-size: 0.6rem;
line-height: 0.6rem;
color: rgb(190, 190, 190);
pointer-events: none;
}

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
RouterTestingModule
],
declarations: [
AppComponent
],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'myteamwallet_frontend'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('myteamwallet_frontend');
});
});

View File

@@ -0,0 +1,46 @@
import { Component } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { environment } from './../environments/environment';
import { SwUpdate } from '@angular/service-worker';
import { MatIconRegistry } from '@angular/material/icon';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
version = environment.appVersion;
constructor(translate: TranslateService, updates: SwUpdate, iconRegistry: MatIconRegistry) {
iconRegistry.setDefaultFontSetClass('material-symbols-outlined');
translate.setDefaultLang('de');
translate.use('de');
this.update(updates);
}
update(updates: SwUpdate) {
console.log("Checking for updates: ", updates.isEnabled);
if (!updates.isEnabled) { return; }
updates.versionUpdates.subscribe(evt => {
switch (evt.type) {
case 'VERSION_DETECTED':
console.log(`Downloading new app version: ${evt.version.hash}`);
break;
case 'VERSION_READY':
console.log(`Current app version: ${evt.currentVersion.hash}`);
console.log(`New app version ready for use: ${evt.latestVersion.hash}`);
window.location.reload();
break;
case 'VERSION_INSTALLATION_FAILED':
console.log(`Failed to install app version '${evt.version.hash}': ${evt.error}`);
break;
case 'NO_NEW_VERSION_DETECTED':
break;
default:
}
});
}
}

View File

@@ -0,0 +1,55 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { LOCALE_ID, isDevMode} from '@angular/core';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import { registerLocaleData } from '@angular/common';
import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { AuthInterceptorService } from './core/interceptor/auth.interceptor';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { CustomTranslateLoader } from './core/translate/translate-loader';
import { provideRouter, RouterModule } from '@angular/router';
import { routes } from './app.routes';
import { ServiceWorkerModule } from '@angular/service-worker';
registerLocaleData(localeDe, localeDeExtra);
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
RouterModule,
BrowserAnimationsModule,
HttpClientModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: CustomTranslateLoader,
deps: [HttpClient],
},
defaultLanguage: 'de',
}),
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: !isDevMode(),
// Register the ServiceWorker as soon as the application is stable
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000'
}),
],
providers: [
{ provide: LOCALE_ID, useValue: 'de' },
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptorService,
multi: true
},
provideRouter(routes)
],
bootstrap: [AppComponent]
})
export class AppModule { }

View File

@@ -0,0 +1,18 @@
import { Routes } from '@angular/router';
import { homeRoutes } from './modules/home/home.routes';
import { authRoutes } from './modules/auth/auth.routes';
export const routes: Routes = [
{
path: '', children: homeRoutes
},
{
path: 'auth', children: authRoutes
},
{
path: 'password-change/:hash', loadComponent: () => import('./modules/auth/password-change/password-change.component').then(m => m.PasswordChangeComponent)
},
{
path: 'teams', loadChildren: () => import('./modules/teams/teams.module').then(m => m.TeamsModule)
}
];

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core';
import { ActivatedRoute, ActivatedRouteSnapshot, Route, Router, RouterStateSnapshot, UrlSegment, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from 'src/app/modules/auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard {
constructor(private router: Router, private activatedRoute: ActivatedRoute, private authService: AuthService) {}
private async check(): Promise<boolean> {
console.log("checking")
if (this.authService.isLoggedIn()) { return true; }
const success = await this.authService.loginFromLocalStorage();
if (success) {
return true;
}
this.router.navigate(['/auth']).then();
return false;
}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return this.check();
}
canActivateChild(
childRoute: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return this.check();
}
canLoad(): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return this.check();
}
}

View File

@@ -0,0 +1,34 @@
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Observable, catchError, throwError } from 'rxjs';
import { AuthService } from 'src/app/modules/auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthInterceptorService implements HttpInterceptor {
constructor(private authService: AuthService, private snackBar: MatSnackBar) {}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = this.authService.getAuthToken();
if (token) {
// If we have a token, we set it to the header
request = request.clone({
setHeaders: {Authorization: `Bearer ${token}`}
});
}
return next.handle(request).pipe(
catchError((err) => {
if (err instanceof HttpErrorResponse) {
if (err.status === 401 && this.authService.isLoggedIn()) {
this.snackBar.open('Sitzung abgelaufen, bitte erneut anmelden', undefined, { duration: 5000 });
this.authService.logout();
}
}
return throwError(err);
})
)
}
}

View File

@@ -0,0 +1,13 @@
import { HttpClient } from '@angular/common/http';
import { TranslateLoader } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
export class CustomTranslateLoader implements TranslateLoader {
constructor(private http: HttpClient) {}
getTranslation(lang: string): Observable<any> {
return this.http.get(`${environment.apiUrl}translations/${lang}`);
}
}

View File

@@ -0,0 +1,19 @@
import { HttpClientModule } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ HttpClientModule ]
});
service = TestBed.inject(UserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,30 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { AuthService } from 'src/app/modules/auth.service';
import { environment } from 'src/environments/environment';
@Injectable({
providedIn: 'root'
})
export class UserService {
players: any[] = [];
constructor(private http: HttpClient, private authService: AuthService, private snackBar: MatSnackBar) { }
loadTeamsOfUser() {
this.http.get(`${environment.apiUrl}users/${this.authService.user.id}/teams`).subscribe({
next: data => { this.players = data as any[]; },
error: () => { this.onUserLoadFailure(); }
})
}
onUserLoadFailure() {
this.snackBar.open('Teams konnten nicht geladen werden', undefined, {
duration: 5000,
panelClass: 'snackbar_error'
})
}
}

View File

@@ -0,0 +1,3 @@
export * from './player';
export * from './teamrole';
export * from './transaction';

View File

@@ -0,0 +1,16 @@
import { Team } from '../modules/teams/model/team';
import { TeamRole } from './teamrole';
export interface Player {
firstName: string;
lastName: string;
id: number;
balance: number;
team: Team;
teamRole: TeamRole;
active: boolean;
hide: boolean;
usersPlayer?: boolean;
}

View File

@@ -0,0 +1,4 @@
export interface TeamRole {
id: number;
name: string;
}

View File

@@ -0,0 +1,9 @@
export interface Transaction {
id: number;
amount: number;
playerName?: string;
date: string;
type: string;
note: string;
isTeamWalletTransaction: boolean;
}

View File

@@ -0,0 +1,17 @@
export interface User {
createdAt: string;
deletedAt: string;
email: string;
firstName: string;
lastName: string;
id: number;
photo: string;
role: Role;
status: any;
updatedAt: string;
}
export interface Role {
id: number;
name: string;
}

View File

@@ -0,0 +1,37 @@
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let service: AuthService;
const mockHttp = jasmine.createSpyObj('HttpClient', ['post']);
mockHttp.post.and.returnValue(of({
token: 'token',
user: {id: 1, email: 'mail', firstName: 'first', lastName: 'last'}
}))
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [{ provide: HttpClient, useValue: mockHttp }]
});
service = TestBed.inject(AuthService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should log in', async () => {
expect(service['authInfoSubject'].value).toBeNull();
const success = await service.login('mail', 'passwort123');
expect(success).toBeTrue();
expect(service['authInfoSubject'].value).not.toBeNull();
expect(service['authInfoSubject'].value.id).toBe(1);
expect(service['authInfoSubject'].value.email).toEqual('mail');
expect(service['authInfoSubject'].value.firstName).toEqual('first');
expect(service['authInfoSubject'].value.lastName).toEqual('last');
})
});

View File

@@ -0,0 +1,117 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { BehaviorSubject, Observable } from 'rxjs';
import { environment } from 'src/environments/environment';
import { User } from '../model/user';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private authInfoSubject: BehaviorSubject<User> = new BehaviorSubject(null as any);
authInfo$: Observable<User> = this.authInfoSubject.asObservable();
private token: string = '';
constructor(private http: HttpClient, private router: Router) {
}
get isAdmin(): boolean {
return this.user != null && this.user.role != null && this.user.role.id == 1;
}
isLoggedIn(): boolean {
return this.authInfoSubject.value != null;
}
get user(): User {
return this.authInfoSubject.value;
}
public async login(email: string, password: string): Promise<boolean> {
return new Promise(resolve => {
this.http.post(`${environment.apiUrl}auth/email/login`, { email, password }).subscribe((result: any) => {
localStorage.setItem('accessToken', window.btoa(result['token']));
this.authInfoSubject.next(result['user']);
this.token = result['token'];
resolve(true);
}, error => {
this.http.post(`${environment.apiUrl}auth/admin/email/login`, { email, password }).subscribe((result: any) => {
if (result && result['token']) {
localStorage.setItem('accessToken', window.btoa(result['token']));
this.authInfoSubject.next(result['user']);
this.token = result['token'];
resolve(true)
}
}, error => {
resolve(false);
})
})
})
}
getAuthToken(): string {
return this.token;
}
async verifyAccessToken(token: string): Promise<boolean> {
this.token = token;
return new Promise(resolve => {
this.http.get<User>(`${environment.apiUrl}auth/me`).subscribe(res => {
if (res && res.firstName) {
this.authInfoSubject.next(res);
if ((res as any)['token']) {
const token = (res as any)['token'];
this.token = token;
localStorage.setItem('accessToken', window.btoa(token));
}
return resolve(true);
}
return resolve(false);
}, () => {
return resolve(false);
})
})
}
logout() {
this.authInfoSubject.next(null as any);
localStorage.removeItem('accessToken');
this.router.navigate(['auth'])
}
register(data: {email: string, password: string, firstName: string, lastName: string, linkPlayerId?: number}): Observable<any> {
return this.http.post(`${environment.apiUrl}auth/email/register`, data);
}
forgotPassword(email: string): Observable<any> {
return this.http.post(`${environment.apiUrl}auth/forgot/password`, { email });
}
resetPassword(hash: string, password: string): Observable<any> {
return this.http.post(`${environment.apiUrl}auth/reset/password`, { hash, password });
}
async loginFromLocalStorage(suppressNavigation = false): Promise<boolean> {
return new Promise<boolean>(async resolve => {
const authToken = localStorage.getItem('accessToken');
if (!authToken || authToken.length == 0) {
if (!suppressNavigation) {
this.router.navigate(['/auth']).then();
}
return resolve(false);
}
const accessToken = window.atob(authToken);
const success = await this.verifyAccessToken(accessToken);
resolve(success)
})
}
}

View File

@@ -0,0 +1,12 @@
<mat-card>
<router-outlet></router-outlet>
</mat-card>
<div class="cached_teams" *ngIf="cachedTeams.length > 0" >
<div>Teams:</div>
@for(t of cachedTeams; track t) {
<button mat-flat-button (click)="openTeam(t.alias)" >{{ t.name }}</button>
}
<button mat-stroked-button color="warn" style="margin-top: 16px;" (click)="clear()" >Clear</button>
</div>

View File

@@ -0,0 +1,25 @@
:host {
display: flex;
justify-content: center;
align-items: center;
width: 100vw;
height: 100%;
flex-direction: column;
gap: 64px;
}
mat-card {
width: 500px;
max-width: 90vw;
}
.cached_teams {
display: flex;
flex-direction: column;
justify-content: stretch;
gap: 4px;
button {
width: 100%;
}
}

View File

@@ -0,0 +1,27 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { AuthComponent } from './auth.component';
describe('AuthComponent', () => {
let component: AuthComponent;
let fixture: ComponentFixture<AuthComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AuthComponent ],
imports: [ MatCardModule, RouterModule ],
providers: [ ]
})
.compileComponents();
fixture = TestBed.createComponent(AuthComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,63 @@
import { Component, inject } from '@angular/core';
import { AuthService } from '../auth.service';
import { Router, RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { MatCardModule } from '@angular/material/card';
import { AuthGuard } from '../../core/guards/auth.guard';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-auth',
templateUrl: './auth.component.html',
styleUrls: ['./auth.component.scss'],
standalone: true,
imports: [
CommonModule,
MatCardModule,
HttpClientModule,
RouterModule,
MatButtonModule
],
providers: [ AuthGuard ]
})
export class AuthComponent {
private auth: AuthService = inject(AuthService);
private router: Router = inject(Router);
cachedTeams: any[] = [];
constructor() {
// this.login();
this.getCachedTeams();
}
login() {
this.auth.loginFromLocalStorage().then(res => {
if (res) {
this.router.navigate(['/dashboard']);
}
})
}
getCachedTeams() {
let t = localStorage.getItem('cached_teams');
if (!t) { return; }
let teams = JSON.parse(t);
this.cachedTeams = Object.keys(teams).map((k: any) => {
return { alias: k, name: teams[k] }
});
}
openTeam(alias: string) {
this.router.navigate([`teams/${alias}`])
}
clear() {
localStorage.removeItem('cached_teams');
this.cachedTeams = [];
}
}

View File

@@ -0,0 +1,18 @@
import { Routes } from '@angular/router';
import { AuthComponent } from './auth.component';
export const authRoutes: Routes = [
{
path: '', component: AuthComponent,
children: [{
path: '', loadChildren: () => import('./login/login.module').then(m => m.LoginModule)
},
{
path: 'register', loadChildren: () => import('./register/register.module').then(m => m.RegisterModule)
},
{
path: 'forgot-password', loadComponent: () => import('./forgot-password/forgot-password.component').then(m => m.ForgotPasswordComponent)
}]
},
];

View File

@@ -0,0 +1,29 @@
<mat-card-header>
<mat-card-title>Passwort vergessen</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-container *ngIf="!submitted">
<p class="mat-body">Gib deine E-Mail-Adresse ein. Falls sie bei uns registriert ist, schicken wir dir einen Link zum Zurücksetzen des Passworts.</p>
<form [formGroup]="form" (keyup.enter)="submit()">
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput placeholder="pat@example.com" formControlName="email" required>
</mat-form-field>
</form>
<p *ngIf="error" class="forgot-error">Anfrage konnte nicht gesendet werden. Bitte später erneut versuchen.</p>
</ng-container>
<ng-container *ngIf="submitted">
<p class="mat-body">Falls die E-Mail-Adresse bei uns registriert ist, wurde eine Nachricht mit einem Link zum Zurücksetzen des Passworts verschickt. Bitte Posteingang (und Spam-Ordner) prüfen.</p>
</ng-container>
</mat-card-content>
<mat-card-actions align="end">
<a mat-button routerLink="/auth">Zurück zum Login</a>
<button *ngIf="!submitted" mat-button color="primary" [disabled]="form.invalid" (click)="submit()">Link anfordern</button>
</mat-card-actions>

View File

@@ -0,0 +1,13 @@
mat-form-field {
width: 100%;
}
mat-card-header {
margin-bottom: 16px;
}
.forgot-error {
color: #b3261e;
margin: 8px 0 0;
font-size: 0.9rem;
}

View File

@@ -0,0 +1,56 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { RouterModule } from '@angular/router';
import { AuthService } from '../../auth.service';
@Component({
selector: 'app-forgot-password',
templateUrl: './forgot-password.component.html',
styleUrls: ['./forgot-password.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
RouterModule
]
})
export class ForgotPasswordComponent {
submitted = false;
error = false;
form = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email])
});
constructor(private authService: AuthService) {}
submit() {
if (this.form.invalid) { return; }
const email = this.form.controls.email.value + '';
this.error = false;
this.authService.forgotPassword(email).subscribe({
next: () => { this.submitted = true; },
error: (err) => {
// Aus Datenschutzgründen wird bei unbekannter E-Mail dieselbe
// Erfolgsmeldung angezeigt wie bei einer bekannten Adresse.
if (err?.error?.errors?.email === 'emailNotExists') {
this.submitted = true;
return;
}
this.error = true;
}
});
}
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LoginComponent } from './login.component';
const routes: Routes = [
{
path: '', component: LoginComponent
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LoginRoutingModule { }

View File

@@ -0,0 +1,31 @@
<mat-card-header>
<mat-card-title>Login</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="loginForm" (keyup.enter)="login()">
<div>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput placeholder="pat@example.com" formControlName="email" required name="username">
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input matInput autocomplete="current-password" type="password" placeholder="pat@example.com" formControlName="password" required>
</mat-form-field>
</div>
<p *ngIf="loginFailed" class="login-error">E-Mail oder Passwort ist falsch.</p>
</form>
</mat-card-content>
<mat-card-actions align="end">
<a mat-button routerLink="/auth/forgot-password">Passwort vergessen?</a>
<button mat-button color="primary" [disabled]="loginForm.invalid" (click)="login()">Login</button>
</mat-card-actions>

View File

@@ -0,0 +1,13 @@
mat-form-field {
width: 100%;
}
mat-card-header {
margin-bottom: 24px;
}
.login-error {
color: #b3261e;
margin: 0 0 8px;
font-size: 0.9rem;
}

View File

@@ -0,0 +1,35 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { AuthService } from '../../auth.service';
import { LoginComponent } from './login.component';
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
const mockAuthService = jasmine.createSpyObj('AuthService', [''])
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ LoginComponent ],
providers: [
{ provide: AuthService, useValue: mockAuthService }
],
imports: [ MatCardModule, FormsModule, MatFormFieldModule, ReactiveFormsModule, MatInputModule, MatButtonModule, MatSnackBarModule, NoopAnimationsModule ]
})
.compileComponents();
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,42 @@
import { Component } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { AuthService } from '../../auth.service';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent {
loginFailed = false;
constructor(private authService: AuthService, private router: Router, private snackBar: MatSnackBar) {
}
loginForm = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [Validators.required])
});
async login() {
const email = this.loginForm.controls.email.value + '';
const password = this.loginForm.controls.password.value + ''
this.loginFailed = false;
const success = await this.authService.login(email, password);
if (success) {
this.router.navigate(['/dashboard']).then();
} else {
this.loginFailed = true;
this.snackBar.open('E-Mail oder Passwort ist falsch', undefined, {
duration: 5000,
panelClass: 'snackbar_error'
});
}
}
}

View File

@@ -0,0 +1,25 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LoginComponent } from './login.component';
import { LoginRoutingModule } from './login-routing.module';
import { MatCardModule } from '@angular/material/card';
import {MatButtonModule} from '@angular/material/button';
import {MatFormFieldModule} from '@angular/material/form-field';
import { ReactiveFormsModule } from '@angular/forms';
import {MatInputModule} from '@angular/material/input';
@NgModule({
declarations: [
LoginComponent
],
imports: [
CommonModule,
LoginRoutingModule,
MatCardModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
]
})
export class LoginModule { }

View File

@@ -0,0 +1,45 @@
<mat-card>
<mat-card-header>
<mat-card-title>Neues Passwort vergeben</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-container *ngIf="!success && hash !== null">
<form [formGroup]="form" (keyup.enter)="submit()">
<mat-form-field appearance="outline">
<mat-label>Neues Passwort</mat-label>
<input matInput type="password" autocomplete="new-password" formControlName="password" required>
<mat-hint>Mindestens 6 Zeichen</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Passwort wiederholen</mat-label>
<input matInput type="password" autocomplete="new-password" formControlName="confirmPassword" required>
</mat-form-field>
<p *ngIf="form.errors?.['passwordMismatch'] && form.controls.confirmPassword.dirty" class="password-error">
Die Passwörter stimmen nicht überein.
</p>
</form>
<p *ngIf="error" class="password-error">
Der Link ist ungültig oder abgelaufen. Bitte fordere über "Passwort vergessen" einen neuen Link an.
</p>
</ng-container>
<ng-container *ngIf="success">
<p class="mat-body">Dein Passwort wurde geändert. Du wirst gleich zum Login weitergeleitet.</p>
</ng-container>
<ng-container *ngIf="hash === null">
<p class="mat-body">Dieser Link ist unvollständig. Bitte fordere über "Passwort vergessen" einen neuen Link an.</p>
</ng-container>
</mat-card-content>
<mat-card-actions align="end">
<a mat-button routerLink="/auth">Zurück zum Login</a>
<button *ngIf="!success && hash !== null" mat-button color="primary" [disabled]="form.invalid" (click)="submit()">Passwort speichern</button>
</mat-card-actions>
</mat-card>

View File

@@ -0,0 +1,26 @@
:host {
display: flex;
justify-content: center;
align-items: center;
width: 100vw;
height: 100%;
}
mat-card {
width: 500px;
max-width: 90vw;
}
mat-form-field {
width: 100%;
}
mat-card-header {
margin-bottom: 16px;
}
.password-error {
color: #b3261e;
margin: 8px 0 0;
font-size: 0.9rem;
}

View File

@@ -0,0 +1,65 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthService } from '../../auth.service';
function passwordsMatch(control: AbstractControl): ValidationErrors | null {
const password = control.get('password')?.value;
const confirmPassword = control.get('confirmPassword')?.value;
return password && confirmPassword && password !== confirmPassword ? { passwordMismatch: true } : null;
}
@Component({
selector: 'app-password-change',
templateUrl: './password-change.component.html',
styleUrls: ['./password-change.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
RouterModule
]
})
export class PasswordChangeComponent implements OnInit {
hash: string | null = null;
success = false;
error = false;
form = new FormGroup({
password: new FormControl('', [Validators.required, Validators.minLength(6)]),
confirmPassword: new FormControl('', [Validators.required]),
}, { validators: passwordsMatch });
constructor(private route: ActivatedRoute, private router: Router, private authService: AuthService) {}
ngOnInit(): void {
this.hash = this.route.snapshot.paramMap.get('hash');
if (!this.hash) {
this.error = true;
}
}
submit() {
if (this.form.invalid || !this.hash) { return; }
this.error = false;
this.authService.resetPassword(this.hash, this.form.controls.password.value + '').subscribe({
next: () => {
this.success = true;
setTimeout(() => this.router.navigateByUrl('/auth'), 3000);
},
error: () => { this.error = true; }
});
}
}

View File

@@ -0,0 +1,9 @@
<mat-card-header>
<mat-card-title>Registrieren nicht möglich</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="mat-body">
Es ist kein gültiger Registrierungstoken vorhanden. Das Registrieren ist derzeit nur über einen Link mit gültigem Token möglich.
</div>
</mat-card-content>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoTokenProvidedComponent } from './no-token-provided.component';
describe('NoTokenProvidedComponent', () => {
let component: NoTokenProvidedComponent;
let fixture: ComponentFixture<NoTokenProvidedComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ NoTokenProvidedComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(NoTokenProvidedComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-no-token-provided',
templateUrl: './no-token-provided.component.html',
styleUrls: ['./no-token-provided.component.scss']
})
export class NoTokenProvidedComponent {
}

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegisterBaseComponent } from './register-base.component';
describe('RegisterBaseComponent', () => {
let component: RegisterBaseComponent;
let fixture: ComponentFixture<RegisterBaseComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ RegisterBaseComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(RegisterBaseComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-register-base',
templateUrl: './register-base.component.html',
styleUrls: ['./register-base.component.scss']
})
export class RegisterBaseComponent {
}

View File

@@ -0,0 +1,30 @@
<form [formGroup]="registerForm" (keyup.enter)="register()">
<div class="splitted_form">
<mat-form-field appearance="outline" class="first">
<mat-label>Vorname</mat-label>
<input matInput autocomplete="current-password" type="text" placeholder="Max" formControlName="firstName" required>
</mat-form-field>
<mat-form-field appearance="outline" class="last">
<mat-label>Nachname</mat-label>
<input matInput autocomplete="current-password" type="text" placeholder="Mustermann" formControlName="lastName" required>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput placeholder="pat@example.com" formControlName="email" required>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input matInput autocomplete="current-password" type="password" formControlName="password" required>
<mat-hint>Mindestens 6 Zeichen</mat-hint>
</mat-form-field>
</div>
</form>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegisterFormComponent } from './register-form.component';
describe('RegisterFormComponent', () => {
let component: RegisterFormComponent;
let fixture: ComponentFixture<RegisterFormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ RegisterFormComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(RegisterFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,37 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { TeamInvite } from 'src/app/modules/teams/model/team-invite';
@Component({
selector: 'app-register-form',
templateUrl: './register-form.component.html',
styleUrls: ['./register-form.component.scss']
})
export class RegisterFormComponent implements OnInit {
@Input('teamInfo') teamInfo: TeamInvite | undefined;
registerForm = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [Validators.required, Validators.minLength(6)]),
lastName: new FormControl('', [Validators.required]),
firstName: new FormControl('', [Validators.required]),
});
ngOnInit(): void {
if (this.teamInfo) {
let names = this.teamInfo.playerName.split(' ');
if (names && names.length == 2) {
this.registerForm.controls.firstName.patchValue(names[0])
this.registerForm.controls.lastName.patchValue(names[1])
}
}
}
register() {
}
}

View File

@@ -0,0 +1,27 @@
<mat-card-header>
<mat-card-title>Registrieren</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-container *ngIf="teamInfo">
<div class="mat-body">Hallo <span class="info">{{ teamInfo.playerName }}</span>,</div>
<span>
du wurdes eingeladen dich für das Team <span class="info">{{ teamInfo.teamName }}</span> zu registrieren.
</span>
</ng-container>
<div *ngIf="teamInfo" class="register_form">
<app-register-form [teamInfo]="teamInfo" #formComponent></app-register-form>
</div>
<div class="error" *ngIf="error">
Registrierung fehlgeschlagen. Bitte wende dich an deinen Ansprechpartner.
</div>
</mat-card-content>
<mat-card-actions align="end">
<button mat-button color="primary" [disabled]="registerFormInvalid" (click)="register()">Registrieren</button>
</mat-card-actions>

View File

@@ -0,0 +1,12 @@
.info {
font-weight: bold;
}
.register_form {
margin-top: 24px;
}
.error {
margin-top: 24px;
color: red;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegisterComponent } from './register.component';
describe('RegisterComponent', () => {
let component: RegisterComponent;
let fixture: ComponentFixture<RegisterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ RegisterComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(RegisterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,85 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from 'src/app/modules/auth.service';
import { TeamsService } from 'src/app/modules/teams/teams.service';
import { RegisterFormComponent } from './register-form/register-form.component';
@Component({
selector: 'app-register',
templateUrl: './register.component.html',
styleUrls: ['./register.component.scss']
})
export class RegisterComponent implements OnInit {
private token: string | null= null;
public teamInfo: { playerId: number, playerName: string, teamId: number, teamName: string} | undefined;
error: boolean = false;
constructor(private route: ActivatedRoute, private router: Router, private teamsService: TeamsService, private authService: AuthService, private snackBar: MatSnackBar) {}
@ViewChild('formComponent') formComponent!: RegisterFormComponent;
ngOnInit(): void {
this.validateToken(this.route.snapshot.paramMap.get('token'));
}
private async validateToken(token: string | null) {
if (!token) {
return this.onNoTokenProvided();
} else {
this.teamsService.validateRegistrationToken(token).subscribe({
next: data => { this.teamInfo = data; },
error: () => { return this.onNoTokenProvided(); }
});
}
this.token = token;
}
private onNoTokenProvided() {
this.router.navigateByUrl('auth/register/no-token');
}
get registerForm() {
return this.formComponent?.registerForm;
}
get registerFormInvalid(): boolean {
return !this.registerForm || this.registerForm.invalid;
}
register() {
const data = this.registerForm.value as any;
data.linkPlayerId = this.teamInfo?.playerId;
this.authService.register(data).subscribe({
next: () => { this.onPlayerRegistrationSuccess() },
error: () => { this.onPlayerRegistrationFailure() }
})
}
onPlayerRegistrationSuccess() {
this.error = false;
this.snackBar.open('Erfolgreich registriert', undefined, {
duration: 5000
});
this.router.navigateByUrl('/auth')
}
onPlayerRegistrationFailure() {
this.snackBar.open('Registrierung nicht möglich', undefined, {
duration: 5000,
panelClass: 'snackbar_error'
});
this.registerForm.reset()
}
}

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { NoTokenProvidedComponent } from './components/no-token-provided/no-token-provided.component';
import { RegisterBaseComponent } from './components/register-base/register-base.component';
import { RegisterComponent } from './components/register/register.component';
const routes: Routes = [
{ path: '', component: RegisterBaseComponent, children: [
{
path: 'no-token',
component: NoTokenProvidedComponent
},
{
path: ':token',
component: RegisterComponent
}
] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class RegisterRoutingModule { }

View File

@@ -0,0 +1,35 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RegisterRoutingModule } from './register-routing.module';
import { NoTokenProvidedComponent } from './components/no-token-provided/no-token-provided.component';
import { RegisterBaseComponent } from './components/register-base/register-base.component';
import { MatCardModule } from '@angular/material/card';
import { RegisterComponent } from './components/register/register.component';
import { RegisterFormComponent } from './components/register/register-form/register-form.component';
import { ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar';
@NgModule({
declarations: [
NoTokenProvidedComponent,
RegisterBaseComponent,
RegisterComponent,
RegisterFormComponent
],
imports: [
CommonModule,
RegisterRoutingModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
MatButtonModule,
MatSnackBarModule
]
})
export class RegisterModule { }

View File

@@ -0,0 +1,34 @@
<div class="card mat-elevation-z2">
Welcome {{ name }}, your Role is {{ role }}
</div>
<div class="card mat-elevation-z2">
<div class="mat-h2">Deine Teams</div>
<div class="mat-body">Klicke auf eins der Teams um es zu verwalten</div>
<div *ngFor="let p of players" class="flex-row team_row">
<div class="team">
<div>
<span>{{ p.team.name }}</span>
</div>
<div>Balance: {{ p.team.balance | currency:'EUR' }}</div>
<div>
{{ p.firstName }} {{ p.lastName }}
</div>
<div>
Rolle: <span>{{ p.teamRole.name | translate }}</span>
</div>
<button (click)="copyLink(p)" mat-stroked-button >Öffentlichen Link kopieren</button>
</div>
<div class="icons">
<button mat-icon-button (click)="onTeamClick(p.team)" aria-label="Team-Details öffnen">
<mat-icon>speed</mat-icon>
</button>
<button mat-icon-button (click)="onTeamClick(p.team)" aria-label="Team bearbeiten">
<mat-icon>edit</mat-icon>
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,38 @@
.icons {
display: flex;
align-items: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease-in-out;
}
.team_row {
padding: 2px 8px;
border: 1px solid #ccc;
border-radius: 4px;
align-items: stretch;
&:hover > .icons {
opacity: 1;
pointer-events: all;
}
&:not(:first-of-type) {
margin-top: 8px;
}
}
@media screen and (max-width: 700px) {
.icons {
opacity: 1;
pointer-events: all;
align-items: stretch;
button {
height: inherit;
width: 48px;
}
}
}

View File

@@ -0,0 +1,25 @@
import { HttpClientModule } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardComponent } from './dashboard.component';
describe('DashboardComponent', () => {
let component: DashboardComponent;
let fixture: ComponentFixture<DashboardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DashboardComponent ],
imports: [ HttpClientModule ]
})
.compileComponents();
fixture = TestBed.createComponent(DashboardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,60 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { UserService } from 'src/app/core/user/user.service';
import { AuthService } from '../../auth.service';
import { CommonModule } from '@angular/common';
import { Player } from 'src/app/model';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
standalone: true,
imports: [ CommonModule, TranslateModule, MatButtonModule, MatIconModule ]
})
export class DashboardComponent {
link: string = window.location.host;
constructor(
private authService: AuthService
, private userService: UserService
, private router: Router
, public translate: TranslateService) {}
get role(): string {
if (!this.authService || !this.authService.isLoggedIn()) { return ''}
return this.authService.user.role.name;
}
get name(): string {
if (!this.authService || !this.authService.isLoggedIn()) { return ''}
return this.authService.user.firstName + ' ' + this.authService.user.lastName;
}
get players(): any[] {
return this.userService.players;
}
onTeamClick(team: any) {
this.router.navigate([`dashboard/${team.alias}/details`])
}
onLinkClick(event: any) {
event.stopPropagation();
return;
}
async copyLink(p: Player) {
const link = `https://${this.link}/teams/${p.team.alias}`
await navigator.clipboard.writeText(link);
}
}

View File

@@ -0,0 +1,7 @@
import { Routes } from '@angular/router';
export const dashboardRoutes: Routes = [
{ path: '', loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent) },
{ path: ':id/details', loadComponent: () => import('./team-details/team-details.component').then(m => m.TeamDetailsComponent) }
];

View File

@@ -0,0 +1,11 @@
<div class="player_icon" [class.line_through]="!player.active" [class.inactive]="!player.active">
{{ playerInitials }}
</div>
<div class="content" [class.inactive]="!player.active">
<div class="name" [class.line_through]="!player.active">{{ player.firstName}} {{ player.lastName }} </div>
<div class="role"> {{ player.teamRole.name | translate }}</div>
<div class="balance"> {{ 'balance' | translate }}: {{ player.balance | currency:'EUR' }}</div>
</div>

View File

@@ -0,0 +1,31 @@
:host {
display: flex;
flex: 250px 1 1;
cursor: pointer;
}
.player_icon {
width: 48px;
height: 48px;
line-height: 48px;
text-align: center;
font-size: 20px;
border-radius: 50%;
border: 1px solid #ccc
}
.content{
margin-left: 12px;
}
.name {
font-weight: bold;
}
.line_through{
text-decoration: line-through;
}
.inactive {
opacity: 0.5;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PlayerCardComponent } from './player-card.component';
describe('PlayerCardComponent', () => {
let component: PlayerCardComponent;
let fixture: ComponentFixture<PlayerCardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PlayerCardComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(PlayerCardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,27 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit, Output } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { Player } from 'src/app/model';
@Component({
selector: 'app-player-card',
templateUrl: './player-card.component.html',
styleUrls: ['./player-card.component.scss'],
standalone: true,
imports: [CommonModule, TranslateModule]
})
export class PlayerCardComponent implements OnInit {
@Input() player!: Player;
ngOnInit(): void {}
get playerInitials(): string {
if (!this.player) { return ' '; }
return this.player.firstName.substring(0, 1) + this.player.lastName.substring(0, 1);
}
}

View File

@@ -0,0 +1,9 @@
<h1 style="text-align: center;">{{ player.firstName }} {{ player.lastName }}</h1>
<div class="body">
<div>
<mat-slide-toggle [checked]="player.active" (change)="setPlayerActive($event)" [disabled]="!isAdmin" >Spieler aktiv</mat-slide-toggle>
</div>
</div>

View File

@@ -0,0 +1,10 @@
:host {
display: flex;
overflow: hidden;
flex-direction: column;
padding: 8px 14px 8px 14px;
}
.body {
padding: 6px 12px 24px 6px;
}

View File

@@ -0,0 +1,59 @@
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, EventEmitter, inject, Inject, Output } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Player } from '../../../../../../model';
import { AuthService } from 'src/app/modules/auth.service';
import { environment } from 'src/environments/environment';
@Component({
selector: 'app-player-details',
templateUrl: './player-details.component.html',
styleUrls: ['./player-details.component.scss'],
standalone: true,
imports: [CommonModule, MatSlideToggleModule]
})
export class PlayerDetailsComponent {
player: Player;
loading: boolean = false;
private auth: AuthService = inject(AuthService);
@Output('onReload') changed = new EventEmitter();
constructor(@Inject(MAT_DIALOG_DATA) public data: DialogData, private http: HttpClient, private snackBar: MatSnackBar) {
this.player = data.player;
}
get isAdmin() {
return this.auth.isAdmin;
}
setPlayerActive(event: any) {
this.loading = true;
const copy = { ...this.player }
this.player.active = event.checked;
this.http.put<Player>(`${environment.apiUrl}teams/${this.player.team.id}/players`, this.player).subscribe({
next: r => {
this.changed.emit();
},
error: e => {
this.player.active = copy.active;
this.snackBar.open('Spieler konnte nicht aktualisiert werden', undefined, {
duration: 5000,
panelClass: 'snackbar_error'
})
},
complete: () => { this.loading = false; }
})
}
}
interface DialogData {
player: Player;
}

View File

@@ -0,0 +1,92 @@
<div class="card" *ngIf="team">
<div class="title-row">
<span class="name" id="teamName">{{ team.name }} ({{ translateRolename(roleName) }})</span>
<div>Spieleranzahl: {{ team.players?.length }} (aktiv: {{ activePlayerCount }}) </div>
</div>
<div class="flex-row">
<div>Saldo: {{ team.balance | currency:'EUR' }}</div>
<div>Ausstehend: {{ team.outstanding | currency:'EUR' }}
</div>
</div>
<mat-divider style="margin-top: 4px"></mat-divider>
<div class="flex-row" style="margin-top: 4px;">
<button mat-icon-button color="primary" aria-label="Back" (click)="back()" matTooltip="Zurück">
<mat-icon>arrow_back</mat-icon>
</button>
<button mat-button mat-raised-button color="primary" (click)="openTeamTransactionDialog()" [disabled]="isLoading || !canDoTransactions">Buchung</button>
<button mat-button mat-stroked-button color="accent" (click)="onAllClick()" [disabled]="isLoading || !canDoTransactions" matTooltip="Buchung für alle Spieler anlegen">Umlage</button>
<button mat-icon-button color="primary" matTooltip="Verwaltung" [matMenuTriggerFor]="menu" [disabled]="isLoading">
<mat-icon>menu</mat-icon>
</button>
</div>
</div>
<mat-tab-group>
<mat-tab label="Buchungen">
<ng-container *ngTemplateOutlet="playerList"></ng-container>
</mat-tab>s
<mat-tab label="Bearbeiten">
<div class="mat-tab__content flex-row break" style="overflow: auto;">
<app-player-card *ngFor="let player of team?.players" class="card" [player]="player"
matRipple (click)="openPlayerdetails(player)" (onReload)="loadTeamDetails()" ></app-player-card>
</div>
</mat-tab>
</mat-tab-group>
<ng-template #playerList>
<div class="mat-tab__content">
<div class="flex-row">
<div></div>
<div class="select_all" (click)="selectAll()">Alle</div>
</div>
<div class="card list">
<mat-form-field style="width: 100%;" (keyup)="filter($event)">
<mat-label>Suche</mat-label>
<input type="text" matInput >
</mat-form-field>
<mat-selection-list #players id="playerlist" [(ngModel)]="selectedPlayers">
<ng-container *ngFor="let p of team?.players" >
<mat-list-option [value]="p" *ngIf="!p.hide && p.active">
<div matListItemTitle [class.highlight]="p.usersPlayer">{{ p.firstName}} {{ p.lastName }} <span *ngIf="p.usersPlayer">- Ich</span> </div>
<div matListItemLine>{{ p.balance | currency:'EUR'}}</div>
</mat-list-option>
</ng-container>
</mat-selection-list>
</div>
</div>
</ng-template>
<div class="card">
<div class="flex-row" style="height: 48px; overflow: hidden; justify-content: flex-end;" *ngIf="isLoading">
<mat-spinner diameter="38"></mat-spinner>
</div>
<div class="flex-row" *ngIf="!isLoading">
<span *ngIf="players && players.selectedOptions.selected.length > 0">{{players.selectedOptions.selected.length}} Spieler gewählt</span>
<span *ngIf="players && players.selectedOptions.selected.length == 0">Spieler für einen Eintrag wählen</span>
<button mat-button mat-raised-button color="primary" (click)="openDialog()"
[disabled]="players.selectedOptions.selected.length == 0 || !canDoTransactions" id="buttonNewTransaction"
matTooltip="Transaktionen für die gewählten Spieler anlegen">Buchung anlegen</button>
</div>
</div>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="onAddPlayerClick()" [disabled]="!canInvite">Spieler hinzufügen</button>
<button mat-menu-item (click)="onCreateLink()" [disabled]="selectedPlayers.length != 1 || !canInvite">Registrierungslink erstellen</button>
<button mat-menu-item (click)="onShowPrivileges()">Berechtigungen</button>
<button mat-menu-item (click)="onShowTeamTransactions()">Transaktionshistorie</button>
</mat-menu>

View File

@@ -0,0 +1,59 @@
.title-row {
display: flex;
justify-content: space-between;
}
:host {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1 1 auto;
}
.list{
flex: 1 1 100%;
overflow: auto;
}
.back {
width: 28px;
height: 28px;
background-color: red;
cursor: pointer;
}
.select_all {
margin-right: 52px;
text-decoration: underline;
cursor: pointer;
color: #646464;
&:hover {
color: black;
}
}
.buttons {
margin-top: 6px;
}
.highlight {
// text-decoration: wavy;
font-style: italic;
}
mat-tab-group {
flex: 1 1 100%;
height: 257px;
}
.mat-tab__content {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.break {
flex-wrap: wrap;
}

View File

@@ -0,0 +1,93 @@
import { HttpClient } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatListModule } from '@angular/material/list';
import { By } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { TeamDetailsComponent } from './team-details.component';
describe('TeamDetailsComponent', () => {
let component: TeamDetailsComponent;
let fixture: ComponentFixture<TeamDetailsComponent>;
const paramMap = jasmine.createSpyObj('ParamMap', ['get'])
const fakeActivatedRoute = {
snapshot: { paramMap: paramMap }
}
paramMap.get.and.returnValue('9999');
const mockhttp = jasmine.createSpyObj('HttpClient', ['get']);
mockhttp.get.and.returnValue(of({
alias: "9999",
balance: 48.09,
name: "Development Team",
outstanding: -582.00,
players: [{id: 1, firstName: 'Filiberto', lastName: 'Spencer', balance: -32.97}, {id: 2, firstName: 'Adam', lastName: 'West', balance: -50}],
settings: []
}))
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TeamDetailsComponent ],
providers: [
{provide: ActivatedRoute, useValue: fakeActivatedRoute},
{provide: HttpClient, useValue: mockhttp},
],
imports: [ MatListModule ]
})
.compileComponents();
fixture = TestBed.createComponent(TeamDetailsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should extract the id from the url', () => {
expect(component.id).toEqual("9999");
});
it('should load the teams data', () => {
expect(component.team).not.toBeNull();
});
it('should display name and alias', () => {
const nameElement = fixture.debugElement.query(By.css('#teamName'));
const text = (nameElement.nativeElement as HTMLElement).textContent;
expect(text).toEqual(`${component.team.name} (${component.team.alias})`)
// const htmlList = (list.nativeElement as HTMLElement).children;
// expect(htmlList).not.toBeNull();
// expect(htmlList.length).toBe(4)
});
it('should display a list with players', () => {
const element = fixture.debugElement.query(By.css('#playerlist'));
const ch = (element.nativeElement as HTMLElement).children;
expect(ch.length).toBe(component.team.players.length);
for (let index = 0; index < component.team.players.length; index++) {
const playerName = ch[index].textContent?.trim();
const expectedName = `${component.team.players[index].firstName} ${component.team.players[index].lastName}`;
expect(playerName).toEqual(expectedName)
}
});
it('button should be disabled', () => {
const element = fixture.debugElement.query(By.css('#buttonNewTransaction'));
expect(element.nativeElement.disabled).toBeTrue();
});
it('should enable the button when selecting a player', () => {
const element = fixture.debugElement.query(By.css('.mdc-checkbox'));
element.nativeElement.click();
fixture.detectChanges();
const buttonelement = fixture.debugElement.query(By.css('#buttonNewTransaction'));
expect(buttonelement.nativeElement.disabled).toBeFalse();
})
});

View File

@@ -0,0 +1,303 @@
import { Component, createNgModuleRef, Injector, OnInit, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { Player } from 'src/app/model';
import { AuthService } from 'src/app/modules/auth.service';
import { Team } from 'src/app/modules/teams/model/team';
import { TeamInvite } from 'src/app/modules/teams/model/team-invite';
import { TeamsService } from 'src/app/modules/teams/teams.service';
import { NewTransactionDialogComponent, TeamTransactionDialogComponent } from 'src/app/shared';
import { CreatePlayerDialogComponent } from 'src/app/shared/dialog/create-player-dialog/create-player.dialog.component';
import { PrivilegesInfoDialogComponent } from 'src/app/shared/dialog/privileges-info-dialog/privileges-info-dialog.component';
import { PlayerDetailsComponent } from './player-card/player-details/player-details.component';
import { CommonModule } from '@angular/common';
import { MatDividerModule } from '@angular/material/divider';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTabsModule } from '@angular/material/tabs';
import { PlayerCardComponent } from './player-card/player-card.component';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatListModule } from '@angular/material/list';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatInputModule } from '@angular/material/input';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@Component({
selector: 'app-team-details',
templateUrl: './team-details.component.html',
styleUrls: ['./team-details.component.scss'],
standalone: true,
imports: [
CommonModule
, ReactiveFormsModule
, FormsModule
, MatProgressSpinnerModule
, MatDividerModule
, MatButtonModule
, MatIconModule
, MatMenuModule
, MatTabsModule
, MatFormFieldModule
, MatListModule
, MatSlideToggleModule
, MatInputModule
, PlayerCardComponent
]
})
export class TeamDetailsComponent implements OnInit {
id: string | null = null;
team: Team | undefined;
correspondingPlayer: Player | undefined;
canDoTransactions: boolean = false;
canInvite: boolean = false;
roleName: string = '';
isLoading: boolean = true;
selectedPlayers: Player[] = [];
usersPlayers: Player[] = [];
@ViewChild('players') players: any;
constructor(private route: ActivatedRoute, private teamsService: TeamsService, private authService: AuthService,
private router: Router, public dialog: MatDialog, private injector: Injector, private _snackBar: MatSnackBar) {}
ngOnInit(): void {
this.id = this.route.snapshot.paramMap.get('id');
if (this.id) {
this.loadTeamDetails();
}
}
protected loadTeamDetails() {
if (!this.id) { return; }
this.teamsService.loadTeamDetails(this.id).subscribe(result => {
this.team = result;
this.findCorrespondingPlayer();
this.isLoading = false;
})
}
back() {
this.router.navigate(['../dashboard'])
}
openDialog(): void {
const dialogRef = this.dialog.open(NewTransactionDialogComponent, {
data: {
team: this.team,
players: this.selectedPlayers
}
});
dialogRef.afterClosed().subscribe(async result => {
if (result && result.length > 0) {
this.isLoading = true;
this.teamsService.createTransactions(result).subscribe(res => {
if (res && res.length > 0) {
this.loadTeamDetails();
}
})
}
});
}
openTeamTransactionDialog() {
if (!this.team) { return; }
const dialogRef = this.dialog.open(TeamTransactionDialogComponent, {
data: {
teamId: this.team.id
}
});
dialogRef.afterClosed().subscribe(async result => {
if (result) { this.saveTeamTransaction(result); }
});
}
saveTeamTransaction(transaction: any) {
this.isLoading = true;
this.teamsService.createTeamTransaction(transaction).subscribe(res => {
if (res && res['id']) {
this.loadTeamDetails();
}
})
}
selectAll() {
if (this.team == null || this.team.players == null) { return; }
if (this.selectedPlayers.length < this.team.players.length) {
this.selectedPlayers = [];
for (let p of this.team.players) {
this.selectedPlayers.push(p);
}
} else {
this.selectedPlayers = [];
}
}
onAllClick() {
if (!this.team) { return; }
const dialogRef = this.dialog.open(NewTransactionDialogComponent, {
data: {
team: this.team,
players: this.selectedPlayers,
all: this.team.players?.length == this.selectedPlayers.length
}
});
}
onAddPlayerClick() {
if (!this.team) { return; }
const dialogRef = this.dialog.open(CreatePlayerDialogComponent, {
data: { },
width: '350px'
});
dialogRef.afterClosed().subscribe(result => {
if (result && this.team) {
this.teamsService.createNewPlayer(this.team.id, result).subscribe({
next: () => { this.loadTeamDetails()},
error: () => { this.onPlayerCreateError();}
})
}
})
}
onPlayerCreateError() {
this._snackBar.open('Spieler konnte nicht erstellt werden', undefined, {
duration: 10000,
panelClass: 'snackbar_error'
})
}
async onCreateLink() {
if (!this.team) { return; }
try {
const invite: TeamInvite = {
teamId: this.team.id,
teamName: this.team.name,
playerId: this.selectedPlayers[0].id,
playerName: this.selectedPlayers[0].firstName + ' ' + this.selectedPlayers[0].lastName
}
const token = await this.getInviteFromServer(invite);
const link = location.origin + '/auth/register/' + token
await navigator.clipboard.writeText(link);
this._snackBar.open('Einladungslink für ' + invite.playerName + ' in die Zwischenablage kopiert. Der Link ist auf den gewählten Spieler personalisiert und darf nicht an mehrere Spieler verteilt werden.', undefined, {
duration: 10000,
});
} catch {
this._snackBar.open('Der Einladungslink konnte nicht erzeugt werden.', undefined, {
panelClass: 'snackbar_error',
duration: 5000,
});
}
}
private async getInviteFromServer(invite: TeamInvite): Promise<any> {
return new Promise<any>((resolve, reject) => {
this.teamsService.createRegisterLink(invite).subscribe({
next: token => { return resolve(token.token); },
error: () => { return reject(null); }
})
})
}
findCorrespondingPlayer() {
if (!this.team || !this.team.players) { return; }
const id = this.authService.user?.id;
this.usersPlayers = this.team.players.filter((p: any) => { return p.user?.id == id });
this.canInvite = this.authService.isAdmin || this.usersPlayers.find(p => p.teamRole.id > 2) != null;
this.canDoTransactions = this.authService.isAdmin || this.usersPlayers.find(p => p.teamRole.id >= 2) != null;
this.roleName = this.usersPlayers.reduce((prev, curr) => { return (prev.teamRole.id > curr.teamRole.id) ? prev : curr})?.teamRole.name;
for (let u of this.usersPlayers) {
u.usersPlayer = true;
}
}
onShowPrivileges() {
const dialogRef = this.dialog.open(PrivilegesInfoDialogComponent, {
data: null
});
}
translateRolename(roleName: string): string {
switch (roleName) {
case 'treasurer':
return 'Kassenwart'
case 'scnd_treasurer':
return 'zweiter Kassenwart'
case 'captain':
return 'Kapitän'
case 'coach':
return 'Trainer'
default:
return 'Spieler'
}
}
async onShowTeamTransactions() {
if (!this.team) { return; }
const { ShowTeamTransactionsComponent } = await import(
'src/app/shared/dialog/show-team-transactions/show-team-transactions.component'
);
this.dialog.open(ShowTeamTransactionsComponent, {
data: {
id: this.team.id
}
});
}
openPlayerdetails(player: Player) {
if (!player) { return; }
const dialogRef = this.dialog.open(PlayerDetailsComponent, {
data: {
player
}
});
dialogRef.componentInstance.changed.subscribe(() => {
this.loadTeamDetails();
})
}
filter(event: any) {
const search = event.target.value;
const s = search.toLowerCase().trim();
this.team?.players?.map(p => {
const n = p.firstName.toLowerCase().trim() + '' + p.lastName.toLowerCase().trim();
p.hide = !n.includes(s);
});
}
get activePlayerCount(): number {
if (!this.team || !this.team.players) { return 0; }
return this.team.players.filter(p => p.active).length;
}
}

View File

@@ -0,0 +1,9 @@
<mat-toolbar color="accent" class="toolbar">
<span>{{ username }}</span>
<button mat-button (click)="logout()" *ngIf="isLoggedIn">Logout</button>
<button mat-button (click)="login()" *ngIf="!isLoggedIn">Login</button>
</mat-toolbar>
<div class="content_container">
<router-outlet></router-outlet>
</div>

View File

@@ -0,0 +1,18 @@
.content_container {
display: flex;
flex-direction: column;
padding: 8px;
flex: 1 1 auto;
overflow: hidden;
.card {
padding: 12px;
}
}
:host {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}

View File

@@ -0,0 +1,73 @@
import { Component, inject } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { UserService } from 'src/app/core/user/user.service';
import { AuthService } from '../auth.service';
import { CommonModule } from '@angular/common';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
standalone: true,
imports: [
CommonModule,
MatToolbarModule,
RouterModule,
MatButtonModule
]
})
export class HomeComponent {
private authService: AuthService = inject(AuthService);
private userService: UserService = inject(UserService);
private router: Router = inject(Router);
constructor(
) {}
ngOnInit(): void {
this.loginFromLocalStorage();
}
get username(): string {
if (!this.authService || !this.authService.isLoggedIn()) { return ''}
return this.authService.user.firstName + ' ' + this.authService.user.lastName
}
logout() {
this.authService.logout();
}
get isLoggedIn(): boolean {
return this.authService.isLoggedIn()
}
private async loginFromLocalStorage() {
const success = await this.authService.loginFromLocalStorage();
if (!success) {
this.router.navigate(['/auth']).then();
return;
}
if (this.authService && this.authService.isLoggedIn()) {
this.userService.loadTeamsOfUser();
}
if (this.router.url == '/') {
this.router.navigate(['/dashboard']).then();
}
}
async login() {
// this.router.navigate(['/auth']).then();
}
}

View File

@@ -0,0 +1,9 @@
import { AuthGuard } from 'src/app/core/guards/auth.guard';
import { Route } from '@angular/router';
import { dashboardRoutes } from './dashboard/dashboard.routes';
export const homeRoutes: Route[] = [
{ path: '', loadComponent: () => import('./home.component').then(m => m.HomeComponent), children: [
{ path: 'dashboard', canLoad: [AuthGuard], children: dashboardRoutes },
] },
];

View File

@@ -0,0 +1,6 @@
export interface TeamInvite {
teamId: number;
teamName: string;
playerId: number;
playerName: string;
}

View File

@@ -0,0 +1,11 @@
import { Player } from 'src/app/model';
export interface Team {
id: number;
alias: string;
balance: number;
name: string;
outstanding?: number;
players?: Player[];
settings?: any[];
}

View File

@@ -0,0 +1,48 @@
<!-- <div *ngFor="let transaction of transactions" class="row">
<div class="amount">{{transaction.amount | currency }}</div>
<div class="date"> {{ transaction.date | date }}</div>
<div class="date"> {{ transaction.createdAt | date }}</div>
<div class="date"> {{ transaction.note }}</div>
</div> -->
<div>
<div id="userBalance" class="balance" [ngClass]="{'pos': data.user.balance >= 0, 'neg': data.user.balance < 0}"><span style="font-weight: 300">{{ data.user.firstName }}:</span> {{ data.user.balance | currency:'EUR' }}</div>
</div>
@if(isLoading) {
<mat-spinner style="align-self: center; margin: 48px;"></mat-spinner>
} @else if (transactions.length > 0) {
<mat-list class="list" id="transactionsList">
<mat-list-item *ngFor="let transaction of transactions">
<span matListItemTitle>{{ transaction.date | date }}</span>
<span matListItemLine>
<span class="amount" [class.negative]="transaction.type.id > 9">
<ng-container *ngIf="transaction.type.id > 9">-</ng-container>
{{transaction.amount | currency:'EUR' }}
</span>
{{ transaction.type.name | translate }}
-
{{ transaction.note }}
</span>
</mat-list-item>
</mat-list>
} @else {
<div style="align-self: center; margin: 48px;">Es sind keine Einträge vorhanden</div>
}
<!-- <div>
<div class="actions">
<div>Für <span class="name">{{ data.user.firstName}}</span></div>
<div class="actions__buttons">
<button mat-flat-button color="primary" disabled>Beantragen</button>
<button mat-flat-button color="warn" disabled>Beanstanden</button>
</div>
</div>
</div> -->

View File

@@ -0,0 +1,61 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
min-height: 200px;
}
.row {
display: flex;
}
.list {
max-height: 500px;
overflow: auto;
}
.amount {
color: rgb(49, 119, 49);
}
.negative {
color: rgb(202, 62, 62);
}
.actions {
display: flex;
flex-direction: row;
justify-content: space-between;
padding-left: 8px;
align-items: center;
.name {
font-weight: bold;
}
&__buttons {
display: flex;
flex-direction: row;
padding-right: 2px;
button {
margin: 4px 2px;
}
}
}
.balance {
color: #fff;
padding: 12px;
text-align: center;
font-size: 1.5rem;
&.pos {
background-color: rgb(49, 119, 49);
}
&.neg {
background-color: rgb(202, 62, 62);
}
}

View File

@@ -0,0 +1,81 @@
import { HttpClientModule } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatListModule } from '@angular/material/list';
import { By } from '@angular/platform-browser';
import { TeamsService } from '../../teams.service';
import { DetailsComponent } from './details.component';
describe('DetailsComponent', () => {
let component: DetailsComponent;
let fixture: ComponentFixture<DetailsComponent>;
const mockTeamService = jasmine.createSpyObj('TeamsService', ['loadTransactionsOfUser']);
mockTeamService.selectedUserTransaction = [
{id: 1, note: 'dev1', createdAt: '', date: '', amount: 100, type: {id: 1}},
{id: 2, note: 'dev2', createdAt: '', date: '', amount: 100, type: {id: 1}},
{id: 3, note: 'dev3', createdAt: '', date: '', amount: 100, type: {id: 1}},
{id: 4, note: 'dev4', createdAt: '', date: '', amount: 100, type: {id: 1}},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DetailsComponent ],
imports: [ MatDialogModule, HttpClientModule, MatListModule ],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: {} },
{ provide: MatDialogRef, useValue: {} },
{ provide: TeamsService, useValue: mockTeamService }
]
})
.compileComponents();
fixture = TestBed.createComponent(DetailsComponent);
component = fixture.componentInstance;
component.data.user = {
id: 2,
balance: 46.64,
firstName: 'Rover',
lastName: 'Stiedeman',
user: null
}
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialise the user', () => {
expect(component.data.user).not.toBeNull();
})
it('should have a users transactions', () => {
expect(component.transactions).not.toBeNull();
expect(component.transactions.length).toBe(4)
})
it('should display the users balance', () => {
const balance = 46.64;
component.data.user.balance = balance;
const el = fixture.debugElement.query(By.css('#userBalance'));
expect(el).not.toBeNull();
expect(el.nativeElement.textContent.trim()).toEqual('Rover: €' + balance);
})
it('should display users transactions', () => {
const list = fixture.debugElement.query(By.css('#transactionsList'));
const htmlList = (list.nativeElement as HTMLElement).children;
expect(htmlList).not.toBeNull();
expect(htmlList.length).toBe(4)
})
});

View File

@@ -0,0 +1,38 @@
import { Component, Inject, OnInit } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { TeamsService } from '../../teams.service';
import {MatListModule} from '@angular/material/list';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import { CommonModule } from '@angular/common';
import { TranslateModule, TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'app-details',
templateUrl: './details.component.html',
styleUrls: ['./details.component.scss'],
standalone: true,
imports: [CommonModule, MatDialogModule, MatListModule, MatProgressSpinnerModule, TranslateModule],
providers: []
})
export class DetailsComponent implements OnInit {
constructor(
public dialogRef: MatDialogRef<DetailsComponent>,
@Inject(MAT_DIALOG_DATA) public data: { user: any },
private teamService: TeamsService
) {}
ngOnInit(): void {
console.log("Onmit")
this.teamService.loadTransactionsOfUser(this.data.user.id);
}
get transactions(): any[] {
return this.teamService.selectedUserTransaction;
}
get isLoading(): boolean {
return this.teamService.transactionsLoading;
}
}

View File

@@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { OverviewComponent } from './overview.component';
const routes: Routes = [
{
path: '', component: OverviewComponent, pathMatch: 'full'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class OverviewRoutingModule { }

View File

@@ -0,0 +1,72 @@
<mat-toolbar color="primary" class="toolbar">
<span>{{ teamsService.team?.name }}</span>
<button mat-button (click)="onLoginClick()">Login</button>
</mat-toolbar>
<div class="team-info mat-elevation-z2">
<ng-container *ngIf="teamsService && teamsService.team">
<div style="text-align: center;" matTooltip="Aktuell in der Kasse">
Kontostand: {{ teamsService.team.balance | currency:'EUR' }}
</div>
<div style="text-align: center;"
[matTooltip]="teamsService.team.outstanding > 0 ? 'Die Spieler schulden der Mannschaftskasse diesen Betrag '
: 'Die Mannschaftskasse schuldet den Spielern diesen Betrag'">
Ausstehend: {{ teamsService.team.outstanding | currency:'EUR' }}
</div>
<div style="text-align: center;" matTooltip="Theoretisch in der Kasse">
Total: {{ teamsService.team.outstanding + teamsService.team.balance | currency:'EUR' }}
</div>
</ng-container>
</div>
<div class="flex-row" style="margin: 12px 24px -24px 24px; align-items: flex-start; justify-content: center; gap: 24px">
<mat-form-field style="flex: 1 1 auto; " appearance="outline">
<mat-label>Suche</mat-label>
<input matInput (keyup)="applyFilter($event)" placeholder="Name" #input type="search" (search)="applyFilter($event)">
</mat-form-field>
<button mat-raised-button (click)="openPenalties()">
Strafenkatalog
</button>
</div>
<div class="table-container mat-elevation-z2">
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8" matSort (matSortChange)="announceSortChange($event)" matSortActive="balance" matSortDirection="asc">
<!--- Note that these columns can be defined in any order.
The actual rendered columns are set as a property on the row definition" -->
<!-- Position Column -->
<ng-container matColumnDef="firstName">
<th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by firstName"> Vorname </th>
<td mat-cell *matCellDef="let element"> {{element.firstName}} </td>
<td mat-footer-cell *matFooterCellDef> </td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="lastName">
<th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by lastName"> Nachname </th>
<td mat-cell *matCellDef="let element" [class.blurr]="false"> {{element.lastName}} </td>
<td mat-footer-cell *matFooterCellDef> Total </td>
</ng-container>
<!-- Weight Column -->
<ng-container matColumnDef="balance">
<th mat-header-cell *matHeaderCellDef class="align-end" mat-sort-header sortActionDescription="Sort by number"> Saldo </th>
<td mat-cell *matCellDef="let element" class="align-end"> {{element.balance | currency:'EUR' }} </td>
<td mat-footer-cell *matFooterCellDef> {{getTotalBalance() | currency:'EUR' }} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;" (click)="openDetails(row)" class="table-row"></tr>
<tr mat-footer-row *matFooterRowDef="displayedColumns; sticky: true"></tr>
</table>
</div>

View File

@@ -0,0 +1,41 @@
:host {
width: 100vw;
height: 100%;
}
.table-container {
position: relative;
min-height: 200px;
max-height: calc(100% - 280px);
width: calc(100vw - 48px);
margin: 12px 24px;
overflow: auto;
}
.mat-mdc-table-sticky {
border-top: 1px solid #e0e0e0;
}
.align-end {
text-align: end;
width: 120px;
}
.team-info {
width: calc(100vw - 48px);
margin: 12px 24px;
height: 100px;
display: flex;
align-items: center;
justify-content: space-around;
}
.blurr {
color: transparent;
text-shadow: 1px 1px 5px #000000;
pointer-events: none;
}
.tr {
cursor: pointer;
}

View File

@@ -0,0 +1,40 @@
import { HttpClientModule } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTableModule } from '@angular/material/table';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TeamsService } from '../teams.service';
import { OverviewComponent } from './overview.component';
describe('OverviewComponent', () => {
let component: OverviewComponent;
let fixture: ComponentFixture<OverviewComponent>;
const mockTeamsService = jasmine.createSpyObj('TeamsService', ['']);
mockTeamsService.team = { players: []}
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ OverviewComponent ],
imports: [ HttpClientModule, MatDialogModule, MatToolbarModule, MatTableModule, MatTooltipModule ],
providers: [
{ provide: TeamsService, useValue: mockTeamsService }
]
})
.compileComponents();
fixture = TestBed.createComponent(OverviewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialise the datasource', () => {
expect(component.dataSource).not.toBeNull();
})
});
// this.teamsService.team.players

View File

@@ -0,0 +1,100 @@
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort, Sort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { AuthService } from '../../auth.service';
import { TeamsService } from '../teams.service';
import { DetailsComponent } from './details/details.component';
import { Player } from 'src/app/model';
import { PenaltiesComponent } from '../../../shared/penalties/penalties.component';
@Component({
selector: 'app-overview',
templateUrl: './overview.component.html',
styleUrls: ['./overview.component.scss']
})
export class OverviewComponent implements OnInit, AfterViewInit {
displayedColumns: string[] = ['firstName', 'lastName', 'balance'];
dataSource: any;
@ViewChild(MatSort) sort!: MatSort;
constructor(public teamsService: TeamsService
, private _liveAnnouncer: LiveAnnouncer
, public dialog: MatDialog, private authService: AuthService
, private router: Router) {}
ngAfterViewInit(): void {
this.dataSource.sort = this.sort;
}
ngOnInit(): void {
if (this.teamsService.team && this.teamsService.team.players) {
this.dataSource = new MatTableDataSource(this.teamsService.team.players.filter((p: Player) => p.active));
this.addCachedTeam();
}
}
addCachedTeam() {
let t = localStorage.getItem('cached_teams');
let teams: any = {};
if (t) {
teams = JSON.parse(t);
}
const key = this.teamsService.team.alias as any;
const name = this.teamsService.team.name as any;
teams[key] = name;
localStorage.setItem('cached_teams', JSON.stringify(teams));
}
getTotalBalance(): number {
if (!this.teamsService || !this.teamsService.team || !this.teamsService.team.players) { return 0 }
const sum = this.teamsService.team.players.filter((p: Player) => p.active).reduce((acc: any, succ: { balance: any; }) => acc + succ.balance, 0);
return sum;
}
/** Announce the change in sort state for assistive technology. */
announceSortChange(sortState: any) {
// This example uses English messages. If your application supports
// multiple language, you would internationalize these strings.
// Furthermore, you can customize the message to add additional
// details about the values being sorted.
if (sortState.direction) {
this._liveAnnouncer.announce(`Sorted ${sortState.direction}ending`);
} else {
this._liveAnnouncer.announce('Sorting cleared');
}
}
openDetails(row: any) {
this.dialog.open(DetailsComponent, {
width: '500px',
enterAnimationDuration: 500,
data: { user: row }
})
}
onLoginClick() {
if (this.authService.isLoggedIn()) { return; }
this.router.navigate(['auth'])
}
applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
this.dataSource.filter = filterValue.trim().toLowerCase();
}
openPenalties() {
this.dialog.open(PenaltiesComponent, {
data: this.teamsService.team.id,
height: '80vh'
})
}
}

View File

@@ -0,0 +1,43 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {MatToolbarModule} from '@angular/material/toolbar';
import {MatTableModule} from '@angular/material/table';
import {MatButtonModule} from '@angular/material/button';
import { MatSortModule } from '@angular/material/sort';
import {MatDialogModule} from '@angular/material/dialog';
import {MatListModule} from '@angular/material/list';
import {MatTooltipModule} from '@angular/material/tooltip';
import {MatInputModule} from '@angular/material/input';
import {MatFormFieldModule} from '@angular/material/form-field';
import { OverviewRoutingModule } from './overview-routing.module';
import { OverviewComponent } from './overview.component';
import { TeamsService } from '../teams.service';
import { DetailsComponent } from './details/details.component';
import { TranslateModule } from '@ngx-translate/core';
@NgModule({
declarations: [
OverviewComponent,
],
imports: [
CommonModule,
OverviewRoutingModule,
MatToolbarModule,
MatTableModule,
MatSortModule,
MatButtonModule,
MatDialogModule,
MatListModule,
MatTooltipModule,
TranslateModule,
MatInputModule,
MatFormFieldModule,
DetailsComponent
],
providers: [ TeamsService ],
exports: [ OverviewComponent ]
})
export class OverviewModule { }

View File

@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { OverviewComponent } from './overview/overview.component';
import { TeamsComponent } from './teams.component';
const routes: Routes = [
{
path: ':hash', component: TeamsComponent
},
{
path: 'overview', loadChildren: () => import('./overview/overview.module').then(m => m.OverviewModule)
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class TeamsRoutingModule { }

View File

@@ -0,0 +1,12 @@
<ng-container *ngIf="!teamsService || !teamsService.team">
<div class="loading-indicator">
<div>
<mat-spinner></mat-spinner>
<div class="loading-text">Loading Data...</div>
</div>
</div>
</ng-container>
<ng-container *ngIf="teamsService && teamsService.team">
<app-overview></app-overview>
</ng-container>

View File

@@ -0,0 +1,11 @@
.loading-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100%;
.loading-text {
margin-top: 24px;
}
}

View File

@@ -0,0 +1,56 @@
import { HttpClient } from '@angular/common/http';
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ActivatedRoute, Router } from '@angular/router';
import { of } from 'rxjs';
import { TeamsComponent } from './teams.component';
import { TeamsService } from './teams.service';
describe('TeamsComponent', () => {
let component: TeamsComponent;
let fixture: ComponentFixture<TeamsComponent>;
const mockRoute = jasmine.createSpyObj('ActivatedRoute', ['']);
const mockParam = jasmine.createSpyObj('P', ['get']);
mockParam.get.and.returnValue('abc');
mockRoute.snapshot = { paramMap: mockParam };
const mockTeamService = jasmine.createSpyObj('TeamsService', ['']);
const mockRouter = jasmine.createSpyObj('Router', ['']);
const mockHttp = jasmine.createSpyObj('HttpClient', ['get']);
mockHttp.get.and.returnValue(of({}))
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TeamsComponent, MockOverviewComponent ],
imports: [ MatProgressSpinnerModule ],
providers: [
{ provide: ActivatedRoute, useValue: mockRoute},
{ provide: Router, useValue: mockRouter},
{ provide: TeamsService, useValue: mockTeamService},
{ provide: HttpClient, useValue: mockHttp},
]
})
.compileComponents();
fixture = TestBed.createComponent(TeamsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load a team', () => {
expect(component.teamsService.team).not.toBeNull();
})
});
@Component({
selector: 'app-overview',
template: '',
})
class MockOverviewComponent {
}

View File

@@ -0,0 +1,41 @@
import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { environment } from 'src/environments/environment';
import { TeamsService } from './teams.service';
@Component({
selector: 'app-teams',
templateUrl: './teams.component.html',
styleUrls: ['./teams.component.scss']
})
export class TeamsComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private http: HttpClient,
private router: Router,
public teamsService: TeamsService) {}
ngOnInit() {
const hash = this.route.snapshot.paramMap.get('hash');
if (hash) {
this.loadTeamOverviewByAlias(hash);
}
}
private async loadTeamOverviewByAlias(alias: string) {
const url = `${environment.apiUrl}teams/` + alias;
this.http.get(url).subscribe(result => {
if (result) {
this.teamsService.team = result;
} else {
this.router.navigate(['/auth'])
}
}, error => {
this.router.navigate(['/auth'])
})
}
}

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import { TeamsRoutingModule } from './teams-routing.module';
import { TeamsComponent } from './teams.component';
import { TeamsService } from './teams.service';
import { OverviewModule } from './overview/overview.module';
@NgModule({
declarations: [
TeamsComponent
],
imports: [
CommonModule,
TeamsRoutingModule,
HttpClientModule,
OverviewModule,
MatProgressSpinnerModule
],
providers: [ TeamsService ]
})
export class TeamsModule { }

View File

@@ -0,0 +1,19 @@
import { HttpClientModule } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { TeamsService } from './teams.service';
describe('TeamsService', () => {
let service: TeamsService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ HttpClientModule ]
});
service = TestBed.inject(TeamsService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,61 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Transaction } from '../../model';
import { environment } from 'src/environments/environment';
import { TeamInvite } from './model/team-invite';
@Injectable({
providedIn: 'root'
})
export class TeamsService {
team: any;
selectedUserTransaction: any[];
transactionsLoading: boolean = false;
constructor(private http: HttpClient) {
this.selectedUserTransaction = [];
}
loadTransactionsOfUser(userId: number) {
this.transactionsLoading = true;
const url = `${environment.apiUrl}teams/${this.team.id}/${userId}`;
this.http.get(url).subscribe(res => {
this.selectedUserTransaction = res as any;
this.transactionsLoading = false;
})
}
loadTeamDetails(id: string): Observable<any> {
return this.http.get(`${environment.apiUrl}teams/${id}`);
}
createTransactions(transactions: any[]): Observable<any> {
return this.http.post(`${environment.apiUrl}transactions`, transactions);
}
reverseTransaction(transactionId: number): Observable<any> {
return this.http.post(`${environment.apiUrl}transactions/${transactionId}/reverse`, {});
}
createTeamTransaction(transaction: any): Observable<any> {
return this.http.post(`${environment.apiUrl}team-wallet-transactions`, transaction);
}
createNewPlayer(teamId: number, player: {firstName: string, lastName: string, teamRole: number}) {
return this.http.post(`${environment.apiUrl}teams/${teamId}/players`, player);
}
createRegisterLink(invite: TeamInvite): Observable<any> {
return this.http.post(`${environment.apiUrl}auth/invite`, invite);
}
validateRegistrationToken(token: string): Observable<any> {
return this.http.post(`${environment.apiUrl}auth/verify-invite`, { token });
}
loadTeamsTransactions(id: number): Observable<Transaction[]> {
return this.http.get<Transaction[]>(`${environment.apiUrl}teams/${id}/transactions`);
}
}

View File

@@ -0,0 +1,32 @@
<form [formGroup]="form">
<div class="form-field-container">
<mat-form-field appearance="outline" class="">
<mat-label>Vorname</mat-label>
<input matInput type="text" name="firstName" formControlName="firstName">
</mat-form-field>
</div>
<div class="form-field-container">
<mat-form-field appearance="outline" class="">
<mat-label>Nachname</mat-label>
<input matInput type="text" name="lastName" formControlName="lastName">
</mat-form-field>
</div>
<div class="form-field-container">
<mat-form-field appearance="outline" class="first">
<mat-label>Rolle</mat-label>
<mat-select name="teamRole" formControlName="teamRole">
<mat-option [value]="1">Spieler</mat-option>
<mat-option [value]="2">zweiter Kassenwart</mat-option>
<mat-option [value]="3">Kapitän</mat-option>
<mat-option [value]="4">Kassenwart</mat-option>
<mat-option [value]="5">Trainer</mat-option>
</mat-select>
</mat-form-field>
</div>
</form>

View File

@@ -0,0 +1,16 @@
:host {
display: block;
}
mat-form-field {
width: 100%;
}
.form-field-container {
margin-bottom: 12px;
}
.mat-body{
margin-bottom: 24px;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PlayerInfoFormComponent } from './player-info-form.component';
describe('PlayerInfoFormComponent', () => {
let component: PlayerInfoFormComponent;
let fixture: ComponentFixture<PlayerInfoFormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PlayerInfoFormComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(PlayerInfoFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,27 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormGroup, FormControl, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
@Component({
selector: 'app-player-info-form',
templateUrl: './player-info-form.component.html',
styleUrls: ['./player-info-form.component.scss'],
standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, MatOptionModule, MatSelectModule, MatInputModule]
})
export class PlayerInfoFormComponent {
disableSelect = new FormControl(true);
form = new FormGroup({
firstName: new FormControl('', [Validators.required]),
lastName: new FormControl(null, [Validators.required]),
teamRole: new FormControl({value: 1, disabled: false})
});
}

View File

@@ -0,0 +1,36 @@
<div class="mat-body" *ngIf="text">
{{ text }}
</div>
<form [formGroup]="form">
<div>
<mat-form-field appearance="outline" class="input_note">
<mat-label>Anmerkung</mat-label>
<input matInput type="text" name="note" formControlName="note">
<mat-hint>Optionale Bemerkung / Erklärung</mat-hint>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="first">
<mat-label>Art der Buchung</mat-label>
<mat-select name="type" formControlName="type" (selectionChange)="onTypeChange()">
<mat-option *ngFor="let t of transactionTypes" [value]="t">
{{ transactionType(t) }}
</mat-option>
</mat-select>
<mat-hint>{{ description }}</mat-hint>
</mat-form-field>
<mat-form-field class="input_amount second" appearance="outline">
<mat-label>Betrag</mat-label>
<input matInput type="number" inputmode="decimal" min="0.01" max="10000" step="0.01" name="amount" formControlName="amount">
<span matSuffix></span>
<mat-hint *ngIf="!form.controls.amount.errors">als positiver Betrag, z. B. 12,50</mat-hint>
<mat-error *ngIf="form.controls.amount.errors?.['max']">Bitte Betrag prüfen, maximal 10.000 €</mat-error>
<mat-error *ngIf="form.controls.amount.errors?.['min']">Betrag muss größer als 0 € sein</mat-error>
</mat-form-field>
<div style="display: flex; justify-content: flex-end; margin-top: 12px;" *ngIf="showTotalSlider">
<mat-slide-toggle formControlName="total">Betrag gleichmäßig aufteilen</mat-slide-toggle>
</div>
</form>

View File

@@ -0,0 +1,28 @@
:host {
display: flex;
flex-direction: column;
overflow: hidden;
}
.first {
margin-right: 2px;
}
.second {
margin-left: 2px;
}
.input_note {
width: 100%;
margin-bottom: 24px;
}
.mat-body{
margin-bottom: 24px;
}
@media screen and (max-width: 700px) {
mat-form-field {
width: 100%;
}
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TransactionFormComponent } from './transaction-form.component';
describe('TransactionFormComponent', () => {
let component: TransactionFormComponent;
let fixture: ComponentFixture<TransactionFormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TransactionFormComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(TransactionFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show More