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

@@ -19,10 +19,22 @@
overflow-y: auto;
}
main {
padding-bottom: 48px;
}
.shell-bottom-nav {
position: absolute;
bottom: 0;
display: flex;
width: calc(100% - 48px);
align-self: center;
border-top-right-radius: 16px;
border-top-left-radius: 12px;
border: 1px solid var(--mat-sys-outline-variant);
border-top: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface);
z-index: 2;
&__item {
flex: 1;

View File

@@ -22,7 +22,7 @@ describe('TeamStatsApi', () => {
it('loads the overview stats for a team', () => {
const stats: TeamOverviewStats = {
balanceHistory: [{ month: '2026-07', balance: 125, theoreticalBalance: 150 }],
monthlyFlow: [{ month: '2026-07', income: 50, expense: 12 }],
monthlyFlow: [{ month: '2026-07', income: 50, expense: 12, penalties: 8 }],
topOutstanding: [{ playerId: 3, playerName: 'Alex Muster', balance: 20 }],
};

View File

@@ -1,4 +1,4 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
@@ -6,6 +6,8 @@ import {
CreatePlayerTransaction,
CreateTeamWalletTransaction,
TeamActivity,
TransactionsJournalPage,
TransactionsJournalQuery,
} from '../../models/transaction.model';
@Injectable({ providedIn: 'root' })
@@ -16,6 +18,24 @@ export class TransactionsApi {
return this.http.get<TeamActivity[]>(`${environment.apiUrl}teams/${teamId}/transactions`);
}
loadTeamTransactionsJournal(
teamId: number,
query: TransactionsJournalQuery,
): Observable<TransactionsJournalPage> {
let params = new HttpParams()
.set('page', query.page)
.set('limit', query.limit)
.set('sortBy', query.sortBy)
.set('sortDir', query.sortDir);
if (query.search) params = params.set('search', query.search);
if (query.type) params = params.set('type', query.type);
return this.http.get<TransactionsJournalPage>(
`${environment.apiUrl}teams/${teamId}/transactions/journal`,
{ params },
);
}
createPlayerTransactions(transactions: CreatePlayerTransaction[]): Observable<TeamActivity[]> {
return this.http.post<TeamActivity[]>(`${environment.apiUrl}transactions`, transactions);
}

View File

@@ -151,50 +151,46 @@
<p class="eyebrow">Journal</p>
<h2>Alle Buchungen</h2>
</div>
<span>{{ activities().length }} Einträge</span>
@if (journalTotal(); as total) {
<span>{{ total }} Einträge</span>
}
</div>
@if (loading()) {
<div class="state"><mat-spinner diameter="36" /><span>Buchungen werden geladen …</span></div>
} @else if (activities().length === 0) {
<div class="state">
<mat-icon>receipt_long</mat-icon><span>Noch keine Buchungen vorhanden.</span>
</div>
} @else {
<div class="activity-list">
@for (activity of activities(); track activity.id) {
<article class="activity-item">
<div class="activity-icon" [class.team]="activity.isTeamWalletTransaction">
<mat-icon>{{
activity.isTeamWalletTransaction ? 'account_balance' : 'person'
}}</mat-icon>
</div>
<div class="activity-copy">
<strong>{{ activity.playerName || 'Teamkasse' }}</strong>
<span>{{ typeLabel(activity.type) }} · {{ activity.date | date: 'dd.MM.yyyy' }}</span>
@if (activity.note) {
<small>{{ activity.note }}</small>
}
</div>
<strong>
<app-transaction-amount
[amount]="activity.amount"
[type]="activity.type"
[context]="activity.isTeamWalletTransaction ? 'team' : 'player'"
/>
</strong>
@if (canReverse(activity)) {
<button
mat-icon-button
data-testid="reverse-booking"
aria-label="Buchung stornieren"
(click)="reverseBooking(activity)"
>
<mat-icon>undo</mat-icon>
</button>
}
</article>
}
</div>
}
<div class="journal-toolbar">
<mat-form-field appearance="outline" class="journal-search">
<mat-label>Suche</mat-label>
<input
matInput
placeholder="Name oder Notiz"
[value]="journalSearch()"
(input)="onJournalSearchInput($any($event.target).value)"
/>
<mat-icon matTextSuffix>search</mat-icon>
</mat-form-field>
<mat-form-field appearance="outline" class="journal-type">
<mat-label>Typ</mat-label>
<mat-select
[value]="journalTypeFilter()"
(selectionChange)="onJournalTypeChange($event.value)"
>
@for (option of journalTypeOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<ag-grid-angular
class="journal-grid"
[theme]="gridTheme"
[columnDefs]="journalColumnDefs"
[rowModelType]="'infinite'"
[pagination]="true"
[paginationPageSize]="25"
[paginationPageSizeSelector]="[10, 25, 50, 100]"
[cacheBlockSize]="25"
[getRowId]="journalGetRowId"
[suppressCellFocus]="true"
(gridReady)="onJournalGridReady($event)"
/>
</section>

View File

@@ -92,52 +92,39 @@ form button {
.section-heading h2 {
margin-bottom: 0;
}
.activity-list {
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 20px;
overflow: hidden;
.journal-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
}
.activity-item {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 14px;
align-items: center;
padding: 14px 18px;
background: var(--mat-sys-surface);
.journal-search {
flex: 1 1 240px;
}
.activity-item + .activity-item {
border-top: 1px solid var(--mat-sys-outline-variant);
.journal-type {
flex: 0 1 200px;
}
.activity-icon {
.journal-grid {
height: 640px;
width: 100%;
display: block;
}
.cashbox-grid-icon {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 14px;
width: 32px;
height: 32px;
border-radius: 10px;
color: var(--mat-sys-primary);
// background: var(--mat-sys-primary-container);
background: var(--mat-sys-secondary-container);
font-size: 18px;
line-height: 32px;
margin-top: 6px;
}
.activity-icon.team {
.cashbox-grid-icon.team {
color: var(--mat-sys-tertiary);
background: var(--mat-sys-tertiary-container);
}
.activity-copy {
display: grid;
gap: 2px;
}
.activity-copy span,
.activity-copy small {
color: var(--mat-sys-on-surface-variant);
}
.state {
min-height: 160px;
display: grid;
place-content: center;
justify-items: center;
gap: 12px;
color: var(--mat-sys-on-surface-variant);
}
@media (max-width: 900px) {
.booking-grid {
grid-template-columns: 1fr;
@@ -154,10 +141,10 @@ form button {
grid-template-columns: 1fr;
gap: 0;
}
.activity-item {
grid-template-columns: auto 1fr auto;
.journal-toolbar {
flex-direction: column;
}
.activity-item > button {
grid-column: 3;
.journal-grid {
height: 520px;
}
}

View File

@@ -83,6 +83,9 @@ describe('Cashbox', () => {
const createTeamWalletTransaction = vi.fn(() => of(activities[0]));
const reverseTransaction = vi.fn(() => of(activities[0]));
const loadTeamTransactions = vi.fn(() => of(activities));
const loadTeamTransactionsJournal = vi.fn(() =>
of({ data: activities, total: activities.length }),
);
const loadPenalties = vi.fn(() => of(penalties));
const refreshTeam = vi.fn();
const dialog = {
@@ -112,6 +115,7 @@ describe('Cashbox', () => {
provide: TransactionsApi,
useValue: {
loadTeamTransactions,
loadTeamTransactionsJournal,
createPlayerTransactions,
createTeamWalletTransaction,
reverseTransaction,
@@ -142,35 +146,66 @@ describe('Cashbox', () => {
fixture,
component: fixture.componentInstance,
createPlayerTransactions,
loadTeamTransactionsJournal,
reverseTransaction,
dialog,
refreshTeam,
};
}
it('shows the activity feed and booking controls to a treasurer', async () => {
it('shows the Kassenjournal grid and booking controls to a treasurer', async () => {
const { fixture } = await setup();
expect(fixture.nativeElement.textContent).toContain('Bea Test');
expect(fixture.nativeElement.querySelector('[data-testid="player-booking"]')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Buchungen verstehen');
expect(fixture.nativeElement.textContent).toContain('Alle Buchungen');
expect(fixture.nativeElement.querySelector('ag-grid-angular')).not.toBeNull();
});
it('renders inflow, outflow, and neutral activity amounts with their cash-flow meaning', async () => {
const { fixture } = await setup();
const amounts = [
...fixture.nativeElement.querySelectorAll(
'app-transaction-amount [data-testid="transaction-amount"]',
),
] as HTMLElement[];
// AG Grid virtualizes rows based on real layout measurements (container
// height, ResizeObserver) that jsdom doesn't provide, so it never actually
// requests rows in this environment - row/cell content is verified
// manually in the browser (see AmountCellRenderer/ReverseActionCellRenderer
// specs for the cell logic in isolation). Here we call the grid's
// datasource factory directly to verify the query it builds.
it('builds a journal datasource that requests page 1 sorted by date descending', async () => {
const { component, loadTeamTransactionsJournal } = await setup();
const successCallback = vi.fn();
expect(amounts).toHaveLength(3);
expect(amounts[0].classList).toContain('inflow');
expect(amounts[1].classList).toContain('outflow');
expect(amounts[2].classList).toContain('neutral');
expect(
amounts.map((amount) => amount.querySelector('.transaction-amount__sign')?.textContent),
).toEqual(['+', '', '']);
const datasource = component['buildJournalDatasource'](5);
datasource.getRows({
startRow: 0,
endRow: 25,
sortModel: [],
filterModel: {},
successCallback,
failCallback: vi.fn(),
} as unknown as Parameters<typeof datasource.getRows>[0]);
expect(loadTeamTransactionsJournal).toHaveBeenCalledWith(
5,
expect.objectContaining({ page: 1, limit: 25, sortBy: 'date', sortDir: 'desc' }),
);
expect(successCallback).toHaveBeenCalledWith(activities, activities.length);
});
it('maps the grid sort model onto the journal query for a different column', async () => {
const { component, loadTeamTransactionsJournal } = await setup();
const datasource = component['buildJournalDatasource'](5);
datasource.getRows({
startRow: 25,
endRow: 50,
sortModel: [{ colId: 'amount', sort: 'asc' }],
filterModel: {},
successCallback: vi.fn(),
failCallback: vi.fn(),
} as unknown as Parameters<typeof datasource.getRows>[0]);
expect(loadTeamTransactionsJournal).toHaveBeenCalledWith(
5,
expect.objectContaining({ page: 2, limit: 25, sortBy: 'amount', sortDir: 'asc' }),
);
});
it('submits cent-preserving split transactions for selected players', async () => {

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 {

View File

@@ -25,8 +25,8 @@ const sampleStats: TeamOverviewStats = {
{ month: '2026-07', balance: 125, theoreticalBalance: 150 },
],
monthlyFlow: [
{ month: '2026-06', income: 50, expense: 10 },
{ month: '2026-07', income: 40, expense: 15 },
{ month: '2026-06', income: 50, expense: 10, penalties: 20 },
{ month: '2026-07', income: 40, expense: 15, penalties: 5 },
],
topOutstanding: [{ playerId: 3, playerName: 'Chris Beispiel', balance: 20 }],
};
@@ -199,7 +199,10 @@ describe('Overview', () => {
expect(balanceChart.data.datasets[1].data).toEqual([100, 150]);
expect(balanceChart.options?.plugins?.legend?.position).toBe('bottom');
expect(flowChart.type).toBe('bar');
expect(flowChart.data.datasets).toHaveLength(2);
expect(flowChart.data.datasets).toHaveLength(3);
expect(flowChart.data.datasets[2].label).toBe('Strafen & Umlagen');
expect(flowChart.data.datasets[2].data).toEqual([20, 5]);
expect(flowChart.data.datasets[2].backgroundColor).toBe('#9e9e9e');
expect(outstandingChart.type).toBe('bar');
expect(outstandingChart.data.labels).toEqual(['Chris Beispiel']);
});

View File

@@ -24,6 +24,7 @@ const BALANCE_COLOR = '#4f8f46';
const THEORETICAL_BALANCE_COLOR = '#1d70b8';
const INCOME_COLOR = '#4f8f46';
const EXPENSE_COLOR = '#c1121f';
const PENALTY_COLOR = '#9e9e9e';
function formatMonthLabel(month: string): string {
const [year, monthNumber] = month.split('-').map(Number);
@@ -75,6 +76,13 @@ export class Overview {
backgroundColor: BALANCE_COLOR,
tension: 0.3,
fill: false,
tooltip: {
callbacks: {
label: function(context) {
return `In der Kasse: ${context.formattedValue}`
}
}
}
},
{
label: 'Theoretisch (inkl. offene Beiträge)',
@@ -84,6 +92,13 @@ export class Overview {
borderDash: [6, 4],
tension: 0.3,
fill: false,
tooltip: {
callbacks: {
label: function(context) {
return `Kasse + Offen: ${context.formattedValue}`
}
}
}
},
],
};
@@ -98,11 +113,40 @@ export class Overview {
label: 'Einnahmen',
data: points.map((point) => point.income),
backgroundColor: INCOME_COLOR,
borderRadius: 4,
tooltip: {
callbacks: {
label: function(context) {
return `Einnahen: ${context.formattedValue}`
}
}
}
},
{
label: 'Ausgaben',
data: points.map((point) => point.expense),
backgroundColor: EXPENSE_COLOR,
borderRadius: 4,
tooltip: {
callbacks: {
label: function(context) {
return `Ausgaben: ${context.formattedValue}`
}
}
}
},
{
label: 'Strafen & Umlagen',
data: points.map((point) => point.penalties),
backgroundColor: PENALTY_COLOR,
borderRadius: 4,
tooltip: {
callbacks: {
label: function(context) {
return `Strafen & Umlagen: ${context.formattedValue}`
}
}
}
},
],
};
@@ -117,6 +161,13 @@ export class Overview {
label: 'Offener Betrag',
data: players.map((player) => player.balance),
backgroundColor: EXPENSE_COLOR,
tooltip: {
callbacks: {
label: function(context) {
return ` Offen: ${context.formattedValue}`
}
}
}
},
],
};
@@ -130,17 +181,28 @@ export class Overview {
y: {
ticks: {
callback: function(value, index, ticks) {
return value.toLocaleString() + '€';
return value.toLocaleString() + ' €';
}
}
}
}
},
locale: 'de-DE'
};
protected readonly flowChartOptions: ChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' } },
scales: {
y: {
ticks: {
callback: function(value, index, ticks) {
return value.toLocaleString() + ' €';
}
}
}
},
};
protected readonly topOutstandingChartOptions: ChartOptions = {
@@ -148,6 +210,15 @@ export class Overview {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: {
ticks: {
callback: function(value, index, ticks) {
return value.toLocaleString() + ' €';
}
}
}
},
};
constructor() {

View File

@@ -8,6 +8,7 @@ export interface MonthlyFlowPoint {
month: string;
income: number;
expense: number;
penalties: number;
}
export interface TopOutstandingPlayer {

View File

@@ -31,3 +31,19 @@ export interface CreateTeamWalletTransaction {
amount: number;
type: number;
}
export type TransactionsSortField = 'date' | 'amount' | 'playerName' | 'type';
export interface TransactionsJournalQuery {
page: number;
limit: number;
search?: string;
type?: string;
sortBy: TransactionsSortField;
sortDir: 'asc' | 'desc';
}
export interface TransactionsJournalPage {
data: TeamActivity[];
total: number;
}

View File

@@ -0,0 +1,23 @@
import {
CellStyleModule,
ColumnApiModule,
InfiniteRowModelModule,
ModuleRegistry,
PaginationModule,
ValidationModule,
} from 'ag-grid-community';
// Imported for its side effect: registers only the (free, Community) AG Grid
// features the Kassenjournal grid actually uses - Infinite Row Model for
// server-side paging, plus pagination and cell styling. Scoped like this
// (instead of AllCommunityModule) keeps the grid's lazy route chunk from
// pulling in unused features (CSV/Excel export, charting integration,
// master/detail, etc). Only imported from cashbox.ts, so this code - and the
// rest of ag-grid-community - stays out of the eagerly-loaded main bundle.
ModuleRegistry.registerModules([
InfiniteRowModelModule,
PaginationModule,
CellStyleModule,
ColumnApiModule,
ValidationModule,
]);

View File

@@ -0,0 +1,21 @@
import { themeQuartz } from 'ag-grid-community';
// Maps the grid's look onto the app's Material 3 system tokens (see
// src/styles.scss) so it reads as part of the app rather than a bolted-on
// widget. Values are CSS custom properties, resolved at paint time, so this
// theme automatically follows the app's green/orange palette.
export const teamwalletGridTheme = themeQuartz.withParams({
accentColor: 'var(--mat-sys-primary)',
backgroundColor: 'var(--mat-sys-surface)',
foregroundColor: 'var(--mat-sys-on-surface)',
chromeBackgroundColor: 'var(--mat-sys-surface)',
headerBackgroundColor: 'var(--mat-sys-surface)',
headerTextColor: 'var(--mat-sys-on-surface-variant)',
headerFontWeight: 600,
borderColor: 'var(--mat-sys-outline-variant)',
wrapperBorderRadius: 20,
borderRadius: 14,
selectedRowBackgroundColor: 'var(--mat-sys-secondary-container)',
fontFamily: 'Roboto, sans-serif',
spacing: 8,
});

View File

@@ -0,0 +1,41 @@
import { TestBed } from '@angular/core/testing';
import { ICellRendererParams } from 'ag-grid-community';
import { AmountCellRenderer } from './amount-cell-renderer';
import { TeamActivity } from '../../models/transaction.model';
describe('AmountCellRenderer', () => {
function create(activity: TeamActivity) {
const fixture = TestBed.createComponent(AmountCellRenderer);
fixture.componentInstance.agInit({
data: activity,
} as unknown as ICellRendererParams<TeamActivity>);
fixture.detectChanges();
return fixture;
}
it('renders an inflow amount for a payment', () => {
const fixture = create({
id: 1,
date: '2026-07-01',
amount: 12,
type: 'payment',
isTeamWalletTransaction: false,
});
const el = fixture.nativeElement.querySelector('[data-testid="transaction-amount"]');
expect(el.classList).toContain('inflow');
});
it('renders a neutral amount for a fine (no real cash flow)', () => {
const fixture = create({
id: 2,
date: '2026-07-01',
amount: 5,
type: 'fine',
isTeamWalletTransaction: false,
});
const el = fixture.nativeElement.querySelector('[data-testid="transaction-amount"]');
expect(el.classList).toContain('neutral');
});
});

View File

@@ -0,0 +1,31 @@
import { Component } from '@angular/core';
import { ICellRendererAngularComp } from 'ag-grid-angular';
import { ICellRendererParams } from 'ag-grid-community';
import { TeamActivity } from '../../models/transaction.model';
import { TransactionAmount } from '../transaction-amount/transaction-amount';
@Component({
selector: 'app-amount-cell-renderer',
imports: [TransactionAmount],
template: `
@if (data) {
<app-transaction-amount
[amount]="data.amount"
[type]="data.type"
[context]="data.isTeamWalletTransaction ? 'team' : 'player'"
/>
}
`,
})
export class AmountCellRenderer implements ICellRendererAngularComp {
protected data?: TeamActivity;
agInit(params: ICellRendererParams<TeamActivity>): void {
this.data = params.data;
}
refresh(params: ICellRendererParams<TeamActivity>): boolean {
this.data = params.data;
return true;
}
}

View File

@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import {
ReverseActionCellRenderer,
ReverseActionCellRendererParams,
} from './reverse-action-cell-renderer';
import { TeamActivity } from '../../models/transaction.model';
describe('ReverseActionCellRenderer', () => {
const activity: TeamActivity = {
id: 7,
date: '2026-07-01',
amount: 5,
type: 'fine',
isTeamWalletTransaction: false,
};
function create(canReverse: boolean, onReverse = vi.fn()) {
const fixture = TestBed.createComponent(ReverseActionCellRenderer);
fixture.componentInstance.agInit({
data: activity,
canReverse: () => canReverse,
onReverse,
} as unknown as ReverseActionCellRendererParams);
fixture.detectChanges();
return { fixture, onReverse };
}
it('shows the reverse button when the activity can be reversed', () => {
const { fixture } = create(true);
expect(fixture.nativeElement.querySelector('[data-testid="reverse-booking"]')).not.toBeNull();
});
it('hides the reverse button when it cannot be reversed', () => {
const { fixture } = create(false);
expect(fixture.nativeElement.querySelector('[data-testid="reverse-booking"]')).toBeNull();
});
it('invokes onReverse with the row activity when clicked', () => {
const { fixture, onReverse } = create(true);
fixture.nativeElement.querySelector('[data-testid="reverse-booking"]').click();
expect(onReverse).toHaveBeenCalledWith(activity);
});
});

View File

@@ -0,0 +1,48 @@
import { Component } from '@angular/core';
import { ICellRendererAngularComp } from 'ag-grid-angular';
import { ICellRendererParams } from 'ag-grid-community';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { TeamActivity } from '../../models/transaction.model';
export interface ReverseActionCellRendererParams extends ICellRendererParams<TeamActivity> {
canReverse: (activity: TeamActivity) => boolean;
onReverse: (activity: TeamActivity) => void;
}
@Component({
selector: 'app-reverse-action-cell-renderer',
imports: [MatButtonModule, MatIconModule],
template: `
@if (activity && canReverseActivity) {
<button
mat-icon-button
data-testid="reverse-booking"
aria-label="Buchung stornieren"
(click)="onReverse()"
>
<mat-icon>undo</mat-icon>
</button>
}
`,
})
export class ReverseActionCellRenderer implements ICellRendererAngularComp {
protected activity?: TeamActivity;
protected canReverseActivity = false;
private params?: ReverseActionCellRendererParams;
agInit(params: ReverseActionCellRendererParams): void {
this.params = params;
this.activity = params.data;
this.canReverseActivity = this.activity ? params.canReverse(this.activity) : false;
}
refresh(params: ReverseActionCellRendererParams): boolean {
this.agInit(params);
return true;
}
protected onReverse(): void {
if (this.activity) this.params?.onReverse(this.activity);
}
}

View File

@@ -2,4 +2,7 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
// AG Grid's community modules are registered from cashbox.ts instead of
// here, so their (sizable) code only loads as part of that lazy route chunk
// rather than bloating the eagerly-loaded initial bundle.
bootstrapApplication(App, appConfig).catch((err) => console.error(err));