From b21641f37aa460320ece9fb304ccb62f1e6c9541 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 3 Aug 2026 14:49:35 +0200 Subject: [PATCH] ag grid --- .../src/teams/teams.controller.ts | 21 ++ .../src/teams/teams.service.spec.ts | 157 +++++++++++++ .../src/teams/teams.service.ts | 118 +++++++++- .../dto/transactions-query.dto.ts | 36 +++ .../package-lock.json | 37 +++ myteamwallet_frontend_modern/package.json | 1 + .../src/app/core/layout/shell/shell.scss | 12 + .../src/app/core/team/team-stats-api.spec.ts | 2 +- .../src/app/core/team/transactions-api.ts | 22 +- .../app/features/team/cashbox/cashbox.html | 84 ++++--- .../app/features/team/cashbox/cashbox.scss | 65 +++--- .../app/features/team/cashbox/cashbox.spec.ts | 67 ++++-- .../src/app/features/team/cashbox/cashbox.ts | 212 +++++++++++++++--- .../features/team/overview/overview.spec.ts | 9 +- .../app/features/team/overview/overview.ts | 75 ++++++- .../src/app/models/team-stats.model.ts | 1 + .../src/app/models/transaction.model.ts | 16 ++ .../src/app/shared/ag-grid/ag-grid-modules.ts | 23 ++ .../src/app/shared/ag-grid/ag-grid-theme.ts | 21 ++ .../ag-grid/amount-cell-renderer.spec.ts | 41 ++++ .../shared/ag-grid/amount-cell-renderer.ts | 31 +++ .../reverse-action-cell-renderer.spec.ts | 43 ++++ .../ag-grid/reverse-action-cell-renderer.ts | 48 ++++ myteamwallet_frontend_modern/src/main.ts | 3 + 24 files changed, 1009 insertions(+), 136 deletions(-) create mode 100644 myteamwallet_backend/src/transactions/dto/transactions-query.dto.ts create mode 100644 myteamwallet_frontend_modern/src/app/shared/ag-grid/ag-grid-modules.ts create mode 100644 myteamwallet_frontend_modern/src/app/shared/ag-grid/ag-grid-theme.ts create mode 100644 myteamwallet_frontend_modern/src/app/shared/ag-grid/amount-cell-renderer.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/shared/ag-grid/amount-cell-renderer.ts create mode 100644 myteamwallet_frontend_modern/src/app/shared/ag-grid/reverse-action-cell-renderer.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/shared/ag-grid/reverse-action-cell-renderer.ts diff --git a/myteamwallet_backend/src/teams/teams.controller.ts b/myteamwallet_backend/src/teams/teams.controller.ts index a1b25ae..a9b5974 100644 --- a/myteamwallet_backend/src/teams/teams.controller.ts +++ b/myteamwallet_backend/src/teams/teams.controller.ts @@ -9,9 +9,11 @@ import { Put, Patch, ParseIntPipe, + Query, Req, UseGuards, } from '@nestjs/common'; +import { TransactionsQueryDto } from 'src/transactions/dto/transactions-query.dto'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Roles } from 'src/roles/roles.decorator'; @@ -142,6 +144,25 @@ export class TeamsController { return this.service.getTeamTransactions(id, userId); } + @ApiOperation({ + summary: 'Paginiertes Kassenjournal für ein Team', + description: + 'Gibt Transaktionen (Spieler und Teamwallet) seitenweise zurück, mit serverseitiger Sortierung, Typ-Filter und Freitextsuche - für das AG-Grid-Journal.', + }) + @ApiBearerAuth() + @Roles([RoleEnum.user, RoleEnum.admin]) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Get(':id/transactions/journal') + @HttpCode(HttpStatus.OK) + getTransactionsJournal( + @Req() req, + @Param('id') id: string, + @Query() query: TransactionsQueryDto, + ) { + const userId = req.user?.id; + return this.service.getTeamTransactionsJournal(id, userId, query); + } + @ApiOperation({ summary: 'Neuen Spieler anlegen', description: diff --git a/myteamwallet_backend/src/teams/teams.service.spec.ts b/myteamwallet_backend/src/teams/teams.service.spec.ts index 9219989..4c1e63d 100644 --- a/myteamwallet_backend/src/teams/teams.service.spec.ts +++ b/myteamwallet_backend/src/teams/teams.service.spec.ts @@ -192,6 +192,41 @@ describe('TeamsService#getOverviewStats theoretical balance', () => { expect(now!.theoreticalBalance).toBeLessThan(now!.balance); }); + it('sums fine/levy/fee bookings per month into monthlyFlow.penalties, excluded from income/expense', async () => { + repository.findOneOrFail.mockResolvedValue({ + id: 9, + balance: 100, + transactions: [{ date: isoDate(0, 1), amount: 100, type: { name: 'credit' } }], + players: [ + { + id: 1, + firstName: 'Alex', + lastName: 'Muster', + active: true, + balance: -45, + transactions: [ + { date: isoDate(0, 5), amount: 10, type: { id: 11, name: 'fine' }, note: 'Zu spät' }, + { date: isoDate(0, 6), amount: 15, type: { id: 12, name: 'levy' }, note: 'Umlage' }, + { date: isoDate(0, 7), amount: 5, type: { id: 13, name: 'fee' }, note: 'Gebühr' }, + { date: isoDate(1, 8), amount: 20, type: { id: 11, name: 'fine' }, note: 'Vormonat' }, + { date: isoDate(0, 9), amount: 12, type: { id: 0, name: 'payment' }, note: 'Beitrag' }, + ], + }, + ], + }); + + const result = await service.getOverviewStats(9, 42); + + const now = result.monthlyFlow.find((p) => p.month === monthKey(0)); + const lastMonth = result.monthlyFlow.find((p) => p.month === monthKey(1)); + + expect(now?.penalties).toBe(30); + expect(lastMonth?.penalties).toBe(20); + // Payment still counts as income, fine/levy/fee never do. + expect(now?.income).toBe(12); + expect(now?.expense).toBe(0); + }); + it('keeps returning an empty balance history when the team has no cash movement at all', async () => { repository.findOneOrFail.mockResolvedValue({ id: 9, @@ -205,3 +240,125 @@ describe('TeamsService#getOverviewStats theoretical balance', () => { expect(result.balanceHistory).toEqual([]); }); }); + +describe('TeamsService#getTeamTransactionsJournal', () => { + const repository = { findOneOrFail: jest.fn() }; + const access = { assertMember: jest.fn() }; + let service: TeamsService; + + const team = { + id: 9, + transactions: [ + { id: 101, date: '2026-06-01', amount: 50, type: { name: 'credit' }, note: 'Sponsoring' }, + { id: 102, date: '2026-06-15', amount: 20, type: { name: 'expense' }, note: 'Bälle' }, + ], + players: [ + { + id: 1, + firstName: 'Alex', + lastName: 'Muster', + transactions: [ + { id: 1, date: '2026-06-10', amount: 12, type: { name: 'payment' }, note: 'Beitrag' }, + { id: 2, date: '2026-06-20', amount: 5, type: { name: 'fine' }, note: 'Zu spät' }, + ], + }, + { + id: 2, + firstName: 'Bea', + lastName: 'Beispiel', + transactions: [ + { id: 3, date: '2026-06-05', amount: 30, type: { name: 'levy' }, note: 'Turnier-Umlage' }, + ], + }, + ], + }; + + beforeEach(() => { + jest.resetAllMocks(); + access.assertMember.mockResolvedValue(undefined); + repository.findOneOrFail.mockResolvedValue(team); + service = new TeamsService( + repository as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any, + access as any, + ); + }); + + function query(overrides: Partial> = {}) { + return { + page: 1, + limit: 25, + sortBy: 'date', + sortDir: 'desc', + ...overrides, + } as any; + } + + it('checks membership before returning any data', async () => { + await service.getTeamTransactionsJournal(9, 42, query()); + expect(access.assertMember).toHaveBeenCalledWith(42, 9); + }); + + it('returns the total count of all 5 bookings across both sources, unpaginated', async () => { + const result = await service.getTeamTransactionsJournal(9, 42, query()); + expect(result.total).toBe(5); + }); + + it('paginates using page/limit and slices from the already-sorted result', async () => { + const page1 = await service.getTeamTransactionsJournal(9, 42, query({ page: 1, limit: 2 })); + const page2 = await service.getTeamTransactionsJournal(9, 42, query({ page: 2, limit: 2 })); + + expect(page1.data).toHaveLength(2); + expect(page2.data).toHaveLength(2); + expect(page1.total).toBe(5); + expect(page2.total).toBe(5); + // Newest first by default (date desc) - no overlap between the two pages. + expect(page1.data.map((row) => row.id)).toEqual([2, 102]); + expect(page2.data.map((row) => row.id)).toEqual([1, 3]); + }); + + it('sorts by amount in both directions', async () => { + const asc = await service.getTeamTransactionsJournal( + 9, + 42, + query({ sortBy: 'amount', sortDir: 'asc', limit: 100 }), + ); + const desc = await service.getTeamTransactionsJournal( + 9, + 42, + query({ sortBy: 'amount', sortDir: 'desc', limit: 100 }), + ); + + expect(asc.data.map((row) => row.amount)).toEqual([5, 12, 20, 30, 50]); + expect(desc.data.map((row) => row.amount)).toEqual([50, 30, 20, 12, 5]); + }); + + it('filters by exact booking type', async () => { + const result = await service.getTeamTransactionsJournal(9, 42, query({ type: 'fine' })); + + expect(result.total).toBe(1); + expect(result.data[0].note).toBe('Zu spät'); + }); + + it('filters by a case-insensitive search across player name and note', async () => { + const byName = await service.getTeamTransactionsJournal(9, 42, query({ search: 'bea' })); + const byNote = await service.getTeamTransactionsJournal(9, 42, query({ search: 'BÄLLE' })); + + expect(byName.total).toBe(1); + expect(byName.data[0].playerName).toBe('Bea Beispiel'); + expect(byNote.total).toBe(1); + expect(byNote.data[0].note).toBe('Bälle'); + }); + + it('rejects when the actor is not a team member', async () => { + access.assertMember.mockRejectedValue(new Error('forbidden')); + + await expect(service.getTeamTransactionsJournal(9, 42, query())).rejects.toThrow('forbidden'); + expect(repository.findOneOrFail).not.toHaveBeenCalled(); + }); +}); diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index 22027fc..0861871 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -9,12 +9,26 @@ import { TEAM_SETTING_DEFAULTS } from 'src/team-settings/team-setting-defaults'; import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity'; import { Transaction } from 'src/transactions/entitites/transaction.entity'; import { Repository } from 'typeorm'; +import { + TransactionsQueryDto, + TransactionsSortableField, +} from 'src/transactions/dto/transactions-query.dto'; import { CreateTeamDTO } from './dto/create-team.dto'; import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto'; import { Team } from './entities/team.entity'; import { TeamAccessService } from './team-access.service'; import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service'; +interface TeamActivityRow { + id: number; + date: string; + amount: number; + type: string; + note: string | null; + playerName?: string; + isTeamWalletTransaction: boolean; +} + @Injectable() export class TeamsService { constructor( @@ -229,12 +243,98 @@ export class TeamsService { return result; } + async getTeamTransactionsJournal( + teamId: string | number, + userId: string | number, + query: TransactionsQueryDto, + ): Promise<{ data: TeamActivityRow[]; total: number }> { + const id = Number(teamId); + + await this.access.assertMember(Number(userId), id); + + const team = await this.repository.findOneOrFail({ + where: { id }, + relations: ['players', 'players.transactions', 'transactions'], + }); + + const transactions: TeamActivityRow[] = []; + + for (const t of team.transactions) { + transactions.push({ + id: t.id, + date: t.date, + amount: Number(t.amount), + type: t.type.name, + note: t.note, + isTeamWalletTransaction: true, + }); + } + + for (const p of team.players) { + for (const t of p.transactions) { + transactions.push({ + id: t.id, + date: t.date, + amount: Number(t.amount), + type: t.type.name, + note: t.note, + playerName: p.firstName + ' ' + p.lastName, + isTeamWalletTransaction: false, + }); + } + } + + const search = query.search?.trim().toLowerCase(); + const filtered = transactions.filter((row) => { + if (query.type && row.type !== query.type) return false; + if (search) { + const haystack = `${row.playerName ?? 'Teamkasse'} ${row.note ?? ''}`.toLowerCase(); + if (!haystack.includes(search)) return false; + } + return true; + }); + + const sortBy = query.sortBy ?? 'date'; + const direction = query.sortDir === 'asc' ? 1 : -1; + const sorted = [...filtered].sort((a, b) => { + const aValue = this.transactionSortValue(a, sortBy); + const bValue = this.transactionSortValue(b, sortBy); + if (aValue < bValue) return -1 * direction; + if (aValue > bValue) return 1 * direction; + return 0; + }); + + const page = query.page ?? 1; + const limit = query.limit ?? 25; + const start = (page - 1) * limit; + const data = sorted.slice(start, start + limit); + + return { data, total: filtered.length }; + } + + private transactionSortValue( + row: TeamActivityRow, + field: TransactionsSortableField, + ): string | number { + switch (field) { + case 'amount': + return row.amount; + case 'playerName': + return (row.playerName ?? 'Teamkasse').toLowerCase(); + case 'type': + return row.type; + case 'date': + default: + return row.date; + } + } + async getOverviewStats( teamId: string | number, actorUserId: string | number, ): Promise<{ balanceHistory: { month: string; balance: number; theoreticalBalance: number }[]; - monthlyFlow: { month: string; income: number; expense: number }[]; + monthlyFlow: { month: string; income: number; expense: number; penalties: number }[]; topOutstanding: { playerId: number; playerName: string; balance: number }[]; }> { const id = Number(teamId); @@ -256,6 +356,10 @@ export class TeamsService { // TeamWalletTransaction/Transaction) — fine/levy/fee raise a player's // debt but never move money, so they are excluded entirely. const movements: { date: string; amount: number; type: string }[] = []; + // Fine/levy/fee (type.id > 10) raise a player's debt but never move real + // cash, so they're kept out of `movements` and tracked separately here + // purely to chart "how much was booked as penalties/levies this month". + const penaltyMovements: { date: string; amount: number }[] = []; for (const t of teamTransactions) { movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); @@ -265,6 +369,8 @@ export class TeamsService { for (const t of p.transactions ?? []) { if (t.type.name === 'payment') { movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); + } else if (t.type.id > 10) { + penaltyMovements.push({ date: t.date, amount: Number(t.amount) }); } } } @@ -318,7 +424,15 @@ export class TeamsService { const expense = monthMovements .filter((m) => m.type === 'expense') .reduce((sum, m) => sum + m.amount, 0); - return { month, income: this.round(income), expense: this.round(expense) }; + const penalties = penaltyMovements + .filter((m) => m.date.slice(0, 7) === month) + .reduce((sum, m) => sum + m.amount, 0); + return { + month, + income: this.round(income), + expense: this.round(expense), + penalties: this.round(penalties), + }; }); const outstandingHistory = this.reconstructOutstandingHistory(months, players); diff --git a/myteamwallet_backend/src/transactions/dto/transactions-query.dto.ts b/myteamwallet_backend/src/transactions/dto/transactions-query.dto.ts new file mode 100644 index 0000000..83d618a --- /dev/null +++ b/myteamwallet_backend/src/transactions/dto/transactions-query.dto.ts @@ -0,0 +1,36 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export const TRANSACTIONS_SORTABLE_FIELDS = ['date', 'amount', 'playerName', 'type'] as const; +export type TransactionsSortableField = (typeof TRANSACTIONS_SORTABLE_FIELDS)[number]; + +export class TransactionsQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit = 25; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + type?: string; + + @IsOptional() + @IsIn(TRANSACTIONS_SORTABLE_FIELDS) + sortBy: TransactionsSortableField = 'date'; + + @IsOptional() + @IsIn(['asc', 'desc']) + sortDir: 'asc' | 'desc' = 'desc'; +} diff --git a/myteamwallet_frontend_modern/package-lock.json b/myteamwallet_frontend_modern/package-lock.json index 611998e..7ab8377 100644 --- a/myteamwallet_frontend_modern/package-lock.json +++ b/myteamwallet_frontend_modern/package-lock.json @@ -17,6 +17,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "@angular/service-worker": "^21.2.0", + "ag-grid-angular": "^36.0.2", "chart.js": "^4.5.1", "qrcode": "^1.5.4", "rxjs": "~7.8.0", @@ -4236,6 +4237,42 @@ "node": ">= 0.6" } }, + "node_modules/ag-charts-types": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.0.2.tgz", + "integrity": "sha512-F7ZG0g8Y+iKhJi50AfZRwEyUM/TBsNyh2IoXB0JaDN97lnbemIK8GE5kF1eBtXtN4mcC+lPXK9oZUeVXwO9EWA==", + "license": "MIT" + }, + "node_modules/ag-grid-angular": { + "version": "36.0.2", + "resolved": "https://registry.npmjs.org/ag-grid-angular/-/ag-grid-angular-36.0.2.tgz", + "integrity": "sha512-qBEvOmkcmioJTLZOozoJMYFMSu0/+I6fmeYJ7xrzxv3X88Vr/MoytdiR1j9GbLuhG8tusePfNiwuDnAJxEzbBw==", + "license": "MIT", + "dependencies": { + "ag-grid-community": "36.0.2", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@angular/common": ">= 20.0.0", + "@angular/core": ">= 20.0.0" + } + }, + "node_modules/ag-grid-community": { + "version": "36.0.2", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.0.2.tgz", + "integrity": "sha512-TINZfuFvMY2nc3JfQHiUWT7dNIxI89ZxS5XkXIPi/rYICoNupRqpaM41KVzGPPfSkM0AwhuzTFxAiF08zEkV1Q==", + "license": "MIT", + "dependencies": { + "ag-charts-types": "14.0.2", + "ag-stack": "36.0.2" + } + }, + "node_modules/ag-stack": { + "version": "36.0.2", + "resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.0.2.tgz", + "integrity": "sha512-YuhQExQw5YsWK0wxrksRyYBAqOU0v08lJH5uxRsKx+49ko5vkDgnJuhX4yF995BBVdLY1LKlXukLEub+olKyuA==", + "license": "MIT" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", diff --git a/myteamwallet_frontend_modern/package.json b/myteamwallet_frontend_modern/package.json index 1657c43..83ec52c 100644 --- a/myteamwallet_frontend_modern/package.json +++ b/myteamwallet_frontend_modern/package.json @@ -21,6 +21,7 @@ "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "@angular/service-worker": "^21.2.0", + "ag-grid-angular": "^36.0.2", "chart.js": "^4.5.1", "qrcode": "^1.5.4", "rxjs": "~7.8.0", diff --git a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss index 52059d8..17fbe19 100644 --- a/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss +++ b/myteamwallet_frontend_modern/src/app/core/layout/shell/shell.scss @@ -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; diff --git a/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts index 274f710..23a9e00 100644 --- a/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts +++ b/myteamwallet_frontend_modern/src/app/core/team/team-stats-api.spec.ts @@ -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 }], }; diff --git a/myteamwallet_frontend_modern/src/app/core/team/transactions-api.ts b/myteamwallet_frontend_modern/src/app/core/team/transactions-api.ts index 15aa179..b95bb09 100644 --- a/myteamwallet_frontend_modern/src/app/core/team/transactions-api.ts +++ b/myteamwallet_frontend_modern/src/app/core/team/transactions-api.ts @@ -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(`${environment.apiUrl}teams/${teamId}/transactions`); } + loadTeamTransactionsJournal( + teamId: number, + query: TransactionsJournalQuery, + ): Observable { + 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( + `${environment.apiUrl}teams/${teamId}/transactions/journal`, + { params }, + ); + } + createPlayerTransactions(transactions: CreatePlayerTransaction[]): Observable { return this.http.post(`${environment.apiUrl}transactions`, transactions); } diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html index 24e3bd7..d2a89b5 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.html @@ -151,50 +151,46 @@

Journal

Alle Buchungen

- {{ activities().length }} Einträge + @if (journalTotal(); as total) { + {{ total }} Einträge + } - @if (loading()) { -
Buchungen werden geladen …
- } @else if (activities().length === 0) { -
- receipt_longNoch keine Buchungen vorhanden. -
- } @else { -
- @for (activity of activities(); track activity.id) { -
-
- {{ - activity.isTeamWalletTransaction ? 'account_balance' : 'person' - }} -
-
- {{ activity.playerName || 'Teamkasse' }} - {{ typeLabel(activity.type) }} · {{ activity.date | date: 'dd.MM.yyyy' }} - @if (activity.note) { - {{ activity.note }} - } -
- - - - @if (canReverse(activity)) { - - } -
- } -
- } +
+ + Suche + + search + + + Typ + + @for (option of journalTypeOptions; track option.value) { + {{ option.label }} + } + + +
+ + diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.scss b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.scss index d4fde23..6ad53a5 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.scss +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.scss @@ -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; } } diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts index 108b026..19a88df 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.spec.ts @@ -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[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[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 () => { diff --git a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts index 7899365..dfcfbde 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts @@ -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([]); protected readonly penalties = signal([]); - 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(null); + private readonly journalSearchInput$ = new Subject(); + private gridApi?: GridApi; + private journalTeamId: number | null = null; + + protected readonly journalColumnDefs: ColDef[] = [ + { + headerName: '', + colId: 'icon', + sortable: false, + resizable: false, + width: 56, + cellRenderer: (params: { data?: TeamActivity }) => { + const isTeam = !!params.data?.isTeamWalletTransaction; + const span = document.createElement('span'); + span.className = 'material-icons cashbox-grid-icon' + (isTeam ? ' team' : ''); + span.textContent = isTeam ? 'account_balance' : 'person'; + return span; + }, + }, + { + headerName: 'Name', + field: 'playerName', + minWidth: 140, + flex: 1, + valueGetter: (params) => params.data?.playerName || 'Teamkasse', + }, + { + headerName: 'Typ', + field: 'type', + width: 130, + valueFormatter: (params) => this.typeLabel(params.value), + }, + { + headerName: 'Datum', + field: 'date', + width: 120, + valueFormatter: (params) => + params.value ? new Intl.DateTimeFormat('de-DE').format(new Date(params.value)) : '', + }, + { + headerName: 'Notiz', + field: 'note', + colId: 'note', + minWidth: 160, + flex: 2, + }, + { + headerName: 'Betrag', + field: 'amount', + width: 150, + cellRenderer: AmountCellRenderer, + }, + { + headerName: '', + colId: 'actions', + width: 60, + sortable: false, + resizable: false, + cellRenderer: ReverseActionCellRenderer, + cellRendererParams: { + canReverse: (activity: TeamActivity) => this.canReverse(activity), + onReverse: (activity: TeamActivity) => this.reverseBooking(activity), + } satisfies Partial, + }, + ]; + + protected readonly journalGetRowId = (params: GetRowIdParams) => + `${params.data.isTeamWalletTransaction ? 'w' : 'p'}-${params.data.id}`; + protected readonly canBook = computed(() => this.permissions.canDo(this.team(), 'transactionCreate'), ); @@ -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): 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 { diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts index b81431e..1f3630e 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts @@ -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']); }); diff --git a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts index cb57dfa..83eeeef 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts +++ b/myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts @@ -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() { diff --git a/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts b/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts index d49075c..89e5ad0 100644 --- a/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts +++ b/myteamwallet_frontend_modern/src/app/models/team-stats.model.ts @@ -8,6 +8,7 @@ export interface MonthlyFlowPoint { month: string; income: number; expense: number; + penalties: number; } export interface TopOutstandingPlayer { diff --git a/myteamwallet_frontend_modern/src/app/models/transaction.model.ts b/myteamwallet_frontend_modern/src/app/models/transaction.model.ts index e6e5f6a..7cee514 100644 --- a/myteamwallet_frontend_modern/src/app/models/transaction.model.ts +++ b/myteamwallet_frontend_modern/src/app/models/transaction.model.ts @@ -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; +} diff --git a/myteamwallet_frontend_modern/src/app/shared/ag-grid/ag-grid-modules.ts b/myteamwallet_frontend_modern/src/app/shared/ag-grid/ag-grid-modules.ts new file mode 100644 index 0000000..ef1a9a8 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/ag-grid/ag-grid-modules.ts @@ -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, +]); diff --git a/myteamwallet_frontend_modern/src/app/shared/ag-grid/ag-grid-theme.ts b/myteamwallet_frontend_modern/src/app/shared/ag-grid/ag-grid-theme.ts new file mode 100644 index 0000000..2658f54 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/ag-grid/ag-grid-theme.ts @@ -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, +}); diff --git a/myteamwallet_frontend_modern/src/app/shared/ag-grid/amount-cell-renderer.spec.ts b/myteamwallet_frontend_modern/src/app/shared/ag-grid/amount-cell-renderer.spec.ts new file mode 100644 index 0000000..50701db --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/ag-grid/amount-cell-renderer.spec.ts @@ -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); + 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'); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/shared/ag-grid/amount-cell-renderer.ts b/myteamwallet_frontend_modern/src/app/shared/ag-grid/amount-cell-renderer.ts new file mode 100644 index 0000000..4acf959 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/ag-grid/amount-cell-renderer.ts @@ -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) { + + } + `, +}) +export class AmountCellRenderer implements ICellRendererAngularComp { + protected data?: TeamActivity; + + agInit(params: ICellRendererParams): void { + this.data = params.data; + } + + refresh(params: ICellRendererParams): boolean { + this.data = params.data; + return true; + } +} diff --git a/myteamwallet_frontend_modern/src/app/shared/ag-grid/reverse-action-cell-renderer.spec.ts b/myteamwallet_frontend_modern/src/app/shared/ag-grid/reverse-action-cell-renderer.spec.ts new file mode 100644 index 0000000..a6fe055 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/ag-grid/reverse-action-cell-renderer.spec.ts @@ -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); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/shared/ag-grid/reverse-action-cell-renderer.ts b/myteamwallet_frontend_modern/src/app/shared/ag-grid/reverse-action-cell-renderer.ts new file mode 100644 index 0000000..cf0c72f --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/ag-grid/reverse-action-cell-renderer.ts @@ -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 { + canReverse: (activity: TeamActivity) => boolean; + onReverse: (activity: TeamActivity) => void; +} + +@Component({ + selector: 'app-reverse-action-cell-renderer', + imports: [MatButtonModule, MatIconModule], + template: ` + @if (activity && canReverseActivity) { + + } + `, +}) +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); + } +} diff --git a/myteamwallet_frontend_modern/src/main.ts b/myteamwallet_frontend_modern/src/main.ts index 190f341..3eb526b 100644 --- a/myteamwallet_frontend_modern/src/main.ts +++ b/myteamwallet_frontend_modern/src/main.ts @@ -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));