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,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);
},
});
}
}