ag grid
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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<Record<string, unknown>> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
37
myteamwallet_frontend_modern/package-lock.json
generated
37
myteamwallet_frontend_modern/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user