ag grid
This commit is contained in:
@@ -9,9 +9,11 @@ import {
|
|||||||
Put,
|
Put,
|
||||||
Patch,
|
Patch,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
|
Query,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { TransactionsQueryDto } from 'src/transactions/dto/transactions-query.dto';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { Roles } from 'src/roles/roles.decorator';
|
import { Roles } from 'src/roles/roles.decorator';
|
||||||
@@ -142,6 +144,25 @@ export class TeamsController {
|
|||||||
return this.service.getTeamTransactions(id, userId);
|
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({
|
@ApiOperation({
|
||||||
summary: 'Neuen Spieler anlegen',
|
summary: 'Neuen Spieler anlegen',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -192,6 +192,41 @@ describe('TeamsService#getOverviewStats theoretical balance', () => {
|
|||||||
expect(now!.theoreticalBalance).toBeLessThan(now!.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 () => {
|
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
|
||||||
repository.findOneOrFail.mockResolvedValue({
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
id: 9,
|
id: 9,
|
||||||
@@ -205,3 +240,125 @@ describe('TeamsService#getOverviewStats theoretical balance', () => {
|
|||||||
expect(result.balanceHistory).toEqual([]);
|
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 { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity';
|
||||||
import { Transaction } from 'src/transactions/entitites/transaction.entity';
|
import { Transaction } from 'src/transactions/entitites/transaction.entity';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import {
|
||||||
|
TransactionsQueryDto,
|
||||||
|
TransactionsSortableField,
|
||||||
|
} from 'src/transactions/dto/transactions-query.dto';
|
||||||
import { CreateTeamDTO } from './dto/create-team.dto';
|
import { CreateTeamDTO } from './dto/create-team.dto';
|
||||||
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
|
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
|
||||||
import { Team } from './entities/team.entity';
|
import { Team } from './entities/team.entity';
|
||||||
import { TeamAccessService } from './team-access.service';
|
import { TeamAccessService } from './team-access.service';
|
||||||
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.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()
|
@Injectable()
|
||||||
export class TeamsService {
|
export class TeamsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -229,12 +243,98 @@ export class TeamsService {
|
|||||||
return result;
|
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(
|
async getOverviewStats(
|
||||||
teamId: string | number,
|
teamId: string | number,
|
||||||
actorUserId: string | number,
|
actorUserId: string | number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[];
|
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 }[];
|
topOutstanding: { playerId: number; playerName: string; balance: number }[];
|
||||||
}> {
|
}> {
|
||||||
const id = Number(teamId);
|
const id = Number(teamId);
|
||||||
@@ -256,6 +356,10 @@ export class TeamsService {
|
|||||||
// TeamWalletTransaction/Transaction) — fine/levy/fee raise a player's
|
// TeamWalletTransaction/Transaction) — fine/levy/fee raise a player's
|
||||||
// debt but never move money, so they are excluded entirely.
|
// debt but never move money, so they are excluded entirely.
|
||||||
const movements: { date: string; amount: number; type: string }[] = [];
|
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) {
|
for (const t of teamTransactions) {
|
||||||
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
|
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 ?? []) {
|
for (const t of p.transactions ?? []) {
|
||||||
if (t.type.name === 'payment') {
|
if (t.type.name === 'payment') {
|
||||||
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
|
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
|
const expense = monthMovements
|
||||||
.filter((m) => m.type === 'expense')
|
.filter((m) => m.type === 'expense')
|
||||||
.reduce((sum, m) => sum + m.amount, 0);
|
.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);
|
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/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"@angular/service-worker": "^21.2.0",
|
"@angular/service-worker": "^21.2.0",
|
||||||
|
"ag-grid-angular": "^36.0.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
@@ -4236,6 +4237,42 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"@angular/service-worker": "^21.2.0",
|
"@angular/service-worker": "^21.2.0",
|
||||||
|
"ag-grid-angular": "^36.0.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
|
|||||||
@@ -19,10 +19,22 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
.shell-bottom-nav {
|
.shell-bottom-nav {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
display: flex;
|
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);
|
border-top: 1px solid var(--mat-sys-outline-variant);
|
||||||
background: var(--mat-sys-surface);
|
background: var(--mat-sys-surface);
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('TeamStatsApi', () => {
|
|||||||
it('loads the overview stats for a team', () => {
|
it('loads the overview stats for a team', () => {
|
||||||
const stats: TeamOverviewStats = {
|
const stats: TeamOverviewStats = {
|
||||||
balanceHistory: [{ month: '2026-07', balance: 125, theoreticalBalance: 150 }],
|
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 }],
|
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 { Injectable, inject } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
CreatePlayerTransaction,
|
CreatePlayerTransaction,
|
||||||
CreateTeamWalletTransaction,
|
CreateTeamWalletTransaction,
|
||||||
TeamActivity,
|
TeamActivity,
|
||||||
|
TransactionsJournalPage,
|
||||||
|
TransactionsJournalQuery,
|
||||||
} from '../../models/transaction.model';
|
} from '../../models/transaction.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -16,6 +18,24 @@ export class TransactionsApi {
|
|||||||
return this.http.get<TeamActivity[]>(`${environment.apiUrl}teams/${teamId}/transactions`);
|
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[]> {
|
createPlayerTransactions(transactions: CreatePlayerTransaction[]): Observable<TeamActivity[]> {
|
||||||
return this.http.post<TeamActivity[]>(`${environment.apiUrl}transactions`, transactions);
|
return this.http.post<TeamActivity[]>(`${environment.apiUrl}transactions`, transactions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,50 +151,46 @@
|
|||||||
<p class="eyebrow">Journal</p>
|
<p class="eyebrow">Journal</p>
|
||||||
<h2>Alle Buchungen</h2>
|
<h2>Alle Buchungen</h2>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ activities().length }} Einträge</span>
|
@if (journalTotal(); as total) {
|
||||||
|
<span>{{ total }} Einträge</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (loading()) {
|
<div class="journal-toolbar">
|
||||||
<div class="state"><mat-spinner diameter="36" /><span>Buchungen werden geladen …</span></div>
|
<mat-form-field appearance="outline" class="journal-search">
|
||||||
} @else if (activities().length === 0) {
|
<mat-label>Suche</mat-label>
|
||||||
<div class="state">
|
<input
|
||||||
<mat-icon>receipt_long</mat-icon><span>Noch keine Buchungen vorhanden.</span>
|
matInput
|
||||||
</div>
|
placeholder="Name oder Notiz"
|
||||||
} @else {
|
[value]="journalSearch()"
|
||||||
<div class="activity-list">
|
(input)="onJournalSearchInput($any($event.target).value)"
|
||||||
@for (activity of activities(); track activity.id) {
|
/>
|
||||||
<article class="activity-item">
|
<mat-icon matTextSuffix>search</mat-icon>
|
||||||
<div class="activity-icon" [class.team]="activity.isTeamWalletTransaction">
|
</mat-form-field>
|
||||||
<mat-icon>{{
|
<mat-form-field appearance="outline" class="journal-type">
|
||||||
activity.isTeamWalletTransaction ? 'account_balance' : 'person'
|
<mat-label>Typ</mat-label>
|
||||||
}}</mat-icon>
|
<mat-select
|
||||||
</div>
|
[value]="journalTypeFilter()"
|
||||||
<div class="activity-copy">
|
(selectionChange)="onJournalTypeChange($event.value)"
|
||||||
<strong>{{ activity.playerName || 'Teamkasse' }}</strong>
|
>
|
||||||
<span>{{ typeLabel(activity.type) }} · {{ activity.date | date: 'dd.MM.yyyy' }}</span>
|
@for (option of journalTypeOptions; track option.value) {
|
||||||
@if (activity.note) {
|
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||||
<small>{{ activity.note }}</small>
|
}
|
||||||
}
|
</mat-select>
|
||||||
</div>
|
</mat-form-field>
|
||||||
<strong>
|
</div>
|
||||||
<app-transaction-amount
|
|
||||||
[amount]="activity.amount"
|
<ag-grid-angular
|
||||||
[type]="activity.type"
|
class="journal-grid"
|
||||||
[context]="activity.isTeamWalletTransaction ? 'team' : 'player'"
|
[theme]="gridTheme"
|
||||||
/>
|
[columnDefs]="journalColumnDefs"
|
||||||
</strong>
|
[rowModelType]="'infinite'"
|
||||||
@if (canReverse(activity)) {
|
[pagination]="true"
|
||||||
<button
|
[paginationPageSize]="25"
|
||||||
mat-icon-button
|
[paginationPageSizeSelector]="[10, 25, 50, 100]"
|
||||||
data-testid="reverse-booking"
|
[cacheBlockSize]="25"
|
||||||
aria-label="Buchung stornieren"
|
[getRowId]="journalGetRowId"
|
||||||
(click)="reverseBooking(activity)"
|
[suppressCellFocus]="true"
|
||||||
>
|
(gridReady)="onJournalGridReady($event)"
|
||||||
<mat-icon>undo</mat-icon>
|
/>
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -92,52 +92,39 @@ form button {
|
|||||||
.section-heading h2 {
|
.section-heading h2 {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.activity-list {
|
.journal-toolbar {
|
||||||
border: 1px solid var(--mat-sys-outline-variant);
|
display: flex;
|
||||||
border-radius: 20px;
|
flex-wrap: wrap;
|
||||||
overflow: hidden;
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.activity-item {
|
.journal-search {
|
||||||
display: grid;
|
flex: 1 1 240px;
|
||||||
grid-template-columns: auto 1fr auto auto;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 14px 18px;
|
|
||||||
background: var(--mat-sys-surface);
|
|
||||||
}
|
}
|
||||||
.activity-item + .activity-item {
|
.journal-type {
|
||||||
border-top: 1px solid var(--mat-sys-outline-variant);
|
flex: 0 1 200px;
|
||||||
}
|
}
|
||||||
.activity-icon {
|
.journal-grid {
|
||||||
|
height: 640px;
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.cashbox-grid-icon {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 42px;
|
width: 32px;
|
||||||
height: 42px;
|
height: 32px;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
color: var(--mat-sys-primary);
|
color: var(--mat-sys-primary);
|
||||||
// background: var(--mat-sys-primary-container);
|
|
||||||
background: var(--mat-sys-secondary-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);
|
color: var(--mat-sys-tertiary);
|
||||||
background: var(--mat-sys-tertiary-container);
|
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) {
|
@media (max-width: 900px) {
|
||||||
.booking-grid {
|
.booking-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -154,10 +141,10 @@ form button {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
.activity-item {
|
.journal-toolbar {
|
||||||
grid-template-columns: auto 1fr auto;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.activity-item > button {
|
.journal-grid {
|
||||||
grid-column: 3;
|
height: 520px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ describe('Cashbox', () => {
|
|||||||
const createTeamWalletTransaction = vi.fn(() => of(activities[0]));
|
const createTeamWalletTransaction = vi.fn(() => of(activities[0]));
|
||||||
const reverseTransaction = vi.fn(() => of(activities[0]));
|
const reverseTransaction = vi.fn(() => of(activities[0]));
|
||||||
const loadTeamTransactions = vi.fn(() => of(activities));
|
const loadTeamTransactions = vi.fn(() => of(activities));
|
||||||
|
const loadTeamTransactionsJournal = vi.fn(() =>
|
||||||
|
of({ data: activities, total: activities.length }),
|
||||||
|
);
|
||||||
const loadPenalties = vi.fn(() => of(penalties));
|
const loadPenalties = vi.fn(() => of(penalties));
|
||||||
const refreshTeam = vi.fn();
|
const refreshTeam = vi.fn();
|
||||||
const dialog = {
|
const dialog = {
|
||||||
@@ -112,6 +115,7 @@ describe('Cashbox', () => {
|
|||||||
provide: TransactionsApi,
|
provide: TransactionsApi,
|
||||||
useValue: {
|
useValue: {
|
||||||
loadTeamTransactions,
|
loadTeamTransactions,
|
||||||
|
loadTeamTransactionsJournal,
|
||||||
createPlayerTransactions,
|
createPlayerTransactions,
|
||||||
createTeamWalletTransaction,
|
createTeamWalletTransaction,
|
||||||
reverseTransaction,
|
reverseTransaction,
|
||||||
@@ -142,35 +146,66 @@ describe('Cashbox', () => {
|
|||||||
fixture,
|
fixture,
|
||||||
component: fixture.componentInstance,
|
component: fixture.componentInstance,
|
||||||
createPlayerTransactions,
|
createPlayerTransactions,
|
||||||
|
loadTeamTransactionsJournal,
|
||||||
reverseTransaction,
|
reverseTransaction,
|
||||||
dialog,
|
dialog,
|
||||||
refreshTeam,
|
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();
|
const { fixture } = await setup();
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Bea Test');
|
|
||||||
expect(fixture.nativeElement.querySelector('[data-testid="player-booking"]')).not.toBeNull();
|
expect(fixture.nativeElement.querySelector('[data-testid="player-booking"]')).not.toBeNull();
|
||||||
expect(fixture.nativeElement.textContent).toContain('Buchungen verstehen');
|
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 () => {
|
// AG Grid virtualizes rows based on real layout measurements (container
|
||||||
const { fixture } = await setup();
|
// height, ResizeObserver) that jsdom doesn't provide, so it never actually
|
||||||
const amounts = [
|
// requests rows in this environment - row/cell content is verified
|
||||||
...fixture.nativeElement.querySelectorAll(
|
// manually in the browser (see AmountCellRenderer/ReverseActionCellRenderer
|
||||||
'app-transaction-amount [data-testid="transaction-amount"]',
|
// specs for the cell logic in isolation). Here we call the grid's
|
||||||
),
|
// datasource factory directly to verify the query it builds.
|
||||||
] as HTMLElement[];
|
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);
|
const datasource = component['buildJournalDatasource'](5);
|
||||||
expect(amounts[0].classList).toContain('inflow');
|
datasource.getRows({
|
||||||
expect(amounts[1].classList).toContain('outflow');
|
startRow: 0,
|
||||||
expect(amounts[2].classList).toContain('neutral');
|
endRow: 25,
|
||||||
expect(
|
sortModel: [],
|
||||||
amounts.map((amount) => amount.querySelector('.transaction-amount__sign')?.textContent),
|
filterModel: {},
|
||||||
).toEqual(['+', '−', '']);
|
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 () => {
|
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 localeDe from '@angular/common/locales/de';
|
||||||
import { Component, LOCALE_ID, computed, effect, inject, signal } from '@angular/core';
|
import { Component, LOCALE_ID, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
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 { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
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 { TeamPermissionsService } from '../../../core/team/team-permissions';
|
||||||
import { PenaltyApi } from '../../../core/team/penalty-api';
|
import { PenaltyApi } from '../../../core/team/penalty-api';
|
||||||
import { TeamStore } from '../../../core/team/team-store';
|
import { TeamStore } from '../../../core/team/team-store';
|
||||||
@@ -22,21 +33,38 @@ import {
|
|||||||
CreatePlayerTransaction,
|
CreatePlayerTransaction,
|
||||||
CreateTeamWalletTransaction,
|
CreateTeamWalletTransaction,
|
||||||
TeamActivity,
|
TeamActivity,
|
||||||
|
TransactionsJournalQuery,
|
||||||
|
TransactionsSortField,
|
||||||
} from '../../../models/transaction.model';
|
} from '../../../models/transaction.model';
|
||||||
import { ConfirmDialog, ConfirmDialogData } from '../../../shared/confirm-dialog/confirm-dialog';
|
import { ConfirmDialog, ConfirmDialogData } from '../../../shared/confirm-dialog/confirm-dialog';
|
||||||
import { ContextHelp } from '../../../shared/context-help/context-help';
|
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';
|
import { splitAmounts } from './transaction-calculation';
|
||||||
|
|
||||||
registerLocaleData(localeDe);
|
registerLocaleData(localeDe);
|
||||||
|
|
||||||
const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300;
|
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({
|
@Component({
|
||||||
selector: 'app-cashbox',
|
selector: 'app-cashbox',
|
||||||
imports: [
|
imports: [
|
||||||
CurrencyPipe,
|
CurrencyPipe,
|
||||||
DatePipe,
|
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
@@ -44,11 +72,10 @@ const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300;
|
|||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatProgressSpinnerModule,
|
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
ContextHelp,
|
ContextHelp,
|
||||||
TransactionAmount,
|
AgGridAngular,
|
||||||
],
|
],
|
||||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||||
templateUrl: './cashbox.html',
|
templateUrl: './cashbox.html',
|
||||||
@@ -69,9 +96,7 @@ export class Cashbox {
|
|||||||
Number(this.route.snapshot.queryParamMap.get('penaltyId')) || null;
|
Number(this.route.snapshot.queryParamMap.get('penaltyId')) || null;
|
||||||
|
|
||||||
protected readonly team = this.teamStore.team;
|
protected readonly team = this.teamStore.team;
|
||||||
protected readonly activities = signal<TeamActivity[]>([]);
|
|
||||||
protected readonly penalties = signal<Penalty[]>([]);
|
protected readonly penalties = signal<Penalty[]>([]);
|
||||||
protected readonly loading = signal(false);
|
|
||||||
protected readonly saving = signal(false);
|
protected readonly saving = signal(false);
|
||||||
protected readonly playerTransactionTypes = [
|
protected readonly playerTransactionTypes = [
|
||||||
{ id: 0, label: 'Zahlung' },
|
{ id: 0, label: 'Zahlung' },
|
||||||
@@ -85,6 +110,81 @@ export class Cashbox {
|
|||||||
{ id: 14, label: 'Ausgabe' },
|
{ 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(() =>
|
protected readonly canBook = computed(() =>
|
||||||
this.permissions.canDo(this.team(), 'transactionCreate'),
|
this.permissions.canDo(this.team(), 'transactionCreate'),
|
||||||
);
|
);
|
||||||
@@ -114,9 +214,12 @@ export class Cashbox {
|
|||||||
const teamId = this.team()?.id;
|
const teamId = this.team()?.id;
|
||||||
if (teamId && teamId !== this.loadedTeamId) {
|
if (teamId && teamId !== this.loadedTeamId) {
|
||||||
this.loadedTeamId = teamId;
|
this.loadedTeamId = teamId;
|
||||||
this.loadActivities(teamId);
|
|
||||||
this.loadPenalties(teamId);
|
this.loadPenalties(teamId);
|
||||||
}
|
}
|
||||||
|
if (teamId && teamId !== this.journalTeamId) {
|
||||||
|
this.journalTeamId = teamId;
|
||||||
|
this.refreshJournal();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.pendingPenaltyId === null) return;
|
if (this.pendingPenaltyId === null) return;
|
||||||
@@ -126,6 +229,77 @@ export class Cashbox {
|
|||||||
this.applyPenaltyPreset(penalty);
|
this.applyPenaltyPreset(penalty);
|
||||||
void this.router.navigate([], { queryParams: {}, replaceUrl: true });
|
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 {
|
protected onPenaltySelect(penaltyId: number): void {
|
||||||
@@ -268,25 +442,7 @@ export class Cashbox {
|
|||||||
this.saving.set(false);
|
this.saving.set(false);
|
||||||
this.snackBar.open(message, undefined, { duration: 4000 });
|
this.snackBar.open(message, undefined, { duration: 4000 });
|
||||||
this.teamStore.refreshTeam();
|
this.teamStore.refreshTeam();
|
||||||
const teamId = this.team()?.id;
|
this.refreshJournal();
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleError(message: string): void {
|
private handleError(message: string): void {
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ const sampleStats: TeamOverviewStats = {
|
|||||||
{ month: '2026-07', balance: 125, theoreticalBalance: 150 },
|
{ month: '2026-07', balance: 125, theoreticalBalance: 150 },
|
||||||
],
|
],
|
||||||
monthlyFlow: [
|
monthlyFlow: [
|
||||||
{ month: '2026-06', income: 50, expense: 10 },
|
{ month: '2026-06', income: 50, expense: 10, penalties: 20 },
|
||||||
{ month: '2026-07', income: 40, expense: 15 },
|
{ month: '2026-07', income: 40, expense: 15, penalties: 5 },
|
||||||
],
|
],
|
||||||
topOutstanding: [{ playerId: 3, playerName: 'Chris Beispiel', balance: 20 }],
|
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.data.datasets[1].data).toEqual([100, 150]);
|
||||||
expect(balanceChart.options?.plugins?.legend?.position).toBe('bottom');
|
expect(balanceChart.options?.plugins?.legend?.position).toBe('bottom');
|
||||||
expect(flowChart.type).toBe('bar');
|
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.type).toBe('bar');
|
||||||
expect(outstandingChart.data.labels).toEqual(['Chris Beispiel']);
|
expect(outstandingChart.data.labels).toEqual(['Chris Beispiel']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const BALANCE_COLOR = '#4f8f46';
|
|||||||
const THEORETICAL_BALANCE_COLOR = '#1d70b8';
|
const THEORETICAL_BALANCE_COLOR = '#1d70b8';
|
||||||
const INCOME_COLOR = '#4f8f46';
|
const INCOME_COLOR = '#4f8f46';
|
||||||
const EXPENSE_COLOR = '#c1121f';
|
const EXPENSE_COLOR = '#c1121f';
|
||||||
|
const PENALTY_COLOR = '#9e9e9e';
|
||||||
|
|
||||||
function formatMonthLabel(month: string): string {
|
function formatMonthLabel(month: string): string {
|
||||||
const [year, monthNumber] = month.split('-').map(Number);
|
const [year, monthNumber] = month.split('-').map(Number);
|
||||||
@@ -75,6 +76,13 @@ export class Overview {
|
|||||||
backgroundColor: BALANCE_COLOR,
|
backgroundColor: BALANCE_COLOR,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: false,
|
fill: false,
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return `In der Kasse: ${context.formattedValue} €`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Theoretisch (inkl. offene Beiträge)',
|
label: 'Theoretisch (inkl. offene Beiträge)',
|
||||||
@@ -84,6 +92,13 @@ export class Overview {
|
|||||||
borderDash: [6, 4],
|
borderDash: [6, 4],
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
fill: false,
|
fill: false,
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return `Kasse + Offen: ${context.formattedValue} €`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -98,11 +113,40 @@ export class Overview {
|
|||||||
label: 'Einnahmen',
|
label: 'Einnahmen',
|
||||||
data: points.map((point) => point.income),
|
data: points.map((point) => point.income),
|
||||||
backgroundColor: INCOME_COLOR,
|
backgroundColor: INCOME_COLOR,
|
||||||
|
borderRadius: 4,
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return `Einnahen: ${context.formattedValue} €`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Ausgaben',
|
label: 'Ausgaben',
|
||||||
data: points.map((point) => point.expense),
|
data: points.map((point) => point.expense),
|
||||||
backgroundColor: EXPENSE_COLOR,
|
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',
|
label: 'Offener Betrag',
|
||||||
data: players.map((player) => player.balance),
|
data: players.map((player) => player.balance),
|
||||||
backgroundColor: EXPENSE_COLOR,
|
backgroundColor: EXPENSE_COLOR,
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return ` Offen: ${context.formattedValue} €`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -130,17 +181,28 @@ export class Overview {
|
|||||||
y: {
|
y: {
|
||||||
ticks: {
|
ticks: {
|
||||||
callback: function(value, index, ticks) {
|
callback: function(value, index, ticks) {
|
||||||
return value.toLocaleString() + '€';
|
return value.toLocaleString() + ' €';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
locale: 'de-DE'
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
protected readonly flowChartOptions: ChartOptions = {
|
protected readonly flowChartOptions: ChartOptions = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: { legend: { position: 'bottom' } },
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
callback: function(value, index, ticks) {
|
||||||
|
return value.toLocaleString() + ' €';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
protected readonly topOutstandingChartOptions: ChartOptions = {
|
protected readonly topOutstandingChartOptions: ChartOptions = {
|
||||||
@@ -148,6 +210,15 @@ export class Overview {
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
callback: function(value, index, ticks) {
|
||||||
|
return value.toLocaleString() + ' €';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface MonthlyFlowPoint {
|
|||||||
month: string;
|
month: string;
|
||||||
income: number;
|
income: number;
|
||||||
expense: number;
|
expense: number;
|
||||||
|
penalties: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TopOutstandingPlayer {
|
export interface TopOutstandingPlayer {
|
||||||
|
|||||||
@@ -31,3 +31,19 @@ export interface CreateTeamWalletTransaction {
|
|||||||
amount: number;
|
amount: number;
|
||||||
type: 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 { appConfig } from './app/app.config';
|
||||||
import { App } from './app/app';
|
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));
|
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
||||||
|
|||||||
Reference in New Issue
Block a user