first commit
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { toString as qrToString } from 'qrcode';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { PublicAccessApi } from '../../../../core/team/public-access-api';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { PublicAccessStatus } from '../../../../models/public-access.model';
|
||||
import { ConfirmDialog, ConfirmDialogData } from '../../../../shared/confirm-dialog/confirm-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-public-access',
|
||||
imports: [
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
templateUrl: './public-access.html',
|
||||
styleUrl: './public-access.scss',
|
||||
})
|
||||
export class PublicAccess {
|
||||
private readonly api = inject(PublicAccessApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
private loadedTeamId: number | null = null;
|
||||
|
||||
protected readonly team = this.teamStore.team;
|
||||
protected readonly status = signal<PublicAccessStatus | null>(null);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly loadFailed = signal(false);
|
||||
protected readonly qrDataUrl = signal('');
|
||||
protected readonly publicUrl = computed(() => {
|
||||
const status = this.status();
|
||||
return status?.enabled && status.token ? `${location.origin}/t/${status.token}` : '';
|
||||
});
|
||||
protected readonly canManage = computed(() => {
|
||||
const user = this.authStore.currentUser();
|
||||
if (user?.role?.id === 1) return true;
|
||||
return (
|
||||
this.team()?.players?.some(
|
||||
(player) =>
|
||||
player.active && player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 3,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const teamId = this.team()?.id;
|
||||
if (teamId && teamId !== this.loadedTeamId) {
|
||||
this.loadedTeamId = teamId;
|
||||
this.load(teamId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected toggleAccess(): void {
|
||||
const teamId = this.team()?.id;
|
||||
const status = this.status();
|
||||
if (!this.canManage() || !teamId || !status || this.saving()) return;
|
||||
this.saveStatus(this.api.setEnabled(teamId, !status.enabled));
|
||||
}
|
||||
|
||||
protected rotateLink(): void {
|
||||
const teamId = this.team()?.id;
|
||||
if (!this.canManage() || !teamId || this.saving()) return;
|
||||
const data: ConfirmDialogData = {
|
||||
title: 'Öffentlichen Link erneuern',
|
||||
message: 'Der bisherige Link wird sofort ungültig. Wirklich einen neuen Link erzeugen?',
|
||||
confirmLabel: 'Link erneuern',
|
||||
};
|
||||
this.dialog
|
||||
.open(ConfirmDialog, { data })
|
||||
.afterClosed()
|
||||
.subscribe((confirmed) => {
|
||||
if (confirmed) this.saveStatus(this.api.rotate(teamId));
|
||||
});
|
||||
}
|
||||
|
||||
protected async copyLink(): Promise<void> {
|
||||
const url = this.publicUrl();
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
this.snackBar.open('Öffentlicher Link wurde kopiert.', undefined, { duration: 4000 });
|
||||
} catch {
|
||||
this.snackBar.open('Link konnte nicht kopiert werden.', undefined, { duration: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
protected async shareLink(): Promise<void> {
|
||||
const url = this.publicUrl();
|
||||
if (!url) return;
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: this.team()?.name ?? 'TeamWallet', url });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
}
|
||||
}
|
||||
await this.copyLink();
|
||||
}
|
||||
|
||||
protected retry(): void {
|
||||
const teamId = this.team()?.id;
|
||||
if (teamId) this.load(teamId);
|
||||
}
|
||||
|
||||
private load(teamId: number): void {
|
||||
this.loading.set(true);
|
||||
this.loadFailed.set(false);
|
||||
this.api.getStatus(teamId).subscribe({
|
||||
next: (status) => {
|
||||
this.loading.set(false);
|
||||
this.acceptStatus(status);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.loadFailed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private saveStatus(request: ReturnType<PublicAccessApi['setEnabled']>): void {
|
||||
this.saving.set(true);
|
||||
request.subscribe({
|
||||
next: (status) => {
|
||||
this.saving.set(false);
|
||||
this.acceptStatus(status);
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Freigabe konnte nicht aktualisiert werden.', undefined, {
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private acceptStatus(status: PublicAccessStatus): void {
|
||||
this.status.set(status);
|
||||
void this.generateQrCode();
|
||||
}
|
||||
|
||||
private async generateQrCode(): Promise<void> {
|
||||
const url = this.publicUrl();
|
||||
if (!url) {
|
||||
this.qrDataUrl.set('');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const svg = await qrToString(url, { type: 'svg', width: 240, margin: 1 });
|
||||
this.qrDataUrl.set(`data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`);
|
||||
} catch {
|
||||
this.qrDataUrl.set('');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<section class="overview-page">
|
||||
<header>
|
||||
<p class="eyebrow">Teamübersicht</p>
|
||||
<h1>{{ team()?.name ?? 'TeamWallet' }}</h1>
|
||||
</header>
|
||||
<div class="balance-grid">
|
||||
<mat-card class="balance-card balance-card--primary"
|
||||
><mat-card-content
|
||||
><span>Teamkasse</span><strong>{{ team()?.balance ?? 0 | currency: 'EUR' }}</strong
|
||||
><small>Verfügbarer Kassenstand</small></mat-card-content
|
||||
></mat-card
|
||||
>
|
||||
<mat-card class="balance-card"
|
||||
><mat-card-content
|
||||
><span>Offene Beiträge</span
|
||||
><strong>{{ team()?.outstanding ?? 0 | currency: 'EUR' }}</strong
|
||||
><small>Summe aktiver Mitglieder</small></mat-card-content
|
||||
></mat-card
|
||||
>
|
||||
</div>
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Zuletzt passiert</p>
|
||||
<h2>Aktivitäten</h2>
|
||||
</div>
|
||||
</div>
|
||||
@if (loadingActivities()) {
|
||||
<div class="state"><mat-spinner diameter="32" /></div>
|
||||
} @else if (activities().length === 0) {
|
||||
<div class="state">
|
||||
<mat-icon>receipt_long</mat-icon><strong>Noch keine Buchungen</strong
|
||||
><span>Neue Aktivitäten erscheinen hier.</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="activity-list">
|
||||
@for (activity of activities(); track activity.id) {
|
||||
<article class="activity">
|
||||
<div class="activity__icon">
|
||||
<mat-icon>{{ activityIcon(activity) }}</mat-icon>
|
||||
</div>
|
||||
<div class="activity__copy">
|
||||
<strong>{{ activity.playerName ?? 'Teamkasse' }}</strong
|
||||
><span>{{ activity.note || activity.type }}</span
|
||||
><small>{{ activity.date | date: 'dd.MM.yyyy' }}</small>
|
||||
</div>
|
||||
<strong class="activity__amount" [class.negative]="displayAmount(activity) < 0">{{
|
||||
displayAmount(activity) | currency: 'EUR'
|
||||
}}</strong>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,104 @@
|
||||
.overview-page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 2rem;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
font: var(--mat-sys-headline-medium);
|
||||
font-weight: 700;
|
||||
}
|
||||
h2 {
|
||||
font: var(--mat-sys-title-large);
|
||||
font-weight: 700;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--mat-sys-primary);
|
||||
font: var(--mat-sys-label-large);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.balance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0 2rem;
|
||||
}
|
||||
.balance-card mat-card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.balance-card strong {
|
||||
font: var(--mat-sys-headline-small);
|
||||
font-weight: 700;
|
||||
}
|
||||
.balance-card small {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
.balance-card--primary {
|
||||
background: var(--mat-sys-primary-container);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
}
|
||||
.section-heading {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.activity {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 0;
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
.activity__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
background: var(--mat-sys-secondary-container);
|
||||
color: var(--mat-sys-on-secondary-container);
|
||||
}
|
||||
.activity__copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.activity__copy span,
|
||||
.activity__copy small {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.activity__amount {
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
.activity__amount.negative {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
.state {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.balance-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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 } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { Overview } from './overview';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
describe('Overview', () => {
|
||||
it('renders balances and the recent team activity', async () => {
|
||||
const routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Overview],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: TeamStore,
|
||||
useValue: {
|
||||
team: signal({ id: 5, name: 'Team A', alias: 'a', balance: 125, outstanding: 40 }),
|
||||
loading: signal(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
parent: {
|
||||
snapshot: { paramMap: convertToParamMap({ id: '5' }) },
|
||||
paramMap: routeParams,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Overview);
|
||||
fixture.detectChanges();
|
||||
TestBed.inject(HttpTestingController)
|
||||
.expectOne(`${environment.apiUrl}teams/5/transactions`)
|
||||
.flush([
|
||||
{
|
||||
id: 1,
|
||||
date: '2026-07-31',
|
||||
amount: 12,
|
||||
type: 'fine',
|
||||
note: 'Beitrag',
|
||||
playerName: 'Alex',
|
||||
isTeamWalletTransaction: false,
|
||||
},
|
||||
]);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('125,00');
|
||||
expect(fixture.nativeElement.textContent).toContain('Alex');
|
||||
expect(fixture.nativeElement.textContent).toContain('Beitrag');
|
||||
expect(fixture.nativeElement.textContent).toContain('-12,00');
|
||||
|
||||
routeParams.next(convertToParamMap({ id: '6' }));
|
||||
TestBed.inject(HttpTestingController)
|
||||
.expectOne(`${environment.apiUrl}teams/6/transactions`)
|
||||
.flush([
|
||||
{
|
||||
id: 2,
|
||||
date: '2026-08-01',
|
||||
amount: 7,
|
||||
type: 'credit',
|
||||
note: 'Neues Team',
|
||||
playerName: 'Bea',
|
||||
isTeamWalletTransaction: false,
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Neues Team');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Beitrag');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import { Component, LOCALE_ID, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
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 { TransactionsApi } from '../../../core/team/transactions-api';
|
||||
import { TeamActivity } from '../../../models/transaction.model';
|
||||
import { signedTransactionAmount } from '../../../models/transaction-amount';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@Component({
|
||||
selector: 'app-overview',
|
||||
imports: [CurrencyPipe, DatePipe, MatCardModule, MatIconModule, MatProgressSpinnerModule],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './overview.html',
|
||||
styleUrl: './overview.scss',
|
||||
})
|
||||
export class Overview {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly transactionsApi = inject(TransactionsApi);
|
||||
protected readonly team = inject(TeamStore).team;
|
||||
protected readonly activities = signal<TeamActivity[]>([]);
|
||||
protected readonly loadingActivities = signal(true);
|
||||
|
||||
constructor() {
|
||||
const parentRoute = this.route.parent;
|
||||
if (!parentRoute) {
|
||||
this.loadingActivities.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
parentRoute.paramMap
|
||||
.pipe(
|
||||
map((params) => Number(params.get('id'))),
|
||||
distinctUntilChanged(),
|
||||
tap((id) => {
|
||||
this.activities.set([]);
|
||||
this.loadingActivities.set(Number.isInteger(id) && id > 0);
|
||||
}),
|
||||
switchMap((id) =>
|
||||
Number.isInteger(id) && id > 0
|
||||
? this.transactionsApi.loadTeamTransactions(id).pipe(catchError(() => of([])))
|
||||
: of([]),
|
||||
),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe((activities) => {
|
||||
this.activities.set(activities.slice(0, 10));
|
||||
this.loadingActivities.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
protected activityIcon(activity: TeamActivity): string {
|
||||
return activity.isTeamWalletTransaction ? 'account_balance' : 'person';
|
||||
}
|
||||
|
||||
protected displayAmount(activity: TeamActivity): number {
|
||||
return signedTransactionAmount(activity.amount, activity.type);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user