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([]); protected readonly penalties = signal([]); 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([], 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(); } }