first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
import { Component, Input } from '@angular/core';
import { FormGroup, FormControl, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { TransactionType } from '../../new-transaction/model/transaction-type.enum';
import { CommonModule } from '@angular/common';
import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
@Component({
selector: 'app-transaction-form',
templateUrl: './transaction-form.component.html',
styleUrls: ['./transaction-form.component.scss'],
standalone: true,
imports: [
CommonModule
, FormsModule
, ReactiveFormsModule
, MatFormFieldModule
, MatOptionModule
, MatSelectModule
, MatInputModule
, MatButtonModule
, MatSlideToggleModule
]
})
export class TransactionFormComponent {
@Input('transactionTypes') transactionTypes = [TransactionType.credit, TransactionType.fee, TransactionType.fine, TransactionType.levy, TransactionType.payment];
@Input('totalSlider') showTotalSlider: boolean = true;
@Input('text') text: string | undefined;
description = 'Art wählen';
translations = {
fee: 'Gebühr',
fine: 'Strafe',
levy: 'Umlage',
payment: 'Zahlung',
credit: 'Guthaben',
expense: 'Ausgabe'
}
form = new FormGroup({
note: new FormControl('', []),
amount: new FormControl(null, [Validators.required, Validators.min(0.01), Validators.max(10000)]),
type: new FormControl(null, [Validators.required]),
total: new FormControl(false, [])
});
transactionType(id: number | null): string {
if (id == null) { return ''; }
const key: any = TransactionType[id];
const t = this.translations as any;
return t[key];
}
onTypeChange() {
if (this.form.controls.type.value != null && this.form.controls.type.value < 10) {
this.description = 'Spieler bekommt Geld'
} else {
this.description = 'Spieler zahlt Geld'
}
}
}

View File

@@ -0,0 +1,8 @@
<h1 mat-dialog-title>{{ data.title }}</h1>
<div mat-dialog-content>
<p>{{ data.message }}</p>
</div>
<div mat-dialog-actions align="end">
<button mat-button (click)="cancel()">{{ data.cancelLabel || 'Abbrechen' }}</button>
<button mat-flat-button color="warn" (click)="confirm()">{{ data.confirmLabel || 'Bestätigen' }}</button>
</div>

View File

@@ -0,0 +1,33 @@
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
@Component({
selector: 'app-confirm-dialog',
templateUrl: './confirm-dialog.component.html',
standalone: true,
imports: [CommonModule, MatButtonModule, MatDialogModule]
})
export class ConfirmDialogComponent {
constructor(
public dialogRef: MatDialogRef<ConfirmDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData,
) {}
confirm(): void {
this.dialogRef.close(true);
}
cancel(): void {
this.dialogRef.close(false);
}
}
export interface ConfirmDialogData {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
}

View File

@@ -0,0 +1,10 @@
<h1 style="text-align: center;">Spieler anlegen</h1>
<div>
<app-player-info-form #formComponent></app-player-info-form>
</div>
<div class="flex-row">
<button mat-stroked-button color="warn" (click)="close()">Schließen</button>
<button mat-stroked-button color="primary" [disabled]="disableSave" (click)="close(true)">Anlegen</button>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import { Component, ViewChild } from '@angular/core';
import { MatDialogRef } from '@angular/material/dialog';
import { PlayerInfoFormComponent } from '../components/player-info-form/player-info-form.component';
import { CommonModule } from '@angular/common';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-create-player.dialog',
templateUrl: './create-player.dialog.component.html',
styleUrls: ['./create-player.dialog.component.scss'],
standalone: true,
imports: [ CommonModule, PlayerInfoFormComponent, MatButtonModule ]
})
export class CreatePlayerDialogComponent {
@ViewChild('formComponent') formComponent!: PlayerInfoFormComponent;
constructor(
public dialogRef: MatDialogRef<CreatePlayerDialogComponent>
) {}
get disableSave(): boolean {
return !this.formComponent || !this.formComponent.form || this.formComponent?.form.invalid;
}
close(save?: boolean) {
if (save) {
this.dialogRef.close(this.formComponent.form.value);
return;
}
this.dialogRef.close();
}
}

View File

@@ -0,0 +1,60 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NewTransactionDialogComponent } from './new-transaction/new-transaction.component';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { TeamTransactionDialogComponent } from './team-transaction-dialog/team-transaction-dialog.component';
import { TransactionFormComponent } from './components/transaction-form/transaction-form.component';
import { CreatePlayerDialogComponent } from './create-player-dialog/create-player.dialog.component';
import { PlayerInfoFormComponent } from './components/player-info-form/player-info-form.component';
import { PrivilegesInfoDialogComponent } from './privileges-info-dialog/privileges-info-dialog.component';
import { MatDialogModule } from '@angular/material/dialog';
import { ShowTeamTransactionsComponent } from './show-team-transactions/show-team-transactions.component';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {MatTableModule} from '@angular/material/table';
import { MatSortModule } from '@angular/material/sort';
import { MatPaginatorModule } from '@angular/material/paginator';
const components = [ ]
@NgModule({
declarations: [],
imports: [
CommonModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
MatButtonModule,
MatSlideToggleModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatProgressSpinnerModule,
MatTableModule,
MatSortModule,
MatPaginatorModule,
PlayerInfoFormComponent,
TransactionFormComponent,
TeamTransactionDialogComponent,
PrivilegesInfoDialogComponent,
CreatePlayerDialogComponent,
ShowTeamTransactionsComponent,
NewTransactionDialogComponent
],
exports: [
PlayerInfoFormComponent,
TransactionFormComponent,
TeamTransactionDialogComponent,
PrivilegesInfoDialogComponent,
CreatePlayerDialogComponent,
ShowTeamTransactionsComponent,
NewTransactionDialogComponent
]
})
export class DialogModule {
}

View File

@@ -0,0 +1,2 @@
export * from './new-transaction/new-transaction.component';
export * from './team-transaction-dialog/team-transaction-dialog.component';

View File

@@ -0,0 +1,8 @@
export enum TransactionType {
payment = 0,
credit = 1,
fine = 11,
levy = 12,
fee = 13,
expense = 14
}

View File

@@ -0,0 +1,27 @@
<h1 style="text-align: center;">Buchung anlegen</h1>
<mat-dialog-content>
<div class="selected_players">
<div class="header">Gewählte Spieler:</div>
<ng-container *ngIf="!this.data.all">
<div *ngFor="let p of data.players | slice:0:5" class="selected_players__entry">{{ p.firstName }} {{ p.lastName }}</div>
<div *ngIf="data.players.length > 5">
+ {{ data.players.length - 5 }} weitere
</div>
</ng-container>
<ng-container *ngIf="this.data.all">
<div class="selected_players__entry" style="text-align: center; margin: 8px;">Gesamte Mannschaft</div>
</ng-container>
</div>
<app-transaction-form #formComponent class="form"></app-transaction-form>
</mat-dialog-content>
<div class="summary">
<div class="mat-body">Jeder Spieler bekommt <span style="font-style: italic;">{{ transactionType }} über {{ amount | currency:'EUR' }}</span></div>
</div>
<mat-dialog-actions>
<button mat-button color="warn" (click)="close()">Schließen</button>
<button mat-button color="primary" (click)="create()" [disabled]="!formValid">Anlegen</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,30 @@
:host {
display: flex;
overflow: hidden;
flex-direction: column;
padding: 2px 4px 4px 4px;
}
.selected_players {
padding: 2px;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 4px;
.header {
background-color: #e7e7e7;
margin: -2px -2px 0 -2px;
padding: 2px;
text-align: center;
}
}
.form {
margin-top: 20px;
}
.summary {
margin-top: 32px;
margin-bottom: 20px;
text-align: center;
}

View File

@@ -0,0 +1,51 @@
import { forwardRef } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { NewTransactionDialogComponent } from './new-transaction.component';
describe('NewTransactionDialogComponent', () => {
let component: NewTransactionDialogComponent;
let fixture: ComponentFixture<NewTransactionDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ NewTransactionDialogComponent ],
providers: [
{ provide: MatDialogRef, useValue: {} },
{ provide: MAT_DIALOG_DATA, useValue: {} },
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NewTransactionDialogComponent),
multi: true,
}
],
imports: [
MatDialogModule,
MatFormFieldModule,
ReactiveFormsModule,
MatInputModule,
NoopAnimationsModule,
MatSelectModule,
FormsModule,
MatSlideToggleModule
]
})
.compileComponents();
fixture = TestBed.createComponent(NewTransactionDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,121 @@
import { Component, Inject, OnInit, ViewChild } from '@angular/core';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatDialog, MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { Player } from 'src/app/model';
import { Team } from 'src/app/modules/teams/model/team';
import { TransactionFormComponent } from '../components/transaction-form/transaction-form.component';
import { TransactionType } from './model/transaction-type.enum';
import { CommonModule } from '@angular/common';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component';
const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300;
@Component({
selector: 'app-new-transaction',
templateUrl: './new-transaction.component.html',
styleUrls: ['./new-transaction.component.scss'],
standalone: true,
imports: [
CommonModule
, TransactionFormComponent
, MatButtonModule
, MatDialogModule
]
})
export class NewTransactionDialogComponent implements OnInit {
@ViewChild('formComponent') formComponent!: TransactionFormComponent;
selectedTransactionType: number | null = null;
isTotal: boolean = false;
constructor(
public dialogRef: MatDialogRef<NewTransactionDialogComponent>,
private dialog: MatDialog,
@Inject(MAT_DIALOG_DATA) public data: DialogData,
) {}
ngOnInit(): void {
}
close() {
this.dialogRef.close();
}
create() {
const value = this.formComponent.form.value;
if (!value || value.amount == null || value.total == null || value.type == null) { return; }
let amount = value.total ? value.amount / this.data.players.length : value.amount;
amount = Math.round(amount * 100) / 100;
let res: Transaction[] = [];
for (let p of this.data.players) {
res.push({
amount,
playerId: p.id,
type: value.type as any,
note: value.note as any
})
}
if (value.amount >= HIGH_AMOUNT_CONFIRM_THRESHOLD) {
const confirmRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Betrag prüfen',
message: `Es wird${this.data.players.length > 1 ? ' für jede:n der ' + this.data.players.length + ' gewählten Spieler:innen' : ''} ein Betrag von ${value.amount} € gebucht. Bitte kurz prüfen, bevor du fortfährst.`,
confirmLabel: 'Buchen'
}
});
confirmRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this.dialogRef.close(res);
}
});
return;
}
this.dialogRef.close(res);
}
get amount(): number {
const value = this.formComponent?.form?.value;
if (!value || value.amount == null) { return 0; }
return Math.round((value.total ? value.amount / this.data.players.length : value.amount) * 100) / 100;
}
get transactionType(): string {
if (this.formComponent?.form?.value?.type == null) { return ''; }
return this.formComponent.transactionType(this.formComponent.form.value.type);
}
get formValid(): boolean {
return this.formComponent?.form != null && this.formComponent.form.valid;
}
}
interface DialogData {
team: Team,
players: Player[];
all?: boolean;
}
export interface Transaction {
playerId: Number;
amount: number;
type: TransactionType;
note: string;
}

View File

@@ -0,0 +1,46 @@
<h2 mat-dialog-title>Berechtigungen nach Teamrolle</h2>
<mat-dialog-content>
<div class="mat-body description">
Für jeden Spieler kann ein Einladungslink erstellt werden, mit dem sich der Spieler registrieren und verknüpfen kann.
Jeder Link darf nur an einen Spieler gegeben werden, da jeder Spieler (pro Team) nur mit einem User-Account verbunden sein kann.
Derzeit ist eine Verlinkung nur mit einer neuen Registrierung möglich. Jeder User-Account erhält pro Team die Rolle seines zugeordneten Spielers.
</div>
<div class="mat-h4">Zweiter Kassenwart:</div>
<div class="mat-body">
<ul>
<li>Transaktionen und Buchungen anlegen</li>
</ul>
</div>
<div class="mat-h4">Kassenwart:</div>
<div class="mat-body">
<ul>
<li>Transaktionen und Buchungen anlegen</li>
<li>Spieler anlegen & verwalten</li>
<li>Registrierungslinks erstellen</li>
</ul>
</div>
<div class="mat-h4">Kapitän:</div>
<div class="mat-body">
<ul>
<li>Transaktionen und Buchungen anlegen</li>
<li>Spieler anlegen & verwalten</li>
<li>Registrierungslinks erstellen</li>
</ul>
</div>
<div class="mat-h4">Trainer:</div>
<div class="mat-body">
<ul>
<li>Transaktionen und Buchungen anlegen</li>
<li>Spieler anlegen & verwalten</li>
<li>Registrierungslinks erstellen</li>
</ul>
</div>
</mat-dialog-content>

View File

@@ -0,0 +1,9 @@
:host {
display: flex;
flex-direction: column;
max-width: 850px;
}
.description{
margin-bottom: 24px;
}

View File

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

View File

@@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatDialogModule } from '@angular/material/dialog';
@Component({
selector: 'app-privileges-info-dialog',
templateUrl: './privileges-info-dialog.component.html',
styleUrls: ['./privileges-info-dialog.component.scss'],
standalone: true,
imports: [CommonModule, MatDialogModule]
})
export class PrivilegesInfoDialogComponent {
}

View File

@@ -0,0 +1,118 @@
<div class="dialog-header">
<div>
<h2 mat-dialog-title>Alle Transaktionen</h2>
<p class="dialog-subtitle">Buchungen des gesamten Teams</p>
</div>
<button
mat-icon-button
mat-dialog-close
class="close-button"
aria-label="Dialog schließen">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content>
<div class="dialog-state" *ngIf="isLoading">
<mat-spinner diameter="44"></mat-spinner>
<span>Transaktionen werden geladen …</span>
</div>
<div class="dialog-state error-state" *ngIf="loadError">
<mat-icon>error_outline</mat-icon>
<strong>Transaktionen konnten nicht geladen werden.</strong>
<button mat-stroked-button (click)="load()">Erneut versuchen</button>
</div>
<ng-container *ngIf="!isLoading && !loadError">
<div class="table-toolbar">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Transaktionen durchsuchen</mat-label>
<mat-icon matPrefix>search</mat-icon>
<input
matInput
(keyup)="applyFilter($event)"
placeholder="Name, Betrag oder Notiz"
#input>
</mat-form-field>
<button
mat-stroked-button
class="note-toggle"
(click)="toggleNote()"
[class.active]="displayedColumns.includes('note')">
<mat-icon>notes</mat-icon>
{{ displayedColumns.includes('note') ? 'Notizen ausblenden' : 'Notizen anzeigen' }}
</button>
</div>
<div class="table-shell">
<div class="table-scroll">
<table
mat-table
[dataSource]="dataSource"
matSort
matSortActive="date"
matSortDirection="desc">
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Betrag</th>
<td mat-cell *matCellDef="let element" class="amount-cell">
{{ element.amount | currency:'EUR' }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Datum</th>
<td mat-cell *matCellDef="let element">{{ element.date | date:'shortDate' }}</td>
</ng-container>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Typ</th>
<td mat-cell *matCellDef="let element">{{ element.type }}</td>
</ng-container>
<ng-container matColumnDef="note">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Notiz</th>
<td mat-cell *matCellDef="let element" class="note-cell">{{ element.note || '' }}</td>
</ng-container>
<ng-container matColumnDef="playerName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Spieler</th>
<td mat-cell *matCellDef="let element">{{ element.playerName }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef aria-label="Aktionen"></th>
<td mat-cell *matCellDef="let element" class="actions-cell">
<button
mat-icon-button
*ngIf="!element.isTeamWalletTransaction && !element.note?.startsWith('Stornierung von Buchung #')"
(click)="reverseTransaction(element)"
matTooltip="Buchung stornieren"
aria-label="Buchung stornieren">
<mat-icon>undo</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
<tr class="empty-row" *matNoDataRow>
<td [attr.colspan]="displayedColumns.length">
{{ input.value ? 'Keine passenden Transaktionen gefunden.' : 'Noch keine Transaktionen vorhanden.' }}
</td>
</tr>
</table>
</div>
<mat-paginator
[pageSizeOptions]="[5, 10, 20]"
[pageSize]="10"
showFirstLastButtons
aria-label="Seite der Transaktionen auswählen">
</mat-paginator>
</div>
</ng-container>
</mat-dialog-content>

View File

@@ -0,0 +1,172 @@
:host {
display: block;
width: min(900px, 86vw);
}
.dialog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 24px 24px 16px;
border-bottom: 1px solid var(--mat-divider-color, #e0e0e0);
h2[mat-dialog-title] {
margin: 0;
padding: 0;
line-height: 1.25;
}
}
.dialog-subtitle {
margin: 4px 0 0;
color: rgba(0, 0, 0, 0.6);
}
.close-button {
flex: 0 0 auto;
margin: -8px -8px 0 0;
}
mat-dialog-content {
display: flex;
height: min(70vh, 620px);
max-height: none;
flex-direction: column;
overflow: hidden;
padding: 20px 24px 24px !important;
}
.dialog-state {
min-height: 260px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
color: rgba(0, 0, 0, 0.6);
text-align: center;
}
.error-state {
mat-icon {
width: 44px;
height: 44px;
color: #ba1a1a;
font-size: 44px;
}
}
.table-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
mat-form-field {
flex: 1 1 360px;
max-width: 520px;
}
}
.note-toggle {
flex: 0 0 auto;
&.active {
color: var(--mat-sys-primary, #343dff);
background: #eef3ff;
}
}
.table-shell {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--mat-divider-color, #e0e0e0);
border-radius: 12px;
background: #fff;
}
.table-scroll {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
}
table {
width: 100%;
min-width: 690px;
}
th.mat-mdc-header-cell {
color: rgba(0, 0, 0, 0.68);
font-weight: 600;
white-space: nowrap;
}
td.mat-mdc-cell {
border-bottom-color: rgba(0, 0, 0, 0.08);
}
.amount-cell {
font-weight: 600;
}
.note-cell {
min-width: 180px;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions-cell {
width: 48px;
padding-right: 8px;
text-align: right;
}
.empty-row td {
height: 128px;
color: rgba(0, 0, 0, 0.6);
text-align: center;
}
mat-paginator {
flex: 0 0 auto;
border-top: 1px solid var(--mat-divider-color, #e0e0e0);
}
@media (max-width: 700px) {
:host {
width: 92vw;
}
.dialog-header {
padding: 20px 16px 14px;
}
mat-dialog-content {
height: min(72vh, 620px);
padding: 16px !important;
}
.table-toolbar {
align-items: stretch;
flex-direction: column;
mat-form-field {
flex-basis: auto;
max-width: none;
}
}
.note-toggle {
align-self: flex-start;
}
}

View File

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

View File

@@ -0,0 +1,137 @@
import { CommonModule } from '@angular/common';
import { Component, Inject, OnInit, ViewChild } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Transaction } from 'src/app/model';
import { TeamsService } from 'src/app/modules/teams/teams.service';
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component';
@Component({
selector: 'app-show-team-transactions',
templateUrl: './show-team-transactions.component.html',
styleUrls: ['./show-team-transactions.component.scss'],
standalone: true,
imports: [ CommonModule, MatDialogModule, MatTableModule, MatSortModule, MatPaginatorModule, MatFormFieldModule, MatInputModule, MatProgressSpinnerModule, MatButtonModule, MatIconModule, MatTooltipModule ]
})
export class ShowTeamTransactionsComponent implements OnInit {
isLoading: boolean = true;
loadError: boolean = false;
displayedColumns: string[] = ['amount', 'date', 'type', 'playerName', 'actions'];
dataSource: MatTableDataSource<Transaction> = new MatTableDataSource<Transaction>([]);
private paginator?: MatPaginator;
private sort?: MatSort;
@ViewChild(MatPaginator)
set matPaginator(paginator: MatPaginator | undefined) {
this.paginator = paginator;
if (paginator) {
this.dataSource.paginator = paginator;
}
}
@ViewChild(MatSort)
set matSort(sort: MatSort | undefined) {
this.sort = sort;
if (sort) {
this.dataSource.sort = sort;
}
}
constructor(private teamService: TeamsService,
private dialog: MatDialog,
private snackBar: MatSnackBar,
@Inject(MAT_DIALOG_DATA) public data: DialogData,) {}
ngOnInit(): void {
this.load();
}
load(): void {
this.isLoading = true;
this.loadError = false;
this.teamService.loadTeamsTransactions(this.data.id).subscribe({
next: data => {
this.setupData(data);
},
error: () => {
this.isLoading = false;
this.loadError = true;
}
})
}
setupData(data: Transaction[]) {
this.isLoading = false;
this.dataSource = new MatTableDataSource<Transaction>(data);
if (this.paginator) {
this.dataSource.paginator = this.paginator;
}
if (this.sort) {
this.dataSource.sort = this.sort;
}
}
applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
this.dataSource.filter = filterValue.trim().toLowerCase();
if (this.dataSource.paginator) {
this.dataSource.paginator.firstPage();
}
}
reverseTransaction(transaction: Transaction) {
if (transaction.isTeamWalletTransaction || transaction.note?.startsWith('Stornierung von Buchung #')) {
return;
}
const confirmRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Buchung stornieren',
message: `Buchung über ${transaction.amount} € (${transaction.playerName}, ${transaction.type}) wirklich stornieren? Es wird eine Ausgleichsbuchung erstellt, die Originalbuchung bleibt sichtbar.`,
confirmLabel: 'Stornieren'
}
});
confirmRef.afterClosed().subscribe(confirmed => {
if (!confirmed) { return; }
this.teamService.reverseTransaction(transaction.id).subscribe({
next: () => {
this.snackBar.open('Buchung wurde storniert', undefined, { duration: 5000 });
this.load();
},
error: (err) => {
const message = err?.error?.message || 'Buchung konnte nicht storniert werden';
this.snackBar.open(message, undefined, { duration: 5000, panelClass: 'snackbar_error' });
}
});
});
}
toggleNote() {
const index = this.displayedColumns.indexOf('note');
if (index != -1) {
this.displayedColumns = this.displayedColumns.filter(column => column !== 'note');
} else {
this.displayedColumns = [...this.displayedColumns, 'note'];
}
}
}
interface DialogData {
id: number;
}

View File

@@ -0,0 +1,10 @@
<h1 style="text-align: center;">Buchung anlegen</h1>
<app-transaction-form #formComponent [transactionTypes]="[1, 14]" [totalSlider]="false" [text]="explanation"></app-transaction-form>
<mat-dialog-actions>
<button mat-button mat-button color="warn" (click)="close()">Abbrechen</button>
<button mat-button mat-flat-button color="primary" (click)="onOkClick()" [disabled]="formInvalid" >Anlegen</button>
</mat-dialog-actions>
<!-- <div class="flex-row" style="margin-top: 24px">
</div> -->

View File

@@ -0,0 +1,6 @@
:host {
display: flex;
overflow: hidden;
flex-direction: column;
padding: 2px 4px 4px 4px;
}

View File

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

View File

@@ -0,0 +1,51 @@
import { Component, Inject, ViewChild } from '@angular/core';
import { FormGroup, FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { TransactionFormComponent } from '../components/transaction-form/transaction-form.component';
import { CommonModule } from '@angular/common';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-team-transaction-dialog',
templateUrl: './team-transaction-dialog.component.html',
styleUrls: ['./team-transaction-dialog.component.scss'],
standalone: true,
imports: [CommonModule, FormsModule, ReactiveFormsModule, MatFormFieldModule, TransactionFormComponent, MatButtonModule, MatDialogModule]
})
export class TeamTransactionDialogComponent {
constructor(
public dialogRef: MatDialogRef<TeamTransactionDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: DialogData,
) {}
@ViewChild('formComponent') formComponent!: TransactionFormComponent;
explanation = 'Hier kann eine Ausgabe oder Einnahme des Teams gebucht werden.';
onOkClick() {
const form = this.formComponent.form;
const transaction = { ...form.value, teamId: this.data.teamId };
if (transaction.amount != null && transaction.teamId != null && transaction.type != null) {
this.dialogRef.close(transaction);
}
}
close(): void {
this.dialogRef.close();
}
get formInvalid(): boolean {
return !this.formComponent || !this.formComponent.form.value || this.formComponent.form.value.amount == null ||
this.formComponent.form.value.total == null || this.formComponent.form.value.type == null;
}
}
interface DialogData {
teamId: number;
}

View File

@@ -0,0 +1 @@
export * from './dialog';

View File

@@ -0,0 +1,6 @@
export interface Penalty {
id: number;
description: string;
createdAt: string;
amount: number;
}

View File

@@ -0,0 +1,19 @@
<h2 mat-dialog-title>Strafenkatalog</h2>
<mat-dialog-content>
<div>
<mat-form-field>
<mat-label>Suche</mat-label>
<input matInput type="text" [(ngModel)]="searchStr">
</mat-form-field>
</div>
@if(penalties.length > 0) {
<mat-list role="list">
<mat-list-item role="listitem" *ngFor="let p of getPenalties">
<span matListItemTitle>{{ p.description }}</span>
<span matListItemLine>{{ p.amount | currency:'EUR' }}</span>
</mat-list-item>
</mat-list>
} @else {
<div>Nix da...</div>
}
</mat-dialog-content>

View File

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

View File

@@ -0,0 +1,49 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { environment } from '../../../environments/environment';
import { Penalty } from './model/penalty.interface';
import { MatButtonModule } from '@angular/material/button';
import { MatListModule } from '@angular/material/list';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
@Component({
selector: 'app-penalties',
standalone: true,
imports: [MatDialogModule, MatButtonModule, MatListModule, CommonModule, MatInputModule, FormsModule, ReactiveFormsModule],
templateUrl: './penalties.component.html',
styleUrl: './penalties.component.scss'
})
export class PenaltiesComponent {
searchStr = "";
private http: HttpClient = inject(HttpClient);
public penalties: Penalty[] = [];
constructor(
public dialogRef: MatDialogRef<PenaltiesComponent>,
@Inject(MAT_DIALOG_DATA) public teamId: string,
) {}
ngOnInit(): void {
this.loadPenalties();
}
get getPenalties(): any[] {
return this.penalties.filter(p => p.description.toLocaleLowerCase().includes(this.searchStr.toLowerCase()))
return [];
}
loadPenalties() {
const url = `${environment.apiUrl}penalty/` + this.teamId;
this.http.get(url).subscribe({
next: n => {
this.penalties = n as Penalty[];
}
})
}
}

View File

@@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UnderConstructionComponent } from './under-construction/under-construction.component';
import { DialogModule } from './dialog/dialog.module';
@NgModule({
declarations: [ UnderConstructionComponent ],
imports: [
CommonModule,
DialogModule
],
exports: [ UnderConstructionComponent ]
})
export class SharedModule { }

View File

@@ -0,0 +1,4 @@
<div class="mat-h1">Under Construction</div>
<div class="mat-body">please stand by for news</div>

View File

@@ -0,0 +1,8 @@
:host {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
}

View File

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

View File

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