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