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';
|
||||
}
|
||||
Reference in New Issue
Block a user