first commit
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user