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,20 @@
import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideServiceWorker } from '@angular/service-worker';
import { routes } from './app.routes';
import { authInterceptor } from './core/http/auth-interceptor';
import { errorInterceptor } from './core/http/error-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',
}),
],
};

View File

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

View File

@@ -0,0 +1,63 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { routes } from './app.routes';
import { AuthStore } from './core/auth/auth-store';
describe('app routing', () => {
let router: Router;
let authStore: AuthStore;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [provideRouter(routes), provideHttpClient(), provideHttpClientTesting()],
});
router = TestBed.inject(Router);
authStore = TestBed.inject(AuthStore);
});
it('redirects the root path to login when logged out', async () => {
await router.navigateByUrl('/');
expect(router.url).toBe('/auth/login');
});
it('redirects a protected team route to login when logged out', async () => {
await router.navigateByUrl('/team/1/overview');
expect(router.url).toBe('/auth/login');
});
it('redirects the root path to team-select when logged in', async () => {
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
await router.navigateByUrl('/');
expect(router.url).toBe('/team-select');
});
it('redirects the bare team route to its overview child', async () => {
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
await router.navigateByUrl('/team/1');
expect(router.url).toBe('/team/1/overview');
});
it('falls back to the not-found route for unknown paths', async () => {
await router.navigateByUrl('/does-not-exist');
expect(router.url).toBe('/does-not-exist');
});
it('allows public team routes without a session', async () => {
await router.navigateByUrl('/t/team-a');
expect(router.url).toBe('/t/team-a');
});
it('registers public access management below the protected team shell', () => {
const teamRoute = routes.find((route) => route.path === 'team/:id');
expect(teamRoute?.children?.some((route) => route.path === 'more/public-access')).toBe(true);
});
it('accepts the password reset URL generated by the backend', async () => {
expect(routes.some((route) => route.path === 'password-change/:hash')).toBe(true);
await router.navigateByUrl('/password-change/reset-hash');
expect(router.url).toBe('/password-change/reset-hash');
});
});

View File

@@ -0,0 +1,100 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/auth/auth-guard';
import { rootRedirectGuard } from './core/auth/root-redirect-guard';
import { Shell } from './core/layout/shell/shell';
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
canActivate: [rootRedirectGuard],
children: [],
},
{
path: 'auth/login',
loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
},
{
path: 'auth/register',
loadComponent: () => import('./features/auth/register/register').then((m) => m.Register),
},
{
path: 'auth/forgot-password',
loadComponent: () =>
import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword),
},
{
path: 'auth/reset-password/:hash',
loadComponent: () =>
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
},
{
path: 'password-change/:hash',
loadComponent: () =>
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
},
{
path: 'team-select',
canActivate: [authGuard],
loadComponent: () => import('./features/team-select/team-select').then((m) => m.TeamSelect),
},
{
path: 't/:token/:playerId',
loadComponent: () => import('./features/public-team/public-player').then((m) => m.PublicPlayer),
},
{
path: 't/:token',
loadComponent: () => import('./features/public-team/public-team').then((m) => m.PublicTeam),
},
{
path: 'team/:id',
canActivate: [authGuard],
component: Shell,
children: [
{ path: '', pathMatch: 'full', redirectTo: 'overview' },
{
path: 'overview',
loadComponent: () => import('./features/team/overview/overview').then((m) => m.Overview),
},
{
path: 'members',
loadComponent: () => import('./features/team/members/members').then((m) => m.Members),
},
{
path: 'members/:playerId',
loadComponent: () =>
import('./features/team/members/player-detail').then((m) => m.PlayerDetail),
},
{
path: 'cashbox',
loadComponent: () => import('./features/team/cashbox/cashbox').then((m) => m.Cashbox),
},
{
path: 'more',
loadComponent: () => import('./features/team/more/more').then((m) => m.More),
},
{
path: 'more/penalties',
loadComponent: () =>
import('./features/team/more/penalties/penalties').then((m) => m.Penalties),
},
{
path: 'more/invite',
loadComponent: () => import('./features/team/more/invite/invite').then((m) => m.Invite),
},
{
path: 'more/profile',
loadComponent: () => import('./features/team/more/profile/profile').then((m) => m.Profile),
},
{
path: 'more/public-access',
loadComponent: () =>
import('./features/team/more/public-access/public-access').then((m) => m.PublicAccess),
},
],
},
{
path: '**',
loadComponent: () => import('./core/layout/not-found/not-found').then((m) => m.NotFound),
},
];

View File

@@ -0,0 +1,76 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { App } from './app';
import { AuthApi } from './core/auth/auth-api';
import { AuthStore } from './core/auth/auth-store';
describe('App', () => {
const token = signal<string | null>(null);
const updateUser = vi.fn();
const meResponse = signal<Record<string, unknown>>({
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
});
const me = vi.fn(() => of(meResponse()));
const setSession = vi.fn();
beforeEach(async () => {
token.set(null);
meResponse.set({ id: 1, email: 'a@b.de', firstName: 'Alex', lastName: 'Muster' });
updateUser.mockClear();
setSession.mockClear();
me.mockClear();
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter([]),
{ provide: AuthStore, useValue: { token, updateUser, setSession } },
{ provide: AuthApi, useValue: { me } },
],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
expect(fixture.componentInstance).toBeTruthy();
});
it('validates and refreshes a restored session on startup', () => {
token.set('stored-token');
TestBed.createComponent(App);
expect(me).toHaveBeenCalled();
expect(updateUser).toHaveBeenCalledWith({
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
});
});
it('replaces an expired stored token when me returns a refreshed token', () => {
token.set('expired-token');
meResponse.set({
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
token: 'refreshed-token',
});
TestBed.createComponent(App);
expect(setSession).toHaveBeenCalledWith('refreshed-token', {
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
});
expect(updateUser).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { AuthApi } from './core/auth/auth-api';
import { AuthStore } from './core/auth/auth-store';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
private readonly authApi = inject(AuthApi);
private readonly authStore = inject(AuthStore);
constructor() {
if (this.authStore.token()) {
this.authApi.me().subscribe({
next: (response) => {
const { token, ...user } = response;
if (token) {
this.authStore.setSession(token, user);
} else {
this.authStore.updateUser(user);
}
},
error: () => undefined,
});
}
}
}

View File

@@ -0,0 +1,120 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { AuthApi } from './auth-api';
import { environment } from '../../../environments/environment';
import { User } from '../../models/user.model';
describe('AuthApi', () => {
let service: AuthApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(AuthApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('posts credentials to the login endpoint', () => {
const user: User = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };
service.login('a@b.de', 'secret').subscribe((response) => {
expect(response).toEqual({ token: 'jwt-token', user });
});
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/login`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({ email: 'a@b.de', password: 'secret' });
request.flush({ token: 'jwt-token', user });
});
it('fetches the current user from the me endpoint', () => {
const user: User = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };
service.me().subscribe((response) => {
expect(response).toEqual(user);
});
const request = httpMock.expectOne(`${environment.apiUrl}auth/me`);
expect(request.request.method).toBe('GET');
request.flush(user);
});
it('verifies an invitation token', () => {
const invitation = { teamId: 5, teamName: 'Team A', playerId: 7, playerName: 'Alex' };
service
.verifyInvite('invite-token')
.subscribe((response) => expect(response).toEqual(invitation));
const request = httpMock.expectOne(`${environment.apiUrl}auth/verify-invite`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({ token: 'invite-token' });
request.flush(invitation);
});
it('registers and links an invited player', () => {
const registration = {
email: 'alex@example.de',
password: 'secret1',
firstName: 'Alex',
lastName: 'Muster',
linkPlayerId: 7,
};
service.register(registration).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(registration);
request.flush(null);
});
it('requests a password reset email', () => {
service.forgotPassword('alex@example.de').subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}auth/forgot/password`);
expect(request.request.body).toEqual({ email: 'alex@example.de' });
request.flush(null);
});
it('sets a new password using a reset hash', () => {
service.resetPassword('reset-hash', 'new-secret').subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}auth/reset/password`);
expect(request.request.body).toEqual({ hash: 'reset-hash', password: 'new-secret' });
request.flush(null);
});
it('creates a personalized team invitation', () => {
const invitation = {
teamId: 5,
teamName: 'Team A',
playerId: 7,
playerName: 'Alex Muster',
};
service.createInvite(invitation).subscribe((response) => expect(response.token).toBe('invite'));
const request = httpMock.expectOne(`${environment.apiUrl}auth/invite`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(invitation);
request.flush({ token: 'invite' });
});
it('updates the current profile', () => {
const update = { firstName: 'Alex', lastName: 'Neu' };
const user: User = { id: 1, email: 'a@b.de', ...update };
service.updateProfile(update).subscribe((response) => expect(response).toEqual(user));
const request = httpMock.expectOne(`${environment.apiUrl}auth/me`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual(update);
request.flush(user);
});
});

View File

@@ -0,0 +1,74 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { User } from '../../models/user.model';
export interface LoginResponse {
token: string;
user: User;
}
export type AuthMeResponse = User & { token?: string };
export interface InviteDetails {
teamId: number;
teamName: string;
playerId: number;
playerName: string;
}
export interface RegistrationRequest {
email: string;
password: string;
firstName: string;
lastName: string;
linkPlayerId: number;
}
export interface CreateInviteRequest extends InviteDetails {}
export interface UpdateProfileRequest {
firstName?: string;
lastName?: string;
oldPassword?: string;
password?: string;
}
@Injectable({ providedIn: 'root' })
export class AuthApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}auth`;
login(email: string, password: string): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`${this.baseUrl}/email/login`, { email, password });
}
me(): Observable<AuthMeResponse> {
return this.http.get<AuthMeResponse>(`${this.baseUrl}/me`);
}
verifyInvite(token: string): Observable<InviteDetails> {
return this.http.post<InviteDetails>(`${this.baseUrl}/verify-invite`, { token });
}
register(request: RegistrationRequest): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/email/register`, request);
}
forgotPassword(email: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/forgot/password`, { email });
}
resetPassword(hash: string, password: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/reset/password`, { hash, password });
}
createInvite(request: CreateInviteRequest): Observable<{ token: string }> {
return this.http.post<{ token: string }>(`${this.baseUrl}/invite`, request);
}
updateProfile(request: UpdateProfileRequest): Observable<User> {
return this.http.patch<User>(`${this.baseUrl}/me`, request);
}
}

View File

@@ -0,0 +1,28 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { authGuard } from './auth-guard';
import { AuthStore } from './auth-store';
describe('authGuard', () => {
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({ providers: [provideRouter([])] });
});
it('allows navigation when logged in', () => {
const authStore = TestBed.inject(AuthStore);
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const result = TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
expect(result).toBe(true);
});
it('redirects to login when logged out', () => {
const router = TestBed.inject(Router);
const result = TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
expect(result).toEqual(router.parseUrl('/auth/login'));
});
});

View File

@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthStore } from './auth-store';
export const authGuard: CanActivateFn = () => {
const authStore = inject(AuthStore);
const router = inject(Router);
return authStore.isLoggedIn() ? true : router.parseUrl('/auth/login');
};

View File

@@ -0,0 +1,79 @@
import { TestBed } from '@angular/core/testing';
import { AuthStore } from './auth-store';
describe('AuthStore', () => {
beforeEach(() => {
localStorage.clear();
});
it('starts logged out when nothing is stored', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
expect(store.isLoggedIn()).toBe(false);
expect(store.currentUser()).toBeNull();
});
it('stores the session and exposes it as logged in', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
const user = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };
store.setSession('jwt-token', user);
expect(store.isLoggedIn()).toBe(true);
expect(store.token()).toBe('jwt-token');
expect(store.currentUser()).toEqual(user);
expect(localStorage.getItem('tw_token')).toBe('jwt-token');
});
it('restores the session from localStorage on creation', () => {
localStorage.setItem('tw_token', 'stored-token');
localStorage.setItem(
'tw_user',
JSON.stringify({ id: 2, email: 'c@d.de', firstName: 'C', lastName: 'D' }),
);
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
expect(store.isLoggedIn()).toBe(true);
expect(store.token()).toBe('stored-token');
expect(store.currentUser()?.email).toBe('c@d.de');
});
it('does not throw and starts logged out when stored user JSON is invalid', () => {
localStorage.setItem('tw_user', 'not-json');
TestBed.configureTestingModule({});
expect(() => TestBed.inject(AuthStore)).not.toThrow();
const store = TestBed.inject(AuthStore);
expect(store.currentUser()).toBeNull();
});
it('clears the session', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
store.setSession('jwt-token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
store.clearSession();
expect(store.isLoggedIn()).toBe(false);
expect(store.currentUser()).toBeNull();
expect(localStorage.getItem('tw_token')).toBeNull();
});
it('updates and persists the current user without changing the token', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(AuthStore);
store.setSession('jwt-token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const updated = { id: 1, email: 'a@b.de', firstName: 'Alex', lastName: 'Neu' };
store.updateUser(updated);
expect(store.currentUser()).toEqual(updated);
expect(localStorage.getItem('tw_token')).toBe('jwt-token');
expect(JSON.parse(localStorage.getItem('tw_user')!)).toEqual(updated);
});
});

View File

@@ -0,0 +1,51 @@
import { Injectable, computed, signal } from '@angular/core';
import { User } from '../../models/user.model';
@Injectable({ providedIn: 'root' })
export class AuthStore {
private static readonly TOKEN_KEY = 'tw_token';
private static readonly USER_KEY = 'tw_user';
private readonly tokenSignal = signal<string | null>(AuthStore.readToken());
private readonly userSignal = signal<User | null>(AuthStore.readStoredUser());
readonly token = this.tokenSignal.asReadonly();
readonly currentUser = this.userSignal.asReadonly();
readonly isLoggedIn = computed(() => this.tokenSignal() !== null);
setSession(token: string, user: User): void {
localStorage.setItem(AuthStore.TOKEN_KEY, token);
localStorage.setItem(AuthStore.USER_KEY, JSON.stringify(user));
this.tokenSignal.set(token);
this.userSignal.set(user);
}
clearSession(): void {
localStorage.removeItem(AuthStore.TOKEN_KEY);
localStorage.removeItem(AuthStore.USER_KEY);
this.tokenSignal.set(null);
this.userSignal.set(null);
}
updateUser(user: User): void {
localStorage.setItem(AuthStore.USER_KEY, JSON.stringify(user));
this.userSignal.set(user);
}
private static readToken(): string | null {
try {
return localStorage.getItem(AuthStore.TOKEN_KEY);
} catch {
return null;
}
}
private static readStoredUser(): User | null {
try {
const raw = localStorage.getItem(AuthStore.USER_KEY);
return raw ? (JSON.parse(raw) as User) : null;
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { rootRedirectGuard } from './root-redirect-guard';
import { AuthStore } from './auth-store';
describe('rootRedirectGuard', () => {
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({ providers: [provideRouter([])] });
});
it('redirects to team-select when logged in', () => {
const authStore = TestBed.inject(AuthStore);
const router = TestBed.inject(Router);
authStore.setSession('token', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const result = TestBed.runInInjectionContext(() => rootRedirectGuard({} as never, {} as never));
expect(result).toEqual(router.parseUrl('/team-select'));
});
it('redirects to login when logged out', () => {
const router = TestBed.inject(Router);
const result = TestBed.runInInjectionContext(() => rootRedirectGuard({} as never, {} as never));
expect(result).toEqual(router.parseUrl('/auth/login'));
});
});

View File

@@ -0,0 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthStore } from './auth-store';
export const rootRedirectGuard: CanActivateFn = () => {
const authStore = inject(AuthStore);
const router = inject(Router);
return router.parseUrl(authStore.isLoggedIn() ? '/team-select' : '/auth/login');
};

View File

@@ -0,0 +1,46 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { authInterceptor } from './auth-interceptor';
import { AuthStore } from '../auth/auth-store';
describe('authInterceptor', () => {
let httpMock: HttpTestingController;
let httpClient: HttpClient;
let authStore: AuthStore;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
],
});
httpMock = TestBed.inject(HttpTestingController);
httpClient = TestBed.inject(HttpClient);
authStore = TestBed.inject(AuthStore);
});
afterEach(() => {
httpMock.verify();
});
it('attaches the bearer token when a session exists', () => {
authStore.setSession('abc123', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
httpClient.get('/ping').subscribe();
const request = httpMock.expectOne('/ping');
expect(request.request.headers.get('Authorization')).toBe('Bearer abc123');
request.flush({});
});
it('does not attach a header when no session exists', () => {
httpClient.get('/ping').subscribe();
const request = httpMock.expectOne('/ping');
expect(request.request.headers.has('Authorization')).toBe(false);
request.flush({});
});
});

View File

@@ -0,0 +1,14 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthStore } from '../auth/auth-store';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authStore = inject(AuthStore);
const token = authStore.token();
if (!token) {
return next(req);
}
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};

View File

@@ -0,0 +1,96 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter, Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { errorInterceptor } from './error-interceptor';
import { AuthStore } from '../auth/auth-store';
describe('errorInterceptor', () => {
let httpMock: HttpTestingController;
let httpClient: HttpClient;
let authStore: AuthStore;
let router: Router;
let snackBar: MatSnackBar;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([errorInterceptor])),
provideHttpClientTesting(),
provideRouter([]),
],
});
httpMock = TestBed.inject(HttpTestingController);
httpClient = TestBed.inject(HttpClient);
authStore = TestBed.inject(AuthStore);
router = TestBed.inject(Router);
snackBar = TestBed.inject(MatSnackBar);
});
afterEach(() => {
httpMock.verify();
});
it('clears the session and redirects to login on a 401 response', () => {
authStore.setSession('abc123', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const navigateSpy = vi.spyOn(router, 'navigate');
httpClient.get('/secure').subscribe({ error: () => undefined });
httpMock
.expectOne('/secure')
.flush('unauthorized', { status: 401, statusText: 'Unauthorized' });
expect(authStore.isLoggedIn()).toBe(false);
expect(navigateSpy).toHaveBeenCalledWith(['/auth/login']);
});
it('shows the backend message for other 4xx errors without clearing the session', () => {
authStore.setSession('abc123', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const navigateSpy = vi.spyOn(router, 'navigate');
const snackSpy = vi.spyOn(snackBar, 'open');
httpClient.get('/secure').subscribe({ error: () => undefined });
httpMock
.expectOne('/secure')
.flush(
{ message: 'Ungültige Eingabe.' },
{ status: 422, statusText: 'Unprocessable Entity' },
);
expect(snackSpy).toHaveBeenCalledWith('Ungültige Eingabe.', 'OK', { duration: 5000 });
expect(authStore.isLoggedIn()).toBe(true);
expect(navigateSpy).not.toHaveBeenCalled();
});
it('shows a generic message for 4xx errors without a backend message', () => {
const snackSpy = vi.spyOn(snackBar, 'open');
httpClient.get('/secure').subscribe({ error: () => undefined });
httpMock.expectOne('/secure').flush(null, { status: 404, statusText: 'Not Found' });
expect(snackSpy).toHaveBeenCalledWith('Es ist ein Fehler aufgetreten.', 'OK', {
duration: 5000,
});
});
it('shows a generic message for 5xx errors without clearing the session', () => {
authStore.setSession('abc123', { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' });
const navigateSpy = vi.spyOn(router, 'navigate');
const snackSpy = vi.spyOn(snackBar, 'open');
httpClient.get('/secure').subscribe({ error: () => undefined });
httpMock
.expectOne('/secure')
.flush('server error', { status: 500, statusText: 'Server Error' });
expect(snackSpy).toHaveBeenCalledWith(
'Es ist ein Fehler aufgetreten. Bitte versuche es später erneut.',
'OK',
{ duration: 5000 },
);
expect(authStore.isLoggedIn()).toBe(true);
expect(navigateSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,35 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthStore } from '../auth/auth-store';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const authStore = inject(AuthStore);
const router = inject(Router);
const snackBar = inject(MatSnackBar);
return next(req).pipe(
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse) {
if (error.status === 401) {
authStore.clearSession();
void router.navigate(['/auth/login']);
snackBar.open('Sitzung abgelaufen. Bitte erneut anmelden.', 'OK', { duration: 5000 });
} else if (error.status >= 400 && error.status < 500) {
const message =
typeof error.error?.message === 'string' && error.error.message.trim().length > 0
? error.error.message
: 'Es ist ein Fehler aufgetreten.';
snackBar.open(message, 'OK', { duration: 5000 });
} else if (error.status >= 500) {
snackBar.open('Es ist ein Fehler aufgetreten. Bitte versuche es später erneut.', 'OK', {
duration: 5000,
});
}
}
return throwError(() => error);
}),
);
};

View File

@@ -0,0 +1,21 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { NotFound } from './not-found';
describe('NotFound', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NotFound],
providers: [provideRouter([])],
}).compileComponents();
});
it('renders a not-found message', () => {
const fixture = TestBed.createComponent(NotFound);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h1')?.textContent).toContain(
'Seite nicht gefunden',
);
});
});

View File

@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-not-found',
imports: [RouterLink],
template: `
<div class="not-found">
<h1>Seite nicht gefunden</h1>
<a routerLink="/">Zurück zur Startseite</a>
</div>
`,
})
export class NotFound {}

View File

@@ -0,0 +1,38 @@
<mat-toolbar class="shell-header">
@if (myTeams().length > 1) {
<button mat-button [matMenuTriggerFor]="teamMenu" class="shell-team-switcher">
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
<mat-icon>arrow_drop_down</mat-icon>
</button>
<mat-menu #teamMenu="matMenu">
@for (team of myTeams(); track team.id) {
<button mat-menu-item (click)="switchTeam(team.id)">{{ team.name }}</button>
}
</mat-menu>
} @else {
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
}
</mat-toolbar>
<main class="shell-content">
<router-outlet />
</main>
<nav class="shell-bottom-nav">
<a routerLink="overview" routerLinkActive="active" class="shell-bottom-nav__item">
<mat-icon>account_balance_wallet</mat-icon>
<span>Übersicht</span>
</a>
<a routerLink="members" routerLinkActive="active" class="shell-bottom-nav__item">
<mat-icon>groups</mat-icon>
<span>Mitglieder</span>
</a>
<a routerLink="cashbox" routerLinkActive="active" class="shell-bottom-nav__item">
<mat-icon>payments</mat-icon>
<span>Kasse</span>
</a>
<a routerLink="more" routerLinkActive="active" class="shell-bottom-nav__item">
<mat-icon>more_horiz</mat-icon>
<span>Mehr</span>
</a>
</nav>

View File

@@ -0,0 +1,42 @@
:host {
display: flex;
flex-direction: column;
height: 100dvh;
}
.shell-header {
background: #F7F8F2;
color: #20251F;
border-bottom: 1px solid #DDE3D8;
}
.shell-team-switcher {
color: var(--mat-sys-on-primary);
}
.shell-content {
flex: 1;
overflow-y: auto;
}
.shell-bottom-nav {
display: flex;
border-top: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface);
&__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.5rem 0;
color: var(--mat-sys-on-surface-variant);
text-decoration: none;
font-size: 0.75rem;
&.active {
color: var(--mat-sys-primary);
}
}
}

View File

@@ -0,0 +1,156 @@
import { TestBed } from '@angular/core/testing';
import { BehaviorSubject } from 'rxjs';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Shell } from './shell';
import { environment } from '../../../../environments/environment';
import { AuthStore } from '../../auth/auth-store';
import { Player } from '../../../models/player.model';
describe('Shell', () => {
let httpMock: HttpTestingController;
let authStore: AuthStore;
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
beforeEach(async () => {
localStorage.clear();
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
await TestBed.configureTestingModule({
imports: [Shell],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { paramMap: routeParams.asObservable() },
},
],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
authStore = TestBed.inject(AuthStore);
authStore.setSession('token', { id: 42, email: 'a@b.de', firstName: 'A', lastName: 'B' });
});
afterEach(() => {
httpMock.verify();
});
it('renders the bottom navigation with four tabs', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
});
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
const links = fixture.nativeElement.querySelectorAll('.shell-bottom-nav__item');
expect(links.length).toBe(4);
});
it('loads the team for the route id and shows its name in the header', async () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
});
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.shell-header')?.textContent).toContain('Team A');
});
it('shows a team switcher when the user belongs to more than one team', async () => {
const players: Player[] = [
{
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'a', balance: 0 },
},
{
id: 2,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 6, name: 'Team B', alias: 'b', balance: 0 },
},
];
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
});
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.shell-team-switcher')).toBeTruthy();
});
it('does not show a team switcher when the user belongs to only one team', async () => {
const players: Player[] = [
{
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'a', balance: 0 },
},
];
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
});
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.shell-team-switcher')).toBeFalsy();
});
it('ignores missing, non-positive, fractional, and non-numeric team ids', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
});
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
for (const id of [null, '0', '-1', '1.5', 'abc']) {
routeParams.next(convertToParamMap(id === null ? {} : { id }));
}
expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0);
});
});

View File

@@ -0,0 +1,78 @@
import { Component, computed, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
Router,
RouterLink,
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { AuthStore } from '../../auth/auth-store';
import { MyTeamsStore } from '../../team/my-teams-store';
import { TeamStore } from '../../team/team-store';
import { Team } from '../../../models/team.model';
@Component({
selector: 'app-shell',
imports: [
RouterOutlet,
RouterLink,
RouterLinkActive,
MatToolbarModule,
MatIconModule,
MatMenuModule,
MatButtonModule,
],
templateUrl: './shell.html',
styleUrl: './shell.scss',
})
export class Shell {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly authStore = inject(AuthStore);
private readonly myTeamsStore = inject(MyTeamsStore);
private readonly teamStore = inject(TeamStore);
protected readonly currentTeam = this.teamStore.team;
protected readonly myTeams = computed(() => {
const seen = new Set<number>();
const teams: Team[] = [];
for (const player of this.myTeamsStore.players()) {
if (player.team && !seen.has(player.team.id)) {
seen.add(player.team.id);
teams.push(player.team);
}
}
return teams;
});
constructor() {
const userId = this.authStore.currentUser()?.id;
if (userId) {
this.myTeamsStore.ensureLoaded(userId);
}
// A direct subscription (not `effect()` + `toSignal()`) so the initial
// team load happens synchronously during construction, exactly like
// `ensureLoaded` above — `ActivatedRoute.paramMap` always replays its
// current value synchronously to a new subscriber. This keeps the
// component's behavior deterministic and trivial to test: no signal
// effect scheduling to wait for.
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const raw = params.get('id');
const id = raw === null ? Number.NaN : Number(raw);
if (Number.isInteger(id) && id > 0) {
this.teamStore.loadTeam(id);
}
});
}
protected switchTeam(teamId: number): void {
void this.router.navigate(['/team', teamId, 'overview']);
}
}

View File

@@ -0,0 +1,64 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { MyTeamsStore } from './my-teams-store';
import { environment } from '../../../environments/environment';
import { Player } from '../../models/player.model';
describe('MyTeamsStore', () => {
let store: MyTeamsStore;
let httpMock: HttpTestingController;
const player: Player = {
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'team-a', balance: 0 },
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
store = TestBed.inject(MyTeamsStore);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('loads and exposes the players for a user', () => {
store.ensureLoaded(42);
expect(store.loading()).toBe(true);
const request = httpMock.expectOne(`${environment.apiUrl}users/42/teams`);
request.flush([player]);
expect(store.loading()).toBe(false);
expect(store.players()).toEqual([player]);
});
it('does not reload for the same user id', () => {
store.ensureLoaded(42);
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([player]);
store.ensureLoaded(42);
httpMock.expectNone(`${environment.apiUrl}users/42/teams`);
});
it('resets loading on error without throwing', () => {
store.ensureLoaded(42);
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush('error', {
status: 500,
statusText: 'Server Error',
});
expect(store.loading()).toBe(false);
expect(store.players()).toEqual([]);
});
});

View File

@@ -0,0 +1,33 @@
import { Injectable, inject, signal } from '@angular/core';
import { Player } from '../../models/player.model';
import { TeamsApi } from './teams-api';
@Injectable({ providedIn: 'root' })
export class MyTeamsStore {
private readonly teamsApi = inject(TeamsApi);
private readonly playersSignal = signal<Player[]>([]);
private readonly loadingSignal = signal(false);
private readonly loadedForUserId = signal<number | null>(null);
readonly players = this.playersSignal.asReadonly();
readonly loading = this.loadingSignal.asReadonly();
ensureLoaded(userId: number): void {
if (this.loadedForUserId() === userId || this.loadingSignal()) {
return;
}
this.loadingSignal.set(true);
this.teamsApi.loadMyTeams(userId).subscribe({
next: (players) => {
this.playersSignal.set(players);
this.loadedForUserId.set(userId);
this.loadingSignal.set(false);
},
error: () => {
this.loadingSignal.set(false);
},
});
}
}

View File

@@ -0,0 +1,36 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../environments/environment';
import { PenaltyApi } from './penalty-api';
describe('PenaltyApi', () => {
let api: PenaltyApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(PenaltyApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads a team penalty catalog', () => {
api.loadPenalties(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}penalty/5`);
expect(request.request.method).toBe('GET');
request.flush([]);
});
it('creates a penalty catalog entry', () => {
const penalty = { teamId: 5, description: 'Zu spät', amount: 5 };
api.createPenalty(penalty).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}penalty`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(penalty);
request.flush({ id: 1, ...penalty });
});
});

View File

@@ -0,0 +1,19 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { CreatePenalty, Penalty } from '../../models/penalty.model';
@Injectable({ providedIn: 'root' })
export class PenaltyApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}penalty`;
loadPenalties(teamId: number): Observable<Penalty[]> {
return this.http.get<Penalty[]>(`${this.baseUrl}/${teamId}`);
}
createPenalty(penalty: CreatePenalty): Observable<Penalty> {
return this.http.post<Penalty>(this.baseUrl, penalty);
}
}

View File

@@ -0,0 +1,43 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../environments/environment';
import { PublicAccessApi } from './public-access-api';
describe('PublicAccessApi', () => {
let api: PublicAccessApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(PublicAccessApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads the sharing status', () => {
api.getStatus(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/public-access`);
expect(request.request.method).toBe('GET');
request.flush({ enabled: false, token: null });
});
it('updates the sharing status', () => {
api.setEnabled(5, true).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/public-access`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual({ enabled: true });
request.flush({ enabled: true, token: 'token' });
});
it('rotates the sharing token', () => {
api.rotate(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/public-access/rotate`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({});
request.flush({ enabled: true, token: 'new-token' });
});
});

View File

@@ -0,0 +1,28 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { PublicAccessStatus } from '../../models/public-access.model';
@Injectable({ providedIn: 'root' })
export class PublicAccessApi {
private readonly http = inject(HttpClient);
getStatus(teamId: number): Observable<PublicAccessStatus> {
return this.http.get<PublicAccessStatus>(`${environment.apiUrl}teams/${teamId}/public-access`);
}
setEnabled(teamId: number, enabled: boolean): Observable<PublicAccessStatus> {
return this.http.patch<PublicAccessStatus>(
`${environment.apiUrl}teams/${teamId}/public-access`,
{ enabled },
);
}
rotate(teamId: number): Observable<PublicAccessStatus> {
return this.http.post<PublicAccessStatus>(
`${environment.apiUrl}teams/${teamId}/public-access/rotate`,
{},
);
}
}

View File

@@ -0,0 +1,36 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../environments/environment';
import { PublicTeamApi } from './public-team-api';
describe('PublicTeamApi', () => {
let api: PublicTeamApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(PublicTeamApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads a public team by access token', () => {
api.loadTeam('public-token').subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}public/teams/public-token`);
expect(request.request.method).toBe('GET');
request.flush({ name: 'Team A', balance: 0, outstanding: 0, players: [], penalties: [] });
});
it('loads public player history by access token and player id', () => {
api.loadPlayerHistory('public-token', 7).subscribe();
const request = httpMock.expectOne(
`${environment.apiUrl}public/teams/public-token/players/7/transactions`,
);
expect(request.request.method).toBe('GET');
request.flush({ player: { id: 7 }, transactions: [] });
});
});

View File

@@ -0,0 +1,22 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { PublicPlayerHistory, PublicTeamOverview } from '../../models/public-access.model';
@Injectable({ providedIn: 'root' })
export class PublicTeamApi {
private readonly http = inject(HttpClient);
loadTeam(token: string): Observable<PublicTeamOverview> {
return this.http.get<PublicTeamOverview>(
`${environment.apiUrl}public/teams/${encodeURIComponent(token)}`,
);
}
loadPlayerHistory(token: string, playerId: number): Observable<PublicPlayerHistory> {
return this.http.get<PublicPlayerHistory>(
`${environment.apiUrl}public/teams/${encodeURIComponent(token)}/players/${playerId}/transactions`,
);
}
}

View File

@@ -0,0 +1,109 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TeamStore } from './team-store';
import { environment } from '../../../environments/environment';
import { Team } from '../../models/team.model';
describe('TeamStore', () => {
let store: TeamStore;
let httpMock: HttpTestingController;
const team: Team = { id: 5, name: 'Team A', alias: 'team-a', balance: 100 };
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
store = TestBed.inject(TeamStore);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('loads and exposes the active team', () => {
store.loadTeam(5);
expect(store.loading()).toBe(true);
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/overview`);
request.flush(team);
expect(store.loading()).toBe(false);
expect(store.team()).toEqual(team);
});
it('does not reload for the same team id', () => {
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush(team);
store.loadTeam(5);
httpMock.expectNone(`${environment.apiUrl}teams/5/overview`);
});
it('reloads when the team id changes', () => {
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush(team);
store.loadTeam(6);
httpMock.expectOne(`${environment.apiUrl}teams/6/overview`).flush({ ...team, id: 6 });
expect(store.team()?.id).toBe(6);
});
it('resets loading on error without throwing', () => {
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush('error', {
status: 500,
statusText: 'Server Error',
});
expect(store.loading()).toBe(false);
expect(store.team()).toBeNull();
});
it('allows retrying the same team after a failed request', () => {
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush('error', {
status: 500,
statusText: 'Server Error',
});
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush(team);
expect(store.team()).toEqual(team);
expect(store.loading()).toBe(false);
});
it('refreshes an already loaded team on demand', () => {
store.loadTeam(5);
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush(team);
store.refreshTeam();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ ...team, balance: 200 });
expect(store.team()?.balance).toBe(200);
});
it('discards a stale response when the requested team changes before it resolves', () => {
store.loadTeam(5);
const firstRequest = httpMock.expectOne(`${environment.apiUrl}teams/5/overview`);
store.loadTeam(6);
const secondRequest = httpMock.expectOne(`${environment.apiUrl}teams/6/overview`);
// The second request supersedes the first via switchMap — Angular's test
// harness refuses to flush a request once its subscription has been
// cancelled, which is precisely the guarantee this test is after: a late
// arriving stale response can never reach the store and overwrite it.
expect(firstRequest.cancelled).toBe(true);
secondRequest.flush({ ...team, id: 6 });
expect(store.team()?.id).toBe(6);
});
});

View File

@@ -0,0 +1,56 @@
import { Injectable, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { Team } from '../../models/team.model';
import { TeamsApi } from './teams-api';
@Injectable({ providedIn: 'root' })
export class TeamStore {
private readonly teamsApi = inject(TeamsApi);
private readonly teamSignal = signal<Team | null>(null);
private readonly loadingSignal = signal(false);
private readonly requestedTeamId = signal<number | null>(null);
private readonly requests = new Subject<number>();
readonly team = this.teamSignal.asReadonly();
readonly loading = this.loadingSignal.asReadonly();
constructor() {
this.requests
.pipe(
switchMap((teamId) =>
this.teamsApi.loadTeamOverview(teamId).pipe(
catchError(() => {
this.requestedTeamId.set(null);
this.loadingSignal.set(false);
return EMPTY;
}),
),
),
takeUntilDestroyed(),
)
.subscribe((team) => {
this.teamSignal.set(team);
this.loadingSignal.set(false);
});
}
loadTeam(teamId: number): void {
if (this.requestedTeamId() === teamId) {
return;
}
this.requestedTeamId.set(teamId);
this.loadingSignal.set(true);
this.requests.next(teamId);
}
refreshTeam(): void {
const teamId = this.requestedTeamId();
if (teamId === null) return;
this.loadingSignal.set(true);
this.requests.next(teamId);
}
}

View File

@@ -0,0 +1,79 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TeamsApi } from './teams-api';
import { environment } from '../../../environments/environment';
import { Player } from '../../models/player.model';
import { Team } from '../../models/team.model';
describe('TeamsApi', () => {
let service: TeamsApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(TeamsApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('fetches the players/teams belonging to a user', () => {
const players: Player[] = [{ id: 1, firstName: 'A', lastName: 'B', balance: 0, active: true }];
service.loadMyTeams(42).subscribe((response) => {
expect(response).toEqual(players);
});
const request = httpMock.expectOne(`${environment.apiUrl}users/42/teams`);
expect(request.request.method).toBe('GET');
request.flush(players);
});
it('fetches a team overview by id', () => {
const team: Team = { id: 5, name: 'Team A', alias: 'team-a', balance: 0 };
service.loadTeamOverview(5).subscribe((response) => {
expect(response).toEqual(team);
});
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/overview`);
expect(request.request.method).toBe('GET');
request.flush(team);
});
it('creates a player in a team', () => {
const player = { firstName: 'Alex', lastName: 'Muster', teamRole: 1 };
service.createPlayer(5, player).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/players`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(player);
request.flush({ id: 7, ...player, balance: 0, active: true });
});
it('updates an existing player', () => {
const player: Player = {
id: 7,
firstName: 'Alex',
lastName: 'Muster',
balance: 0,
active: false,
};
service.updatePlayer(5, player).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/players`);
expect(request.request.method).toBe('PUT');
expect(request.request.body).toEqual(player);
request.flush(player);
});
it('loads an authenticated player history through the team id', () => {
service.loadPlayerTransactions(5, 7).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/players/7/transactions`);
expect(request.request.method).toBe('GET');
request.flush([]);
});
});

View File

@@ -0,0 +1,40 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Player } from '../../models/player.model';
import { Team } from '../../models/team.model';
import { PlayerTransaction } from '../../models/transaction.model';
export interface CreatePlayerRequest {
firstName: string;
lastName: string;
teamRole: number;
}
@Injectable({ providedIn: 'root' })
export class TeamsApi {
private readonly http = inject(HttpClient);
loadMyTeams(userId: number): Observable<Player[]> {
return this.http.get<Player[]>(`${environment.apiUrl}users/${userId}/teams`);
}
loadTeamOverview(teamId: number): Observable<Team> {
return this.http.get<Team>(`${environment.apiUrl}teams/${teamId}/overview`);
}
createPlayer(teamId: number, player: CreatePlayerRequest): Observable<Player> {
return this.http.post<Player>(`${environment.apiUrl}teams/${teamId}/players`, player);
}
updatePlayer(teamId: number, player: Player): Observable<Player> {
return this.http.put<Player>(`${environment.apiUrl}teams/${teamId}/players`, player);
}
loadPlayerTransactions(teamId: number, playerId: number): Observable<PlayerTransaction[]> {
return this.http.get<PlayerTransaction[]>(
`${environment.apiUrl}teams/${teamId}/players/${playerId}/transactions`,
);
}
}

View File

@@ -0,0 +1,87 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TransactionsApi } from './transactions-api';
import { environment } from '../../../environments/environment';
import {
CreatePlayerTransaction,
CreateTeamWalletTransaction,
} from '../../models/transaction.model';
describe('TransactionsApi', () => {
let api: TransactionsApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(TransactionsApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads the combined activity feed for a team', () => {
const activities = [
{
id: 1,
date: '2026-07-31',
amount: 12,
type: 'credit',
note: '',
playerName: 'Alex',
isTeamWalletTransaction: false,
},
];
api.loadTeamTransactions(5).subscribe((response) => expect(response).toEqual(activities));
const request = httpMock.expectOne(`${environment.apiUrl}teams/5/transactions`);
expect(request.request.method).toBe('GET');
request.flush(activities);
});
it('creates player transactions as one batch', () => {
const transactions: CreatePlayerTransaction[] = [
{
playerId: 7,
date: '2026-07-31T10:00:00.000Z',
amount: 12.5,
type: 11,
note: 'Training',
},
];
api.createPlayerTransactions(transactions).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}transactions`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(transactions);
request.flush([]);
});
it('creates a team wallet transaction', () => {
const transaction: CreateTeamWalletTransaction = {
teamId: 5,
date: '2026-07-31T10:00:00.000Z',
amount: 35,
type: 14,
note: 'Material',
};
api.createTeamWalletTransaction(transaction).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}team-wallet-transactions`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(transaction);
request.flush({ id: 3 });
});
it('reverses a player transaction', () => {
api.reverseTransaction(42).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}transactions/42/reverse`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({});
request.flush({ id: 43 });
});
});

View File

@@ -0,0 +1,36 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
CreatePlayerTransaction,
CreateTeamWalletTransaction,
TeamActivity,
} from '../../models/transaction.model';
@Injectable({ providedIn: 'root' })
export class TransactionsApi {
private readonly http = inject(HttpClient);
loadTeamTransactions(teamId: number): Observable<TeamActivity[]> {
return this.http.get<TeamActivity[]>(`${environment.apiUrl}teams/${teamId}/transactions`);
}
createPlayerTransactions(transactions: CreatePlayerTransaction[]): Observable<TeamActivity[]> {
return this.http.post<TeamActivity[]>(`${environment.apiUrl}transactions`, transactions);
}
createTeamWalletTransaction(transaction: CreateTeamWalletTransaction): Observable<TeamActivity> {
return this.http.post<TeamActivity>(
`${environment.apiUrl}team-wallet-transactions`,
transaction,
);
}
reverseTransaction(transactionId: number): Observable<TeamActivity> {
return this.http.post<TeamActivity>(
`${environment.apiUrl}transactions/${transactionId}/reverse`,
{},
);
}
}

View File

@@ -0,0 +1,28 @@
<div class="auth-page">
<mat-card class="auth-card"
><mat-card-header><mat-card-title>Passwort vergessen</mat-card-title></mat-card-header
><mat-card-content>
@if (sent()) {
<div class="state">
<strong>E-Mail versendet</strong>
<p>Wenn ein Konto existiert, erhältst du einen Link zum Zurücksetzen.</p>
<a mat-button routerLink="/auth/login">Zur Anmeldung</a>
</div>
} @else {
<p>Gib die E-Mail-Adresse deines Kontos ein.</p>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline"
><mat-label>E-Mail</mat-label
><input matInput type="email" formControlName="email" autocomplete="email"
/></mat-form-field>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
<button mat-flat-button type="submit" [disabled]="form.invalid || submitting()">
Link anfordern</button
><a mat-button routerLink="/auth/login">Abbrechen</a>
</form>
}
</mat-card-content></mat-card
>
</div>

View File

@@ -0,0 +1,20 @@
.auth-page {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1rem;
box-sizing: border-box;
}
.auth-card {
width: min(100%, 420px);
}
form,
.state {
display: flex;
flex-direction: column;
gap: 0.75rem;
text-align: center;
}
.error {
color: var(--mat-sys-error);
}

View File

@@ -0,0 +1,30 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { ForgotPassword } from './forgot-password';
import { environment } from '../../../../environments/environment';
describe('ForgotPassword', () => {
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ForgotPassword],
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('requests an email and shows the success state', () => {
const fixture = TestBed.createComponent(ForgotPassword);
fixture.componentInstance['form'].setValue({ email: 'alex@example.de' });
fixture.componentInstance['onSubmit']();
httpMock.expectOne(`${environment.apiUrl}auth/forgot/password`).flush(null);
expect(fixture.componentInstance['sent']()).toBe(true);
});
});

View File

@@ -0,0 +1,47 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
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 { AuthApi } from '../../../core/auth/auth-api';
@Component({
selector: 'app-forgot-password',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
],
templateUrl: './forgot-password.html',
styleUrl: './forgot-password.scss',
})
export class ForgotPassword {
private readonly authApi = inject(AuthApi);
private readonly formBuilder = inject(FormBuilder);
protected readonly sent = signal(false);
protected readonly submitting = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly form = this.formBuilder.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
});
protected onSubmit(): void {
if (this.form.invalid || this.submitting()) return;
this.submitting.set(true);
this.authApi.forgotPassword(this.form.getRawValue().email).subscribe({
next: () => {
this.submitting.set(false);
this.sent.set(true);
},
error: () => {
this.submitting.set(false);
this.errorMessage.set('Die Anfrage konnte nicht gesendet werden.');
},
});
}
}

View File

@@ -0,0 +1,45 @@
<div class="login-page">
<mat-card class="login-card">
<mat-card-header>
<mat-card-title>TeamWallet</mat-card-title>
<mat-card-subtitle>Anmelden</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline">
<mat-label>E-Mail</mat-label>
<input matInput type="email" formControlName="email" autocomplete="email" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input
matInput
type="password"
formControlName="password"
autocomplete="current-password"
/>
</mat-form-field>
@if (errorMessage()) {
<p class="login-error">{{ errorMessage() }}</p>
}
<button
mat-flat-button
class="login-submit"
type="submit"
[disabled]="form.invalid || isSubmitting()"
>
@if (isSubmitting()) {
<mat-spinner diameter="20" />
} @else {
Anmelden
}
</button>
<a mat-button routerLink="/auth/forgot-password">Passwort vergessen?</a>
</form>
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,28 @@
.login-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100dvh;
padding: 1rem;
}
.login-card {
width: 100%;
max-width: 360px;
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}
.login-error {
color: var(--mat-sys-error);
margin: 0;
}
.login-submit {
background-color: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
}

View File

@@ -0,0 +1,56 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter, Router } from '@angular/router';
import { Login } from './login';
import { environment } from '../../../../environments/environment';
import { AuthStore } from '../../../core/auth/auth-store';
describe('Login', () => {
let httpMock: HttpTestingController;
let router: Router;
let authStore: AuthStore;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [Login],
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
authStore = TestBed.inject(AuthStore);
});
afterEach(() => {
httpMock.verify();
});
it('logs in and navigates to team-select on success', () => {
const fixture = TestBed.createComponent(Login);
const navigateSpy = vi.spyOn(router, 'navigate');
fixture.componentInstance['form'].setValue({ email: 'a@b.de', password: 'secret' });
fixture.componentInstance['onSubmit']();
const user = { id: 1, email: 'a@b.de', firstName: 'A', lastName: 'B' };
httpMock.expectOne(`${environment.apiUrl}auth/email/login`).flush({ token: 'jwt-token', user });
expect(authStore.isLoggedIn()).toBe(true);
expect(navigateSpy).toHaveBeenCalledWith(['/team-select']);
});
it('shows an error message when login fails', () => {
const fixture = TestBed.createComponent(Login);
fixture.componentInstance['form'].setValue({ email: 'a@b.de', password: 'wrong' });
fixture.componentInstance['onSubmit']();
httpMock
.expectOne(`${environment.apiUrl}auth/email/login`)
.flush('unauthorized', { status: 401, statusText: 'Unauthorized' });
expect(fixture.componentInstance['errorMessage']()).toBe(
'Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.',
);
});
});

View File

@@ -0,0 +1,61 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthApi } from '../../../core/auth/auth-api';
import { AuthStore } from '../../../core/auth/auth-store';
@Component({
selector: 'app-login',
imports: [
ReactiveFormsModule,
RouterLink,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatProgressSpinnerModule,
],
templateUrl: './login.html',
styleUrl: './login.scss',
})
export class Login {
private readonly formBuilder = inject(FormBuilder);
private readonly authApi = inject(AuthApi);
private readonly authStore = inject(AuthStore);
private readonly router = inject(Router);
protected readonly isSubmitting = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly form = this.formBuilder.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required]],
});
protected onSubmit(): void {
if (this.form.invalid || this.isSubmitting()) {
return;
}
this.isSubmitting.set(true);
this.errorMessage.set(null);
const { email, password } = this.form.getRawValue();
this.authApi.login(email, password).subscribe({
next: ({ token, user }) => {
this.authStore.setSession(token, user);
this.isSubmitting.set(false);
void this.router.navigate(['/team-select']);
},
error: () => {
this.isSubmitting.set(false);
this.errorMessage.set('Anmeldung fehlgeschlagen. Bitte E-Mail und Passwort prüfen.');
},
});
}
}

View File

@@ -0,0 +1,61 @@
<div class="auth-page">
<mat-card class="auth-card">
<mat-card-header
><mat-card-title>TeamWallet</mat-card-title
><mat-card-subtitle>Konto erstellen</mat-card-subtitle></mat-card-header
>
<mat-card-content>
@if (loadingInvitation()) {
<div class="state"><mat-spinner diameter="32" /><span>Einladung wird geprüft …</span></div>
} @else if (invitation(); as invite) {
<div class="invite-summary">
<strong>{{ invite.teamName }}</strong
><span>Du registrierst dich als {{ invite.playerName }}.</span>
</div>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline"
><mat-label>E-Mail</mat-label
><input matInput type="email" formControlName="email" autocomplete="email"
/></mat-form-field>
<div class="name-row">
<mat-form-field appearance="outline"
><mat-label>Vorname</mat-label
><input matInput formControlName="firstName" autocomplete="given-name"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Nachname</mat-label
><input matInput formControlName="lastName" autocomplete="family-name"
/></mat-form-field>
</div>
<mat-form-field appearance="outline"
><mat-label>Passwort</mat-label
><input matInput type="password" formControlName="password" autocomplete="new-password"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Passwort wiederholen</mat-label
><input
matInput
type="password"
formControlName="passwordConfirmation"
autocomplete="new-password"
/></mat-form-field>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
<button mat-flat-button type="submit" [disabled]="form.invalid || isSubmitting()">
@if (isSubmitting()) {
<mat-spinner diameter="20" />
} @else {
Konto erstellen
}
</button>
</form>
} @else {
<div class="state error">
<span>{{ errorMessage() }}</span
><a mat-button routerLink="/auth/login">Zur Anmeldung</a>
</div>
}
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,38 @@
.auth-page {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1rem;
box-sizing: border-box;
}
.auth-card {
width: min(100%, 520px);
}
.state,
.invite-summary {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem 0;
text-align: center;
}
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.name-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.error {
color: var(--mat-sys-error);
}
@media (max-width: 440px) {
.name-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,58 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
import { Register } from './register';
import { environment } from '../../../../environments/environment';
describe('Register', () => {
let httpMock: HttpTestingController;
let router: Router;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Register],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { snapshot: { queryParamMap: convertToParamMap({ token: 'invite-token' }) } },
},
],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
});
afterEach(() => httpMock.verify());
it('verifies the invitation and links its player during registration', () => {
const fixture = TestBed.createComponent(Register);
const navigateSpy = vi.spyOn(router, 'navigate');
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}auth/verify-invite`).flush({
teamId: 5,
teamName: 'Team A',
playerId: 7,
playerName: 'Alex Muster',
});
fixture.componentInstance['form'].setValue({
email: 'alex@example.de',
firstName: 'Alex',
lastName: 'Muster',
password: 'secret1',
passwordConfirmation: 'secret1',
});
fixture.componentInstance['onSubmit']();
const request = httpMock.expectOne(`${environment.apiUrl}auth/email/register`);
expect(request.request.body.linkPlayerId).toBe(7);
request.flush(null);
expect(navigateSpy).toHaveBeenCalledWith(['/auth/login'], { replaceUrl: true });
});
});

View File

@@ -0,0 +1,94 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthApi, InviteDetails } from '../../../core/auth/auth-api';
@Component({
selector: 'app-register',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
],
templateUrl: './register.html',
styleUrl: './register.scss',
})
export class Register {
private readonly authApi = inject(AuthApi);
private readonly formBuilder = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly token = this.route.snapshot.queryParamMap.get('token');
protected readonly invitation = signal<InviteDetails | null>(null);
protected readonly loadingInvitation = signal(true);
protected readonly isSubmitting = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly form = this.formBuilder.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
firstName: ['', Validators.required],
lastName: ['', Validators.required],
password: ['', [Validators.required, Validators.minLength(6)]],
passwordConfirmation: ['', [Validators.required, Validators.minLength(6)]],
});
constructor() {
if (!this.token) {
this.loadingInvitation.set(false);
this.errorMessage.set('Der Einladungslink ist unvollständig.');
return;
}
this.authApi.verifyInvite(this.token).subscribe({
next: (invitation) => {
this.invitation.set(invitation);
this.loadingInvitation.set(false);
},
error: () => {
this.loadingInvitation.set(false);
this.errorMessage.set('Der Einladungslink ist ungültig oder abgelaufen.');
},
});
}
protected onSubmit(): void {
const invitation = this.invitation();
const value = this.form.getRawValue();
if (this.form.invalid || !invitation || this.isSubmitting()) return;
if (value.password !== value.passwordConfirmation) {
this.errorMessage.set('Die Passwörter stimmen nicht überein.');
return;
}
this.isSubmitting.set(true);
this.errorMessage.set(null);
this.authApi
.register({
email: value.email,
password: value.password,
firstName: value.firstName,
lastName: value.lastName,
linkPlayerId: invitation.playerId,
})
.subscribe({
next: () => {
this.isSubmitting.set(false);
void this.router.navigate(['/auth/login'], { replaceUrl: true });
},
error: () => {
this.isSubmitting.set(false);
this.errorMessage.set('Die Registrierung ist fehlgeschlagen.');
},
});
}
}

View File

@@ -0,0 +1,28 @@
<div class="auth-page">
<mat-card class="auth-card">
<mat-card-header><mat-card-title>Neues Passwort</mat-card-title></mat-card-header>
<mat-card-content>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline"
><mat-label>Passwort</mat-label
><input matInput type="password" formControlName="password" autocomplete="new-password"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Passwort wiederholen</mat-label
><input
matInput
type="password"
formControlName="passwordConfirmation"
autocomplete="new-password"
/></mat-form-field>
@if (errorMessage()) {
<p class="error">{{ errorMessage() }}</p>
}
<button mat-flat-button type="submit" [disabled]="form.invalid || submitting()">
Passwort speichern
</button>
<a mat-button routerLink="/auth/login">Abbrechen</a>
</form>
</mat-card-content>
</mat-card>
</div>

View File

@@ -0,0 +1,18 @@
.auth-page {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1rem;
box-sizing: border-box;
}
.auth-card {
width: min(100%, 420px);
}
form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.error {
color: var(--mat-sys-error);
}

View File

@@ -0,0 +1,46 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
import { ResetPassword } from './reset-password';
import { environment } from '../../../../environments/environment';
describe('ResetPassword', () => {
let httpMock: HttpTestingController;
let router: Router;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ResetPassword],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ hash: 'reset-hash' }) } },
},
],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
});
afterEach(() => httpMock.verify());
it('sets the password for the route hash and returns to login', () => {
const fixture = TestBed.createComponent(ResetPassword);
const navigateSpy = vi.spyOn(router, 'navigate');
fixture.componentInstance['form'].setValue({
password: 'new-secret',
passwordConfirmation: 'new-secret',
});
fixture.componentInstance['onSubmit']();
const request = httpMock.expectOne(`${environment.apiUrl}auth/reset/password`);
expect(request.request.body).toEqual({ hash: 'reset-hash', password: 'new-secret' });
request.flush(null);
expect(navigateSpy).toHaveBeenCalledWith(['/auth/login'], { replaceUrl: true });
});
});

View File

@@ -0,0 +1,55 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
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 { AuthApi } from '../../../core/auth/auth-api';
@Component({
selector: 'app-reset-password',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
],
templateUrl: './reset-password.html',
styleUrl: './reset-password.scss',
})
export class ResetPassword {
private readonly authApi = inject(AuthApi);
private readonly formBuilder = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly hash = this.route.snapshot.paramMap.get('hash');
protected readonly submitting = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly form = this.formBuilder.nonNullable.group({
password: ['', [Validators.required, Validators.minLength(6)]],
passwordConfirmation: ['', [Validators.required, Validators.minLength(6)]],
});
protected onSubmit(): void {
const value = this.form.getRawValue();
if (this.form.invalid || !this.hash || this.submitting()) return;
if (value.password !== value.passwordConfirmation) {
this.errorMessage.set('Die Passwörter stimmen nicht überein.');
return;
}
this.submitting.set(true);
this.authApi.resetPassword(this.hash, value.password).subscribe({
next: () => {
this.submitting.set(false);
void this.router.navigate(['/auth/login'], { replaceUrl: true });
},
error: () => {
this.submitting.set(false);
this.errorMessage.set('Das Passwort konnte nicht geändert werden.');
},
});
}
}

View File

@@ -0,0 +1,40 @@
<header class="public-header">
<a class="brand" [routerLink]="['/t', token]"
><mat-icon>account_balance_wallet</mat-icon><span>TeamWallet</span></a
><a mat-stroked-button routerLink="/auth/login"><mat-icon>login</mat-icon>Anmelden</a>
</header>
<main>
<a mat-button [routerLink]="['/t', token]"><mat-icon>arrow_back</mat-icon>Zur Teamübersicht</a>
<header class="page-title">
<p class="eyebrow">Öffentliche Ansicht</p>
<h1>{{ player()?.firstName }} {{ player()?.lastName }}</h1>
<p>Die letzten Buchungen dieses Mitglieds.</p>
</header>
@if (loading()) {
<div class="state"><mat-spinner diameter="40" /></div>
} @else if (notFound()) {
<div class="state"><mat-icon>search_off</mat-icon><span>Verlauf nicht gefunden.</span></div>
} @else if (transactions().length === 0) {
<div class="state">
<mat-icon>receipt_long</mat-icon><span>Noch keine Buchungen vorhanden.</span>
</div>
} @else {
<div class="transactions">
@for (transaction of transactions(); track transaction.id) {
<mat-card
><div class="icon"><mat-icon>receipt_long</mat-icon></div>
<div>
<strong>{{ typeLabel(transaction) }}</strong
><span>{{ transaction.date | date: 'dd.MM.yyyy' }}</span>
@if (transaction.note) {
<small>{{ transaction.note }}</small>
}
</div>
<strong class="amount">{{
displayAmount(transaction) | currency: 'EUR'
}}</strong></mat-card
>
}
</div>
}
</main>

View File

@@ -0,0 +1,92 @@
:host {
display: block;
min-height: 100%;
// background: color-mix(in srgb, var(--mat-sys-primary-container) 18%, var(--mat-sys-surface));
}
.public-header {
height: 64px;
padding: 0 max(20px, calc((100vw - 900px) / 2));
display: flex;
align-items: center;
justify-content: space-between;
background: var(--mat-sys-surface);
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.brand {
display: flex;
align-items: center;
gap: 9px;
color: var(--mat-sys-primary);
text-decoration: none;
font-weight: 800;
font-size: 1.1rem;
}
main {
max-width: 900px;
margin: 0 auto;
padding: 28px 24px 64px;
}
.page-title {
margin: 28px 0;
}
.page-title h1 {
font-size: clamp(2.2rem, 5vw, 3.5rem);
line-height: clamp(2.2rem, 5vw, 3.5rem);
margin: 0 0 8px;
}
.page-title p {
margin-top: 0;
}
.eyebrow {
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
margin-bottom: 6px;
}
.transactions {
display: grid;
gap: 10px;
}
.transactions mat-card {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 14px;
align-items: center;
padding: 16px 18px;
border-radius: 17px;
}
.transactions .icon {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 14px;
color: var(--mat-sys-primary);
background: var(--mat-sys-primary-container);
}
.transactions div:nth-child(2) {
display: grid;
gap: 2px;
}
.transactions span,
.transactions small {
color: var(--mat-sys-on-surface-variant);
}
.amount {
font-variant-numeric: tabular-nums;
}
.state {
min-height: 300px;
display: grid;
place-content: center;
justify-items: center;
gap: 12px;
color: var(--mat-sys-on-surface-variant);
}
@media (max-width: 600px) {
main {
padding: 22px 16px 48px;
}
}

View File

@@ -0,0 +1,53 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { PublicTeamApi } from '../../core/team/public-team-api';
import { PublicPlayer } from './public-player';
describe('PublicPlayer', () => {
it('renders player identity and public transaction history', async () => {
await TestBed.configureTestingModule({
imports: [PublicPlayer],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: { paramMap: convertToParamMap({ token: 'public-token', playerId: '7' }) },
},
},
{
provide: PublicTeamApi,
useValue: {
loadPlayerHistory: vi.fn(() =>
of({
player: {
id: 7,
firstName: 'Ada',
lastName: 'Lovelace',
balance: -5,
active: true,
},
transactions: [
{
id: 1,
date: '2026-07-31',
amount: 5,
note: 'Training',
type: { id: 11, name: 'fine' },
},
],
}),
),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(PublicPlayer);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Ada Lovelace');
expect(fixture.nativeElement.textContent).toContain('Training');
expect(fixture.nativeElement.textContent).toContain('-5,00');
});
});

View File

@@ -0,0 +1,79 @@
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { Component, LOCALE_ID, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { PublicTeamApi } from '../../core/team/public-team-api';
import { PlayerTransaction } from '../../models/transaction.model';
import { signedTransactionAmount } from '../../models/transaction-amount';
import { PublicPlayer as PublicPlayerModel } from '../../models/public-access.model';
registerLocaleData(localeDe);
@Component({
selector: 'app-public-player',
imports: [
CurrencyPipe,
DatePipe,
RouterLink,
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './public-player.html',
styleUrl: './public-player.scss',
})
export class PublicPlayer {
private readonly api = inject(PublicTeamApi);
private readonly route = inject(ActivatedRoute);
protected readonly token = this.route.snapshot.paramMap.get('token') ?? '';
protected readonly player = signal<PublicPlayerModel | null>(null);
protected readonly transactions = signal<PlayerTransaction[]>([]);
protected readonly loading = signal(true);
protected readonly notFound = signal(false);
constructor() {
const playerId = Number(this.route.snapshot.paramMap.get('playerId'));
if (!this.token || !Number.isInteger(playerId) || playerId <= 0) {
this.loading.set(false);
this.notFound.set(true);
return;
}
this.api.loadPlayerHistory(this.token, playerId).subscribe({
next: (history) => {
this.player.set(history.player);
this.transactions.set(history.transactions);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
this.notFound.set(true);
},
});
}
protected typeLabel(transaction: PlayerTransaction): string {
const type =
typeof transaction.type === 'string' ? transaction.type : (transaction.type?.name ?? '');
return (
(
{
payment: 'Zahlung',
credit: 'Guthaben',
fine: 'Strafe',
levy: 'Umlage',
fee: 'Gebühr',
} as Record<string, string>
)[type] ?? type
);
}
protected displayAmount(transaction: PlayerTransaction): number {
return signedTransactionAmount(transaction.amount, transaction.type);
}
}

View File

@@ -0,0 +1,76 @@
<header class="public-header">
<a class="brand" [routerLink]="['/t', token]"
><mat-icon>account_balance_wallet</mat-icon><span>TeamWallet</span></a
>
<a mat-stroked-button routerLink="/auth/login"><mat-icon>login</mat-icon>Anmelden</a>
</header>
<main>
@if (loading()) {
<div class="state"><mat-spinner diameter="42" /><span>Team wird geladen …</span></div>
} @else if (notFound() || !team()) {
<div class="state">
<mat-icon>search_off</mat-icon>
<h1>Team nicht gefunden</h1>
<p>Bitte prüfe den öffentlichen Link.</p>
</div>
} @else {
<section class="hero">
<p class="eyebrow">Öffentliche Teamansicht</p>
<h1>{{ team()!.name }}</h1>
<div class="balance-grid">
<mat-card
><span>In der Kasse</span
><strong>{{ team()!.balance | currency: 'EUR' }}</strong></mat-card
>
<mat-card
><span>Ausstehend</span
><strong>{{ team()!.outstanding | currency: 'EUR' }}</strong></mat-card
>
<mat-card class="total"
><span>Theoretischer Gesamtstand</span
><strong>{{ team()!.balance + team()!.outstanding | currency: 'EUR' }}</strong></mat-card
>
</div>
</section>
<section class="content-grid">
<div>
<div class="section-heading">
<div>
<p class="eyebrow">Mannschaft</p>
<h2>Mitglieder</h2>
</div>
<span>{{ players().length }} aktiv</span>
</div>
<mat-form-field appearance="outline" class="search"
><mat-label>Mitglied suchen</mat-label><mat-icon matPrefix>search</mat-icon
><input matInput [value]="search()" (input)="search.set($any($event.target).value)"
/></mat-form-field>
<div class="player-list">
@for (player of players(); track player.id) {
<a [routerLink]="['/t', token, player.id]"
><span>{{ player.firstName }} {{ player.lastName }}</span
><strong>{{ player.balance | currency: 'EUR' }}</strong
><mat-icon>chevron_right</mat-icon></a
>
} @empty {
<div class="empty">Keine Mitglieder gefunden.</div>
}
</div>
</div>
<aside>
<p class="eyebrow">Teamregeln</p>
<h2>Strafenkatalog</h2>
<div class="penalty-list">
@for (penalty of penalties(); track penalty.id) {
<mat-card
><span>{{ penalty.description }}</span
><strong>{{ penalty.amount | currency: 'EUR' }}</strong></mat-card
>
} @empty {
<div class="empty">Keine Einträge vorhanden.</div>
}
</div>
</aside>
</section>
}
</main>

View File

@@ -0,0 +1,134 @@
:host {
display: block;
min-height: 100%;
// background: color-mix(in srgb, var(--mat-sys-primary-container) 18%, var(--mat-sys-surface));
}
.public-header {
height: 64px;
padding: 0 max(20px, calc((100vw - 1180px) / 2));
display: flex;
align-items: center;
justify-content: space-between;
background: var(--mat-sys-surface);
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.brand {
display: flex;
align-items: center;
gap: 9px;
color: var(--mat-sys-primary);
text-decoration: none;
font-weight: 800;
font-size: 1.1rem;
}
main {
max-width: 1180px;
margin: 0 auto;
padding: 40px 24px 64px;
}
.hero h1 {
font-size: clamp(2.4rem, 6vw, 4.5rem);
line-height: clamp(2.4rem, 6vw, 4.5rem);
margin: 0 0 26px;
}
.eyebrow {
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.09em;
text-transform: uppercase;
margin: 0 0 6px;
}
.balance-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.balance-grid mat-card {
padding: 20px;
border-radius: 20px;
display: grid;
gap: 7px;
}
.balance-grid strong {
font-size: clamp(1.45rem, 3vw, 2rem);
}
.balance-grid .total {
background: var(--mat-sys-primary-container);
}
.content-grid {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.7fr);
gap: 34px;
margin-top: 42px;
}
.section-heading {
display: flex;
justify-content: space-between;
align-items: end;
}
.section-heading h2,
aside h2 {
margin: 0 0 14px;
}
.search {
width: 100%;
}
.player-list {
overflow: hidden;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 20px;
background: var(--mat-sys-surface);
}
.player-list a {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 12px;
padding: 17px 18px;
color: inherit;
text-decoration: none;
}
.player-list a + a {
border-top: 1px solid var(--mat-sys-outline-variant);
}
.player-list a:hover {
background: var(--mat-sys-surface-container);
}
.penalty-list {
display: grid;
gap: 10px;
}
.penalty-list mat-card {
padding: 16px;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 14px;
border-radius: 16px;
}
.empty {
padding: 30px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
}
.state {
min-height: 70vh;
display: grid;
place-content: center;
justify-items: center;
gap: 12px;
text-align: center;
}
@media (max-width: 800px) {
.balance-grid,
.content-grid {
grid-template-columns: 1fr;
}
.content-grid {
gap: 32px;
}
main {
padding: 28px 16px 48px;
}
}

View File

@@ -0,0 +1,38 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { PublicTeamApi } from '../../core/team/public-team-api';
import { PublicTeam } from './public-team';
describe('PublicTeam', () => {
it('renders the combined public team response without a separate penalty request', async () => {
const team = {
name: 'Team A',
balance: 120,
outstanding: 15,
players: [
{ id: 7, firstName: 'Bea', lastName: 'Test', balance: -5, active: true },
{ id: 8, firstName: 'Inaktiv', lastName: 'Mitglied', balance: 0, active: false },
],
penalties: [{ id: 1, description: 'Zu spät', amount: 5 }],
};
await TestBed.configureTestingModule({
imports: [PublicTeam],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ token: 'public-token' }) } },
},
{ provide: PublicTeamApi, useValue: { loadTeam: vi.fn(() => of(team)) } },
],
}).compileComponents();
const fixture = TestBed.createComponent(PublicTeam);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Team A');
expect(fixture.nativeElement.textContent).toContain('Bea Test');
expect(fixture.nativeElement.textContent).not.toContain('Inaktiv Mitglied');
expect(fixture.nativeElement.textContent).toContain('Zu spät');
});
});

View File

@@ -0,0 +1,73 @@
import { CurrencyPipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { Component, LOCALE_ID, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { PublicTeamApi } from '../../core/team/public-team-api';
import { Penalty } from '../../models/penalty.model';
import { PublicTeamOverview } from '../../models/public-access.model';
registerLocaleData(localeDe);
@Component({
selector: 'app-public-team',
imports: [
CurrencyPipe,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './public-team.html',
styleUrl: './public-team.scss',
})
export class PublicTeam {
private readonly publicTeamApi = inject(PublicTeamApi);
protected readonly token = inject(ActivatedRoute).snapshot.paramMap.get('token') ?? '';
protected readonly team = signal<PublicTeamOverview | null>(null);
protected readonly penalties = signal<Penalty[]>([]);
protected readonly loading = signal(true);
protected readonly notFound = signal(false);
protected readonly search = signal('');
protected readonly players = computed(() => {
const query = this.search().trim().toLocaleLowerCase('de');
return (this.team()?.players ?? [])
.filter((player) => player.active)
.filter((player) =>
`${player.firstName} ${player.lastName}`.toLocaleLowerCase('de').includes(query),
)
.sort((a, b) => a.lastName.localeCompare(b.lastName, 'de'));
});
constructor() {
if (!this.token) {
this.loading.set(false);
this.notFound.set(true);
return;
}
this.publicTeamApi.loadTeam(this.token).subscribe({
next: (team) => {
this.team.set({
...team,
balance: Number(team.balance),
outstanding: Number(team.outstanding ?? 0),
});
this.penalties.set(team.penalties);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
this.notFound.set(true);
},
});
}
}

View File

@@ -0,0 +1,21 @@
@if (loading()) {
<div class="team-select-loading">
<mat-spinner diameter="32" />
</div>
} @else if (players().length === 0) {
<div class="team-select-empty">
<p>Du bist noch keinem Team zugeordnet.</p>
</div>
} @else {
<div class="team-select-page">
<h1>Team auswählen</h1>
<mat-nav-list>
@for (player of players(); track player.id) {
<a mat-list-item [routerLink]="['/team', player.team?.id, 'overview']">
<span matListItemTitle>{{ player.team?.name }}</span>
<span matListItemLine>{{ player.firstName }} {{ player.lastName }}</span>
</a>
}
</mat-nav-list>
</div>
}

View File

@@ -0,0 +1,13 @@
.team-select-loading,
.team-select-empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 60dvh;
padding: 1rem;
text-align: center;
}
.team-select-page {
padding: 1rem;
}

View File

@@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter, Router } from '@angular/router';
import { TeamSelect } from './team-select';
import { environment } from '../../../environments/environment';
import { AuthStore } from '../../core/auth/auth-store';
import { Player } from '../../models/player.model';
describe('TeamSelect', () => {
let httpMock: HttpTestingController;
let router: Router;
let authStore: AuthStore;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [TeamSelect],
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
}).compileComponents();
httpMock = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);
authStore = TestBed.inject(AuthStore);
authStore.setSession('token', { id: 42, email: 'a@b.de', firstName: 'A', lastName: 'B' });
});
afterEach(() => {
httpMock.verify();
});
it('renders one entry per team when the user has several', async () => {
const players: Player[] = [
{
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'a', balance: 0 },
},
{
id: 2,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 6, name: 'Team B', alias: 'b', balance: 0 },
},
];
const fixture = TestBed.createComponent(TeamSelect);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players);
await fixture.whenStable();
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('a[mat-list-item]');
expect(items.length).toBe(2);
});
it('redirects automatically when the user has exactly one team', async () => {
const players: Player[] = [
{
id: 1,
firstName: 'A',
lastName: 'B',
balance: 0,
active: true,
team: { id: 5, name: 'Team A', alias: 'a', balance: 0 },
},
];
const navigateSpy = vi.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(TeamSelect);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush(players);
await fixture.whenStable();
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'overview'], { replaceUrl: true });
});
it('shows an empty state when the user has no teams', async () => {
const fixture = TestBed.createComponent(TeamSelect);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Du bist noch keinem Team zugeordnet.');
});
});

View File

@@ -0,0 +1,37 @@
import { Component, effect, inject } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthStore } from '../../core/auth/auth-store';
import { MyTeamsStore } from '../../core/team/my-teams-store';
@Component({
selector: 'app-team-select',
imports: [RouterLink, MatListModule, MatProgressSpinnerModule],
templateUrl: './team-select.html',
styleUrl: './team-select.scss',
})
export class TeamSelect {
private readonly authStore = inject(AuthStore);
private readonly myTeamsStore = inject(MyTeamsStore);
private readonly router = inject(Router);
protected readonly players = this.myTeamsStore.players;
protected readonly loading = this.myTeamsStore.loading;
constructor() {
const userId = this.authStore.currentUser()?.id;
if (userId) {
this.myTeamsStore.ensureLoaded(userId);
}
effect(() => {
const players = this.myTeamsStore.players();
if (!this.myTeamsStore.loading() && players.length === 1 && players[0].team) {
void this.router.navigate(['/team', players[0].team.id, 'overview'], {
replaceUrl: true,
});
}
});
}
}

View File

@@ -0,0 +1,173 @@
<header class="page-header">
<div>
<p class="eyebrow">Finanzen</p>
<h1>Kasse</h1>
<p>Buchungen erfassen und den vollständigen Verlauf nachvollziehen.</p>
</div>
<div class="balance">
<span>Teamkasse</span>
<strong>{{ team()?.balance ?? 0 | currency: 'EUR' }}</strong>
</div>
</header>
@if (canBook()) {
<section class="booking-grid">
<mat-card data-testid="player-booking">
<mat-card-header>
<mat-icon mat-card-avatar>group</mat-icon>
<mat-card-title>Mitgliederbuchung</mat-card-title>
<mat-card-subtitle>Für eine oder mehrere Personen</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="playerForm" (ngSubmit)="submitPlayerBooking()">
<mat-form-field appearance="outline" class="wide">
<mat-label>Mitglieder</mat-label>
<mat-select formControlName="playerIds" multiple>
@for (player of activePlayers(); track player.id) {
<mat-option [value]="player.id"
>{{ player.firstName }} {{ player.lastName }}</mat-option
>
}
</mat-select>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Betrag</mat-label>
<input
matInput
formControlName="amount"
type="number"
min="0.01"
max="10000"
step="0.01"
/>
<span matTextSuffix></span>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Art</mat-label>
<mat-select formControlName="type">
@for (type of playerTransactionTypes; track type.id) {
<mat-option [value]="type.id">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Datum</mat-label>
<input matInput formControlName="date" type="date" />
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="wide">
<mat-label>Notiz (optional)</mat-label>
<input matInput formControlName="note" />
</mat-form-field>
<mat-checkbox formControlName="total">Betrag gleichmäßig auf alle verteilen</mat-checkbox>
<button mat-flat-button type="submit" [disabled]="playerForm.invalid || saving()">
<mat-icon>add_card</mat-icon>
Buchen
</button>
</form>
</mat-card-content>
</mat-card>
<mat-card data-testid="team-booking">
<mat-card-header>
<mat-icon mat-card-avatar>account_balance</mat-icon>
<mat-card-title>Teambuchung</mat-card-title>
<mat-card-subtitle>Einnahme oder Ausgabe der Teamkasse</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="teamForm" (ngSubmit)="submitTeamBooking()">
<div class="form-row">
<mat-form-field appearance="outline">
<mat-label>Betrag</mat-label>
<input
matInput
formControlName="amount"
type="number"
min="0.01"
max="10000"
step="0.01"
/>
<span matTextSuffix></span>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Art</mat-label>
<mat-select formControlName="type">
@for (type of teamTransactionTypes; track type.id) {
<mat-option [value]="type.id">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Datum</mat-label>
<input matInput formControlName="date" type="date" />
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="wide">
<mat-label>Notiz (optional)</mat-label>
<input matInput formControlName="note" />
</mat-form-field>
<button mat-flat-button type="submit" [disabled]="teamForm.invalid || saving()">
<mat-icon>add_card</mat-icon>
Teambuchung speichern
</button>
</form>
</mat-card-content>
</mat-card>
</section>
} @else {
<mat-card class="info-card">
<mat-icon>visibility</mat-icon>
<p>
Du kannst alle Buchungen sehen. Neue Buchungen sind Kassenwart und Teamleitung vorbehalten.
</p>
</mat-card>
}
<section class="activity-section">
<div class="section-heading">
<div>
<p class="eyebrow">Journal</p>
<h2>Alle Buchungen</h2>
</div>
<span>{{ activities().length }} Einträge</span>
</div>
@if (loading()) {
<div class="state"><mat-spinner diameter="36" /><span>Buchungen werden geladen …</span></div>
} @else if (activities().length === 0) {
<div class="state">
<mat-icon>receipt_long</mat-icon><span>Noch keine Buchungen vorhanden.</span>
</div>
} @else {
<div class="activity-list">
@for (activity of activities(); track activity.id) {
<article class="activity-item">
<div class="activity-icon" [class.team]="activity.isTeamWalletTransaction">
<mat-icon>{{
activity.isTeamWalletTransaction ? 'account_balance' : 'person'
}}</mat-icon>
</div>
<div class="activity-copy">
<strong>{{ activity.playerName || 'Teamkasse' }}</strong>
<span>{{ typeLabel(activity.type) }} · {{ activity.date | date: 'dd.MM.yyyy' }}</span>
@if (activity.note) {
<small>{{ activity.note }}</small>
}
</div>
<strong class="amount">{{ displayAmount(activity) | currency: 'EUR' }}</strong>
@if (canReverse(activity)) {
<button
mat-icon-button
data-testid="reverse-booking"
aria-label="Buchung stornieren"
(click)="reverseBooking(activity)"
>
<mat-icon>undo</mat-icon>
</button>
}
</article>
}
</div>
}
</section>

View File

@@ -0,0 +1,161 @@
:host {
display: block;
padding: 28px;
max-width: 1280px;
margin: 0 auto;
}
.page-header,
.section-heading {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: flex-start;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
font-size: clamp(2rem, 4vw, 3rem);
line-height: clamp(2rem, 4vw, 3rem);
margin-bottom: 8px;
}
.eyebrow {
color: var(--mat-sys-primary);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.75rem;
margin-bottom: 6px;
}
.balance {
background: var(--mat-sys-primary-container);
border-radius: 18px;
padding: 16px 22px;
display: grid;
gap: 4px;
min-width: 160px;
}
.balance strong {
font-size: 1.5rem;
}
.booking-grid {
display: grid;
grid-template-columns: 1.35fr 1fr;
gap: 20px;
margin: 28px 0 36px;
}
mat-card {
border-radius: 20px;
}
mat-card-content {
padding-top: 20px;
}
form {
display: grid;
gap: 12px;
}
.form-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.wide {
width: 100%;
}
form button {
justify-self: end;
}
.info-card {
margin: 28px 0;
padding: 18px;
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
}
.info-card p {
margin: 0;
}
.activity-section {
margin-top: 24px;
}
.section-heading {
align-items: end;
margin-bottom: 14px;
}
.section-heading h2 {
margin-bottom: 0;
}
.activity-list {
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 20px;
overflow: hidden;
}
.activity-item {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 14px;
align-items: center;
padding: 14px 18px;
background: var(--mat-sys-surface);
}
.activity-item + .activity-item {
border-top: 1px solid var(--mat-sys-outline-variant);
}
.activity-icon {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 14px;
color: var(--mat-sys-primary);
background: var(--mat-sys-primary-container);
}
.activity-icon.team {
color: var(--mat-sys-tertiary);
background: var(--mat-sys-tertiary-container);
}
.activity-copy {
display: grid;
gap: 2px;
}
.activity-copy span,
.activity-copy small {
color: var(--mat-sys-on-surface-variant);
}
.amount {
font-variant-numeric: tabular-nums;
}
.state {
min-height: 160px;
display: grid;
place-content: center;
justify-items: center;
gap: 12px;
color: var(--mat-sys-on-surface-variant);
}
@media (max-width: 900px) {
.booking-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 650px) {
:host {
padding: 20px 16px;
}
.page-header {
display: grid;
}
.form-row {
grid-template-columns: 1fr;
gap: 0;
}
.activity-item {
grid-template-columns: auto 1fr auto;
}
.activity-item > button {
grid-column: 3;
}
}

View File

@@ -0,0 +1,169 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { AuthStore } from '../../../core/auth/auth-store';
import { TeamStore } from '../../../core/team/team-store';
import { TransactionsApi } from '../../../core/team/transactions-api';
import { Cashbox } from './cashbox';
describe('Cashbox', () => {
const team = {
id: 5,
name: 'Team A',
alias: 'team-a',
balance: 120,
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
balance: -12,
active: true,
teamRole: { id: 2, name: 'scnd_treasurer' },
user: { id: 42, email: 'alex@example.com', firstName: 'Alex', lastName: 'Muster' },
},
{
id: 2,
firstName: 'Bea',
lastName: 'Test',
balance: 5,
active: true,
teamRole: { id: 1, name: 'player' },
},
{
id: 3,
firstName: 'Chris',
lastName: 'Drittel',
balance: 0,
active: true,
teamRole: { id: 1, name: 'player' },
},
],
};
const activities = [
{
id: 9,
date: '2026-07-31T10:00:00.000Z',
amount: 12,
type: 'fine',
note: 'Training',
playerName: 'Bea Test',
isTeamWalletTransaction: false,
},
];
async function setup(roleId = 2, confirm = true) {
const createPlayerTransactions = vi.fn(() => of([]));
const createTeamWalletTransaction = vi.fn(() => of(activities[0]));
const reverseTransaction = vi.fn(() => of(activities[0]));
const loadTeamTransactions = vi.fn(() => of(activities));
const refreshTeam = vi.fn();
const dialog = {
open: vi.fn(() => ({ afterClosed: () => of(confirm) })),
};
await TestBed.configureTestingModule({
imports: [Cashbox],
providers: [
{
provide: AuthStore,
useValue: {
currentUser: signal({
id: 42,
email: 'alex@example.com',
firstName: 'Alex',
lastName: 'Muster',
role: { id: roleId === 99 ? 1 : 2 },
}),
},
},
{
provide: TeamStore,
useValue: { team: signal(team), loading: signal(false), refreshTeam },
},
{
provide: TransactionsApi,
useValue: {
loadTeamTransactions,
createPlayerTransactions,
createTeamWalletTransaction,
reverseTransaction,
},
},
{ provide: MatDialog, useValue: dialog },
],
}).compileComponents();
if (roleId === 1) {
team.players[0].teamRole.id = 1;
} else {
team.players[0].teamRole.id = 2;
}
const fixture = TestBed.createComponent(Cashbox);
fixture.detectChanges();
return {
fixture,
component: fixture.componentInstance,
createPlayerTransactions,
reverseTransaction,
dialog,
refreshTeam,
};
}
it('shows the activity feed and booking controls to a treasurer', async () => {
const { fixture } = await setup();
expect(fixture.nativeElement.textContent).toContain('Bea Test');
expect(fixture.nativeElement.textContent).toContain('-12,00');
expect(fixture.nativeElement.querySelector('[data-testid="player-booking"]')).not.toBeNull();
});
it('submits cent-preserving split transactions for selected players', async () => {
const { component, createPlayerTransactions, refreshTeam } = await setup();
component['playerForm'].setValue({
playerIds: [1, 2, 3],
amount: 10,
type: 11,
note: 'Training',
date: '2026-07-31',
total: true,
});
component['submitPlayerBooking']();
expect(createPlayerTransactions).toHaveBeenCalledWith([
expect.objectContaining({ playerId: 1, amount: 3.34, type: 11 }),
expect.objectContaining({ playerId: 2, amount: 3.33, type: 11 }),
expect.objectContaining({ playerId: 3, amount: 3.33, type: 11 }),
]);
expect(refreshTeam).toHaveBeenCalled();
});
it('asks for confirmation before booking a high amount', async () => {
const { component, createPlayerTransactions, dialog } = await setup(2, false);
component['playerForm'].setValue({
playerIds: [1],
amount: 300,
type: 11,
note: '',
date: '2026-07-31',
total: false,
});
component['submitPlayerBooking']();
expect(dialog.open).toHaveBeenCalled();
expect(createPlayerTransactions).not.toHaveBeenCalled();
});
it('does not show mutation controls to a regular member', async () => {
const { fixture } = await setup(1);
expect(fixture.nativeElement.querySelector('[data-testid="player-booking"]')).toBeNull();
expect(fixture.nativeElement.querySelector('[data-testid="reverse-booking"]')).toBeNull();
});
});

View File

@@ -0,0 +1,283 @@
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { Component, LOCALE_ID, computed, effect, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthStore } from '../../../core/auth/auth-store';
import { TeamStore } from '../../../core/team/team-store';
import { TransactionsApi } from '../../../core/team/transactions-api';
import {
CreatePlayerTransaction,
CreateTeamWalletTransaction,
TeamActivity,
} from '../../../models/transaction.model';
import { ConfirmDialog, ConfirmDialogData } from '../../../shared/confirm-dialog/confirm-dialog';
import { splitAmounts } from './transaction-calculation';
import { signedTransactionAmount } from '../../../models/transaction-amount';
registerLocaleData(localeDe);
const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300;
@Component({
selector: 'app-cashbox',
imports: [
CurrencyPipe,
DatePipe,
ReactiveFormsModule,
MatButtonModule,
MatCardModule,
MatCheckboxModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSelectModule,
MatSnackBarModule,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './cashbox.html',
styleUrl: './cashbox.scss',
})
export class Cashbox {
private readonly authStore = inject(AuthStore);
private readonly dialog = inject(MatDialog);
private readonly formBuilder = inject(FormBuilder);
private readonly snackBar = inject(MatSnackBar);
private readonly teamStore = inject(TeamStore);
private readonly transactionsApi = inject(TransactionsApi);
private loadedTeamId: number | null = null;
protected readonly team = this.teamStore.team;
protected readonly activities = signal<TeamActivity[]>([]);
protected readonly loading = signal(false);
protected readonly saving = signal(false);
protected readonly playerTransactionTypes = [
{ id: 0, label: 'Zahlung' },
{ id: 1, label: 'Guthaben' },
{ id: 11, label: 'Strafe' },
{ id: 12, label: 'Umlage' },
{ id: 13, label: 'Gebühr' },
];
protected readonly teamTransactionTypes = [
{ id: 1, label: 'Guthaben' },
{ id: 14, label: 'Ausgabe' },
];
protected readonly canBook = computed(() => {
const user = this.authStore.currentUser();
if (user?.role?.id === 1) return true;
return (
this.team()?.players?.some(
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 2,
) ?? false
);
});
protected readonly activePlayers = computed(() =>
(this.team()?.players ?? []).filter((player) => player.active),
);
protected readonly playerForm = this.formBuilder.nonNullable.group({
playerIds: this.formBuilder.nonNullable.control<number[]>([], Validators.required),
amount: [0, [Validators.required, Validators.min(0.01), Validators.max(10000)]],
type: [11, Validators.required],
note: [''],
date: [this.today(), Validators.required],
total: [false],
});
protected readonly teamForm = this.formBuilder.nonNullable.group({
amount: [0, [Validators.required, Validators.min(0.01), Validators.max(10000)]],
type: [14, Validators.required],
note: [''],
date: [this.today(), Validators.required],
});
constructor() {
effect(() => {
const teamId = this.team()?.id;
if (teamId && teamId !== this.loadedTeamId) {
this.loadedTeamId = teamId;
this.loadActivities(teamId);
}
});
}
protected submitPlayerBooking(): void {
if (!this.canBook() || this.playerForm.invalid || this.saving()) return;
const value = this.playerForm.getRawValue();
if (value.playerIds.length === 0) return;
const amounts = splitAmounts(value.amount, value.playerIds.length, value.total);
const transactions: CreatePlayerTransaction[] = value.playerIds.map((playerId, index) => ({
playerId,
amount: amounts[index],
type: value.type,
note: value.note.trim() || null,
date: this.toIsoDate(value.date),
}));
if (value.amount >= HIGH_AMOUNT_CONFIRM_THRESHOLD) {
this.confirm({
title: 'Betrag prüfen',
message: `Die Buchung über ${value.amount.toFixed(2)} € ist ungewöhnlich hoch. Wirklich fortfahren?`,
confirmLabel: 'Buchen',
}).subscribe((confirmed) => {
if (confirmed) this.createPlayerTransactions(transactions);
});
return;
}
this.createPlayerTransactions(transactions);
}
protected submitTeamBooking(): void {
const team = this.team();
if (!this.canBook() || !team || this.teamForm.invalid || this.saving()) return;
const value = this.teamForm.getRawValue();
const transaction: CreateTeamWalletTransaction = {
teamId: team.id,
amount: value.amount,
type: value.type,
note: value.note.trim() || null,
date: this.toIsoDate(value.date),
};
const save = () => this.createTeamWalletTransaction(transaction);
if (value.amount >= HIGH_AMOUNT_CONFIRM_THRESHOLD) {
this.confirm({
title: 'Betrag prüfen',
message: `Die Buchung über ${value.amount.toFixed(2)} € ist ungewöhnlich hoch. Wirklich fortfahren?`,
confirmLabel: 'Buchen',
}).subscribe((confirmed) => {
if (confirmed) save();
});
return;
}
save();
}
protected canReverse(activity: TeamActivity): boolean {
return (
this.canBook() &&
!activity.isTeamWalletTransaction &&
!activity.note?.startsWith('Stornierung von Buchung #')
);
}
protected reverseBooking(activity: TeamActivity): void {
if (!this.canReverse(activity) || this.saving()) return;
this.confirm({
title: 'Buchung stornieren',
message: `Die Buchung über ${activity.amount.toFixed(2)} € wirklich stornieren? Die Originalbuchung bleibt sichtbar.`,
confirmLabel: 'Stornieren',
}).subscribe((confirmed) => {
if (!confirmed) return;
this.saving.set(true);
this.transactionsApi.reverseTransaction(activity.id).subscribe({
next: () => this.afterMutation('Buchung wurde storniert.'),
error: () => this.handleError('Buchung konnte nicht storniert werden.'),
});
});
}
protected typeLabel(type: string): string {
return (
{
payment: 'Zahlung',
credit: 'Guthaben',
fine: 'Strafe',
levy: 'Umlage',
fee: 'Gebühr',
expense: 'Ausgabe',
}[type] ?? type
);
}
protected displayAmount(activity: TeamActivity): number {
return signedTransactionAmount(activity.amount, activity.type);
}
private createPlayerTransactions(transactions: CreatePlayerTransaction[]): void {
this.saving.set(true);
this.transactionsApi.createPlayerTransactions(transactions).subscribe({
next: () => {
this.playerForm.reset({
playerIds: [],
amount: 0,
type: 11,
note: '',
date: this.today(),
total: false,
});
this.afterMutation('Buchung wurde gespeichert.');
},
error: () => this.handleError('Buchung konnte nicht gespeichert werden.'),
});
}
private createTeamWalletTransaction(transaction: CreateTeamWalletTransaction): void {
this.saving.set(true);
this.transactionsApi.createTeamWalletTransaction(transaction).subscribe({
next: () => {
this.teamForm.reset({ amount: 0, type: 14, note: '', date: this.today() });
this.afterMutation('Teambuchung wurde gespeichert.');
},
error: () => this.handleError('Teambuchung konnte nicht gespeichert werden.'),
});
}
private afterMutation(message: string): void {
this.saving.set(false);
this.snackBar.open(message, undefined, { duration: 4000 });
this.teamStore.refreshTeam();
const teamId = this.team()?.id;
if (teamId) this.loadActivities(teamId);
}
private loadActivities(teamId: number): void {
this.loading.set(true);
this.transactionsApi.loadTeamTransactions(teamId).subscribe({
next: (activities) => {
this.activities.set(activities);
this.loading.set(false);
},
error: () => {
this.activities.set([]);
this.loading.set(false);
this.snackBar.open('Buchungen konnten nicht geladen werden.', undefined, {
duration: 5000,
});
},
});
}
private handleError(message: string): void {
this.saving.set(false);
this.snackBar.open(message, undefined, { duration: 5000 });
}
private confirm(data: ConfirmDialogData) {
return this.dialog.open(ConfirmDialog, { data }).afterClosed();
}
private today(): string {
const now = new Date();
const offset = now.getTimezoneOffset() * 60_000;
return new Date(now.getTime() - offset).toISOString().slice(0, 10);
}
private toIsoDate(date: string): string {
return new Date(`${date}T12:00:00`).toISOString();
}
}

View File

@@ -0,0 +1,15 @@
import { splitAmounts } from './transaction-calculation';
describe('splitAmounts', () => {
it('splits a total without losing remainder cents', () => {
expect(splitAmounts(10, 3)).toEqual([3.34, 3.33, 3.33]);
});
it('keeps the entered amount when it is not a total', () => {
expect(splitAmounts(10, 3, false)).toEqual([10, 10, 10]);
});
it('rejects an empty player selection', () => {
expect(() => splitAmounts(10, 0)).toThrowError('Mindestens ein Mitglied auswählen.');
});
});

View File

@@ -0,0 +1,17 @@
export function splitAmounts(amount: number, playerCount: number, isTotal = true): number[] {
if (playerCount < 1) {
throw new Error('Mindestens ein Mitglied auswählen.');
}
const amountInCents = Math.round(amount * 100);
if (!isTotal) {
return Array.from({ length: playerCount }, () => amountInCents / 100);
}
const baseCents = Math.floor(amountInCents / playerCount);
const remainderCents = amountInCents % playerCount;
return Array.from(
{ length: playerCount },
(_, index) => (baseCents + (index < remainderCents ? 1 : 0)) / 100,
);
}

View File

@@ -0,0 +1,83 @@
<section class="members-page">
<header class="page-header">
<div>
<p>Team</p>
<h1>Mitglieder</h1>
</div>
@if (canManage()) {
<button mat-flat-button (click)="showCreateForm.set(!showCreateForm())">
<mat-icon>person_add</mat-icon>Neu
</button>
}
</header>
@if (showCreateForm()) {
<mat-card class="create-card"
><mat-card-content
><h2>Mitglied anlegen</h2>
<form [formGroup]="createForm" (ngSubmit)="createPlayer()">
<div class="name-row">
<mat-form-field appearance="outline"
><mat-label>Vorname</mat-label
><input matInput formControlName="firstName" /></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Nachname</mat-label><input matInput formControlName="lastName"
/></mat-form-field>
</div>
<mat-form-field appearance="outline"
><mat-label>Rolle</mat-label
><mat-select formControlName="teamRole"
><mat-option [value]="1">Spieler</mat-option
><mat-option [value]="2">2. 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 class="actions">
<button mat-button type="button" (click)="showCreateForm.set(false)">Abbrechen</button
><button mat-flat-button type="submit" [disabled]="createForm.invalid || saving()">
Anlegen
</button>
</div>
</form></mat-card-content
></mat-card
>
}
<div class="filters">
<mat-form-field appearance="outline" subscriptSizing="dynamic"
><mat-label>Mitglieder suchen</mat-label><mat-icon matPrefix>search</mat-icon
><input
matInput
[value]="search()"
(input)="search.set($any($event.target).value)" /></mat-form-field
><button mat-button (click)="showInactive.set(!showInactive())">
{{ showInactive() ? 'Nur aktive' : 'Inaktive anzeigen' }}
</button>
</div>
@if (players().length === 0) {
<div class="empty">
<mat-icon>group_off</mat-icon><strong>Keine Mitglieder gefunden</strong>
</div>
} @else {
<div class="member-list">
@for (player of players(); track player.id) {
<a class="member" [class.inactive]="!player.active" [routerLink]="[player.id]"
><div class="avatar">{{ player.firstName.charAt(0) }}{{ player.lastName.charAt(0) }}</div>
<div class="member__copy">
<strong>{{ player.firstName }} {{ player.lastName }}</strong
><span
>{{ roleName(player.teamRole?.name) }}
@if (!player.active) {
· Inaktiv
}
</span>
</div>
<strong class="balance" [class.negative]="player.balance < 0">{{
player.balance | currency: 'EUR'
}}</strong
><mat-icon>chevron_right</mat-icon></a
>
}
</div>
}
</section>

View File

@@ -0,0 +1,110 @@
.members-page {
max-width: 760px;
margin: 0 auto;
padding: 1.25rem 1rem 2rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.page-header p,
h1,
h2 {
margin: 0;
}
.page-header p {
color: var(--mat-sys-primary);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.create-card {
margin: 1rem 0;
}
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
}
.name-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.filters {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1rem 0;
}
.filters mat-form-field {
flex: 1;
}
.member-list {
display: flex;
flex-direction: column;
}
.member {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto 24px;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 0;
color: inherit;
text-decoration: none;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.member.inactive {
opacity: 0.6;
}
.avatar {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
font-weight: 700;
}
.member__copy {
display: flex;
flex-direction: column;
min-width: 0;
}
.member__copy span {
color: var(--mat-sys-on-surface-variant);
}
.balance {
color: var(--mat-sys-primary);
}
.balance.negative {
color: var(--mat-sys-error);
}
.empty {
min-height: 220px;
display: grid;
place-content: center;
justify-items: center;
gap: 0.5rem;
color: var(--mat-sys-on-surface-variant);
}
@media (max-width: 520px) {
.name-row {
grid-template-columns: 1fr;
}
.filters {
align-items: stretch;
flex-direction: column;
}
.filters mat-form-field {
width: 100%;
}
}

View File

@@ -0,0 +1,68 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { Members } from './members';
import { TeamStore } from '../../../core/team/team-store';
import { AuthStore } from '../../../core/auth/auth-store';
import { environment } from '../../../../environments/environment';
describe('Members', () => {
it('renders members and lets an authorized captain create one', async () => {
const team = {
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
balance: -12,
active: true,
teamRole: { id: 3, name: 'captain' },
user: { id: 42, email: 'a@b.de', firstName: 'Alex', lastName: 'Muster' },
},
{
id: 2,
firstName: 'Bea',
lastName: 'Test',
balance: 5,
active: true,
teamRole: { id: 1, name: 'player' },
},
],
};
const refreshTeam = vi.fn();
await TestBed.configureTestingModule({
imports: [Members],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{
provide: TeamStore,
useValue: { team: signal(team), loading: signal(false), refreshTeam },
},
],
}).compileComponents();
TestBed.inject(AuthStore).setSession('token', team.players[0].user!);
const fixture = TestBed.createComponent(Members);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Bea Test');
fixture.componentInstance['showCreateForm'].set(true);
fixture.componentInstance['createForm'].setValue({
firstName: 'Chris',
lastName: 'Neu',
teamRole: 1,
});
fixture.componentInstance['createPlayer']();
TestBed.inject(HttpTestingController)
.expectOne(`${environment.apiUrl}teams/5/players`)
.flush({ id: 3 });
expect(refreshTeam).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,99 @@
import { CurrencyPipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { Component, LOCALE_ID, computed, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { AuthStore } from '../../../core/auth/auth-store';
import { TeamStore } from '../../../core/team/team-store';
import { TeamsApi } from '../../../core/team/teams-api';
registerLocaleData(localeDe);
@Component({
selector: 'app-members',
imports: [
CurrencyPipe,
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './members.html',
styleUrl: './members.scss',
})
export class Members {
private readonly authStore = inject(AuthStore);
private readonly formBuilder = inject(FormBuilder);
private readonly teamsApi = inject(TeamsApi);
private readonly teamStore = inject(TeamStore);
protected readonly team = this.teamStore.team;
protected readonly search = signal('');
protected readonly showInactive = signal(false);
protected readonly showCreateForm = signal(false);
protected readonly saving = signal(false);
protected readonly createForm = this.formBuilder.nonNullable.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
teamRole: [1, Validators.required],
});
protected readonly canManage = computed(() => {
const user = this.authStore.currentUser();
if (user?.role?.id === 1) return true;
return (
this.team()?.players?.some(
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 3,
) ?? false
);
});
protected readonly players = computed(() => {
const query = this.search().trim().toLocaleLowerCase('de');
return (this.team()?.players ?? [])
.filter((player) => this.showInactive() || player.active)
.filter((player) =>
`${player.firstName} ${player.lastName}`.toLocaleLowerCase('de').includes(query),
)
.sort((a, b) => a.lastName.localeCompare(b.lastName, 'de'));
});
protected createPlayer(): void {
const team = this.team();
if (!team || this.createForm.invalid || this.saving()) return;
this.saving.set(true);
this.teamsApi.createPlayer(team.id, this.createForm.getRawValue()).subscribe({
next: () => {
this.saving.set(false);
this.showCreateForm.set(false);
this.createForm.reset({ firstName: '', lastName: '', teamRole: 1 });
this.teamStore.refreshTeam();
},
error: () => this.saving.set(false),
});
}
protected roleName(role?: string): string {
return (
(
{
player: 'Spieler',
scnd_treasurer: '2. Kassenwart',
captain: 'Kapitän',
treasurer: 'Kassenwart',
coach: 'Trainer',
} as Record<string, string>
)[role ?? ''] ?? 'Spieler'
);
}
}

View File

@@ -0,0 +1,38 @@
<section class="detail-page">
<a mat-button routerLink="../"><mat-icon>arrow_back</mat-icon>Mitglieder</a>
@if (player(); as player) {
<header>
<div class="avatar">{{ player.firstName.charAt(0) }}{{ player.lastName.charAt(0) }}</div>
<div>
<p>Mitglied</p>
<h1>{{ player.firstName }} {{ player.lastName }}</h1>
<span>{{ player.active ? 'Aktiv' : 'Inaktiv' }}</span>
</div>
<strong [class.negative]="player.balance < 0">{{ player.balance | currency: 'EUR' }}</strong>
</header>
<h2>Buchungsverlauf</h2>
@if (loading()) {
<div class="state"><mat-spinner diameter="32" /></div>
} @else if (transactions().length === 0) {
<div class="state"><mat-icon>receipt_long</mat-icon><span>Noch keine Buchungen</span></div>
} @else {
<div class="history">
@for (transaction of transactions(); track transaction.id) {
<article>
<div>
<strong>{{ transaction.note || typeName(transaction) }}</strong
><span
>{{ transaction.date | date: 'dd.MM.yyyy' }} · {{ typeName(transaction) }}</span
>
</div>
<strong [class.negative]="displayAmount(transaction) < 0">{{
displayAmount(transaction) | currency: 'EUR'
}}</strong>
</article>
}
</div>
}
} @else {
<div class="state">Mitglied wurde nicht gefunden.</div>
}
</section>

View File

@@ -0,0 +1,57 @@
.detail-page {
max-width: 760px;
margin: 0 auto;
padding: 1rem 1rem 2rem;
}
header {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) auto;
align-items: center;
gap: 1rem;
margin: 1rem 0 2rem;
}
.avatar {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: 22px;
background: var(--mat-sys-primary-container);
font: var(--mat-sys-title-large);
font-weight: 700;
}
h1,
h2,
p {
margin: 0;
}
header p,
header span {
color: var(--mat-sys-on-surface-variant);
}
.history article {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.history article div {
display: flex;
flex-direction: column;
}
.history span {
color: var(--mat-sys-on-surface-variant);
}
.negative {
color: var(--mat-sys-error);
}
.state {
min-height: 180px;
display: grid;
place-content: center;
justify-items: center;
gap: 0.5rem;
color: var(--mat-sys-on-surface-variant);
}

View File

@@ -0,0 +1,54 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { PlayerDetail } from './player-detail';
import { TeamStore } from '../../../core/team/team-store';
import { environment } from '../../../../environments/environment';
describe('PlayerDetail', () => {
it('renders the selected player and transaction history', async () => {
const team = {
id: 5,
name: 'Team A',
alias: 'team-a',
balance: 0,
players: [
{
id: 7,
firstName: 'Alex',
lastName: 'Muster',
balance: -12,
active: true,
teamRole: { id: 1, name: 'player' },
},
],
};
await TestBed.configureTestingModule({
imports: [PlayerDetail],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: TeamStore, useValue: { team: signal(team), loading: signal(false) } },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ playerId: '7' }) } },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(PlayerDetail);
fixture.detectChanges();
TestBed.inject(HttpTestingController)
.expectOne(`${environment.apiUrl}teams/5/players/7/transactions`)
.flush([
{ id: 1, date: '2026-07-31', amount: 12, note: 'Beitrag', type: { id: 11, name: 'fine' } },
]);
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Alex Muster');
expect(fixture.nativeElement.textContent).toContain('Beitrag');
expect(fixture.nativeElement.textContent).toContain('-12,00');
});
});

View File

@@ -0,0 +1,70 @@
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { Component, LOCALE_ID, computed, effect, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TeamStore } from '../../../core/team/team-store';
import { TeamsApi } from '../../../core/team/teams-api';
import { PlayerTransaction } from '../../../models/transaction.model';
import { signedTransactionAmount } from '../../../models/transaction-amount';
registerLocaleData(localeDe);
@Component({
selector: 'app-player-detail',
imports: [
CurrencyPipe,
DatePipe,
RouterLink,
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './player-detail.html',
styleUrl: './player-detail.scss',
})
export class PlayerDetail {
private readonly route = inject(ActivatedRoute);
private readonly teamsApi = inject(TeamsApi);
private readonly teamStore = inject(TeamStore);
private readonly playerId = Number(this.route.snapshot.paramMap.get('playerId'));
private readonly loadedKey = signal<string | null>(null);
protected readonly team = this.teamStore.team;
protected readonly player = computed(
() => this.team()?.players?.find((player) => player.id === this.playerId) ?? null,
);
protected readonly transactions = signal<PlayerTransaction[]>([]);
protected readonly loading = signal(true);
constructor() {
effect(() => {
const team = this.team();
if (!team || !Number.isInteger(this.playerId)) return;
const key = `${team.id}:${this.playerId}`;
if (this.loadedKey() === key) return;
this.loadedKey.set(key);
this.teamsApi.loadPlayerTransactions(team.id, this.playerId).subscribe({
next: (transactions) => {
this.transactions.set(transactions);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
});
}
protected typeName(transaction: PlayerTransaction): string {
return typeof transaction.type === 'string'
? transaction.type
: (transaction.type?.name ?? 'Buchung');
}
protected displayAmount(transaction: PlayerTransaction): number {
return signedTransactionAmount(transaction.amount, transaction.type);
}
}

View File

@@ -0,0 +1,45 @@
<a mat-button routerLink="../"><mat-icon>arrow_back</mat-icon>Mehr</a>
<header>
<p class="eyebrow">Mitglied verbinden</p>
<h1>Einladen</h1>
<p>Der Link ist personalisiert und darf nur an die ausgewählte Person gehen.</p>
</header>
@if (!canInvite()) {
<mat-card class="notice"
><mat-icon>lock</mat-icon
><span>Einladungen können Teamleitung und Kassenwart erstellen.</span></mat-card
>
} @else if (availablePlayers().length === 0) {
<mat-card class="notice"
><mat-icon>check_circle</mat-icon
><span>Alle aktiven Mitglieder haben bereits einen Account.</span></mat-card
>
} @else {
<mat-card class="invite-card"
><form [formGroup]="form" (ngSubmit)="generateLink()">
<mat-form-field appearance="outline"
><mat-label>Mitglied</mat-label
><mat-select formControlName="playerId">
@for (player of availablePlayers(); track player.id) {
<mat-option [value]="player.id"
>{{ player.firstName }} {{ player.lastName }}</mat-option
>
}
</mat-select></mat-form-field
>
<button mat-flat-button type="submit" [disabled]="form.invalid || loading()">
<mat-icon>link</mat-icon>Link erzeugen
</button>
</form>
@if (inviteLink()) {
<div class="result">
<mat-form-field appearance="outline"
><mat-label>Einladungslink</mat-label
><input matInput readonly [value]="inviteLink()" /></mat-form-field
><button mat-stroked-button type="button" (click)="copyLink()">
<mat-icon>content_copy</mat-icon>Kopieren
</button>
</div>
}
</mat-card>
}

View File

@@ -0,0 +1,58 @@
:host {
display: block;
padding: 24px 28px;
max-width: 800px;
margin: 0 auto;
}
header {
margin: 20px 0 26px;
}
h1,
p {
margin-top: 0;
}
h1 {
font-size: clamp(2rem, 4vw, 3rem);
margin-bottom: 8px;
}
.eyebrow {
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.invite-card,
.notice {
padding: 22px;
border-radius: 18px;
}
.notice {
display: flex;
flex-direction: row;
gap: 12px;
align-items: center;
}
form,
.result {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: start;
}
.result {
margin-top: 18px;
}
.result mat-form-field {
min-width: 0;
}
@media (max-width: 600px) {
:host {
padding: 20px 16px;
}
form,
.result {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,63 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { provideRouter } from '@angular/router';
import { AuthApi } from '../../../../core/auth/auth-api';
import { AuthStore } from '../../../../core/auth/auth-store';
import { TeamStore } from '../../../../core/team/team-store';
import { Invite } from './invite';
describe('Invite', () => {
it('generates a personalized registration link for an unlinked player', async () => {
const createInvite = vi.fn(() => of({ token: 'invite-token' }));
const team = {
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
players: [
{
id: 7,
firstName: 'Bea',
lastName: 'Test',
balance: 0,
active: true,
teamRole: { id: 1 },
},
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
balance: 0,
active: true,
teamRole: { id: 3 },
user: { id: 42 },
},
],
};
await TestBed.configureTestingModule({
imports: [Invite],
providers: [
provideRouter([]),
{ provide: TeamStore, useValue: { team: signal(team) } },
{ provide: AuthStore, useValue: { currentUser: signal({ id: 42, role: { id: 2 } }) } },
{ provide: AuthApi, useValue: { createInvite } },
],
}).compileComponents();
const fixture = TestBed.createComponent(Invite);
fixture.detectChanges();
fixture.componentInstance['form'].setValue({ playerId: 7 });
fixture.componentInstance['generateLink']();
expect(createInvite).toHaveBeenCalledWith({
teamId: 5,
teamName: 'Team A',
playerId: 7,
playerName: 'Bea Test',
});
expect(fixture.componentInstance['inviteLink']()).toContain(
'/auth/register?token=invite-token',
);
});
});

View File

@@ -0,0 +1,90 @@
import { Component, computed, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthApi } from '../../../../core/auth/auth-api';
import { AuthStore } from '../../../../core/auth/auth-store';
import { TeamStore } from '../../../../core/team/team-store';
@Component({
selector: 'app-invite',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSelectModule,
MatSnackBarModule,
],
templateUrl: './invite.html',
styleUrl: './invite.scss',
})
export class Invite {
private readonly authApi = inject(AuthApi);
private readonly authStore = inject(AuthStore);
private readonly formBuilder = inject(FormBuilder);
private readonly snackBar = inject(MatSnackBar);
private readonly teamStore = inject(TeamStore);
protected readonly team = this.teamStore.team;
protected readonly inviteLink = signal('');
protected readonly loading = signal(false);
protected readonly form = this.formBuilder.nonNullable.group({
playerId: [0, [Validators.required, Validators.min(1)]],
});
protected readonly availablePlayers = computed(() =>
(this.team()?.players ?? []).filter((player) => player.active && !player.user),
);
protected readonly canInvite = computed(() => {
const user = this.authStore.currentUser();
if (user?.role?.id === 1) return true;
return (
this.team()?.players?.some(
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) > 2,
) ?? false
);
});
protected generateLink(): void {
const team = this.team();
const player = this.availablePlayers().find(
(item) => item.id === this.form.controls.playerId.value,
);
if (!this.canInvite() || !team || !player || this.form.invalid || this.loading()) return;
this.loading.set(true);
this.authApi
.createInvite({
teamId: team.id,
teamName: team.name,
playerId: player.id,
playerName: `${player.firstName} ${player.lastName}`,
})
.subscribe({
next: ({ token }) => {
this.inviteLink.set(
`${location.origin}/auth/register?token=${encodeURIComponent(token)}`,
);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
protected async copyLink(): Promise<void> {
if (!this.inviteLink()) return;
try {
await navigator.clipboard.writeText(this.inviteLink());
this.snackBar.open('Einladungslink wurde kopiert.', undefined, { duration: 4000 });
} catch {
this.snackBar.open('Link konnte nicht kopiert werden.', undefined, { duration: 5000 });
}
}
}

View File

@@ -0,0 +1,48 @@
<header>
<p class="eyebrow">Organisation</p>
<h1>Mehr</h1>
<p>Team verwalten und persönliche Einstellungen bearbeiten.</p>
</header>
<section class="link-grid">
<a routerLink="penalties"
><mat-card
><mat-icon>gavel</mat-icon>
<div><strong>Strafenkatalog</strong><span>Regeln und Beträge nachschlagen</span></div>
<mat-icon>chevron_right</mat-icon></mat-card
></a
>
<a routerLink="invite"
><mat-card
><mat-icon>person_add</mat-icon>
<div><strong>Einladen</strong><span>Account mit einem Mitglied verknüpfen</span></div>
<mat-icon>chevron_right</mat-icon></mat-card
></a
>
<a routerLink="profile"
><mat-card
><mat-icon>manage_accounts</mat-icon>
<div><strong>Profil</strong><span>Name und Passwort ändern</span></div>
<mat-icon>chevron_right</mat-icon></mat-card
></a
>
<a routerLink="public-access"
><mat-card
><mat-icon>public</mat-icon>
<div>
<strong>Öffentliche Freigabe</strong><span>Teamstand teilen und Link verwalten</span>
</div>
<mat-icon>chevron_right</mat-icon></mat-card
></a
>
</section>
<mat-card class="account-card">
<div>
<strong>{{ user()?.firstName }} {{ user()?.lastName }}</strong
><span>{{ user()?.email }}</span>
</div>
<button mat-stroked-button type="button" (click)="logout()">
<mat-icon>logout</mat-icon>Abmelden
</button>
</mat-card>

View File

@@ -0,0 +1,76 @@
:host {
display: block;
padding: 28px;
max-width: 960px;
margin: 0 auto;
}
h1,
p {
margin-top: 0;
}
h1 {
font-size: clamp(2rem, 4vw, 3rem);
margin-bottom: 8px;
}
.eyebrow {
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.link-grid {
display: grid;
gap: 14px;
margin: 28px 0;
}
a {
color: inherit;
text-decoration: none;
}
a mat-card {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 18px;
padding: 20px;
border-radius: 18px;
transition:
transform 150ms ease,
box-shadow 150ms ease;
}
a:hover mat-card {
transform: translateY(-2px);
box-shadow: var(--mat-sys-level2);
}
a div,
.account-card div {
display: grid;
gap: 3px;
}
a strong {
font-size: 1.05rem;
}
a span,
.account-card span {
color: var(--mat-sys-on-surface-variant);
}
.account-card {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px;
border-radius: 18px;
}
@media (max-width: 600px) {
:host {
padding: 20px 16px;
}
.account-card {
align-items: stretch;
flex-direction: column;
}
}

View File

@@ -0,0 +1,39 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { AuthStore } from '../../../core/auth/auth-store';
import { More } from './more';
@Component({ template: '' })
class LoginStub {}
describe('More', () => {
it('shows the feature links and logs the user out', async () => {
const clearSession = vi.fn();
await TestBed.configureTestingModule({
imports: [More],
providers: [
provideRouter([{ path: 'auth/login', component: LoginStub }]),
{
provide: AuthStore,
useValue: {
currentUser: signal({ firstName: 'Alex', lastName: 'Muster', email: 'a@b.de' }),
clearSession,
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(More);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Strafenkatalog');
expect(fixture.nativeElement.textContent).toContain('Einladen');
expect(fixture.nativeElement.textContent).toContain('Profil');
expect(fixture.nativeElement.textContent).toContain('Öffentliche Freigabe');
fixture.componentInstance['logout']();
await fixture.whenStable();
expect(clearSession).toHaveBeenCalled();
expect(TestBed.inject(Router).url).toBe('/auth/login');
});
});

View File

@@ -0,0 +1,23 @@
import { Component, inject } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { AuthStore } from '../../../core/auth/auth-store';
@Component({
selector: 'app-more',
imports: [RouterLink, MatButtonModule, MatCardModule, MatIconModule],
templateUrl: './more.html',
styleUrl: './more.scss',
})
export class More {
private readonly authStore = inject(AuthStore);
private readonly router = inject(Router);
protected readonly user = this.authStore.currentUser;
protected logout(): void {
this.authStore.clearSession();
void this.router.navigateByUrl('/auth/login');
}
}

View File

@@ -0,0 +1,43 @@
<a mat-button routerLink="../"><mat-icon>arrow_back</mat-icon>Mehr</a>
<header>
<p class="eyebrow">Teamregeln</p>
<h1>Strafenkatalog</h1>
<p>Klare Regeln, transparent für das ganze Team.</p>
</header>
@if (canManage()) {
<mat-card class="create-card"
><form [formGroup]="form" (ngSubmit)="createPenalty()">
<mat-form-field appearance="outline"
><mat-label>Beschreibung</mat-label><input matInput formControlName="description"
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Betrag</mat-label
><input matInput type="number" min="0.01" step="0.01" formControlName="amount" /><span
matTextSuffix
></span
></mat-form-field
>
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
<mat-icon>add</mat-icon>Eintrag anlegen
</button>
</form></mat-card
>
}
<mat-form-field appearance="outline" class="search"
><mat-label>Strafen durchsuchen</mat-label><mat-icon matPrefix>search</mat-icon
><input matInput [value]="search()" (input)="search.set($any($event.target).value)"
/></mat-form-field>
@if (loading()) {
<div class="state"><mat-spinner diameter="36" /></div>
} @else if (filteredPenalties().length === 0) {
<div class="state"><mat-icon>gavel</mat-icon><span>Keine Einträge gefunden.</span></div>
} @else {
<div class="catalog">
@for (penalty of filteredPenalties(); track penalty.id) {
<mat-card
><span>{{ penalty.description }}</span
><strong>{{ penalty.amount | currency: 'EUR' }}</strong></mat-card
>
}
</div>
}

View File

@@ -0,0 +1,70 @@
:host {
display: block;
padding: 24px 28px;
max-width: 900px;
margin: 0 auto;
}
header {
margin: 20px 0 26px;
}
h1,
p {
margin-top: 0;
}
h1 {
font-size: clamp(2rem, 4vw, 3rem);
margin-bottom: 8px;
}
.eyebrow {
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.create-card {
padding: 20px;
border-radius: 18px;
margin-bottom: 22px;
}
form {
display: grid;
grid-template-columns: 1fr 160px auto;
gap: 12px;
align-items: start;
}
.search {
width: 100%;
}
.catalog {
display: grid;
gap: 10px;
}
.catalog mat-card {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 16px;
padding: 18px;
border-radius: 16px;
}
.state {
min-height: 180px;
display: grid;
place-content: center;
justify-items: center;
gap: 10px;
color: var(--mat-sys-on-surface-variant);
}
@media (max-width: 700px) {
:host {
padding: 20px 16px;
}
form {
grid-template-columns: 1fr;
}
form button {
justify-self: stretch;
}
}

View File

@@ -0,0 +1,54 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { provideRouter } from '@angular/router';
import { AuthStore } from '../../../../core/auth/auth-store';
import { PenaltyApi } from '../../../../core/team/penalty-api';
import { TeamStore } from '../../../../core/team/team-store';
import { Penalties } from './penalties';
describe('Penalties', () => {
it('renders the catalog and lets a captain add an entry', async () => {
const createPenalty = vi.fn(() => of({ id: 2, description: 'Handy in der Kabine', amount: 3 }));
const loadPenalties = vi.fn(() => of([{ id: 1, description: 'Zu spät', amount: 5 }]));
const team = {
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
balance: 0,
active: true,
teamRole: { id: 3 },
user: { id: 42 },
},
],
};
await TestBed.configureTestingModule({
imports: [Penalties],
providers: [
provideRouter([]),
{ provide: TeamStore, useValue: { team: signal(team) } },
{ provide: AuthStore, useValue: { currentUser: signal({ id: 42, role: { id: 2 } }) } },
{ provide: PenaltyApi, useValue: { loadPenalties, createPenalty } },
],
}).compileComponents();
const fixture = TestBed.createComponent(Penalties);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Zu spät');
fixture.componentInstance['form'].setValue({ description: 'Handy in der Kabine', amount: 3 });
fixture.componentInstance['createPenalty']();
expect(createPenalty).toHaveBeenCalledWith({
teamId: 5,
description: 'Handy in der Kabine',
amount: 3,
});
expect(fixture.componentInstance['penalties']().length).toBe(2);
});
});

View File

@@ -0,0 +1,101 @@
import { CurrencyPipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { Component, LOCALE_ID, computed, effect, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AuthStore } from '../../../../core/auth/auth-store';
import { PenaltyApi } from '../../../../core/team/penalty-api';
import { TeamStore } from '../../../../core/team/team-store';
import { Penalty } from '../../../../models/penalty.model';
registerLocaleData(localeDe);
@Component({
selector: 'app-penalties',
imports: [
CurrencyPipe,
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './penalties.html',
styleUrl: './penalties.scss',
})
export class Penalties {
private readonly authStore = inject(AuthStore);
private readonly formBuilder = inject(FormBuilder);
private readonly penaltyApi = inject(PenaltyApi);
private readonly teamStore = inject(TeamStore);
private loadedTeamId: number | null = null;
protected readonly team = this.teamStore.team;
protected readonly penalties = signal<Penalty[]>([]);
protected readonly loading = signal(false);
protected readonly saving = signal(false);
protected readonly search = signal('');
protected readonly form = this.formBuilder.nonNullable.group({
description: ['', Validators.required],
amount: [0, [Validators.required, Validators.min(0.01), Validators.max(10000)]],
});
protected readonly canManage = computed(() => {
const user = this.authStore.currentUser();
if (user?.role?.id === 1) return true;
return (
this.team()?.players?.some(
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) > 2,
) ?? false
);
});
protected readonly filteredPenalties = computed(() => {
const query = this.search().trim().toLocaleLowerCase('de');
return this.penalties().filter((penalty) =>
penalty.description.toLocaleLowerCase('de').includes(query),
);
});
constructor() {
effect(() => {
const teamId = this.team()?.id;
if (teamId && teamId !== this.loadedTeamId) {
this.loadedTeamId = teamId;
this.load(teamId);
}
});
}
protected createPenalty(): void {
const team = this.team();
if (!this.canManage() || !team || this.form.invalid || this.saving()) return;
this.saving.set(true);
this.penaltyApi.createPenalty({ teamId: team.id, ...this.form.getRawValue() }).subscribe({
next: (penalty) => {
this.penalties.update((items) => [...items, penalty]);
this.form.reset({ description: '', amount: 0 });
this.saving.set(false);
},
error: () => this.saving.set(false),
});
}
private load(teamId: number): void {
this.loading.set(true);
this.penaltyApi.loadPenalties(teamId).subscribe({
next: (penalties) => {
this.penalties.set(penalties);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,32 @@
<a mat-button routerLink="../"><mat-icon>arrow_back</mat-icon>Mehr</a>
<header>
<p class="eyebrow">Persönliche Daten</p>
<h1>Profil</h1>
<p>Name und Zugangsdaten verwalten.</p>
</header>
<mat-card
><form [formGroup]="form" (ngSubmit)="save()">
<div class="row">
<mat-form-field appearance="outline"
><mat-label>Vorname</mat-label
><input matInput formControlName="firstName" /></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Nachname</mat-label><input matInput formControlName="lastName"
/></mat-form-field>
</div>
<h2>Passwort ändern</h2>
<p class="hint">Leer lassen, wenn das Passwort unverändert bleiben soll.</p>
<div class="row">
<mat-form-field appearance="outline"
><mat-label>Aktuelles Passwort</mat-label
><input matInput type="password" formControlName="oldPassword" /></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Neues Passwort</mat-label
><input matInput type="password" formControlName="password"
/></mat-form-field>
</div>
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
<mat-icon>save</mat-icon>Speichern
</button>
</form></mat-card
>

View File

@@ -0,0 +1,60 @@
:host {
display: block;
padding: 24px 28px;
max-width: 800px;
margin: 0 auto;
}
header {
margin: 20px 0 26px;
}
h1,
p {
margin-top: 0;
}
h1 {
font-size: clamp(2rem, 4vw, 3rem);
margin-bottom: 8px;
}
.eyebrow {
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
mat-card {
padding: 22px;
border-radius: 18px;
}
form {
display: grid;
gap: 12px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
h2 {
margin: 10px 0 0;
}
.hint {
color: var(--mat-sys-on-surface-variant);
margin-bottom: 2px;
}
form button {
justify-self: end;
}
@media (max-width: 600px) {
:host {
padding: 20px 16px;
}
.row {
grid-template-columns: 1fr;
gap: 0;
}
form button {
justify-self: stretch;
}
}

View File

@@ -0,0 +1,52 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { provideRouter } from '@angular/router';
import { AuthApi } from '../../../../core/auth/auth-api';
import { AuthStore } from '../../../../core/auth/auth-store';
import { Profile } from './profile';
describe('Profile', () => {
it('updates profile names and refreshes the auth store', async () => {
const updated = {
id: 42,
email: 'alex@example.com',
firstName: 'Alexander',
lastName: 'Neu',
};
const updateProfile = vi.fn(() => of(updated));
const updateUser = vi.fn();
await TestBed.configureTestingModule({
imports: [Profile],
providers: [
provideRouter([]),
{ provide: AuthApi, useValue: { updateProfile } },
{
provide: AuthStore,
useValue: {
currentUser: signal({
id: 42,
email: 'alex@example.com',
firstName: 'Alex',
lastName: 'Muster',
}),
updateUser,
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(Profile);
fixture.detectChanges();
fixture.componentInstance['form'].setValue({
firstName: 'Alexander',
lastName: 'Neu',
oldPassword: '',
password: '',
});
fixture.componentInstance['save']();
expect(updateProfile).toHaveBeenCalledWith({ firstName: 'Alexander', lastName: 'Neu' });
expect(updateUser).toHaveBeenCalledWith(updated);
});
});

View File

@@ -0,0 +1,66 @@
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthApi, UpdateProfileRequest } from '../../../../core/auth/auth-api';
import { AuthStore } from '../../../../core/auth/auth-store';
@Component({
selector: 'app-profile',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatSnackBarModule,
],
templateUrl: './profile.html',
styleUrl: './profile.scss',
})
export class Profile {
private readonly authApi = inject(AuthApi);
private readonly authStore = inject(AuthStore);
private readonly snackBar = inject(MatSnackBar);
protected readonly saving = signal(false);
protected readonly form = inject(FormBuilder).nonNullable.group({
firstName: [this.authStore.currentUser()?.firstName ?? '', Validators.required],
lastName: [this.authStore.currentUser()?.lastName ?? '', Validators.required],
oldPassword: [''],
password: ['', Validators.minLength(6)],
});
protected save(): void {
if (this.form.invalid || this.saving()) return;
const value = this.form.getRawValue();
if (value.password && !value.oldPassword) {
this.form.controls.oldPassword.setErrors({ required: true });
return;
}
const request: UpdateProfileRequest = {
firstName: value.firstName.trim(),
lastName: value.lastName.trim(),
};
if (value.password) {
request.oldPassword = value.oldPassword;
request.password = value.password;
}
this.saving.set(true);
this.authApi.updateProfile(request).subscribe({
next: (user) => {
this.authStore.updateUser(user);
this.form.patchValue({ oldPassword: '', password: '' });
this.saving.set(false);
this.snackBar.open('Profil wurde gespeichert.', undefined, { duration: 4000 });
},
error: () => this.saving.set(false),
});
}
}

View File

@@ -0,0 +1,94 @@
<a mat-button routerLink="../"><mat-icon>arrow_back</mat-icon>Mehr</a>
<header>
<p class="eyebrow">Transparenz</p>
<h1>Öffentliche Freigabe</h1>
<p>Teile Kassenstand, Mitgliedersalden, Strafenkatalog und Buchungsverläufe.</p>
</header>
@if (loading()) {
<div class="state"><mat-spinner diameter="38" /><span>Freigabe wird geladen …</span></div>
} @else if (loadFailed()) {
<div class="state">
<mat-icon>cloud_off</mat-icon><strong>Freigabe konnte nicht geladen werden.</strong>
<button mat-stroked-button type="button" (click)="retry()">Erneut versuchen</button>
</div>
} @else if (status(); as access) {
<mat-card class="status-card" [class.active]="access.enabled">
<div class="status-icon">
<mat-icon>{{ access.enabled ? 'public' : 'public_off' }}</mat-icon>
</div>
<div>
<strong>Freigabe ist {{ access.enabled ? 'aktiv' : 'deaktiviert' }}</strong>
<span>
{{
access.enabled
? 'Jeder mit dem Link kann die Teamdaten sehen.'
: 'Die öffentliche Ansicht ist nicht erreichbar.'
}}
</span>
</div>
@if (canManage()) {
<button mat-flat-button type="button" [disabled]="saving()" (click)="toggleAccess()">
{{ access.enabled ? 'Deaktivieren' : 'Aktivieren' }}
</button>
}
</mat-card>
@if (access.enabled && publicUrl()) {
<section class="share-grid">
<mat-card class="share-card">
<p class="eyebrow">Öffentlicher Link</p>
<mat-form-field appearance="outline">
<mat-label>Freigabelink</mat-label>
<input data-testid="public-link" matInput readonly [value]="publicUrl()" />
</mat-form-field>
<div class="actions">
<button mat-flat-button type="button" (click)="copyLink()">
<mat-icon>content_copy</mat-icon>Kopieren
</button>
<button mat-stroked-button type="button" (click)="shareLink()">
<mat-icon>share</mat-icon>Teilen
</button>
<a mat-stroked-button [href]="publicUrl()" target="_blank" rel="noopener">
<mat-icon>open_in_new</mat-icon>Vorschau
</a>
</div>
</mat-card>
<mat-card class="qr-card">
<p class="eyebrow">QR-Code</p>
@if (qrDataUrl()) {
<img
data-testid="public-qr"
[src]="qrDataUrl()"
[alt]="'QR-Code zur öffentlichen Ansicht von ' + (team()?.name ?? 'TeamWallet')"
/>
}
<span>Scannen, um die öffentliche Teamansicht zu öffnen.</span>
</mat-card>
</section>
@if (canManage()) {
<mat-card class="danger-card">
<div>
<strong>Link erneuern</strong>
<span>Der bisherige Link wird sofort ungültig.</span>
</div>
<button
data-testid="rotate-link"
mat-stroked-button
type="button"
[disabled]="saving()"
(click)="rotateLink()"
>
<mat-icon>refresh</mat-icon>Link erneuern
</button>
</mat-card>
}
} @else if (!canManage()) {
<mat-card class="hint-card">
<mat-icon>lock</mat-icon>
<span>Kapitän, Kassenwart oder Trainer können die Freigabe aktivieren.</span>
</mat-card>
}
}

View File

@@ -0,0 +1,139 @@
:host {
display: block;
max-width: 960px;
margin: 0 auto;
padding: 28px;
}
header {
margin: 18px 0 28px;
}
h1,
p {
margin-top: 0;
}
h1 {
margin-bottom: 8px;
font-size: clamp(2rem, 4vw, 3rem);
line-height: clamp(2rem, 4vw, 3rem);
}
.eyebrow {
margin-bottom: 6px;
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.state {
min-height: 220px;
display: grid;
place-items: center;
align-content: center;
gap: 14px;
text-align: center;
}
.status-card,
.danger-card,
.hint-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 18px;
padding: 20px;
border-radius: 18px;
}
.status-card > div:nth-child(2),
.danger-card > div {
flex: 1;
display: grid;
gap: 4px;
}
.status-card span,
.danger-card span,
.qr-card span,
.hint-card span {
color: var(--mat-sys-on-surface-variant);
}
.status-icon {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 14px;
background: var(--mat-sys-surface-container-high);
}
.status-card.active .status-icon {
color: var(--mat-sys-primary);
background: var(--mat-sys-primary-container);
}
.share-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 18px;
margin: 18px 0;
}
.share-card,
.qr-card {
padding: 22px;
border-radius: 18px;
}
.share-card mat-form-field {
width: 100%;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.qr-card {
display: grid;
place-items: center;
align-content: start;
gap: 12px;
text-align: center;
}
.qr-card img {
width: min(100%, 220px);
border-radius: 12px;
}
.danger-card {
margin-top: 18px;
border: 1px solid var(--mat-sys-outline-variant);
}
.hint-card {
margin-top: 18px;
}
@media (max-width: 700px) {
:host {
padding: 20px 16px;
}
.status-card,
.danger-card {
align-items: stretch;
flex-direction: column;
}
.share-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,110 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { AuthStore } from '../../../../core/auth/auth-store';
import { PublicAccessApi } from '../../../../core/team/public-access-api';
import { TeamStore } from '../../../../core/team/team-store';
import { PublicAccess } from './public-access';
describe('PublicAccess', () => {
async function setup(roleId = 3, enabled = true) {
const status = { enabled, token: enabled ? 'a'.repeat(64) : null };
const getStatus = vi.fn(() => of(status));
const setEnabled = vi.fn((_teamId: number, next: boolean) =>
of({ enabled: next, token: 'a'.repeat(64) }),
);
const rotate = vi.fn(() => of({ enabled: true, token: 'b'.repeat(64) }));
const open = vi.fn(() => ({ afterClosed: () => of(true) }));
const snackOpen = vi.fn();
await TestBed.configureTestingModule({
imports: [PublicAccess],
providers: [
provideRouter([]),
{
provide: AuthStore,
useValue: {
currentUser: signal({ id: 4, role: { id: 2 } }),
},
},
{
provide: TeamStore,
useValue: {
team: signal({
id: 7,
name: 'Team A',
alias: 'team-a',
balance: 0,
players: [
{
id: 3,
firstName: 'Ada',
lastName: 'Lovelace',
balance: 0,
active: true,
user: { id: 4 },
teamRole: { id: roleId },
},
],
}),
},
},
{ provide: PublicAccessApi, useValue: { getStatus, setEnabled, rotate } },
{ provide: MatDialog, useValue: { open } },
{ provide: MatSnackBar, useValue: { open: snackOpen } },
],
}).compileComponents();
const fixture = TestBed.createComponent(PublicAccess);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
return { fixture, getStatus, setEnabled, rotate, open, snackOpen };
}
it('shows an active link and QR code to every team member', async () => {
const { fixture } = await setup(1);
expect(fixture.nativeElement.textContent).toContain('Freigabe ist aktiv');
expect(fixture.nativeElement.querySelector('[data-testid="public-link"]').value).toContain(
`/t/${'a'.repeat(64)}`,
);
expect(fixture.nativeElement.querySelector('[data-testid="public-qr"]')).not.toBeNull();
expect(fixture.nativeElement.querySelector('[data-testid="rotate-link"]')).toBeNull();
});
it('allows a captain to activate sharing', async () => {
const { fixture, setEnabled } = await setup(3, false);
fixture.componentInstance['toggleAccess']();
expect(setEnabled).toHaveBeenCalledWith(7, true);
expect(fixture.componentInstance['status']()?.enabled).toBe(true);
});
it('confirms and rotates an active link for managers', async () => {
const { fixture, open, rotate } = await setup(3);
fixture.componentInstance['rotateLink']();
expect(open).toHaveBeenCalled();
expect(rotate).toHaveBeenCalledWith(7);
expect(fixture.componentInstance['status']()?.token).toBe('b'.repeat(64));
});
it('falls back to copying when native sharing is unavailable', async () => {
const clipboard = { writeText: vi.fn(() => Promise.resolve()) };
Object.defineProperty(navigator, 'clipboard', { value: clipboard, configurable: true });
Object.defineProperty(navigator, 'share', { value: undefined, configurable: true });
const { fixture } = await setup(1);
await fixture.componentInstance['shareLink']();
expect(clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining(`/t/${'a'.repeat(64)}`),
);
});
});

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