Merge branch 'worktree-kasse-kpi-charts'

This commit is contained in:
Bastian Wagner
2026-08-01 21:49:07 +02:00
16 changed files with 805 additions and 28 deletions

View File

@@ -86,6 +86,16 @@ export class TeamsController {
return this.service.getOverview(id);
}
@ApiBearerAuth()
@Roles([RoleEnum.user, RoleEnum.admin])
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Get(':id/overview/stats')
@HttpCode(HttpStatus.OK)
getOverviewStats(@Req() req, @Param('id') id: string) {
const userId = req.user?.id;
return this.service.getOverviewStats(id, userId);
}
@ApiOperation({
summary: 'Transactionen für ein Team',
description:

View File

@@ -11,6 +11,7 @@ import { Repository } from 'typeorm';
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';
@Injectable()
export class TeamsService {
@@ -28,6 +29,7 @@ export class TeamsService {
@InjectRepository(TeamWalletTransaction)
private teamWalletTransactionRepository: Repository<TeamWalletTransaction>,
private logger: LoggingService,
private access: TeamAccessService,
) {}
async getOverview(teamId: string) {
@@ -218,6 +220,129 @@ export class TeamsService {
return result;
}
async getOverviewStats(
teamId: string | number,
actorUserId: string | number,
): Promise<{
balanceHistory: { month: string; balance: number }[];
monthlyFlow: { month: string; income: number; expense: number }[];
topOutstanding: { playerId: number; playerName: string; balance: number }[];
}> {
const id = Number(teamId);
// Read access: any active team member may view the KPI charts (same
// audience as the existing :id/overview page), not just managers.
await this.access.assertMember(Number(actorUserId), id);
const team = await this.repository.findOneOrFail({
where: { id },
relations: ['players', 'players.transactions', 'transactions'],
});
const players = team.players ?? [];
const teamTransactions = team.transactions ?? [];
// Only payment/credit/expense movements represent actual cash flow and
// are the only types that touch team.balance (see setBalance() on
// 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 }[] = [];
for (const t of teamTransactions) {
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
}
for (const p of players) {
for (const t of p.transactions ?? []) {
if (t.type.name === 'payment') {
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
}
}
}
movements.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
const topOutstanding = players
.filter((p) => p.active && Number(p.balance) < 0)
.sort((a, b) => Number(a.balance) - Number(b.balance))
.slice(0, 10)
.map((p) => ({
playerId: p.id,
playerName: p.firstName + ' ' + p.lastName,
balance: this.round(Math.abs(Number(p.balance))),
}));
// A brand-new team with no relevant movements at all (ever, not just in
// the last 12 months) has nothing to chart — return empty arrays so the
// frontend's empty-state fires instead of rendering 12 flat zero points.
if (movements.length === 0) {
return { balanceHistory: [], monthlyFlow: [], topOutstanding };
}
const months = this.getLast12Months();
// team.balance is the one authoritative, current value — it can include
// historical adjustments (e.g. from the removed legacy backend) that
// don't trace back to the visible payment/credit/expense rows. Forward-
// summing the movements from zero would silently drift away from
// team.balance for such teams. Instead we anchor to team.balance and
// walk the movements backward (newest first), "undoing" each one to
// reconstruct earlier month-end balances — this guarantees the most
// recent point always equals team.balance by construction, regardless
// of undocumented history.
const descendingMovements = [...movements].sort((a, b) =>
a.date > b.date ? -1 : a.date < b.date ? 1 : 0,
);
const currentBalance = Number(team.balance);
let futureSum = 0;
let movementIndex = 0;
const balanceHistory = [...months]
.reverse()
.map((month) => {
while (
movementIndex < descendingMovements.length &&
descendingMovements[movementIndex].date.slice(0, 7) > month
) {
futureSum += this.signedFlowAmount(descendingMovements[movementIndex]);
movementIndex++;
}
return { month, balance: this.round(currentBalance - futureSum) };
})
.reverse();
const monthlyFlow = months.map((month) => {
const monthMovements = movements.filter((m) => m.date.slice(0, 7) === month);
const income = monthMovements
.filter((m) => m.type === 'payment' || m.type === 'credit')
.reduce((sum, m) => sum + m.amount, 0);
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) };
});
return { balanceHistory, monthlyFlow, topOutstanding };
}
private getLast12Months(): string[] {
const now = new Date();
const months: string[] = [];
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
}
return months;
}
private signedFlowAmount(movement: { amount: number; type: string }): number {
return movement.type === 'expense' ? -movement.amount : movement.amount;
}
private round(value: number): number {
return Math.round(value * 100) / 100;
}
async updatePlayer(playerDTO: UpdatePlayerProfileDto) {
const player = await this.playerRepository.findOneOrFail({
where: {