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 { 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 { MatMenuModule } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { AgGridAngular } from 'ag-grid-angular'; import type { ColDef, GetRowIdParams, GridApi, GridReadyEvent, IDatasource, IGetRowsParams, } from 'ag-grid-community'; import { Subject } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { TeamPermissionsService } from '../../../core/team/team-permissions'; 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, TransactionsJournalQuery, TransactionsSortField, } from '../../../models/transaction.model'; import { ConfirmDialog, ConfirmDialogData } from '../../../shared/confirm-dialog/confirm-dialog'; import { ContextHelp } from '../../../shared/context-help/context-help'; import { CashboxExportDialog } from './cashbox-export-dialog/cashbox-export-dialog'; import { CashboxExportSubscriptionDialog } from './cashbox-export-subscription-dialog/cashbox-export-subscription-dialog'; import '../../../shared/ag-grid/ag-grid-modules'; import { teamwalletGridTheme } from '../../../shared/ag-grid/ag-grid-theme'; import { AmountCellRenderer } from '../../../shared/ag-grid/amount-cell-renderer'; import { ReverseActionCellRenderer, ReverseActionCellRendererParams, } from '../../../shared/ag-grid/reverse-action-cell-renderer'; import { splitAmounts } from './transaction-calculation'; registerLocaleData(localeDe); const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300; const JOURNAL_TYPE_OPTIONS: { value: string; label: string }[] = [ { value: '', label: 'Alle Typen' }, { value: 'payment', label: 'Zahlung' }, { value: 'credit', label: 'Guthaben' }, { value: 'fine', label: 'Strafe' }, { value: 'levy', label: 'Umlage' }, { value: 'fee', label: 'Gebühr' }, { value: 'expense', label: 'Ausgabe' }, ]; @Component({ selector: 'app-cashbox', imports: [ CurrencyPipe, ReactiveFormsModule, MatButtonModule, MatCardModule, MatCheckboxModule, MatFormFieldModule, MatIconModule, MatInputModule, MatMenuModule, MatSelectModule, MatSnackBarModule, ContextHelp, AgGridAngular, ], providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }], templateUrl: './cashbox.html', styleUrl: './cashbox.scss', }) export class Cashbox { private readonly dialog = inject(MatDialog); private readonly formBuilder = inject(FormBuilder); private readonly penaltyApi = inject(PenaltyApi); private readonly permissions = inject(TeamPermissionsService); 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 penalties = signal([]); 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' }, ]; // --- Kassenjournal (AG Grid) --- protected readonly gridTheme = teamwalletGridTheme; protected readonly journalTypeOptions = JOURNAL_TYPE_OPTIONS; protected readonly journalTypeFilter = signal(''); protected readonly journalSearch = signal(''); protected readonly journalTotal = signal(null); private readonly journalSearchInput$ = new Subject(); private gridApi?: GridApi; private journalTeamId: number | null = null; protected readonly journalColumnDefs: ColDef[] = [ { headerName: '', colId: 'icon', sortable: false, resizable: false, width: 56, cellRenderer: (params: { data?: TeamActivity }) => { const isTeam = !!params.data?.isTeamWalletTransaction; const span = document.createElement('span'); span.className = 'material-icons cashbox-grid-icon' + (isTeam ? ' team' : ''); span.textContent = isTeam ? 'account_balance' : 'person'; return span; }, }, { headerName: 'Name', field: 'playerName', minWidth: 140, flex: 1, valueGetter: (params) => params.data?.playerName || 'Teamkasse', }, { headerName: 'Typ', field: 'type', width: 130, valueFormatter: (params) => this.typeLabel(params.value), }, { headerName: 'Datum', field: 'date', width: 120, valueFormatter: (params) => params.value ? new Intl.DateTimeFormat('de-DE').format(new Date(params.value)) : '', }, { headerName: 'Notiz', field: 'note', colId: 'note', minWidth: 160, flex: 2, }, { headerName: 'Betrag', field: 'amount', width: 150, cellRenderer: AmountCellRenderer, }, { headerName: '', colId: 'actions', width: 60, sortable: false, resizable: false, cellRenderer: ReverseActionCellRenderer, cellRendererParams: { canReverse: (activity: TeamActivity) => this.canReverse(activity), onReverse: (activity: TeamActivity) => this.reverseBooking(activity), } satisfies Partial, }, ]; protected readonly journalGetRowId = (params: GetRowIdParams) => `${params.data.isTeamWalletTransaction ? 'w' : 'p'}-${params.data.id}`; protected readonly canBook = computed(() => this.permissions.canDo(this.team(), 'transactionCreate'), ); 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.loadPenalties(teamId); } if (teamId && teamId !== this.journalTeamId) { this.journalTeamId = teamId; this.refreshJournal(); } }); 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 }); }); this.journalSearchInput$ .pipe(debounceTime(300), takeUntilDestroyed()) .subscribe((value) => { this.journalSearch.set(value); this.refreshJournal(); }); } private readonly narrowLayout = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(max-width: 650px)') : null; protected onJournalGridReady(event: GridReadyEvent): void { this.gridApi = event.api; this.updateResponsiveColumns(); this.narrowLayout?.addEventListener('change', () => this.updateResponsiveColumns()); if (this.journalTeamId) this.setJournalDatasource(this.journalTeamId); } private updateResponsiveColumns(): void { this.gridApi?.setColumnsVisible(['note'], !(this.narrowLayout?.matches ?? false)); } protected onJournalTypeChange(value: string): void { this.journalTypeFilter.set(value); this.refreshJournal(); } protected onJournalSearchInput(value: string): void { this.journalSearchInput$.next(value); } private refreshJournal(): void { if (!this.journalTeamId) return; this.setJournalDatasource(this.journalTeamId); } private setJournalDatasource(teamId: number): void { if (!this.gridApi) return; this.gridApi.setGridOption('datasource', this.buildJournalDatasource(teamId)); } private buildJournalDatasource(teamId: number): IDatasource { return { getRows: (params: IGetRowsParams) => { const limit = Math.max(1, params.endRow - params.startRow); const page = Math.floor(params.startRow / limit) + 1; const sortItem = params.sortModel[0]; const query: TransactionsJournalQuery = { page, limit, sortBy: (sortItem?.colId as TransactionsSortField) ?? 'date', sortDir: (sortItem?.sort as 'asc' | 'desc') ?? 'desc', type: this.journalTypeFilter() || undefined, search: this.journalSearch().trim() || undefined, }; this.transactionsApi.loadTeamTransactionsJournal(teamId, query).subscribe({ next: (result) => { this.journalTotal.set(result.total); params.successCallback(result.data, result.total); }, error: () => { this.journalTotal.set(0); params.failCallback(); }, }); }, }; } 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.permissions.canDo(this.team(), 'transactionReverse') && !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 openExportDialog(): void { const teamId = this.team()?.id; if (!this.canBook() || !teamId) return; this.dialog.open(CashboxExportDialog, { data: { teamId } }); } protected openExportSubscriptionDialog(): void { const teamId = this.team()?.id; if (!this.canBook() || !teamId) return; this.dialog.open(CashboxExportSubscriptionDialog, { data: { teamId } }); } 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(); this.refreshJournal(); } 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(); } }