Files
teamwallet/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts
Bastian Wagner 6eeaa3a624 Merge branch 'feature/cash-flow-presentation'
# Conflicts:
#	myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts
#	myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts
#	myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts
2026-08-02 09:49:30 +02:00

317 lines
11 KiB
TypeScript

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 { ActivatedRoute, Router } from '@angular/router';
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 { PenaltyApi } from '../../../core/team/penalty-api';
import { TeamStore } from '../../../core/team/team-store';
import { TransactionsApi } from '../../../core/team/transactions-api';
import { Penalty } from '../../../models/penalty.model';
import {
CreatePlayerTransaction,
CreateTeamWalletTransaction,
TeamActivity,
} from '../../../models/transaction.model';
import { ConfirmDialog, ConfirmDialogData } from '../../../shared/confirm-dialog/confirm-dialog';
import { ContextHelp } from '../../../shared/context-help/context-help';
import { TransactionAmount } from '../../../shared/transaction-amount/transaction-amount';
import { splitAmounts } from './transaction-calculation';
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,
ContextHelp,
TransactionAmount,
],
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 penaltyApi = inject(PenaltyApi);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly teamStore = inject(TeamStore);
private readonly transactionsApi = inject(TransactionsApi);
private loadedTeamId: number | null = null;
private pendingPenaltyId: number | null =
Number(this.route.snapshot.queryParamMap.get('penaltyId')) || null;
protected readonly team = this.teamStore.team;
protected readonly activities = signal<TeamActivity[]>([]);
protected readonly penalties = signal<Penalty[]>([]);
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);
this.loadPenalties(teamId);
}
});
effect(() => {
if (this.pendingPenaltyId === null) return;
const penalty = this.penalties().find((p) => p.id === this.pendingPenaltyId);
if (!penalty) return;
this.pendingPenaltyId = null;
this.applyPenaltyPreset(penalty);
void this.router.navigate([], { queryParams: {}, replaceUrl: true });
});
}
protected onPenaltySelect(penaltyId: number): void {
const penalty = this.penalties().find((p) => p.id === penaltyId);
if (penalty) this.applyPenaltyPreset(penalty);
}
private applyPenaltyPreset(penalty: Penalty): void {
this.playerForm.patchValue({ amount: penalty.amount, note: penalty.description, type: 11 });
}
private loadPenalties(teamId: number): void {
this.penaltyApi.loadPenalties(teamId).subscribe({
next: (penalties) => this.penalties.set(penalties),
error: () => this.penalties.set([]),
});
}
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
);
}
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();
}
}