This commit is contained in:
Bastian Wagner
2026-08-03 14:49:35 +02:00
parent 71d76725a4
commit b21641f37a
24 changed files with 1009 additions and 136 deletions

View File

@@ -1,4 +1,4 @@
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
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';
@@ -10,9 +10,20 @@ 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 { 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';
@@ -22,21 +33,38 @@ 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 { TransactionAmount } from '../../../shared/transaction-amount/transaction-amount';
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,
DatePipe,
ReactiveFormsModule,
MatButtonModule,
MatCardModule,
@@ -44,11 +72,10 @@ const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300;
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSelectModule,
MatSnackBarModule,
ContextHelp,
TransactionAmount,
AgGridAngular,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './cashbox.html',
@@ -69,9 +96,7 @@ export class Cashbox {
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' },
@@ -85,6 +110,81 @@ export class Cashbox {
{ 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'),
);
@@ -114,9 +214,12 @@ export class Cashbox {
const teamId = this.team()?.id;
if (teamId && teamId !== this.loadedTeamId) {
this.loadedTeamId = teamId;
this.loadActivities(teamId);
this.loadPenalties(teamId);
}
if (teamId && teamId !== this.journalTeamId) {
this.journalTeamId = teamId;
this.refreshJournal();
}
});
effect(() => {
if (this.pendingPenaltyId === null) return;
@@ -126,6 +229,77 @@ export class Cashbox {
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 {
@@ -268,25 +442,7 @@ export class Cashbox {
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,
});
},
});
this.refreshJournal();
}
private handleError(message: string): void {