openExportDialog()/openExportSubscriptionDialog() only guarded on teamId truthiness, relying solely on the template @if for permission gating. Every other permission-gated method in Cashbox (submitPlayerBooking, reverseBooking) re-checks the permission internally too. Add the same guard here, plus a test asserting direct invocation without booking rights does not call dialog.open.
483 lines
16 KiB
TypeScript
483 lines
16 KiB
TypeScript
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<Penalty[]>([]);
|
|
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<number | null>(null);
|
|
private readonly journalSearchInput$ = new Subject<string>();
|
|
private gridApi?: GridApi<TeamActivity>;
|
|
private journalTeamId: number | null = null;
|
|
|
|
protected readonly journalColumnDefs: ColDef<TeamActivity>[] = [
|
|
{
|
|
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<ReverseActionCellRendererParams>,
|
|
},
|
|
];
|
|
|
|
protected readonly journalGetRowId = (params: GetRowIdParams<TeamActivity>) =>
|
|
`${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<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.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<TeamActivity>): 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();
|
|
}
|
|
}
|