Compare commits

...

2 Commits

Author SHA1 Message Date
Bastian Wagner
9664187049 altes backend entfenrt 2026-08-01 15:45:49 +02:00
Bastian Wagner
3551641a85 feat(teams): manage player active status and team-role with treasurer safeguard
Team managers (captain and above) can now deactivate/reactivate a player and
change their team-role from the player detail page. Deactivation zeroes the
open balance via an auditable adjustment transaction instead of overwriting
the balance field, and both actions are blocked if they would leave a team
without an active treasurer. Also hardens the existing PUT teams/:id/players
endpoint down to profile-only fields, fixing a typo bug and closing a gap
where any authenticated user could mutate a player's active/role/balance in
any team.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 15:42:09 +02:00
190 changed files with 830 additions and 5838 deletions

View File

@@ -20,6 +20,8 @@ export type LOGEVENT =
| 'admin_user_status_update'
| 'admin_player_assign'
| 'admin_player_unlink'
| 'player_active_update'
| 'player_team_role_update'
| 'penalty_catalog_create'
| 'penalty_catalog_update'
| 'penalty_catalog_delete'

View File

@@ -0,0 +1,19 @@
import { IsBoolean, IsIn, IsInt } from 'class-validator';
import { TeamRolesEnum } from '../../team-roles/team-roles.enum';
export class PlayerActiveDto {
@IsBoolean()
active: boolean;
}
export class PlayerTeamRoleDto {
@IsInt()
@IsIn([
TeamRolesEnum.player,
TeamRolesEnum.scnd_treasurer,
TeamRolesEnum.captain,
TeamRolesEnum.treasurer,
TeamRolesEnum.coach,
])
teamRoleId: TeamRolesEnum;
}

View File

@@ -0,0 +1,14 @@
import { IsInt, IsString, MaxLength } from 'class-validator';
export class UpdatePlayerProfileDto {
@IsInt()
id: number;
@IsString()
@MaxLength(255)
firstName: string;
@IsString()
@MaxLength(255)
lastName: string;
}

View File

@@ -0,0 +1,221 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
import {
DEACTIVATION_ADJUSTMENT_NOTE_PREFIX,
TeamMembersService,
} from './team-members.service';
describe('TeamMembersService', () => {
const teamId = 10;
let player: any;
let treasurers: any[];
let treasurerLockQuery: any;
let lockedPlayerQuery: any;
let playerRepository: any;
let transactionRepository: any;
let manager: any;
let dataSource: any;
let logger: any;
let access: any;
let service: TeamMembersService;
beforeEach(() => {
player = makePlayer(101, true, TeamRolesEnum.treasurer, 0);
treasurers = [player];
treasurerLockQuery = chain({ getMany: jest.fn(() => treasurers) });
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
playerRepository = {
createQueryBuilder: jest.fn((alias: string) =>
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
),
save: jest.fn((value) => Promise.resolve(value)),
findOne: jest.fn(() => player),
};
transactionRepository = {
insert: jest.fn(() => Promise.resolve({ identifiers: [{ id: 999 }] })),
find: jest.fn(() => Promise.resolve([])),
};
manager = {
getRepository: jest.fn((entity) =>
entity.name === 'Player' ? playerRepository : transactionRepository,
),
};
dataSource = { transaction: jest.fn((work) => work(manager)) };
logger = { info: jest.fn() };
access = { assertManager: jest.fn(() => Promise.resolve()) };
service = new TeamMembersService(dataSource, logger, access as any);
});
it('checks the team-manager permission before touching the database', async () => {
access.assertManager.mockRejectedValue(new Error('forbidden'));
await expect(service.setActive(5, teamId, player.id, false)).rejects.toThrow(
'forbidden',
);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects deactivating the last active treasurer and writes nothing', async () => {
player.balance = 42;
treasurers = [player];
await expect(
service.setActive(5, teamId, player.id, false),
).rejects.toBeInstanceOf(ConflictException);
expect(treasurerLockQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(transactionRepository.insert).not.toHaveBeenCalled();
expect(playerRepository.save).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
});
it('allows deactivation when another active treasurer remains, and zeroes the balance via a credit transaction', async () => {
player.balance = 42;
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
const result = await service.setActive(5, teamId, player.id, false);
expect(transactionRepository.insert).toHaveBeenCalledWith(
expect.objectContaining({
amount: -42,
note: expect.stringContaining(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX),
type: { id: TransactionTypeEnum.credit },
}),
);
expect(playerRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ active: false, balance: 0 }),
);
expect(logger.info).toHaveBeenCalledWith(
{
event: 'player_active_update',
details: `teamId=${teamId} playerId=${player.id} active=false`,
userId: 5,
},
manager,
);
expect(result.active).toBe(false);
});
it('deactivating a non-treasurer with zero balance never inserts an adjustment transaction', async () => {
player = makePlayer(101, true, TeamRolesEnum.player, 0);
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
playerRepository.createQueryBuilder = jest.fn((alias: string) =>
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
);
treasurers = [];
await expect(
service.setActive(5, teamId, player.id, false),
).resolves.toBeDefined();
expect(transactionRepository.insert).not.toHaveBeenCalled();
});
it('recomputes balance on reactivation, excluding deactivation adjustment rows across multiple cycles', async () => {
player = makePlayer(101, false, TeamRolesEnum.player, 0);
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
playerRepository.createQueryBuilder = jest.fn((alias: string) =>
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
);
transactionRepository.find.mockResolvedValue([
{ amount: 50, type: { id: TransactionTypeEnum.credit }, note: 'Zahlung' },
{ amount: -20, note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #101` },
{ amount: 10, type: { id: TransactionTypeEnum.credit }, note: 'Zahlung 2' },
{ amount: -40, note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #101` },
{ amount: 15, type: { id: 11 }, note: 'Strafe' },
]);
const result = await service.setActive(5, teamId, player.id, true);
// erwarteter Saldo: 50 + 10 - 15 = 45 (beide Ausgleichsbuchungen ausgeschlossen)
expect(playerRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ active: true, balance: 45 }),
);
expect(result.balance).toBe(45);
});
it('no-ops when the requested active state already matches (idempotent, no writes)', async () => {
player = makePlayer(101, true, TeamRolesEnum.player, 0);
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
playerRepository.createQueryBuilder = jest.fn((alias: string) =>
alias === 'lockedPlayer' ? lockedPlayerQuery : treasurerLockQuery,
);
await service.setActive(5, teamId, player.id, true);
expect(playerRepository.save).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
});
it('rejects changing team-role away from treasurer for the last active treasurer', async () => {
treasurers = [player];
await expect(
service.setTeamRole(5, teamId, player.id, TeamRolesEnum.captain),
).rejects.toBeInstanceOf(ConflictException);
expect(playerRepository.save).not.toHaveBeenCalled();
});
it('allows a team-role change when another active treasurer remains, and never touches balance/transactions', async () => {
treasurers = [player, makePlayer(102, true, TeamRolesEnum.treasurer, 0)];
const result = await service.setTeamRole(
5,
teamId,
player.id,
TeamRolesEnum.captain,
);
expect(transactionRepository.insert).not.toHaveBeenCalled();
expect(playerRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ teamRole: { id: TeamRolesEnum.captain } }),
);
expect(logger.info).toHaveBeenCalledWith(
{
event: 'player_team_role_update',
details: `teamId=${teamId} playerId=${player.id} teamRoleId=${TeamRolesEnum.captain}`,
userId: 5,
},
manager,
);
expect(result.teamRole).toEqual({ id: TeamRolesEnum.captain });
});
it('throws NotFoundException when the locked player does not belong to the given team', async () => {
player.team = { id: 999 };
await expect(
service.setActive(5, teamId, player.id, false),
).rejects.toBeInstanceOf(NotFoundException);
});
function makePlayer(
id: number,
active: boolean,
teamRoleId: number,
balance: number,
) {
return {
id,
firstName: 'Pat',
lastName: 'Player',
active,
balance,
team: { id: teamId },
teamRole: { id: teamRoleId },
};
}
function chain(overrides: Record<string, jest.Mock>) {
const query: Record<string, jest.Mock> = {};
[
'innerJoin',
'innerJoinAndSelect',
'leftJoinAndSelect',
'where',
'andWhere',
'setLock',
'orderBy',
].forEach((method) => (query[method] = jest.fn(() => query)));
return Object.assign(query, overrides);
}
});

View File

@@ -0,0 +1,181 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { LoggingService } from '../database/logging/logging.service';
import { Player } from '../players/entities/player.entity';
import { TeamRole } from '../team-roles/entities/team-roles.entity';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { Transaction } from '../transactions/entitites/transaction.entity';
import { TransactionType } from '../transactions/entitites/transaction-type.entity';
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
import { TeamAccessService } from './team-access.service';
export const DEACTIVATION_ADJUSTMENT_NOTE_PREFIX =
'Ausgleichsbuchung (Deaktivierung)';
@Injectable()
export class TeamMembersService {
constructor(
private readonly dataSource: DataSource,
private readonly logger: LoggingService,
private readonly access: TeamAccessService,
) {}
async setActive(
actorUserId: number,
teamId: number,
playerId: number,
active: boolean,
): Promise<Player> {
await this.access.assertManager(actorUserId, teamId);
return this.dataSource.transaction(async (manager) => {
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
const playerRepository = manager.getRepository(Player);
const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
if (player.active === active) return player;
const isDeactivation = player.active && !active;
if (
isDeactivation &&
player.teamRole?.id === TeamRolesEnum.treasurer &&
activeTreasurers.length <= 1
) {
throw new ConflictException(
'Mindestens ein aktiver Kassenwart muss im Team verbleiben.',
);
}
player.active = active;
if (isDeactivation) {
await this.zeroBalance(manager, player);
} else {
await this.recomputeBalance(manager, player);
}
await playerRepository.save(player);
await this.log(
manager,
'player_active_update',
actorUserId,
`teamId=${teamId} playerId=${playerId} active=${active}`,
);
return player;
});
}
async setTeamRole(
actorUserId: number,
teamId: number,
playerId: number,
teamRoleId: TeamRolesEnum,
): Promise<Player> {
await this.access.assertManager(actorUserId, teamId);
return this.dataSource.transaction(async (manager) => {
const activeTreasurers = await this.lockActiveTreasurers(manager, teamId);
const playerRepository = manager.getRepository(Player);
const player = await this.findLockedPlayer(playerRepository, playerId, teamId);
if (player.teamRole?.id === teamRoleId) return player;
const isDemotionFromTreasurer =
player.active &&
player.teamRole?.id === TeamRolesEnum.treasurer &&
teamRoleId !== TeamRolesEnum.treasurer;
if (isDemotionFromTreasurer && activeTreasurers.length <= 1) {
throw new ConflictException(
'Mindestens ein aktiver Kassenwart muss im Team verbleiben.',
);
}
player.teamRole = { id: teamRoleId } as TeamRole;
await playerRepository.save(player);
await this.log(
manager,
'player_team_role_update',
actorUserId,
`teamId=${teamId} playerId=${playerId} teamRoleId=${teamRoleId}`,
);
return player;
});
}
// insert() statt save(): umgeht bewusst @BeforeInsert setBalance() auf Transaction,
// das intern this.player.save() (ActiveRecord/globale DataSource, NICHT `manager`)
// aufruft -- würde zusammen mit dem pessimistic_write-Lock oben zur Selbstblockade führen.
// Saldo wird stattdessen explizit über den transaktionalen `manager` gesetzt.
private async zeroBalance(manager: EntityManager, player: Player): Promise<void> {
const currentBalance = Number(player.balance);
if (currentBalance === 0) return;
await manager.getRepository(Transaction).insert({
amount: -currentBalance,
date: new Date().toISOString(),
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #${player.id}`,
player: { id: player.id } as Player,
type: { id: TransactionTypeEnum.credit } as TransactionType,
});
player.balance = 0;
}
private async recomputeBalance(manager: EntityManager, player: Player): Promise<void> {
const transactions = await manager.getRepository(Transaction).find({
where: { player: { id: player.id } },
relations: ['type'],
});
player.balance = transactions
.filter((t) => !t.note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX))
.reduce((acc, t) => {
let amount = Number(t.amount);
if (t.type && t.type.id > 10 && amount > 0) amount = -amount;
return acc + amount;
}, 0);
}
private lockActiveTreasurers(
manager: EntityManager,
teamId: number,
): Promise<Player[]> {
return manager
.getRepository(Player)
.createQueryBuilder('player')
.innerJoin('player.team', 'team')
.innerJoinAndSelect('player.teamRole', 'teamRole')
.where('team.id = :teamId', { teamId })
.andWhere('teamRole.id = :treasurerRole', {
treasurerRole: TeamRolesEnum.treasurer,
})
.andWhere('player.active = :active', { active: true })
.setLock('pessimistic_write')
.orderBy('player.id', 'ASC')
.getMany();
}
private async findLockedPlayer(
repository: Repository<Player>,
playerId: number,
teamId: number,
): Promise<Player> {
const player = await repository
.createQueryBuilder('lockedPlayer')
.leftJoinAndSelect('lockedPlayer.teamRole', 'teamRole')
.leftJoinAndSelect('lockedPlayer.team', 'team')
.where('lockedPlayer.id = :playerId', { playerId })
.setLock('pessimistic_write')
.getOne();
if (!player || player.team?.id !== teamId) {
throw new NotFoundException('Spieler nicht gefunden.');
}
return player;
}
private log(
manager: EntityManager,
event: Parameters<LoggingService['info']>[0]['event'],
userId: number,
details: string,
) {
return this.logger.info({ event, userId, details }, manager);
}
}

View File

@@ -21,6 +21,9 @@ import { CreateTeamDTO } from './dto/create-team.dto';
import { TeamsService } from './teams.service';
import { PublicTeamAccessService } from './public-team-access.service';
import { UpdatePublicAccessDto } from './dto/public-access.dto';
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
import { PlayerActiveDto, PlayerTeamRoleDto } from './dto/player-management.dto';
import { TeamMembersService } from './team-members.service';
@ApiTags('Teams')
@Controller({
@@ -31,6 +34,7 @@ export class TeamsController {
constructor(
private service: TeamsService,
private publicAccess: PublicTeamAccessService,
private teamMembers: TeamMembersService,
) {}
@ApiBearerAuth()
@@ -119,10 +123,51 @@ export class TeamsController {
})
@Put(':id/players')
@HttpCode(HttpStatus.CREATED)
updatePlayer(@Param('id') id: string, @Body() playerDto: any) {
updatePlayer(@Body() playerDto: UpdatePlayerProfileDto) {
return this.service.updatePlayer(playerDto);
}
@ApiOperation({
summary: 'Spieler aktivieren/deaktivieren',
description:
'Setzt den Aktiv-Status eines Spielers. Bei Deaktivierung wird die offene Balance über eine Ausgleichsbuchung genullt.',
})
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Patch(':id/players/:playerId/active')
@HttpCode(HttpStatus.OK)
setPlayerActive(
@Req() req,
@Param('id', ParseIntPipe) id: number,
@Param('playerId', ParseIntPipe) playerId: number,
@Body() dto: PlayerActiveDto,
) {
return this.teamMembers.setActive(Number(req.user.id), id, playerId, dto.active);
}
@ApiOperation({
summary: 'Team-Rolle eines Spielers ändern',
description:
'Ändert die Team-Rolle eines Spielers (z.B. Spieler -> Kassenwart). Blockiert, wenn dadurch kein aktiver Kassenwart mehr im Team verbleiben würde.',
})
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Patch(':id/players/:playerId/team-role')
@HttpCode(HttpStatus.OK)
setPlayerTeamRole(
@Req() req,
@Param('id', ParseIntPipe) id: number,
@Param('playerId', ParseIntPipe) playerId: number,
@Body() dto: PlayerTeamRoleDto,
) {
return this.teamMembers.setTeamRole(
Number(req.user.id),
id,
playerId,
dto.teamRoleId,
);
}
@ApiOperation({
summary: 'Neues Team anlegen',
description:

View File

@@ -6,6 +6,7 @@ import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
import { TeamSetting } from 'src/team-settings/entities/team-setting.entity';
import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity';
import { Transaction } from 'src/transactions/entitites/transaction.entity';
import { TransactionType } from 'src/transactions/entitites/transaction-type.entity';
import { Team } from './entities/team.entity';
import { TeamsController } from './teams.controller';
import { TeamsService } from './teams.service';
@@ -14,6 +15,7 @@ import { TeamAccessService } from './team-access.service';
import { PublicTeamAccessService } from './public-team-access.service';
import { PublicTeamsController } from './public-teams.controller';
import { PenaltyEntity } from '../penalty/entities/penalty.entity';
import { TeamMembersService } from './team-members.service';
@Module({
imports: [
@@ -21,6 +23,7 @@ import { PenaltyEntity } from '../penalty/entities/penalty.entity';
Team,
Player,
Transaction,
TransactionType,
TeamRole,
TeamSetting,
TeamWalletTransaction,
@@ -30,7 +33,12 @@ import { PenaltyEntity } from '../penalty/entities/penalty.entity';
LoggingModule,
],
controllers: [TeamsController, PublicTeamsController],
providers: [TeamsService, TeamAccessService, PublicTeamAccessService],
providers: [
TeamsService,
TeamAccessService,
PublicTeamAccessService,
TeamMembersService,
],
exports: [TeamAccessService],
})
export class TeamsModule {}

View File

@@ -9,6 +9,7 @@ import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/tea
import { Transaction } from 'src/transactions/entitites/transaction.entity';
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';
@Injectable()
@@ -217,38 +218,16 @@ export class TeamsService {
return result;
}
async updatePlayer(playerDTO: any) {
async updatePlayer(playerDTO: UpdatePlayerProfileDto) {
const player = await this.playerRepository.findOneOrFail({
where: {
id: playerDTO.id,
},
relations: ['teamRole', 'transactions', 'team', 'transactions.type'],
});
player.active = playerDTO.active;
player.balance = playerDTO.balance;
player.firstName = playerDTO.firstName;
player.lastName = playerDTO.lastName;
if (player.teamRole.id != playerDTO.teamRole.id) {
player.teamRole = await this.rolesRepository.findOneByOrFail({
id: playerDTO.reamRole.id,
});
}
let balance = player.balance;
if (!player.active && player.balance != 0) {
player.balance = 0;
} else if (player.active && player.balance == 0) {
balance = player.transactions.reduce((acc, succ) => {
let b = succ.amount;
if (succ.type?.id > 10 && succ.amount > 0) {
b = b * -1;
}
return acc + b;
}, 0);
player.balance = balance;
}
const res = await this.playerRepository.save(player);
return res;
return this.playerRepository.save(player);
}
}

View File

@@ -5,5 +5,8 @@
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1"
}
}

View File

@@ -0,0 +1,85 @@
import 'reflect-metadata';
import { DataSource, DataSourceOptions } from 'typeorm';
import { Player } from '../src/players/entities/player.entity';
import { Team } from '../src/teams/entities/team.entity';
import { Transaction } from '../src/transactions/entitites/transaction.entity';
import { TransactionTypeEnum } from '../src/transactions/transaction-type.enum';
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from '../src/teams/team-members.service';
// Eigene DataSource statt der geteilten `AppDataSource`: deren `migrations`-Glob
// (`src/database/migrations/**/*`) matcht auch `.spec.ts`-Dateien in diesem Ordner
// (z.B. `AddPlayerLookupIndexes.spec.ts`) und TypeORM requiret sie beim
// Metadaten-Aufbau -- das registriert `describe`/`it` NACH Testlauf-Start und lässt
// Jest mit "Cannot add a test after tests have started running" abbrechen. Diese
// Ausgleichsbuchung braucht keine Migrationen, nur Entities -- migrations: [] umgeht das.
describe('TeamMembers rollback atomicity (real MySQL)', () => {
let dataSource: DataSource;
let team: Team;
let player: Player;
const marker = `rollback-test-${Date.now()}`;
beforeAll(async () => {
dataSource = new DataSource({
type: process.env.DATABASE_TYPE,
url: process.env.DATABASE_URL,
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
username: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
synchronize: false,
entities: [__dirname + '/../src/**/*.entity{.ts,.js}'],
} as DataSourceOptions);
await dataSource.initialize();
team = await dataSource.getRepository(Team).save({
name: marker,
alias: marker,
} as Team);
player = await dataSource.getRepository(Player).save({
firstName: 'Rollback',
lastName: 'Test',
team,
teamRole: { id: 4 }, // treasurer
balance: 42,
active: true,
} as unknown as Player);
});
afterAll(async () => {
await dataSource
.getRepository(Transaction)
.delete({ player: { id: player.id } });
await dataSource.getRepository(Player).delete({ id: player.id });
await dataSource.getRepository(Team).delete({ id: team.id });
await dataSource.destroy();
});
it('rolls back BOTH the inserted adjustment transaction AND the explicit balance write when a later error is thrown in the same manager transaction', async () => {
await expect(
dataSource.transaction(async (manager) => {
await manager.getRepository(Transaction).insert({
amount: -42,
date: new Date().toISOString(),
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #${player.id}`,
player: { id: player.id } as Player,
type: { id: TransactionTypeEnum.credit },
});
await manager
.getRepository(Player)
.save({ ...player, balance: 0, active: false });
throw new Error('forced failure after adjustment transaction would have been created');
}),
).rejects.toThrow('forced failure');
const persistedTransaction = await dataSource.getRepository(Transaction).findOne({
where: { player: { id: player.id } },
});
expect(persistedTransaction).toBeNull();
const reloadedPlayer = await dataSource.getRepository(Player).findOne({
where: { id: player.id },
});
expect(reloadedPlayer?.active).toBe(true);
expect(Number(reloadedPlayer?.balance)).toBe(42);
});
});

View File

@@ -1,16 +0,0 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

View File

@@ -1,49 +0,0 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db
# Claude Code worktrees / plan tooling
/.claude
/.superpowers
# git
package-lock.json

View File

@@ -1,4 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

View File

@@ -1,20 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "pwa-chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

View File

@@ -1,3 +0,0 @@
{
"CodeGPT.apiKey": "CodeGPT Plus Beta"
}

View File

@@ -1,42 +0,0 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

View File

@@ -1,27 +0,0 @@
# MyteamwalletFrontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.0.1.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

View File

@@ -1,135 +0,0 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"myteamwallet_frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": [
"zone.js"
],
"statsJson": true,
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest"
],
"styles": [
"src/styles.scss"
],
"scripts": [],
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.dev.ts"
}
]
},
"remote": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true,
"fileReplacements": []
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "myteamwallet_frontend:build:production"
},
"development": {
"buildTarget": "myteamwallet_frontend:build:development"
},
"remote": {
"buildTarget": "myteamwallet_frontend:build:remote"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "myteamwallet_frontend:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

View File

@@ -1,374 +0,0 @@
# Design-System-Fundament Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Legt die technische und visuelle Basis für das TeamWallet-Redesign: Design-Tokens, ein eigenes Material-3-Theme (fintech-modern, Indigo/Grün), ein konsistentes Icon-System und ein bereinigtes Notification-System — ohne die bestehenden Bildschirme inhaltlich umzubauen (das folgt in Folge-Plänen für Public-Übersicht, Dashboard/Team-Workspace, Auth-Flows).
**Architecture:** Zwei neue SCSS-Partials (`src/styles/_tokens.scss`, `src/styles/_theme.scss`) werden aus `src/styles.scss` eingebunden. Die Angular-Material-Theming-API (`mat.define-theme`, installiert in `node_modules/@angular/material/core/theming/_definition.scss`) erzeugt das Theme aus vordefinierten M3-Paletten (`mat.$blue-palette` als Primary, `mat.$spring-green-palette` als Tertiary) statt des Stock-Themes `azure-blue`. Reine CSS-Custom-Properties (nicht Sass-Variablen) tragen Spacing/Radius/Elevation/Balance-Farben, damit sie direkt in Komponenten-Templates/-Styles nutzbar sind, auch außerhalb von Sass-Kontext.
**Tech Stack:** Angular 18.1, Angular Material 18.1 (M3-Theming-API), SCSS, Angular CLI Builder (`@angular-devkit/build-angular:browser`), Karma/Jasmine.
## Global Constraints
- Kein Dark Mode in diesem Plan — Tokens müssen aber so benannt/strukturiert sein, dass ein späteres Dark-Theme sie überschreiben kann (semantische Namen, keine rohen Paletten-Referenzen in Komponenten).
- Keine neuen Abhängigkeiten hinzufügen; stattdessen wird die ungenutzte Abhängigkeit `@ngxpert/hot-toast` entfernt.
- Bestehende deutsche UI-Texte bleiben unverändert (i18n-Audit ist ein separater Folge-Plan).
- Nach jedem Task muss `npm run build` (aus `myteamwallet_frontend/`) fehlerfrei durchlaufen.
- Angular Material 18 M3-Theming bietet **keine** Funktion, aus einem beliebigen Hex-Wert eine Palette zu generieren (verifiziert: `_palettes.scss` enthält nur die zwölf vordefinierten Paletten `red/green/blue/yellow/cyan/magenta/orange/chartreuse/spring-green/azure/violet/rose`). Primary/Tertiary werden daher aus diesen vordefinierten Paletten gewählt, nicht aus Wunsch-Hexcodes.
---
### Task 1: Design-Tokens (Spacing, Radius, Elevation, Balance-Farben)
**Files:**
- Create: `myteamwallet_frontend/src/styles/_tokens.scss`
- Modify: `myteamwallet_frontend/src/styles.scss`
**Interfaces:**
- Produces: CSS-Custom-Properties auf `:root``--tw-space-{1,2,3,4,6,8}`, `--tw-radius-{sm,md,lg}`, `--tw-elevation-{1,2}`, `--tw-balance-positive`, `--tw-balance-negative`, `--tw-balance-neutral`, `--tw-surface-bg`, `--tw-surface-card`. Alle Folge-Tasks/-Pläne referenzieren ausschließlich diese Namen, nie rohe Hex-Werte.
- [ ] **Step 1: `_tokens.scss` anlegen**
```scss
// myteamwallet_frontend/src/styles/_tokens.scss
:root {
// Spacing (8px-Grid)
--tw-space-1: 4px;
--tw-space-2: 8px;
--tw-space-3: 12px;
--tw-space-4: 16px;
--tw-space-6: 24px;
--tw-space-8: 32px;
// Radius
--tw-radius-sm: 8px;
--tw-radius-md: 12px;
--tw-radius-lg: 16px;
// Elevation (ersetzt die bisher wiederholte box-shadow-Deklaration)
--tw-elevation-1: 0 1px 2px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.1);
--tw-elevation-2: 0 2px 4px rgba(15, 23, 42, 0.06), 0 4px 8px rgba(15, 23, 42, 0.1);
// Semantische Saldo-Farben (fintech-modern: gedämpftes Grün/Rot statt Signalfarben)
--tw-balance-positive: #1b8a5a;
--tw-balance-negative: #c4351c;
--tw-balance-neutral: #64748b;
// Flächen
--tw-surface-bg: #f7f8fa;
--tw-surface-card: #ffffff;
}
```
- [ ] **Step 2: In `styles.scss` einbinden und bestehende Hardcodes ersetzen**
In `myteamwallet_frontend/src/styles.scss` ganz oben ergänzen:
```scss
@use './styles/tokens';
```
Danach folgende bestehende Stellen in derselben Datei ersetzen:
```scss
// vorher: body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; background-color: #fafafa;}
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; background-color: var(--tw-surface-bg); }
```
```scss
// vorher in .card:
// box-shadow: 0 2px 1px -1px #0003, 0 1px 1px #00000024, 0 1px 3px #0000001f;
.card {
padding: var(--tw-space-3);
box-shadow: var(--tw-elevation-1);
margin: var(--tw-space-1);
background-color: var(--tw-surface-card);
border-radius: var(--tw-radius-md);
&.flat {
background-color: transparent;
box-shadow: none;
outline: #ddd solid 1px;
}
}
```
```scss
// vorher: background-color: rgb(148, 16, 16) !important;
.snackbar_error > .mdc-snackbar__surface {
background-color: var(--tw-balance-negative) !important;
.mat-mdc-snack-bar-label {
color: white !important;
}
}
```
- [ ] **Step 3: Build verifizieren**
Run: `cd myteamwallet_frontend && npm run build`
Expected: Build erfolgreich, keine Sass-/Compile-Fehler.
- [ ] **Step 4: Commit**
```bash
git add myteamwallet_frontend/src/styles/_tokens.scss myteamwallet_frontend/src/styles.scss
git commit -m "feat(design): add design tokens and wire into global styles"
```
---
### Task 2: Eigenes Material-3-Theme statt Stock-Theme
**Files:**
- Create: `myteamwallet_frontend/src/styles/_theme.scss`
- Modify: `myteamwallet_frontend/src/styles.scss`
- Modify: `myteamwallet_frontend/angular.json:35` (build-Styles), `myteamwallet_frontend/angular.json:125` (test-Styles)
- Modify: `myteamwallet_frontend/src/index.html`
**Interfaces:**
- Consumes: nichts aus Task 1 direkt (unabhängiges Partial), wird aber in derselben `styles.scss` eingebunden.
- Produces: Sass-Variable `theme.$teamwallet-theme`, die alle Material-Komponentenstyles erzeugt. Spätere Pläne fügen hier bei Bedarf `mat.theme-overrides()` hinzu, ändern aber nicht den Namen `$teamwallet-theme`.
- [ ] **Step 1: `_theme.scss` anlegen**
```scss
// myteamwallet_frontend/src/styles/_theme.scss
@use '@angular/material' as mat;
$teamwallet-theme: mat.define-theme((
color: (
theme-type: light,
primary: mat.$blue-palette,
tertiary: mat.$spring-green-palette,
),
typography: (
brand-family: 'Inter, Roboto, "Helvetica Neue", sans-serif',
plain-family: 'Inter, Roboto, "Helvetica Neue", sans-serif',
),
density: (
scale: 0,
),
));
```
- [ ] **Step 2: Theme in `styles.scss` einbinden (ersetzt Stock-Theme-Import)**
Am Anfang von `myteamwallet_frontend/src/styles.scss` ergänzen (vor den bestehenden Regeln):
```scss
@use '@angular/material' as mat;
@use './styles/theme';
@include mat.core();
@include mat.all-component-themes(theme.$teamwallet-theme);
```
- [ ] **Step 3: Stock-Theme aus `angular.json` entfernen**
In `myteamwallet_frontend/angular.json` im `build`-Target die Zeile entfernen:
```json
"@angular/material/prebuilt-themes/azure-blue.css",
```
Im `test`-Target die Zeile entfernen:
```json
"@angular/material/prebuilt-themes/cyan-orange.css",
```
In beiden Targets bleibt `"src/styles.scss"` als einziger globaler Stylesheet-Eintrag stehen.
- [ ] **Step 4: Google-Font auf Inter umstellen**
In `myteamwallet_frontend/src/index.html` die bestehende Roboto-Zeile ersetzen:
```html
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
```
- [ ] **Step 5: Build und Tests verifizieren**
Run: `cd myteamwallet_frontend && npm run build`
Expected: Build erfolgreich, Material-Komponenten (Buttons, Toolbar, Cards) werden aus dem neuen Theme gestylt statt aus `azure-blue.css`.
Run: `cd myteamwallet_frontend && npm test -- --watch=false`
Expected: Bestehende Karma-Suite läuft weiterhin durch (keine Test-Regression durch den Theme-Wechsel).
- [ ] **Step 6: Commit**
```bash
git add myteamwallet_frontend/src/styles/_theme.scss myteamwallet_frontend/src/styles.scss myteamwallet_frontend/angular.json myteamwallet_frontend/src/index.html
git commit -m "feat(design): replace stock Material azure-blue theme with custom M3 theme"
```
---
### Task 3: Icon-System auf Material Symbols umstellen, PNG-Icon-Klassen entfernen
**Files:**
- Modify: `myteamwallet_frontend/src/index.html`
- Modify: `myteamwallet_frontend/src/app/app.component.ts`
- Modify: `myteamwallet_frontend/src/app/modules/home/dashboard/dashboard.component.ts`
- Modify: `myteamwallet_frontend/src/app/modules/home/dashboard/dashboard.component.html`
- Modify: `myteamwallet_frontend/src/styles.scss`
- Delete: `myteamwallet_frontend/src/assets/edit.png`, `myteamwallet_frontend/src/assets/dashboard.png`, `myteamwallet_frontend/src/assets/tacho.png`
**Interfaces:**
- Consumes: nichts.
- Produces: `<mat-icon>`-basierte Icons überall; keine PNG-Icon-Klassen (`icon`, `icon_edit`, `icon_dashboard`, `icon_tacho`) mehr im Code. Verifiziert: diese Klassen werden ausschließlich in `dashboard.component.html` verwendet (`icon_tacho`, `icon_edit`; `icon_dashboard` ist bereits toter Code).
- [ ] **Step 1: Material Symbols statt Material Icons laden**
In `myteamwallet_frontend/src/index.html` die Material-Icons-Zeile ersetzen:
```html
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet">
```
- [ ] **Step 2: Default-Fontset der App auf Material Symbols umstellen**
In `myteamwallet_frontend/src/app/app.component.ts`:
```ts
import { Component } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { environment } from './../environments/environment';
import { SwUpdate } from '@angular/service-worker';
import { MatIconRegistry } from '@angular/material/icon';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
version = environment.appVersion;
constructor(translate: TranslateService, updates: SwUpdate, iconRegistry: MatIconRegistry) {
iconRegistry.setDefaultFontSetClass('material-symbols-outlined');
translate.setDefaultLang('de');
translate.use('de');
this.update(updates);
}
// ... rest unverändert
```
(Restlichen Methodenkörper `update()` unverändert lassen.)
- [ ] **Step 3: Dashboard-Icons auf `mat-icon` umstellen**
In `myteamwallet_frontend/src/app/modules/home/dashboard/dashboard.component.ts` `MatIconModule` importieren:
```ts
import { MatIconModule } from '@angular/material/icon';
// ...
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
standalone: true,
imports: [ CommonModule, TranslateModule, MatButtonModule, MatIconModule ]
})
```
In `myteamwallet_frontend/src/app/modules/home/dashboard/dashboard.component.html` ersetzen:
```html
<!-- vorher -->
<div class="icons">
<div class="icon icon_tacho" matRipple (click)="onTeamClick(p.team)"></div>
<div class="icon icon_edit" matRipple (click)="onTeamClick(p.team)"></div>
</div>
```
```html
<!-- nachher -->
<div class="icons">
<button mat-icon-button (click)="onTeamClick(p.team)" aria-label="Team-Details öffnen">
<mat-icon>speed</mat-icon>
</button>
<button mat-icon-button (click)="onTeamClick(p.team)" aria-label="Team bearbeiten">
<mat-icon>edit</mat-icon>
</button>
</div>
```
- [ ] **Step 4: Tote Icon-Klassen aus `styles.scss` entfernen**
In `myteamwallet_frontend/src/styles.scss` den gesamten Block entfernen:
```scss
.icon { ... }
.icon_edit { ... }
.icon_dashboard { ... }
.icon_tacho { ... }
```
(Alle vier Regeln inkl. der `.icon:hover`-Verschachtelung löschen — keine andere Datei referenziert diese Klassen mehr.)
- [ ] **Step 5: Ungenutzte PNG-Assets löschen**
```bash
git rm myteamwallet_frontend/src/assets/edit.png myteamwallet_frontend/src/assets/dashboard.png myteamwallet_frontend/src/assets/tacho.png
```
- [ ] **Step 6: Build verifizieren und visuell prüfen**
Run: `cd myteamwallet_frontend && npm run build`
Expected: Build erfolgreich, keine fehlenden Asset-Referenzen.
Run: `cd myteamwallet_frontend && npm start`, Dashboard im Browser öffnen (`http://localhost:4200/dashboard`, eingeloggt).
Expected: Statt der beiden PNG-Icons erscheinen die Material-Symbols-Icons „speed“ und „edit“, klickbar wie zuvor.
- [ ] **Step 7: Commit**
```bash
git add myteamwallet_frontend/src/index.html myteamwallet_frontend/src/app/app.component.ts myteamwallet_frontend/src/app/modules/home/dashboard/dashboard.component.ts myteamwallet_frontend/src/app/modules/home/dashboard/dashboard.component.html myteamwallet_frontend/src/styles.scss
git commit -m "feat(design): migrate to Material Symbols, remove PNG icon classes"
```
---
### Task 4: Notification-System bereinigen
**Files:**
- Modify: `myteamwallet_frontend/package.json`
**Interfaces:**
- Consumes: `--tw-balance-negative`-Token aus Task 1 (bereits in `.snackbar_error` verdrahtet).
- Produces: keine neuen Symbole — reine Bereinigung.
Verifiziert: `@ngxpert/hot-toast` ist in `package.json` als Abhängigkeit deklariert, wird aber im gesamten `src/`-Verzeichnis nirgends importiert (`HotToastService`/`@ngxpert/hot-toast` liefert null Treffer). Es gibt also kein zweites, konkurrierendes Toast-System im Code — nur eine tote Abhängigkeit. Die tatsächliche Benachrichtigung läuft ausschließlich über `MatSnackBar` (3 Aufrufstellen, alle in `team-details.component.ts`), deren Fehler-Variante bereits in Task 1 auf den neuen Token umgestellt wurde.
- [ ] **Step 1: Ungenutzte Abhängigkeit entfernen**
In `myteamwallet_frontend/package.json` die Zeile aus `dependencies` entfernen:
```json
"@ngxpert/hot-toast": "3.0.0",
```
- [ ] **Step 2: Lockfile aktualisieren**
Run: `cd myteamwallet_frontend && npm install`
Expected: `package-lock.json` aktualisiert sich, `@ngxpert/hot-toast` verschwindet aus dem Dependency-Baum, keine anderen Pakete werden unerwartet verändert.
- [ ] **Step 3: Build verifizieren**
Run: `cd myteamwallet_frontend && npm run build`
Expected: Build weiterhin erfolgreich (bestätigt, dass das Paket wirklich ungenutzt war).
- [ ] **Step 4: Commit**
```bash
git add myteamwallet_frontend/package.json myteamwallet_frontend/package-lock.json
git commit -m "chore(deps): remove unused @ngxpert/hot-toast dependency"
```
---
## Hinweis für Folge-Pläne
Die Wrapper-Komponenten-Bibliothek (`BalanceDisplay`, `PageHeader`, `SectionCard`, `EmptyState`, `StatTile` aus dem Gesamtplan) wird bewusst **nicht** in diesem Fundament-Plan gebaut, sondern erst in dem Folge-Plan, der ihren ersten echten Verwendungsort umbaut (Public-Team-Übersicht bzw. Dashboard/Team-Workspace) — YAGNI: eine Komponenten-Bibliothek ohne Konsumenten lässt sich nicht sinnvoll gegen echte Anforderungen entwerfen. Dieser Fundament-Plan liefert die Basis (Tokens, Theme, Icons, saubere Notifications), auf der diese Komponenten aufbauen.

View File

@@ -1,29 +0,0 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

View File

@@ -1,51 +0,0 @@
{
"name": "myteamwallet-frontend",
"version": "1.0.49",
"scripts": {
"ng": "ng",
"start": "ng serve",
"start:remote": "ng serve --configuration=remote",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"patch": "npm --no-git-tag-version version patch",
"post_patch": "git add package.json"
},
"pre-commit": [
"patch",
"post_patch"
],
"private": true,
"dependencies": {
"@angular/animations": "^18.1.4",
"@angular/cdk": "^18.1.4",
"@angular/common": "^18.1.4",
"@angular/compiler": "^18.1.4",
"@angular/core": "^18.1.4",
"@angular/forms": "^18.1.4",
"@angular/material": "^18.1.4",
"@angular/platform-browser": "^18.1.4",
"@angular/platform-browser-dynamic": "^18.1.4",
"@angular/router": "^18.1.4",
"@angular/service-worker": "^18.1.4",
"@ngx-translate/core": "^15.0.0",
"@ngx-translate/http-loader": "^8.0.0",
"rxjs": "~7.5.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.8"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.1.4",
"@angular/cli": "~18.1.4",
"@angular/compiler-cli": "^18.1.4",
"@types/jasmine": "~4.3.0",
"jasmine-core": "~4.5.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.0.0",
"pre-commit": "^1.2.2",
"typescript": "~5.4.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,2 +0,0 @@
<div class="app_version">{{version}}</div>
<router-outlet></router-outlet>

View File

@@ -1,9 +0,0 @@
.app_version{
position: absolute;
left: 0px;
bottom: 0px;
font-size: 0.6rem;
line-height: 0.6rem;
color: rgb(190, 190, 190);
pointer-events: none;
}

View File

@@ -1,29 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
RouterTestingModule
],
declarations: [
AppComponent
],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'myteamwallet_frontend'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('myteamwallet_frontend');
});
});

View File

@@ -1,46 +0,0 @@
import { Component } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { environment } from './../environments/environment';
import { SwUpdate } from '@angular/service-worker';
import { MatIconRegistry } from '@angular/material/icon';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
version = environment.appVersion;
constructor(translate: TranslateService, updates: SwUpdate, iconRegistry: MatIconRegistry) {
iconRegistry.setDefaultFontSetClass('material-symbols-outlined');
translate.setDefaultLang('de');
translate.use('de');
this.update(updates);
}
update(updates: SwUpdate) {
console.log("Checking for updates: ", updates.isEnabled);
if (!updates.isEnabled) { return; }
updates.versionUpdates.subscribe(evt => {
switch (evt.type) {
case 'VERSION_DETECTED':
console.log(`Downloading new app version: ${evt.version.hash}`);
break;
case 'VERSION_READY':
console.log(`Current app version: ${evt.currentVersion.hash}`);
console.log(`New app version ready for use: ${evt.latestVersion.hash}`);
window.location.reload();
break;
case 'VERSION_INSTALLATION_FAILED':
console.log(`Failed to install app version '${evt.version.hash}': ${evt.error}`);
break;
case 'NO_NEW_VERSION_DETECTED':
break;
default:
}
});
}
}

View File

@@ -1,55 +0,0 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { LOCALE_ID, isDevMode} from '@angular/core';
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
import { registerLocaleData } from '@angular/common';
import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { AuthInterceptorService } from './core/interceptor/auth.interceptor';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { CustomTranslateLoader } from './core/translate/translate-loader';
import { provideRouter, RouterModule } from '@angular/router';
import { routes } from './app.routes';
import { ServiceWorkerModule } from '@angular/service-worker';
registerLocaleData(localeDe, localeDeExtra);
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
RouterModule,
BrowserAnimationsModule,
HttpClientModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: CustomTranslateLoader,
deps: [HttpClient],
},
defaultLanguage: 'de',
}),
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: !isDevMode(),
// Register the ServiceWorker as soon as the application is stable
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000'
}),
],
providers: [
{ provide: LOCALE_ID, useValue: 'de' },
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptorService,
multi: true
},
provideRouter(routes)
],
bootstrap: [AppComponent]
})
export class AppModule { }

View File

@@ -1,18 +0,0 @@
import { Routes } from '@angular/router';
import { homeRoutes } from './modules/home/home.routes';
import { authRoutes } from './modules/auth/auth.routes';
export const routes: Routes = [
{
path: '', children: homeRoutes
},
{
path: 'auth', children: authRoutes
},
{
path: 'password-change/:hash', loadComponent: () => import('./modules/auth/password-change/password-change.component').then(m => m.PasswordChangeComponent)
},
{
path: 'teams', loadChildren: () => import('./modules/teams/teams.module').then(m => m.TeamsModule)
}
];

View File

@@ -1,38 +0,0 @@
import { Injectable } from '@angular/core';
import { ActivatedRoute, ActivatedRouteSnapshot, Route, Router, RouterStateSnapshot, UrlSegment, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from 'src/app/modules/auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard {
constructor(private router: Router, private activatedRoute: ActivatedRoute, private authService: AuthService) {}
private async check(): Promise<boolean> {
console.log("checking")
if (this.authService.isLoggedIn()) { return true; }
const success = await this.authService.loginFromLocalStorage();
if (success) {
return true;
}
this.router.navigate(['/auth']).then();
return false;
}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return this.check();
}
canActivateChild(
childRoute: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return this.check();
}
canLoad(): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
return this.check();
}
}

View File

@@ -1,34 +0,0 @@
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Observable, catchError, throwError } from 'rxjs';
import { AuthService } from 'src/app/modules/auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthInterceptorService implements HttpInterceptor {
constructor(private authService: AuthService, private snackBar: MatSnackBar) {}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = this.authService.getAuthToken();
if (token) {
// If we have a token, we set it to the header
request = request.clone({
setHeaders: {Authorization: `Bearer ${token}`}
});
}
return next.handle(request).pipe(
catchError((err) => {
if (err instanceof HttpErrorResponse) {
if (err.status === 401 && this.authService.isLoggedIn()) {
this.snackBar.open('Sitzung abgelaufen, bitte erneut anmelden', undefined, { duration: 5000 });
this.authService.logout();
}
}
return throwError(err);
})
)
}
}

View File

@@ -1,13 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { TranslateLoader } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
export class CustomTranslateLoader implements TranslateLoader {
constructor(private http: HttpClient) {}
getTranslation(lang: string): Observable<any> {
return this.http.get(`${environment.apiUrl}translations/${lang}`);
}
}

View File

@@ -1,19 +0,0 @@
import { HttpClientModule } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ HttpClientModule ]
});
service = TestBed.inject(UserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -1,30 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { AuthService } from 'src/app/modules/auth.service';
import { environment } from 'src/environments/environment';
@Injectable({
providedIn: 'root'
})
export class UserService {
players: any[] = [];
constructor(private http: HttpClient, private authService: AuthService, private snackBar: MatSnackBar) { }
loadTeamsOfUser() {
this.http.get(`${environment.apiUrl}users/${this.authService.user.id}/teams`).subscribe({
next: data => { this.players = data as any[]; },
error: () => { this.onUserLoadFailure(); }
})
}
onUserLoadFailure() {
this.snackBar.open('Teams konnten nicht geladen werden', undefined, {
duration: 5000,
panelClass: 'snackbar_error'
})
}
}

View File

@@ -1,3 +0,0 @@
export * from './player';
export * from './teamrole';
export * from './transaction';

View File

@@ -1,16 +0,0 @@
import { Team } from '../modules/teams/model/team';
import { TeamRole } from './teamrole';
export interface Player {
firstName: string;
lastName: string;
id: number;
balance: number;
team: Team;
teamRole: TeamRole;
active: boolean;
hide: boolean;
usersPlayer?: boolean;
}

View File

@@ -1,4 +0,0 @@
export interface TeamRole {
id: number;
name: string;
}

View File

@@ -1,9 +0,0 @@
export interface Transaction {
id: number;
amount: number;
playerName?: string;
date: string;
type: string;
note: string;
isTeamWalletTransaction: boolean;
}

View File

@@ -1,17 +0,0 @@
export interface User {
createdAt: string;
deletedAt: string;
email: string;
firstName: string;
lastName: string;
id: number;
photo: string;
role: Role;
status: any;
updatedAt: string;
}
export interface Role {
id: number;
name: string;
}

View File

@@ -1,37 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let service: AuthService;
const mockHttp = jasmine.createSpyObj('HttpClient', ['post']);
mockHttp.post.and.returnValue(of({
token: 'token',
user: {id: 1, email: 'mail', firstName: 'first', lastName: 'last'}
}))
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [{ provide: HttpClient, useValue: mockHttp }]
});
service = TestBed.inject(AuthService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should log in', async () => {
expect(service['authInfoSubject'].value).toBeNull();
const success = await service.login('mail', 'passwort123');
expect(success).toBeTrue();
expect(service['authInfoSubject'].value).not.toBeNull();
expect(service['authInfoSubject'].value.id).toBe(1);
expect(service['authInfoSubject'].value.email).toEqual('mail');
expect(service['authInfoSubject'].value.firstName).toEqual('first');
expect(service['authInfoSubject'].value.lastName).toEqual('last');
})
});

View File

@@ -1,117 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { BehaviorSubject, Observable } from 'rxjs';
import { environment } from 'src/environments/environment';
import { User } from '../model/user';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private authInfoSubject: BehaviorSubject<User> = new BehaviorSubject(null as any);
authInfo$: Observable<User> = this.authInfoSubject.asObservable();
private token: string = '';
constructor(private http: HttpClient, private router: Router) {
}
get isAdmin(): boolean {
return this.user != null && this.user.role != null && this.user.role.id == 1;
}
isLoggedIn(): boolean {
return this.authInfoSubject.value != null;
}
get user(): User {
return this.authInfoSubject.value;
}
public async login(email: string, password: string): Promise<boolean> {
return new Promise(resolve => {
this.http.post(`${environment.apiUrl}auth/email/login`, { email, password }).subscribe((result: any) => {
localStorage.setItem('accessToken', window.btoa(result['token']));
this.authInfoSubject.next(result['user']);
this.token = result['token'];
resolve(true);
}, error => {
this.http.post(`${environment.apiUrl}auth/admin/email/login`, { email, password }).subscribe((result: any) => {
if (result && result['token']) {
localStorage.setItem('accessToken', window.btoa(result['token']));
this.authInfoSubject.next(result['user']);
this.token = result['token'];
resolve(true)
}
}, error => {
resolve(false);
})
})
})
}
getAuthToken(): string {
return this.token;
}
async verifyAccessToken(token: string): Promise<boolean> {
this.token = token;
return new Promise(resolve => {
this.http.get<User>(`${environment.apiUrl}auth/me`).subscribe(res => {
if (res && res.firstName) {
this.authInfoSubject.next(res);
if ((res as any)['token']) {
const token = (res as any)['token'];
this.token = token;
localStorage.setItem('accessToken', window.btoa(token));
}
return resolve(true);
}
return resolve(false);
}, () => {
return resolve(false);
})
})
}
logout() {
this.authInfoSubject.next(null as any);
localStorage.removeItem('accessToken');
this.router.navigate(['auth'])
}
register(data: {email: string, password: string, firstName: string, lastName: string, linkPlayerId?: number}): Observable<any> {
return this.http.post(`${environment.apiUrl}auth/email/register`, data);
}
forgotPassword(email: string): Observable<any> {
return this.http.post(`${environment.apiUrl}auth/forgot/password`, { email });
}
resetPassword(hash: string, password: string): Observable<any> {
return this.http.post(`${environment.apiUrl}auth/reset/password`, { hash, password });
}
async loginFromLocalStorage(suppressNavigation = false): Promise<boolean> {
return new Promise<boolean>(async resolve => {
const authToken = localStorage.getItem('accessToken');
if (!authToken || authToken.length == 0) {
if (!suppressNavigation) {
this.router.navigate(['/auth']).then();
}
return resolve(false);
}
const accessToken = window.atob(authToken);
const success = await this.verifyAccessToken(accessToken);
resolve(success)
})
}
}

View File

@@ -1,12 +0,0 @@
<mat-card>
<router-outlet></router-outlet>
</mat-card>
<div class="cached_teams" *ngIf="cachedTeams.length > 0" >
<div>Teams:</div>
@for(t of cachedTeams; track t) {
<button mat-flat-button (click)="openTeam(t.alias)" >{{ t.name }}</button>
}
<button mat-stroked-button color="warn" style="margin-top: 16px;" (click)="clear()" >Clear</button>
</div>

View File

@@ -1,25 +0,0 @@
:host {
display: flex;
justify-content: center;
align-items: center;
width: 100vw;
height: 100%;
flex-direction: column;
gap: 64px;
}
mat-card {
width: 500px;
max-width: 90vw;
}
.cached_teams {
display: flex;
flex-direction: column;
justify-content: stretch;
gap: 4px;
button {
width: 100%;
}
}

View File

@@ -1,27 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { AuthComponent } from './auth.component';
describe('AuthComponent', () => {
let component: AuthComponent;
let fixture: ComponentFixture<AuthComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AuthComponent ],
imports: [ MatCardModule, RouterModule ],
providers: [ ]
})
.compileComponents();
fixture = TestBed.createComponent(AuthComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,63 +0,0 @@
import { Component, inject } from '@angular/core';
import { AuthService } from '../auth.service';
import { Router, RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { MatCardModule } from '@angular/material/card';
import { AuthGuard } from '../../core/guards/auth.guard';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-auth',
templateUrl: './auth.component.html',
styleUrls: ['./auth.component.scss'],
standalone: true,
imports: [
CommonModule,
MatCardModule,
HttpClientModule,
RouterModule,
MatButtonModule
],
providers: [ AuthGuard ]
})
export class AuthComponent {
private auth: AuthService = inject(AuthService);
private router: Router = inject(Router);
cachedTeams: any[] = [];
constructor() {
// this.login();
this.getCachedTeams();
}
login() {
this.auth.loginFromLocalStorage().then(res => {
if (res) {
this.router.navigate(['/dashboard']);
}
})
}
getCachedTeams() {
let t = localStorage.getItem('cached_teams');
if (!t) { return; }
let teams = JSON.parse(t);
this.cachedTeams = Object.keys(teams).map((k: any) => {
return { alias: k, name: teams[k] }
});
}
openTeam(alias: string) {
this.router.navigate([`teams/${alias}`])
}
clear() {
localStorage.removeItem('cached_teams');
this.cachedTeams = [];
}
}

View File

@@ -1,18 +0,0 @@
import { Routes } from '@angular/router';
import { AuthComponent } from './auth.component';
export const authRoutes: Routes = [
{
path: '', component: AuthComponent,
children: [{
path: '', loadChildren: () => import('./login/login.module').then(m => m.LoginModule)
},
{
path: 'register', loadChildren: () => import('./register/register.module').then(m => m.RegisterModule)
},
{
path: 'forgot-password', loadComponent: () => import('./forgot-password/forgot-password.component').then(m => m.ForgotPasswordComponent)
}]
},
];

View File

@@ -1,29 +0,0 @@
<mat-card-header>
<mat-card-title>Passwort vergessen</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-container *ngIf="!submitted">
<p class="mat-body">Gib deine E-Mail-Adresse ein. Falls sie bei uns registriert ist, schicken wir dir einen Link zum Zurücksetzen des Passworts.</p>
<form [formGroup]="form" (keyup.enter)="submit()">
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput placeholder="pat@example.com" formControlName="email" required>
</mat-form-field>
</form>
<p *ngIf="error" class="forgot-error">Anfrage konnte nicht gesendet werden. Bitte später erneut versuchen.</p>
</ng-container>
<ng-container *ngIf="submitted">
<p class="mat-body">Falls die E-Mail-Adresse bei uns registriert ist, wurde eine Nachricht mit einem Link zum Zurücksetzen des Passworts verschickt. Bitte Posteingang (und Spam-Ordner) prüfen.</p>
</ng-container>
</mat-card-content>
<mat-card-actions align="end">
<a mat-button routerLink="/auth">Zurück zum Login</a>
<button *ngIf="!submitted" mat-button color="primary" [disabled]="form.invalid" (click)="submit()">Link anfordern</button>
</mat-card-actions>

View File

@@ -1,13 +0,0 @@
mat-form-field {
width: 100%;
}
mat-card-header {
margin-bottom: 16px;
}
.forgot-error {
color: #b3261e;
margin: 8px 0 0;
font-size: 0.9rem;
}

View File

@@ -1,56 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { RouterModule } from '@angular/router';
import { AuthService } from '../../auth.service';
@Component({
selector: 'app-forgot-password',
templateUrl: './forgot-password.component.html',
styleUrls: ['./forgot-password.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
RouterModule
]
})
export class ForgotPasswordComponent {
submitted = false;
error = false;
form = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email])
});
constructor(private authService: AuthService) {}
submit() {
if (this.form.invalid) { return; }
const email = this.form.controls.email.value + '';
this.error = false;
this.authService.forgotPassword(email).subscribe({
next: () => { this.submitted = true; },
error: (err) => {
// Aus Datenschutzgründen wird bei unbekannter E-Mail dieselbe
// Erfolgsmeldung angezeigt wie bei einer bekannten Adresse.
if (err?.error?.errors?.email === 'emailNotExists') {
this.submitted = true;
return;
}
this.error = true;
}
});
}
}

View File

@@ -1,16 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LoginComponent } from './login.component';
const routes: Routes = [
{
path: '', component: LoginComponent
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LoginRoutingModule { }

View File

@@ -1,31 +0,0 @@
<mat-card-header>
<mat-card-title>Login</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="loginForm" (keyup.enter)="login()">
<div>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput placeholder="pat@example.com" formControlName="email" required name="username">
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input matInput autocomplete="current-password" type="password" placeholder="pat@example.com" formControlName="password" required>
</mat-form-field>
</div>
<p *ngIf="loginFailed" class="login-error">E-Mail oder Passwort ist falsch.</p>
</form>
</mat-card-content>
<mat-card-actions align="end">
<a mat-button routerLink="/auth/forgot-password">Passwort vergessen?</a>
<button mat-button color="primary" [disabled]="loginForm.invalid" (click)="login()">Login</button>
</mat-card-actions>

View File

@@ -1,13 +0,0 @@
mat-form-field {
width: 100%;
}
mat-card-header {
margin-bottom: 24px;
}
.login-error {
color: #b3261e;
margin: 0 0 8px;
font-size: 0.9rem;
}

View File

@@ -1,35 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { AuthService } from '../../auth.service';
import { LoginComponent } from './login.component';
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
const mockAuthService = jasmine.createSpyObj('AuthService', [''])
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ LoginComponent ],
providers: [
{ provide: AuthService, useValue: mockAuthService }
],
imports: [ MatCardModule, FormsModule, MatFormFieldModule, ReactiveFormsModule, MatInputModule, MatButtonModule, MatSnackBarModule, NoopAnimationsModule ]
})
.compileComponents();
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,42 +0,0 @@
import { Component } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { AuthService } from '../../auth.service';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
export class LoginComponent {
loginFailed = false;
constructor(private authService: AuthService, private router: Router, private snackBar: MatSnackBar) {
}
loginForm = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [Validators.required])
});
async login() {
const email = this.loginForm.controls.email.value + '';
const password = this.loginForm.controls.password.value + ''
this.loginFailed = false;
const success = await this.authService.login(email, password);
if (success) {
this.router.navigate(['/dashboard']).then();
} else {
this.loginFailed = true;
this.snackBar.open('E-Mail oder Passwort ist falsch', undefined, {
duration: 5000,
panelClass: 'snackbar_error'
});
}
}
}

View File

@@ -1,25 +0,0 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LoginComponent } from './login.component';
import { LoginRoutingModule } from './login-routing.module';
import { MatCardModule } from '@angular/material/card';
import {MatButtonModule} from '@angular/material/button';
import {MatFormFieldModule} from '@angular/material/form-field';
import { ReactiveFormsModule } from '@angular/forms';
import {MatInputModule} from '@angular/material/input';
@NgModule({
declarations: [
LoginComponent
],
imports: [
CommonModule,
LoginRoutingModule,
MatCardModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
]
})
export class LoginModule { }

View File

@@ -1,45 +0,0 @@
<mat-card>
<mat-card-header>
<mat-card-title>Neues Passwort vergeben</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-container *ngIf="!success && hash !== null">
<form [formGroup]="form" (keyup.enter)="submit()">
<mat-form-field appearance="outline">
<mat-label>Neues Passwort</mat-label>
<input matInput type="password" autocomplete="new-password" formControlName="password" required>
<mat-hint>Mindestens 6 Zeichen</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Passwort wiederholen</mat-label>
<input matInput type="password" autocomplete="new-password" formControlName="confirmPassword" required>
</mat-form-field>
<p *ngIf="form.errors?.['passwordMismatch'] && form.controls.confirmPassword.dirty" class="password-error">
Die Passwörter stimmen nicht überein.
</p>
</form>
<p *ngIf="error" class="password-error">
Der Link ist ungültig oder abgelaufen. Bitte fordere über "Passwort vergessen" einen neuen Link an.
</p>
</ng-container>
<ng-container *ngIf="success">
<p class="mat-body">Dein Passwort wurde geändert. Du wirst gleich zum Login weitergeleitet.</p>
</ng-container>
<ng-container *ngIf="hash === null">
<p class="mat-body">Dieser Link ist unvollständig. Bitte fordere über "Passwort vergessen" einen neuen Link an.</p>
</ng-container>
</mat-card-content>
<mat-card-actions align="end">
<a mat-button routerLink="/auth">Zurück zum Login</a>
<button *ngIf="!success && hash !== null" mat-button color="primary" [disabled]="form.invalid" (click)="submit()">Passwort speichern</button>
</mat-card-actions>
</mat-card>

View File

@@ -1,26 +0,0 @@
:host {
display: flex;
justify-content: center;
align-items: center;
width: 100vw;
height: 100%;
}
mat-card {
width: 500px;
max-width: 90vw;
}
mat-form-field {
width: 100%;
}
mat-card-header {
margin-bottom: 16px;
}
.password-error {
color: #b3261e;
margin: 8px 0 0;
font-size: 0.9rem;
}

View File

@@ -1,65 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { AbstractControl, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthService } from '../../auth.service';
function passwordsMatch(control: AbstractControl): ValidationErrors | null {
const password = control.get('password')?.value;
const confirmPassword = control.get('confirmPassword')?.value;
return password && confirmPassword && password !== confirmPassword ? { passwordMismatch: true } : null;
}
@Component({
selector: 'app-password-change',
templateUrl: './password-change.component.html',
styleUrls: ['./password-change.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
RouterModule
]
})
export class PasswordChangeComponent implements OnInit {
hash: string | null = null;
success = false;
error = false;
form = new FormGroup({
password: new FormControl('', [Validators.required, Validators.minLength(6)]),
confirmPassword: new FormControl('', [Validators.required]),
}, { validators: passwordsMatch });
constructor(private route: ActivatedRoute, private router: Router, private authService: AuthService) {}
ngOnInit(): void {
this.hash = this.route.snapshot.paramMap.get('hash');
if (!this.hash) {
this.error = true;
}
}
submit() {
if (this.form.invalid || !this.hash) { return; }
this.error = false;
this.authService.resetPassword(this.hash, this.form.controls.password.value + '').subscribe({
next: () => {
this.success = true;
setTimeout(() => this.router.navigateByUrl('/auth'), 3000);
},
error: () => { this.error = true; }
});
}
}

View File

@@ -1,9 +0,0 @@
<mat-card-header>
<mat-card-title>Registrieren nicht möglich</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="mat-body">
Es ist kein gültiger Registrierungstoken vorhanden. Das Registrieren ist derzeit nur über einen Link mit gültigem Token möglich.
</div>
</mat-card-content>

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoTokenProvidedComponent } from './no-token-provided.component';
describe('NoTokenProvidedComponent', () => {
let component: NoTokenProvidedComponent;
let fixture: ComponentFixture<NoTokenProvidedComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ NoTokenProvidedComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(NoTokenProvidedComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,10 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-no-token-provided',
templateUrl: './no-token-provided.component.html',
styleUrls: ['./no-token-provided.component.scss']
})
export class NoTokenProvidedComponent {
}

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegisterBaseComponent } from './register-base.component';
describe('RegisterBaseComponent', () => {
let component: RegisterBaseComponent;
let fixture: ComponentFixture<RegisterBaseComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ RegisterBaseComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(RegisterBaseComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,10 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-register-base',
templateUrl: './register-base.component.html',
styleUrls: ['./register-base.component.scss']
})
export class RegisterBaseComponent {
}

View File

@@ -1,30 +0,0 @@
<form [formGroup]="registerForm" (keyup.enter)="register()">
<div class="splitted_form">
<mat-form-field appearance="outline" class="first">
<mat-label>Vorname</mat-label>
<input matInput autocomplete="current-password" type="text" placeholder="Max" formControlName="firstName" required>
</mat-form-field>
<mat-form-field appearance="outline" class="last">
<mat-label>Nachname</mat-label>
<input matInput autocomplete="current-password" type="text" placeholder="Mustermann" formControlName="lastName" required>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput placeholder="pat@example.com" formControlName="email" required>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline">
<mat-label>Passwort</mat-label>
<input matInput autocomplete="current-password" type="password" formControlName="password" required>
<mat-hint>Mindestens 6 Zeichen</mat-hint>
</mat-form-field>
</div>
</form>

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegisterFormComponent } from './register-form.component';
describe('RegisterFormComponent', () => {
let component: RegisterFormComponent;
let fixture: ComponentFixture<RegisterFormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ RegisterFormComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(RegisterFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,37 +0,0 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { TeamInvite } from 'src/app/modules/teams/model/team-invite';
@Component({
selector: 'app-register-form',
templateUrl: './register-form.component.html',
styleUrls: ['./register-form.component.scss']
})
export class RegisterFormComponent implements OnInit {
@Input('teamInfo') teamInfo: TeamInvite | undefined;
registerForm = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [Validators.required, Validators.minLength(6)]),
lastName: new FormControl('', [Validators.required]),
firstName: new FormControl('', [Validators.required]),
});
ngOnInit(): void {
if (this.teamInfo) {
let names = this.teamInfo.playerName.split(' ');
if (names && names.length == 2) {
this.registerForm.controls.firstName.patchValue(names[0])
this.registerForm.controls.lastName.patchValue(names[1])
}
}
}
register() {
}
}

View File

@@ -1,27 +0,0 @@
<mat-card-header>
<mat-card-title>Registrieren</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-container *ngIf="teamInfo">
<div class="mat-body">Hallo <span class="info">{{ teamInfo.playerName }}</span>,</div>
<span>
du wurdes eingeladen dich für das Team <span class="info">{{ teamInfo.teamName }}</span> zu registrieren.
</span>
</ng-container>
<div *ngIf="teamInfo" class="register_form">
<app-register-form [teamInfo]="teamInfo" #formComponent></app-register-form>
</div>
<div class="error" *ngIf="error">
Registrierung fehlgeschlagen. Bitte wende dich an deinen Ansprechpartner.
</div>
</mat-card-content>
<mat-card-actions align="end">
<button mat-button color="primary" [disabled]="registerFormInvalid" (click)="register()">Registrieren</button>
</mat-card-actions>

View File

@@ -1,12 +0,0 @@
.info {
font-weight: bold;
}
.register_form {
margin-top: 24px;
}
.error {
margin-top: 24px;
color: red;
}

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RegisterComponent } from './register.component';
describe('RegisterComponent', () => {
let component: RegisterComponent;
let fixture: ComponentFixture<RegisterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ RegisterComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(RegisterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,85 +0,0 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from 'src/app/modules/auth.service';
import { TeamsService } from 'src/app/modules/teams/teams.service';
import { RegisterFormComponent } from './register-form/register-form.component';
@Component({
selector: 'app-register',
templateUrl: './register.component.html',
styleUrls: ['./register.component.scss']
})
export class RegisterComponent implements OnInit {
private token: string | null= null;
public teamInfo: { playerId: number, playerName: string, teamId: number, teamName: string} | undefined;
error: boolean = false;
constructor(private route: ActivatedRoute, private router: Router, private teamsService: TeamsService, private authService: AuthService, private snackBar: MatSnackBar) {}
@ViewChild('formComponent') formComponent!: RegisterFormComponent;
ngOnInit(): void {
this.validateToken(this.route.snapshot.paramMap.get('token'));
}
private async validateToken(token: string | null) {
if (!token) {
return this.onNoTokenProvided();
} else {
this.teamsService.validateRegistrationToken(token).subscribe({
next: data => { this.teamInfo = data; },
error: () => { return this.onNoTokenProvided(); }
});
}
this.token = token;
}
private onNoTokenProvided() {
this.router.navigateByUrl('auth/register/no-token');
}
get registerForm() {
return this.formComponent?.registerForm;
}
get registerFormInvalid(): boolean {
return !this.registerForm || this.registerForm.invalid;
}
register() {
const data = this.registerForm.value as any;
data.linkPlayerId = this.teamInfo?.playerId;
this.authService.register(data).subscribe({
next: () => { this.onPlayerRegistrationSuccess() },
error: () => { this.onPlayerRegistrationFailure() }
})
}
onPlayerRegistrationSuccess() {
this.error = false;
this.snackBar.open('Erfolgreich registriert', undefined, {
duration: 5000
});
this.router.navigateByUrl('/auth')
}
onPlayerRegistrationFailure() {
this.snackBar.open('Registrierung nicht möglich', undefined, {
duration: 5000,
panelClass: 'snackbar_error'
});
this.registerForm.reset()
}
}

View File

@@ -1,24 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { NoTokenProvidedComponent } from './components/no-token-provided/no-token-provided.component';
import { RegisterBaseComponent } from './components/register-base/register-base.component';
import { RegisterComponent } from './components/register/register.component';
const routes: Routes = [
{ path: '', component: RegisterBaseComponent, children: [
{
path: 'no-token',
component: NoTokenProvidedComponent
},
{
path: ':token',
component: RegisterComponent
}
] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class RegisterRoutingModule { }

View File

@@ -1,35 +0,0 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RegisterRoutingModule } from './register-routing.module';
import { NoTokenProvidedComponent } from './components/no-token-provided/no-token-provided.component';
import { RegisterBaseComponent } from './components/register-base/register-base.component';
import { MatCardModule } from '@angular/material/card';
import { RegisterComponent } from './components/register/register.component';
import { RegisterFormComponent } from './components/register/register-form/register-form.component';
import { ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar';
@NgModule({
declarations: [
NoTokenProvidedComponent,
RegisterBaseComponent,
RegisterComponent,
RegisterFormComponent
],
imports: [
CommonModule,
RegisterRoutingModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
MatButtonModule,
MatSnackBarModule
]
})
export class RegisterModule { }

View File

@@ -1,34 +0,0 @@
<div class="card mat-elevation-z2">
Welcome {{ name }}, your Role is {{ role }}
</div>
<div class="card mat-elevation-z2">
<div class="mat-h2">Deine Teams</div>
<div class="mat-body">Klicke auf eins der Teams um es zu verwalten</div>
<div *ngFor="let p of players" class="flex-row team_row">
<div class="team">
<div>
<span>{{ p.team.name }}</span>
</div>
<div>Balance: {{ p.team.balance | currency:'EUR' }}</div>
<div>
{{ p.firstName }} {{ p.lastName }}
</div>
<div>
Rolle: <span>{{ p.teamRole.name | translate }}</span>
</div>
<button (click)="copyLink(p)" mat-stroked-button >Öffentlichen Link kopieren</button>
</div>
<div class="icons">
<button mat-icon-button (click)="onTeamClick(p.team)" aria-label="Team-Details öffnen">
<mat-icon>speed</mat-icon>
</button>
<button mat-icon-button (click)="onTeamClick(p.team)" aria-label="Team bearbeiten">
<mat-icon>edit</mat-icon>
</button>
</div>
</div>
</div>

View File

@@ -1,38 +0,0 @@
.icons {
display: flex;
align-items: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease-in-out;
}
.team_row {
padding: 2px 8px;
border: 1px solid #ccc;
border-radius: 4px;
align-items: stretch;
&:hover > .icons {
opacity: 1;
pointer-events: all;
}
&:not(:first-of-type) {
margin-top: 8px;
}
}
@media screen and (max-width: 700px) {
.icons {
opacity: 1;
pointer-events: all;
align-items: stretch;
button {
height: inherit;
width: 48px;
}
}
}

View File

@@ -1,25 +0,0 @@
import { HttpClientModule } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardComponent } from './dashboard.component';
describe('DashboardComponent', () => {
let component: DashboardComponent;
let fixture: ComponentFixture<DashboardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DashboardComponent ],
imports: [ HttpClientModule ]
})
.compileComponents();
fixture = TestBed.createComponent(DashboardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,60 +0,0 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { UserService } from 'src/app/core/user/user.service';
import { AuthService } from '../../auth.service';
import { CommonModule } from '@angular/common';
import { Player } from 'src/app/model';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss'],
standalone: true,
imports: [ CommonModule, TranslateModule, MatButtonModule, MatIconModule ]
})
export class DashboardComponent {
link: string = window.location.host;
constructor(
private authService: AuthService
, private userService: UserService
, private router: Router
, public translate: TranslateService) {}
get role(): string {
if (!this.authService || !this.authService.isLoggedIn()) { return ''}
return this.authService.user.role.name;
}
get name(): string {
if (!this.authService || !this.authService.isLoggedIn()) { return ''}
return this.authService.user.firstName + ' ' + this.authService.user.lastName;
}
get players(): any[] {
return this.userService.players;
}
onTeamClick(team: any) {
this.router.navigate([`dashboard/${team.alias}/details`])
}
onLinkClick(event: any) {
event.stopPropagation();
return;
}
async copyLink(p: Player) {
const link = `https://${this.link}/teams/${p.team.alias}`
await navigator.clipboard.writeText(link);
}
}

View File

@@ -1,7 +0,0 @@
import { Routes } from '@angular/router';
export const dashboardRoutes: Routes = [
{ path: '', loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent) },
{ path: ':id/details', loadComponent: () => import('./team-details/team-details.component').then(m => m.TeamDetailsComponent) }
];

View File

@@ -1,11 +0,0 @@
<div class="player_icon" [class.line_through]="!player.active" [class.inactive]="!player.active">
{{ playerInitials }}
</div>
<div class="content" [class.inactive]="!player.active">
<div class="name" [class.line_through]="!player.active">{{ player.firstName}} {{ player.lastName }} </div>
<div class="role"> {{ player.teamRole.name | translate }}</div>
<div class="balance"> {{ 'balance' | translate }}: {{ player.balance | currency:'EUR' }}</div>
</div>

View File

@@ -1,31 +0,0 @@
:host {
display: flex;
flex: 250px 1 1;
cursor: pointer;
}
.player_icon {
width: 48px;
height: 48px;
line-height: 48px;
text-align: center;
font-size: 20px;
border-radius: 50%;
border: 1px solid #ccc
}
.content{
margin-left: 12px;
}
.name {
font-weight: bold;
}
.line_through{
text-decoration: line-through;
}
.inactive {
opacity: 0.5;
}

View File

@@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PlayerCardComponent } from './player-card.component';
describe('PlayerCardComponent', () => {
let component: PlayerCardComponent;
let fixture: ComponentFixture<PlayerCardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PlayerCardComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(PlayerCardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -1,27 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit, Output } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { Player } from 'src/app/model';
@Component({
selector: 'app-player-card',
templateUrl: './player-card.component.html',
styleUrls: ['./player-card.component.scss'],
standalone: true,
imports: [CommonModule, TranslateModule]
})
export class PlayerCardComponent implements OnInit {
@Input() player!: Player;
ngOnInit(): void {}
get playerInitials(): string {
if (!this.player) { return ' '; }
return this.player.firstName.substring(0, 1) + this.player.lastName.substring(0, 1);
}
}

View File

@@ -1,9 +0,0 @@
<h1 style="text-align: center;">{{ player.firstName }} {{ player.lastName }}</h1>
<div class="body">
<div>
<mat-slide-toggle [checked]="player.active" (change)="setPlayerActive($event)" [disabled]="!isAdmin" >Spieler aktiv</mat-slide-toggle>
</div>
</div>

View File

@@ -1,10 +0,0 @@
:host {
display: flex;
overflow: hidden;
flex-direction: column;
padding: 8px 14px 8px 14px;
}
.body {
padding: 6px 12px 24px 6px;
}

View File

@@ -1,59 +0,0 @@
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, EventEmitter, inject, Inject, Output } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Player } from '../../../../../../model';
import { AuthService } from 'src/app/modules/auth.service';
import { environment } from 'src/environments/environment';
@Component({
selector: 'app-player-details',
templateUrl: './player-details.component.html',
styleUrls: ['./player-details.component.scss'],
standalone: true,
imports: [CommonModule, MatSlideToggleModule]
})
export class PlayerDetailsComponent {
player: Player;
loading: boolean = false;
private auth: AuthService = inject(AuthService);
@Output('onReload') changed = new EventEmitter();
constructor(@Inject(MAT_DIALOG_DATA) public data: DialogData, private http: HttpClient, private snackBar: MatSnackBar) {
this.player = data.player;
}
get isAdmin() {
return this.auth.isAdmin;
}
setPlayerActive(event: any) {
this.loading = true;
const copy = { ...this.player }
this.player.active = event.checked;
this.http.put<Player>(`${environment.apiUrl}teams/${this.player.team.id}/players`, this.player).subscribe({
next: r => {
this.changed.emit();
},
error: e => {
this.player.active = copy.active;
this.snackBar.open('Spieler konnte nicht aktualisiert werden', undefined, {
duration: 5000,
panelClass: 'snackbar_error'
})
},
complete: () => { this.loading = false; }
})
}
}
interface DialogData {
player: Player;
}

View File

@@ -1,92 +0,0 @@
<div class="card" *ngIf="team">
<div class="title-row">
<span class="name" id="teamName">{{ team.name }} ({{ translateRolename(roleName) }})</span>
<div>Spieleranzahl: {{ team.players?.length }} (aktiv: {{ activePlayerCount }}) </div>
</div>
<div class="flex-row">
<div>Saldo: {{ team.balance | currency:'EUR' }}</div>
<div>Ausstehend: {{ team.outstanding | currency:'EUR' }}
</div>
</div>
<mat-divider style="margin-top: 4px"></mat-divider>
<div class="flex-row" style="margin-top: 4px;">
<button mat-icon-button color="primary" aria-label="Back" (click)="back()" matTooltip="Zurück">
<mat-icon>arrow_back</mat-icon>
</button>
<button mat-button mat-raised-button color="primary" (click)="openTeamTransactionDialog()" [disabled]="isLoading || !canDoTransactions">Buchung</button>
<button mat-button mat-stroked-button color="accent" (click)="onAllClick()" [disabled]="isLoading || !canDoTransactions" matTooltip="Buchung für alle Spieler anlegen">Umlage</button>
<button mat-icon-button color="primary" matTooltip="Verwaltung" [matMenuTriggerFor]="menu" [disabled]="isLoading">
<mat-icon>menu</mat-icon>
</button>
</div>
</div>
<mat-tab-group>
<mat-tab label="Buchungen">
<ng-container *ngTemplateOutlet="playerList"></ng-container>
</mat-tab>s
<mat-tab label="Bearbeiten">
<div class="mat-tab__content flex-row break" style="overflow: auto;">
<app-player-card *ngFor="let player of team?.players" class="card" [player]="player"
matRipple (click)="openPlayerdetails(player)" (onReload)="loadTeamDetails()" ></app-player-card>
</div>
</mat-tab>
</mat-tab-group>
<ng-template #playerList>
<div class="mat-tab__content">
<div class="flex-row">
<div></div>
<div class="select_all" (click)="selectAll()">Alle</div>
</div>
<div class="card list">
<mat-form-field style="width: 100%;" (keyup)="filter($event)">
<mat-label>Suche</mat-label>
<input type="text" matInput >
</mat-form-field>
<mat-selection-list #players id="playerlist" [(ngModel)]="selectedPlayers">
<ng-container *ngFor="let p of team?.players" >
<mat-list-option [value]="p" *ngIf="!p.hide && p.active">
<div matListItemTitle [class.highlight]="p.usersPlayer">{{ p.firstName}} {{ p.lastName }} <span *ngIf="p.usersPlayer">- Ich</span> </div>
<div matListItemLine>{{ p.balance | currency:'EUR'}}</div>
</mat-list-option>
</ng-container>
</mat-selection-list>
</div>
</div>
</ng-template>
<div class="card">
<div class="flex-row" style="height: 48px; overflow: hidden; justify-content: flex-end;" *ngIf="isLoading">
<mat-spinner diameter="38"></mat-spinner>
</div>
<div class="flex-row" *ngIf="!isLoading">
<span *ngIf="players && players.selectedOptions.selected.length > 0">{{players.selectedOptions.selected.length}} Spieler gewählt</span>
<span *ngIf="players && players.selectedOptions.selected.length == 0">Spieler für einen Eintrag wählen</span>
<button mat-button mat-raised-button color="primary" (click)="openDialog()"
[disabled]="players.selectedOptions.selected.length == 0 || !canDoTransactions" id="buttonNewTransaction"
matTooltip="Transaktionen für die gewählten Spieler anlegen">Buchung anlegen</button>
</div>
</div>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="onAddPlayerClick()" [disabled]="!canInvite">Spieler hinzufügen</button>
<button mat-menu-item (click)="onCreateLink()" [disabled]="selectedPlayers.length != 1 || !canInvite">Registrierungslink erstellen</button>
<button mat-menu-item (click)="onShowPrivileges()">Berechtigungen</button>
<button mat-menu-item (click)="onShowTeamTransactions()">Transaktionshistorie</button>
</mat-menu>

View File

@@ -1,59 +0,0 @@
.title-row {
display: flex;
justify-content: space-between;
}
:host {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1 1 auto;
}
.list{
flex: 1 1 100%;
overflow: auto;
}
.back {
width: 28px;
height: 28px;
background-color: red;
cursor: pointer;
}
.select_all {
margin-right: 52px;
text-decoration: underline;
cursor: pointer;
color: #646464;
&:hover {
color: black;
}
}
.buttons {
margin-top: 6px;
}
.highlight {
// text-decoration: wavy;
font-style: italic;
}
mat-tab-group {
flex: 1 1 100%;
height: 257px;
}
.mat-tab__content {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.break {
flex-wrap: wrap;
}

View File

@@ -1,93 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatListModule } from '@angular/material/list';
import { By } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { TeamDetailsComponent } from './team-details.component';
describe('TeamDetailsComponent', () => {
let component: TeamDetailsComponent;
let fixture: ComponentFixture<TeamDetailsComponent>;
const paramMap = jasmine.createSpyObj('ParamMap', ['get'])
const fakeActivatedRoute = {
snapshot: { paramMap: paramMap }
}
paramMap.get.and.returnValue('9999');
const mockhttp = jasmine.createSpyObj('HttpClient', ['get']);
mockhttp.get.and.returnValue(of({
alias: "9999",
balance: 48.09,
name: "Development Team",
outstanding: -582.00,
players: [{id: 1, firstName: 'Filiberto', lastName: 'Spencer', balance: -32.97}, {id: 2, firstName: 'Adam', lastName: 'West', balance: -50}],
settings: []
}))
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TeamDetailsComponent ],
providers: [
{provide: ActivatedRoute, useValue: fakeActivatedRoute},
{provide: HttpClient, useValue: mockhttp},
],
imports: [ MatListModule ]
})
.compileComponents();
fixture = TestBed.createComponent(TeamDetailsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should extract the id from the url', () => {
expect(component.id).toEqual("9999");
});
it('should load the teams data', () => {
expect(component.team).not.toBeNull();
});
it('should display name and alias', () => {
const nameElement = fixture.debugElement.query(By.css('#teamName'));
const text = (nameElement.nativeElement as HTMLElement).textContent;
expect(text).toEqual(`${component.team.name} (${component.team.alias})`)
// const htmlList = (list.nativeElement as HTMLElement).children;
// expect(htmlList).not.toBeNull();
// expect(htmlList.length).toBe(4)
});
it('should display a list with players', () => {
const element = fixture.debugElement.query(By.css('#playerlist'));
const ch = (element.nativeElement as HTMLElement).children;
expect(ch.length).toBe(component.team.players.length);
for (let index = 0; index < component.team.players.length; index++) {
const playerName = ch[index].textContent?.trim();
const expectedName = `${component.team.players[index].firstName} ${component.team.players[index].lastName}`;
expect(playerName).toEqual(expectedName)
}
});
it('button should be disabled', () => {
const element = fixture.debugElement.query(By.css('#buttonNewTransaction'));
expect(element.nativeElement.disabled).toBeTrue();
});
it('should enable the button when selecting a player', () => {
const element = fixture.debugElement.query(By.css('.mdc-checkbox'));
element.nativeElement.click();
fixture.detectChanges();
const buttonelement = fixture.debugElement.query(By.css('#buttonNewTransaction'));
expect(buttonelement.nativeElement.disabled).toBeFalse();
})
});

View File

@@ -1,303 +0,0 @@
import { Component, createNgModuleRef, Injector, OnInit, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router';
import { Player } from 'src/app/model';
import { AuthService } from 'src/app/modules/auth.service';
import { Team } from 'src/app/modules/teams/model/team';
import { TeamInvite } from 'src/app/modules/teams/model/team-invite';
import { TeamsService } from 'src/app/modules/teams/teams.service';
import { NewTransactionDialogComponent, TeamTransactionDialogComponent } from 'src/app/shared';
import { CreatePlayerDialogComponent } from 'src/app/shared/dialog/create-player-dialog/create-player.dialog.component';
import { PrivilegesInfoDialogComponent } from 'src/app/shared/dialog/privileges-info-dialog/privileges-info-dialog.component';
import { PlayerDetailsComponent } from './player-card/player-details/player-details.component';
import { CommonModule } from '@angular/common';
import { MatDividerModule } from '@angular/material/divider';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTabsModule } from '@angular/material/tabs';
import { PlayerCardComponent } from './player-card/player-card.component';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatListModule } from '@angular/material/list';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatInputModule } from '@angular/material/input';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@Component({
selector: 'app-team-details',
templateUrl: './team-details.component.html',
styleUrls: ['./team-details.component.scss'],
standalone: true,
imports: [
CommonModule
, ReactiveFormsModule
, FormsModule
, MatProgressSpinnerModule
, MatDividerModule
, MatButtonModule
, MatIconModule
, MatMenuModule
, MatTabsModule
, MatFormFieldModule
, MatListModule
, MatSlideToggleModule
, MatInputModule
, PlayerCardComponent
]
})
export class TeamDetailsComponent implements OnInit {
id: string | null = null;
team: Team | undefined;
correspondingPlayer: Player | undefined;
canDoTransactions: boolean = false;
canInvite: boolean = false;
roleName: string = '';
isLoading: boolean = true;
selectedPlayers: Player[] = [];
usersPlayers: Player[] = [];
@ViewChild('players') players: any;
constructor(private route: ActivatedRoute, private teamsService: TeamsService, private authService: AuthService,
private router: Router, public dialog: MatDialog, private injector: Injector, private _snackBar: MatSnackBar) {}
ngOnInit(): void {
this.id = this.route.snapshot.paramMap.get('id');
if (this.id) {
this.loadTeamDetails();
}
}
protected loadTeamDetails() {
if (!this.id) { return; }
this.teamsService.loadTeamDetails(this.id).subscribe(result => {
this.team = result;
this.findCorrespondingPlayer();
this.isLoading = false;
})
}
back() {
this.router.navigate(['../dashboard'])
}
openDialog(): void {
const dialogRef = this.dialog.open(NewTransactionDialogComponent, {
data: {
team: this.team,
players: this.selectedPlayers
}
});
dialogRef.afterClosed().subscribe(async result => {
if (result && result.length > 0) {
this.isLoading = true;
this.teamsService.createTransactions(result).subscribe(res => {
if (res && res.length > 0) {
this.loadTeamDetails();
}
})
}
});
}
openTeamTransactionDialog() {
if (!this.team) { return; }
const dialogRef = this.dialog.open(TeamTransactionDialogComponent, {
data: {
teamId: this.team.id
}
});
dialogRef.afterClosed().subscribe(async result => {
if (result) { this.saveTeamTransaction(result); }
});
}
saveTeamTransaction(transaction: any) {
this.isLoading = true;
this.teamsService.createTeamTransaction(transaction).subscribe(res => {
if (res && res['id']) {
this.loadTeamDetails();
}
})
}
selectAll() {
if (this.team == null || this.team.players == null) { return; }
if (this.selectedPlayers.length < this.team.players.length) {
this.selectedPlayers = [];
for (let p of this.team.players) {
this.selectedPlayers.push(p);
}
} else {
this.selectedPlayers = [];
}
}
onAllClick() {
if (!this.team) { return; }
const dialogRef = this.dialog.open(NewTransactionDialogComponent, {
data: {
team: this.team,
players: this.selectedPlayers,
all: this.team.players?.length == this.selectedPlayers.length
}
});
}
onAddPlayerClick() {
if (!this.team) { return; }
const dialogRef = this.dialog.open(CreatePlayerDialogComponent, {
data: { },
width: '350px'
});
dialogRef.afterClosed().subscribe(result => {
if (result && this.team) {
this.teamsService.createNewPlayer(this.team.id, result).subscribe({
next: () => { this.loadTeamDetails()},
error: () => { this.onPlayerCreateError();}
})
}
})
}
onPlayerCreateError() {
this._snackBar.open('Spieler konnte nicht erstellt werden', undefined, {
duration: 10000,
panelClass: 'snackbar_error'
})
}
async onCreateLink() {
if (!this.team) { return; }
try {
const invite: TeamInvite = {
teamId: this.team.id,
teamName: this.team.name,
playerId: this.selectedPlayers[0].id,
playerName: this.selectedPlayers[0].firstName + ' ' + this.selectedPlayers[0].lastName
}
const token = await this.getInviteFromServer(invite);
const link = location.origin + '/auth/register/' + token
await navigator.clipboard.writeText(link);
this._snackBar.open('Einladungslink für ' + invite.playerName + ' in die Zwischenablage kopiert. Der Link ist auf den gewählten Spieler personalisiert und darf nicht an mehrere Spieler verteilt werden.', undefined, {
duration: 10000,
});
} catch {
this._snackBar.open('Der Einladungslink konnte nicht erzeugt werden.', undefined, {
panelClass: 'snackbar_error',
duration: 5000,
});
}
}
private async getInviteFromServer(invite: TeamInvite): Promise<any> {
return new Promise<any>((resolve, reject) => {
this.teamsService.createRegisterLink(invite).subscribe({
next: token => { return resolve(token.token); },
error: () => { return reject(null); }
})
})
}
findCorrespondingPlayer() {
if (!this.team || !this.team.players) { return; }
const id = this.authService.user?.id;
this.usersPlayers = this.team.players.filter((p: any) => { return p.user?.id == id });
this.canInvite = this.authService.isAdmin || this.usersPlayers.find(p => p.teamRole.id > 2) != null;
this.canDoTransactions = this.authService.isAdmin || this.usersPlayers.find(p => p.teamRole.id >= 2) != null;
this.roleName = this.usersPlayers.reduce((prev, curr) => { return (prev.teamRole.id > curr.teamRole.id) ? prev : curr})?.teamRole.name;
for (let u of this.usersPlayers) {
u.usersPlayer = true;
}
}
onShowPrivileges() {
const dialogRef = this.dialog.open(PrivilegesInfoDialogComponent, {
data: null
});
}
translateRolename(roleName: string): string {
switch (roleName) {
case 'treasurer':
return 'Kassenwart'
case 'scnd_treasurer':
return 'zweiter Kassenwart'
case 'captain':
return 'Kapitän'
case 'coach':
return 'Trainer'
default:
return 'Spieler'
}
}
async onShowTeamTransactions() {
if (!this.team) { return; }
const { ShowTeamTransactionsComponent } = await import(
'src/app/shared/dialog/show-team-transactions/show-team-transactions.component'
);
this.dialog.open(ShowTeamTransactionsComponent, {
data: {
id: this.team.id
}
});
}
openPlayerdetails(player: Player) {
if (!player) { return; }
const dialogRef = this.dialog.open(PlayerDetailsComponent, {
data: {
player
}
});
dialogRef.componentInstance.changed.subscribe(() => {
this.loadTeamDetails();
})
}
filter(event: any) {
const search = event.target.value;
const s = search.toLowerCase().trim();
this.team?.players?.map(p => {
const n = p.firstName.toLowerCase().trim() + '' + p.lastName.toLowerCase().trim();
p.hide = !n.includes(s);
});
}
get activePlayerCount(): number {
if (!this.team || !this.team.players) { return 0; }
return this.team.players.filter(p => p.active).length;
}
}

View File

@@ -1,9 +0,0 @@
<mat-toolbar color="accent" class="toolbar">
<span>{{ username }}</span>
<button mat-button (click)="logout()" *ngIf="isLoggedIn">Logout</button>
<button mat-button (click)="login()" *ngIf="!isLoggedIn">Login</button>
</mat-toolbar>
<div class="content_container">
<router-outlet></router-outlet>
</div>

View File

@@ -1,18 +0,0 @@
.content_container {
display: flex;
flex-direction: column;
padding: 8px;
flex: 1 1 auto;
overflow: hidden;
.card {
padding: 12px;
}
}
:host {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}

Some files were not shown because too many files have changed in this diff Show More