ag grid
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }],
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface MonthlyFlowPoint {
|
||||
month: string;
|
||||
income: number;
|
||||
expense: number;
|
||||
penalties: number;
|
||||
}
|
||||
|
||||
export interface TopOutstandingPlayer {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user