Compare commits
7 Commits
a94caed85f
...
6eeaa3a624
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eeaa3a624 | ||
|
|
26391b5b2c | ||
|
|
367533f1f5 | ||
|
|
0e8ac74116 | ||
|
|
f2e6704943 | ||
|
|
239b157015 | ||
|
|
4e6b945eec |
@@ -0,0 +1,70 @@
|
||||
# Team Cash Flow Presentation 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:** Present every modern-frontend transaction history from the team wallet's cash-flow perspective.
|
||||
|
||||
**Architecture:** Replace the debit-only helper with a context-aware pure presenter and render it through one shared standalone Angular component. All four histories consume the component, while backend contracts and financial calculations remain untouched.
|
||||
|
||||
**Tech Stack:** Angular 21, standalone components, signals, Angular Material, Vitest.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Modify only `myteamwallet_frontend_modern` plus this feature's documentation.
|
||||
- Do not modify the backend, database, legacy frontend, balance calculations, forms, or transaction APIs.
|
||||
- Inflows are green and visibly prefixed with `+`; outflows are red and prefixed with `−`; non-cash entries are grey and unsigned.
|
||||
- Player payment is an inflow unless its stored amount is negative, in which case it is an outflow reversal.
|
||||
- Team credit is an inflow; team expense is an outflow.
|
||||
- Player credit, fine, levy, fee, and unknown types are neutral.
|
||||
- Use Material theme tokens and expose a German accessible direction label; color must not be the only signal.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Central cash-flow semantics and amount component
|
||||
|
||||
**Files:**
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/models/transaction-amount.ts`
|
||||
- Modify: `myteamwallet_frontend_modern/src/app/models/transaction-amount.spec.ts`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.ts`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.html`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.scss`
|
||||
- Create: `myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produce `CashFlowDirection = 'inflow' | 'outflow' | 'neutral'`.
|
||||
- Produce `CashFlowContext = 'player' | 'team'`.
|
||||
- Produce `CashFlowPresentation { direction; amount; sign }`, where amount is absolute and sign is `'+' | '−' | ''`.
|
||||
- Produce `presentCashFlow(amount, type, context): CashFlowPresentation`.
|
||||
- Produce standalone `TransactionAmount` with required `amount`, `type`, and `context` inputs.
|
||||
|
||||
- [ ] Write table-driven helper tests with hand-derived expectations for numeric, string, and object types, payment reversal, and unknown type.
|
||||
- [ ] Write component tests proving visible signs, semantic classes, currency output, and German accessible labels.
|
||||
- [ ] Run the two focused specs and confirm they fail because the presenter and component do not exist.
|
||||
- [ ] Implement the minimal pure presenter and standalone component using `CurrencyPipe`, `LOCALE_ID: de-DE`, `var(--mat-sys-primary)`, `var(--mat-sys-error)`, and `var(--mat-sys-on-surface-variant)`.
|
||||
- [ ] Run the focused specs until green, format only touched files, and commit the task.
|
||||
|
||||
### Task 2: Adopt the shared presentation in every history
|
||||
|
||||
**Files:**
|
||||
- Modify/Test: `myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.*`
|
||||
- Modify/Test: `myteamwallet_frontend_modern/src/app/features/team/overview/overview.*`
|
||||
- Modify/Test: `myteamwallet_frontend_modern/src/app/features/team/members/player-detail.*`
|
||||
- Modify/Test: `myteamwallet_frontend_modern/src/app/features/public-team/public-player.*`
|
||||
|
||||
**Interfaces:**
|
||||
- Consume `TransactionAmount` from Task 1.
|
||||
- Combined `TeamActivity` rows pass `context = activity.isTeamWalletTransaction ? 'team' : 'player'`.
|
||||
- Private and public player rows pass `context = 'player'`.
|
||||
|
||||
- [ ] Extend the four view specs so the old signed-number rendering fails for inflow, outflow, and neutral entries.
|
||||
- [ ] Run the focused view specs and confirm expected failures.
|
||||
- [ ] Import and render `TransactionAmount` in all four standalone components; remove obsolete `displayAmount` methods, helper imports, and local positive/negative amount styling.
|
||||
- [ ] Run the focused view specs until green and format only touched files.
|
||||
- [ ] Run the complete modern-frontend suite, TypeScript check, Angular build, and `git diff --check`.
|
||||
- [ ] Confirm the feature range contains no backend or legacy-frontend paths, then commit the task.
|
||||
|
||||
### Task 3: Review and finish
|
||||
|
||||
- [ ] Request task-level and full-range read-only reviews; fix Critical/Important findings and re-run covering tests.
|
||||
- [ ] Re-run the full modern-frontend suite, TypeScript check, build, and scope/diff checks on final HEAD.
|
||||
- [ ] Use the branch-finishing workflow and preserve the worktree until the user chooses integration.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Team Cash Flow Presentation Design
|
||||
|
||||
## Goal
|
||||
|
||||
Display transaction amounts consistently from the team wallet's perspective in every modern-frontend transaction history.
|
||||
|
||||
## Semantics
|
||||
|
||||
- A player payment and a team-wallet credit are real inflows: green with an explicit plus sign.
|
||||
- A team-wallet expense and a negative player-payment reversal are real outflows: red with a mathematical minus sign.
|
||||
- Player fines, levies, fees, and player credits do not move team-wallet cash: grey and unsigned.
|
||||
- Unknown transaction types are neutral to avoid claiming a cash movement that the application cannot prove.
|
||||
- Player payouts remain general team-wallet expenses with the player's name in the note; no new transaction type is introduced.
|
||||
|
||||
## Design
|
||||
|
||||
Replace the existing debit-only amount helper with a context-aware cash-flow presenter. A shared standalone Angular component owns formatting, semantic color, signs, and accessible labels. Cashbox and overview activities pass `team` or `player` based on `isTeamWalletTransaction`; private and public player histories always pass `player`.
|
||||
|
||||
No backend API, database, balance calculation, booking form, balance card, or legacy-frontend behavior changes.
|
||||
|
||||
## Accessibility and Testing
|
||||
|
||||
The visible amount uses `+`, `−`, or no sign and Material semantic color tokens. The amount exposes an accessible German label identifying Einzahlung, Auszahlung, or keine Kassenbewegung. Unit tests cover numeric, string, and object transaction types plus reversals and unknown types; component and view tests prove consistent rendering in all four histories.
|
||||
24
myteamwallet_backend/src/auth/dto/auth-update.dto.spec.ts
Normal file
24
myteamwallet_backend/src/auth/dto/auth-update.dto.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { validate } from 'class-validator';
|
||||
import { AuthUpdateDto } from './auth-update.dto';
|
||||
|
||||
describe('AuthUpdateDto help preference', () => {
|
||||
it('rejects a non-boolean helpTextsEnabled value', async () => {
|
||||
const dto = Object.assign(new AuthUpdateDto(), {
|
||||
helpTextsEnabled: 'yes',
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
|
||||
expect(errors.some((error) => error.property === 'helpTextsEnabled')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a boolean helpTextsEnabled value without requiring profile fields', async () => {
|
||||
const dto = Object.assign(new AuthUpdateDto(), {
|
||||
helpTextsEnabled: false,
|
||||
});
|
||||
|
||||
await expect(validate(dto)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, MinLength, Validate } from 'class-validator';
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, MinLength } from 'class-validator';
|
||||
import { IsExist } from '../../utils/validators/is-exists.validator';
|
||||
|
||||
export class AuthUpdateDto {
|
||||
@ApiProperty({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
helpTextsEnabled?: boolean;
|
||||
|
||||
@ApiProperty({ example: 'John' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddHelpTextsEnabled1785520800000 implements MigrationInterface {
|
||||
name = 'AddHelpTextsEnabled1785520800000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "user" ADD "helpTextsEnabled" boolean NOT NULL DEFAULT true',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "user" DROP COLUMN "helpTextsEnabled"',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
describe('AddHelpTextsEnabled1785520800000', () => {
|
||||
it('adds a reversible enabled-by-default user preference', async () => {
|
||||
const migrationModule = require('./1785520800000-AddHelpTextsEnabled');
|
||||
const migration = new migrationModule.AddHelpTextsEnabled1785520800000();
|
||||
const queryRunner = { query: jest.fn() } as any;
|
||||
|
||||
await migration.up(queryRunner);
|
||||
expect(queryRunner.query).toHaveBeenCalledWith(
|
||||
'ALTER TABLE "user" ADD "helpTextsEnabled" boolean NOT NULL DEFAULT true',
|
||||
);
|
||||
|
||||
queryRunner.query.mockClear();
|
||||
await migration.down(queryRunner);
|
||||
expect(queryRunner.query).toHaveBeenCalledWith(
|
||||
'ALTER TABLE "user" DROP COLUMN "helpTextsEnabled"',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the user entity default aligned with the migration', () => {
|
||||
const column = getMetadataArgsStorage().columns.find(
|
||||
(candidate) =>
|
||||
candidate.target === User &&
|
||||
candidate.propertyName === 'helpTextsEnabled',
|
||||
);
|
||||
|
||||
expect(column?.options).toMatchObject({ type: Boolean, default: true });
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,9 @@ export class User extends EntityHelper {
|
||||
@Column({ nullable: true })
|
||||
lastName: string | null;
|
||||
|
||||
@Column({ default: true })
|
||||
helpTextsEnabled: boolean;
|
||||
|
||||
@ManyToOne(() => Role, {
|
||||
eager: true,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
|
||||
@@ -10,7 +10,13 @@ import { errorInterceptor } from './core/http/error-interceptor';
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideRouter(
|
||||
routes,
|
||||
withInMemoryScrolling({
|
||||
anchorScrolling: 'enabled',
|
||||
scrollPositionRestoration: 'enabled',
|
||||
}),
|
||||
),
|
||||
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
|
||||
@@ -66,6 +66,11 @@ describe('app routing', () => {
|
||||
expect(teamRoute?.children?.some((route) => route.path === 'more/public-access')).toBe(true);
|
||||
});
|
||||
|
||||
it('registers the guide below the protected team shell', () => {
|
||||
const teamRoute = routes.find((route) => route.path === 'team/:id');
|
||||
expect(teamRoute?.children?.some((route) => route.path === 'more/guide')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts the password reset URL generated by the backend', async () => {
|
||||
expect(routes.some((route) => route.path === 'password-change/:hash')).toBe(true);
|
||||
await router.navigateByUrl('/password-change/reset-hash');
|
||||
|
||||
@@ -101,6 +101,11 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/team/more/public-access/public-access').then((m) => m.PublicAccess),
|
||||
},
|
||||
{
|
||||
path: 'more/guide',
|
||||
loadComponent: () =>
|
||||
import('./features/team/more/guide/guide').then((m) => m.Guide),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface CreateInviteRequest extends InviteDetails {}
|
||||
export interface UpdateProfileRequest {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
helpTextsEnabled?: boolean;
|
||||
oldPassword?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
144
myteamwallet_frontend_modern/src/app/core/help/guide-content.ts
Normal file
144
myteamwallet_frontend_modern/src/app/core/help/guide-content.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
export type GuideSectionId =
|
||||
| 'roles'
|
||||
| 'handover'
|
||||
| 'members'
|
||||
| 'bookings'
|
||||
| 'reversals'
|
||||
| 'penalties'
|
||||
| 'sharing'
|
||||
| 'next-handover'
|
||||
| 'faq';
|
||||
|
||||
export interface GuideSection {
|
||||
id: GuideSectionId;
|
||||
title: string;
|
||||
intro: string;
|
||||
points: readonly string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const HANDOVER_CHECKLIST = [
|
||||
'Prüfe, ob dein Benutzerkonto mit dem richtigen Mitglied und der richtigen Teamrolle verknüpft ist.',
|
||||
'Vergleiche Teamkasse und offene Mitgliedersalden mit den Unterlagen der bisherigen Verantwortung.',
|
||||
'Kontrolliere aktive und inaktive Mitglieder sowie die vergebenen Verantwortungsrollen.',
|
||||
'Lies bestehende Buchungen und Stornierungen, bevor du eine neue Buchung erfasst.',
|
||||
'Prüfe Strafenkatalog, offene Einladungen und die öffentliche Freigabe des Teams.',
|
||||
'Vereinbare, ab welchem Datum du neue Vorgänge in TeamWallet pflegst.',
|
||||
] as const;
|
||||
|
||||
export const ROLE_GUIDE = [
|
||||
{
|
||||
role: '2. Kassenwart',
|
||||
description: 'Kann Buchungen erfassen und nachvollziehen; organisatorische Verwaltung bleibt der Teamleitung vorbehalten.',
|
||||
},
|
||||
{
|
||||
role: 'Kapitän',
|
||||
description: 'Kann Kasse, Mitglieder, Strafenkatalog, Einladungen und Freigaben verwalten.',
|
||||
},
|
||||
{
|
||||
role: 'Kassenwart',
|
||||
description: 'Trägt die zentrale Verantwortung für Kassenstand, Buchungen, Salden und Übergabe.',
|
||||
},
|
||||
{
|
||||
role: 'Trainer',
|
||||
description: 'Kann die organisatorischen Verwaltungsfunktionen einschließlich Kasse und Mitglieder nutzen.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const GUIDE_SECTIONS: readonly GuideSection[] = [
|
||||
{
|
||||
id: 'roles',
|
||||
title: 'Deine Rolle und Berechtigungen',
|
||||
intro: 'TeamWallet zeigt Aktionen passend zu deiner Rolle. Eine ausgeblendete Aktion ist deshalb meist kein Fehler.',
|
||||
points: [
|
||||
'Deine Teamrolle gehört zur Mitgliedschaft im aktuell ausgewählten Team.',
|
||||
'Bei mehreren Teams können Rolle und Berechtigungen je Team unterschiedlich sein.',
|
||||
'Mindestens ein aktiver Kassenwart muss im Team erhalten bleiben.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'handover',
|
||||
title: 'Amtsübergabe prüfen',
|
||||
intro: 'Beginne nicht mit einer Korrekturbuchung, bevor du den übergebenen Stand gemeinsam geprüft hast.',
|
||||
points: [
|
||||
'Vergleiche den angezeigten Kassenstand mit Bargeld, Bankkonto oder der bisherigen Kassenliste.',
|
||||
'Offene Beiträge sind die Summe der Salden aktiver Mitglieder und nicht automatisch Bargeld in der Kasse.',
|
||||
'Dokumentiere Abweichungen außerhalb der App und kläre zuerst ihren Ursprung.',
|
||||
],
|
||||
note: 'Nutze keine künstliche Testbuchung. Eine echte fehlerhafte Buchung wird über eine Stornierung nachvollziehbar korrigiert.',
|
||||
},
|
||||
{
|
||||
id: 'members',
|
||||
title: 'Mitglieder und Rollen verwalten',
|
||||
intro: 'Mitglied, Benutzerkonto und Teamrolle sind getrennte Dinge: Eine Person wird zuerst als Mitglied geführt und kann danach eingeladen werden.',
|
||||
points: [
|
||||
'Lege ein Mitglied nur einmal an und wähle die Rolle passend zur tatsächlichen Verantwortung.',
|
||||
'Deaktiviere ausgetretene Mitglieder, statt ihre Historie zu löschen.',
|
||||
'Prüfe vor einem Rollenwechsel, dass ein aktiver Kassenwart im Team verbleibt.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bookings',
|
||||
title: 'Buchungen sicher erfassen',
|
||||
intro: 'Mitgliederbuchungen verändern persönliche Salden; Teambuchungen erfassen direkte Einnahmen oder Ausgaben der Teamkasse.',
|
||||
points: [
|
||||
'Wähle zuerst die richtige Buchungsart und prüfe Betrag, Datum, Personen und Notiz vor dem Speichern.',
|
||||
'Bei mehreren Mitgliedern entscheidet „Betrag gleichmäßig verteilen“, ob der Gesamtbetrag geteilt oder je Person gebucht wird.',
|
||||
'Nutze eine eindeutige Notiz, damit ein Vorgang auch Monate später verständlich bleibt.',
|
||||
'Ein Strafenkatalogeintrag kann Betrag, Art und Notiz einer Mitgliederbuchung vorbereiten.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reversals',
|
||||
title: 'Fehler nachvollziehen und stornieren',
|
||||
intro: 'Eine Stornierung bewahrt die Originalbuchung und erzeugt eine nachvollziehbare Gegenbewegung.',
|
||||
points: [
|
||||
'Kontrolliere im Journal zuerst Person, Betrag, Datum und Buchungsart.',
|
||||
'Storniere die falsche Mitgliederbuchung und erfasse anschließend den korrekten Vorgang neu.',
|
||||
'Vermeide ausgleichende Fantasiebuchungen, weil dadurch der Prüfpfad unklar wird.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'penalties',
|
||||
title: 'Strafenkatalog verwenden',
|
||||
intro: 'Der Strafenkatalog enthält wiederkehrende Regeln und Beträge, aber noch keine Forderung gegen ein bestimmtes Mitglied.',
|
||||
points: [
|
||||
'Formuliere Einträge kurz und eindeutig und vermeide doppelte Regeln.',
|
||||
'Änderungen am Katalog verändern bereits gebuchte Strafen nicht rückwirkend.',
|
||||
'Übernimm einen Eintrag in der Kasse und wähle dort erst die betroffenen Mitglieder.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sharing',
|
||||
title: 'Einladen und Team teilen',
|
||||
intro: 'Einladungen und öffentliche Freigabe erfüllen unterschiedliche Zwecke.',
|
||||
points: [
|
||||
'Eine Einladung verknüpft ein persönliches Benutzerkonto mit einem bereits angelegten Mitglied.',
|
||||
'Die öffentliche Freigabe zeigt Teamstand, Salden, Strafenkatalog und Buchungsverläufe ohne Anmeldung.',
|
||||
'Aktiviere öffentliche Freigaben nur nach Abstimmung im Team und erneuere den Link, wenn er unerwünscht weitergegeben wurde.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'next-handover',
|
||||
title: 'Verantwortung weitergeben',
|
||||
intro: 'Eine gute Übergabe sorgt dafür, dass Kassenstand und Zuständigkeit zu einem eindeutigen Datum nachvollziehbar sind.',
|
||||
points: [
|
||||
'Stimme Kassenstand und offene Salden gemeinsam ab.',
|
||||
'Übertrage die Teamrolle und prüfe den Zugang der neuen verantwortlichen Person.',
|
||||
'Erkläre besondere offene Vorgänge, Freigaben und vereinbarte Buchungstexte.',
|
||||
'Entferne deine eigene Rolle erst, wenn mindestens ein aktiver Kassenwart verbleibt.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'faq',
|
||||
title: 'Häufige Fragen',
|
||||
intro: 'Diese Antworten helfen bei typischen Unsicherheiten im laufenden Betrieb.',
|
||||
points: [
|
||||
'Falsche Person oder falscher Betrag: Original stornieren und korrekt neu buchen.',
|
||||
'Kassenstand weicht ab: Journal und externe Unterlagen ab dem letzten bestätigten Stand vergleichen.',
|
||||
'Aktion fehlt: ausgewähltes Team, aktive Mitgliedschaft und Teamrolle prüfen.',
|
||||
'Einladung passt nicht: prüfen, ob das richtige Mitglied im richtigen Team ausgewählt wurde.',
|
||||
'Hilfetexte stören: im Profil deaktivieren; diese vollständige Anleitung bleibt erreichbar.',
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AuthStore } from '../auth/auth-store';
|
||||
import { TeamStore } from '../team/team-store';
|
||||
import { HelpAccessService } from './help-access';
|
||||
|
||||
describe('HelpAccessService', () => {
|
||||
it('allows an active second treasurer to open the guide', () => {
|
||||
const access = createAccess(
|
||||
{ id: 7, role: { id: 2 }, helpTextsEnabled: true },
|
||||
teamWithPlayer(7, 2, true),
|
||||
);
|
||||
|
||||
expect(access.canOpenGuide()).toBe(true);
|
||||
expect(access.showContextHelp()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not treat an inactive responsible player as responsible', () => {
|
||||
const access = createAccess(
|
||||
{ id: 7, role: { id: 2 }, helpTextsEnabled: true },
|
||||
teamWithPlayer(7, 4, false),
|
||||
);
|
||||
|
||||
expect(access.canOpenGuide()).toBe(false);
|
||||
expect(access.showContextHelp()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the guide available when contextual help is disabled', () => {
|
||||
const access = createAccess(
|
||||
{ id: 7, role: { id: 2 }, helpTextsEnabled: false },
|
||||
teamWithPlayer(7, 3, true),
|
||||
);
|
||||
|
||||
expect(access.canOpenGuide()).toBe(true);
|
||||
expect(access.showContextHelp()).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults contextual help to enabled for stored users without the new field', () => {
|
||||
const access = createAccess(
|
||||
{ id: 7, role: { id: 2 } },
|
||||
teamWithPlayer(7, 5, true),
|
||||
);
|
||||
|
||||
expect(access.showContextHelp()).toBe(true);
|
||||
});
|
||||
|
||||
it('allows global administrators without a team player assignment', () => {
|
||||
const access = createAccess(
|
||||
{ id: 1, role: { id: 1 }, helpTextsEnabled: true },
|
||||
{ id: 5, name: 'Team', alias: 'team', balance: 0, players: [] },
|
||||
);
|
||||
|
||||
expect(access.canOpenGuide()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function createAccess(user: any, team: any): any {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
HelpAccessService,
|
||||
{ provide: AuthStore, useValue: { currentUser: signal(user) } },
|
||||
{ provide: TeamStore, useValue: { team: signal(team) } },
|
||||
],
|
||||
});
|
||||
return TestBed.inject(HelpAccessService);
|
||||
}
|
||||
|
||||
function teamWithPlayer(userId: number, roleId: number, active: boolean): any {
|
||||
return {
|
||||
id: 5,
|
||||
name: 'Team',
|
||||
alias: 'team',
|
||||
balance: 0,
|
||||
players: [
|
||||
{
|
||||
id: 9,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
balance: 0,
|
||||
active,
|
||||
teamRole: { id: roleId },
|
||||
user: { id: userId },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import { RoleId } from '../../models/role.model';
|
||||
import { TeamRole } from '../../models/team-role.model';
|
||||
import { AuthStore } from '../auth/auth-store';
|
||||
import { TeamStore } from '../team/team-store';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HelpAccessService {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
|
||||
readonly canOpenGuide = computed(() => {
|
||||
const user = this.authStore.currentUser();
|
||||
if (!user) return false;
|
||||
if (user.role?.id === RoleId.Admin) return true;
|
||||
|
||||
return (
|
||||
this.teamStore
|
||||
.team()
|
||||
?.players?.some(
|
||||
(player) =>
|
||||
player.active &&
|
||||
player.user?.id === user.id &&
|
||||
(player.teamRole?.id ?? 0) >= TeamRole.ScndTreasurer,
|
||||
) ?? false
|
||||
);
|
||||
});
|
||||
|
||||
readonly showContextHelp = computed(
|
||||
() => this.canOpenGuide() && this.authStore.currentUser()?.helpTextsEnabled !== false,
|
||||
);
|
||||
}
|
||||
@@ -21,8 +21,8 @@
|
||||
} @else {
|
||||
<div class="transactions">
|
||||
@for (transaction of transactions(); track transaction.id) {
|
||||
<mat-card
|
||||
><div class="icon"><mat-icon>receipt_long</mat-icon></div>
|
||||
<mat-card>
|
||||
<div class="icon"><mat-icon>receipt_long</mat-icon></div>
|
||||
<div>
|
||||
<strong>{{ typeLabel(transaction) }}</strong
|
||||
><span>{{ transaction.date | date: 'dd.MM.yyyy' }}</span>
|
||||
@@ -30,10 +30,14 @@
|
||||
<small>{{ transaction.note }}</small>
|
||||
}
|
||||
</div>
|
||||
<strong class="amount">{{
|
||||
displayAmount(transaction) | currency: 'EUR'
|
||||
}}</strong></mat-card
|
||||
>
|
||||
<strong>
|
||||
<app-transaction-amount
|
||||
[amount]="transaction.amount"
|
||||
[type]="transaction.type"
|
||||
context="player"
|
||||
/>
|
||||
</strong>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -74,9 +74,6 @@ main {
|
||||
.transactions small {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
.amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.state {
|
||||
min-height: 300px;
|
||||
display: grid;
|
||||
|
||||
@@ -32,6 +32,20 @@ describe('PublicPlayer', () => {
|
||||
{
|
||||
id: 1,
|
||||
date: '2026-07-31',
|
||||
amount: 12,
|
||||
note: 'Beitrag',
|
||||
type: { id: 0, name: 'payment' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
date: '2026-07-30',
|
||||
amount: -3,
|
||||
note: 'Korrektur',
|
||||
type: { id: 0, name: 'payment' },
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
date: '2026-07-29',
|
||||
amount: 5,
|
||||
note: 'Training',
|
||||
type: { id: 11, name: 'fine' },
|
||||
@@ -48,6 +62,17 @@ describe('PublicPlayer', () => {
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Ada Lovelace');
|
||||
expect(fixture.nativeElement.textContent).toContain('Training');
|
||||
expect(fixture.nativeElement.textContent).toContain('-5,00');
|
||||
const amounts = [
|
||||
...fixture.nativeElement.querySelectorAll(
|
||||
'app-transaction-amount [data-testid="transaction-amount"]',
|
||||
),
|
||||
] as HTMLElement[];
|
||||
expect(amounts).toHaveLength(3);
|
||||
expect(amounts[0].classList).toContain('inflow');
|
||||
expect(amounts[1].classList).toContain('outflow');
|
||||
expect(amounts[2].classList).toContain('neutral');
|
||||
expect(
|
||||
amounts.map((amount) => amount.querySelector('.transaction-amount__sign')?.textContent),
|
||||
).toEqual(['+', '−', '']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
|
||||
import { DatePipe, registerLocaleData } from '@angular/common';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import { Component, LOCALE_ID, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
@@ -8,21 +8,21 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { PublicTeamApi } from '../../core/team/public-team-api';
|
||||
import { PlayerTransaction } from '../../models/transaction.model';
|
||||
import { signedTransactionAmount } from '../../models/transaction-amount';
|
||||
import { PublicPlayer as PublicPlayerModel } from '../../models/public-access.model';
|
||||
import { TransactionAmount } from '../../shared/transaction-amount/transaction-amount';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@Component({
|
||||
selector: 'app-public-player',
|
||||
imports: [
|
||||
CurrencyPipe,
|
||||
DatePipe,
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
TransactionAmount,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './public-player.html',
|
||||
@@ -72,8 +72,4 @@ export class PublicPlayer {
|
||||
)[type] ?? type
|
||||
);
|
||||
}
|
||||
|
||||
protected displayAmount(transaction: PlayerTransaction): number {
|
||||
return signedTransactionAmount(transaction.amount, transaction.type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,15 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<app-context-help
|
||||
title="Buchungen verstehen"
|
||||
[hints]="[
|
||||
'Mitgliederbuchungen verändern persönliche Salden; Teambuchungen erfassen direkte Einnahmen oder Ausgaben der Kasse.',
|
||||
'Bei mehreren Mitgliedern bestimmt die Verteilungsoption, ob der Gesamtbetrag geteilt oder je Person gebucht wird.',
|
||||
]"
|
||||
sectionId="bookings"
|
||||
/>
|
||||
|
||||
@if (canBook()) {
|
||||
<section class="booking-grid">
|
||||
<mat-card data-testid="player-booking">
|
||||
@@ -167,7 +176,13 @@
|
||||
<small>{{ activity.note }}</small>
|
||||
}
|
||||
</div>
|
||||
<strong class="amount">{{ displayAmount(activity) | currency: 'EUR' }}</strong>
|
||||
<strong>
|
||||
<app-transaction-amount
|
||||
[amount]="activity.amount"
|
||||
[type]="activity.type"
|
||||
[context]="activity.isTeamWalletTransaction ? 'team' : 'player'"
|
||||
/>
|
||||
</strong>
|
||||
@if (canReverse(activity)) {
|
||||
<button
|
||||
mat-icon-button
|
||||
|
||||
@@ -37,12 +37,7 @@ h1 {
|
||||
gap: 4px;
|
||||
min-width: 160px;
|
||||
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#5ca34c 0%,
|
||||
#4f8f46 55%,
|
||||
#3f7f3c 100%
|
||||
);
|
||||
background: linear-gradient(135deg, #5ca34c 0%, #4f8f46 55%, #3f7f3c 100%);
|
||||
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -135,9 +130,6 @@ form button {
|
||||
.activity-copy small {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
.amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.state {
|
||||
min-height: 160px;
|
||||
display: grid;
|
||||
|
||||
@@ -49,9 +49,26 @@ describe('Cashbox', () => {
|
||||
id: 9,
|
||||
date: '2026-07-31T10:00:00.000Z',
|
||||
amount: 12,
|
||||
type: 'payment',
|
||||
note: 'Beitrag',
|
||||
playerName: 'Bea Test',
|
||||
isTeamWalletTransaction: false,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
date: '2026-07-30T10:00:00.000Z',
|
||||
amount: 8,
|
||||
type: 'expense',
|
||||
note: 'Material',
|
||||
isTeamWalletTransaction: true,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
date: '2026-07-29T10:00:00.000Z',
|
||||
amount: 5,
|
||||
type: 'fine',
|
||||
note: 'Training',
|
||||
playerName: 'Bea Test',
|
||||
playerName: 'Alex Muster',
|
||||
isTeamWalletTransaction: false,
|
||||
},
|
||||
];
|
||||
@@ -105,9 +122,7 @@ describe('Cashbox', () => {
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
queryParamMap: convertToParamMap(
|
||||
penaltyIdParam ? { penaltyId: penaltyIdParam } : {},
|
||||
),
|
||||
queryParamMap: convertToParamMap(penaltyIdParam ? { penaltyId: penaltyIdParam } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -137,8 +152,25 @@ describe('Cashbox', () => {
|
||||
const { fixture } = await setup();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Bea Test');
|
||||
expect(fixture.nativeElement.textContent).toContain('-12,00');
|
||||
expect(fixture.nativeElement.querySelector('[data-testid="player-booking"]')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Buchungen verstehen');
|
||||
});
|
||||
|
||||
it('renders inflow, outflow, and neutral activity amounts with their cash-flow meaning', async () => {
|
||||
const { fixture } = await setup();
|
||||
const amounts = [
|
||||
...fixture.nativeElement.querySelectorAll(
|
||||
'app-transaction-amount [data-testid="transaction-amount"]',
|
||||
),
|
||||
] as HTMLElement[];
|
||||
|
||||
expect(amounts).toHaveLength(3);
|
||||
expect(amounts[0].classList).toContain('inflow');
|
||||
expect(amounts[1].classList).toContain('outflow');
|
||||
expect(amounts[2].classList).toContain('neutral');
|
||||
expect(
|
||||
amounts.map((amount) => amount.querySelector('.transaction-amount__sign')?.textContent),
|
||||
).toEqual(['+', '−', '']);
|
||||
});
|
||||
|
||||
it('submits cent-preserving split transactions for selected players', async () => {
|
||||
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
TeamActivity,
|
||||
} from '../../../models/transaction.model';
|
||||
import { ConfirmDialog, ConfirmDialogData } from '../../../shared/confirm-dialog/confirm-dialog';
|
||||
import { ContextHelp } from '../../../shared/context-help/context-help';
|
||||
import { TransactionAmount } from '../../../shared/transaction-amount/transaction-amount';
|
||||
import { splitAmounts } from './transaction-calculation';
|
||||
import { signedTransactionAmount } from '../../../models/transaction-amount';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@@ -46,6 +47,8 @@ const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300;
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
MatSnackBarModule,
|
||||
ContextHelp,
|
||||
TransactionAmount,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './cashbox.html',
|
||||
@@ -62,9 +65,8 @@ export class Cashbox {
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
private readonly transactionsApi = inject(TransactionsApi);
|
||||
private loadedTeamId: number | null = null;
|
||||
private pendingPenaltyId: number | null = Number(
|
||||
this.route.snapshot.queryParamMap.get('penaltyId'),
|
||||
) || null;
|
||||
private pendingPenaltyId: number | null =
|
||||
Number(this.route.snapshot.queryParamMap.get('penaltyId')) || null;
|
||||
|
||||
protected readonly team = this.teamStore.team;
|
||||
protected readonly activities = signal<TeamActivity[]>([]);
|
||||
@@ -239,10 +241,6 @@ export class Cashbox {
|
||||
);
|
||||
}
|
||||
|
||||
protected displayAmount(activity: TeamActivity): number {
|
||||
return signedTransactionAmount(activity.amount, activity.type);
|
||||
}
|
||||
|
||||
private createPlayerTransactions(transactions: CreatePlayerTransaction[]): void {
|
||||
this.saving.set(true);
|
||||
this.transactionsApi.createPlayerTransactions(transactions).subscribe({
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
</button>
|
||||
}
|
||||
</header>
|
||||
<app-context-help
|
||||
title="Mitglieder verwalten"
|
||||
[hints]="[
|
||||
'Ein Mitglied wird zuerst angelegt und erst danach über eine Einladung mit einem Benutzerkonto verbunden.',
|
||||
'Ausgetretene Mitglieder deaktivieren, damit ihre Buchungshistorie erhalten bleibt.',
|
||||
]"
|
||||
sectionId="members"
|
||||
/>
|
||||
@if (showCreateForm()) {
|
||||
<mat-card class="create-card"
|
||||
><mat-card-content
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('Members', () => {
|
||||
const fixture = TestBed.createComponent(Members);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Bea Test');
|
||||
expect(fixture.nativeElement.textContent).toContain('Mitglieder verwalten');
|
||||
|
||||
fixture.componentInstance['showCreateForm'].set(true);
|
||||
fixture.componentInstance['createForm'].setValue({
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MatSelectModule } from '@angular/material/select';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { TeamsApi } from '../../../core/team/teams-api';
|
||||
import { ContextHelp } from '../../../shared/context-help/context-help';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@@ -27,6 +28,7 @@ registerLocaleData(localeDe);
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
ContextHelp,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './members.html',
|
||||
|
||||
@@ -49,9 +49,13 @@
|
||||
>{{ transaction.date | date: 'dd.MM.yyyy' }} · {{ typeName(transaction) }}</span
|
||||
>
|
||||
</div>
|
||||
<strong [class.negative]="displayAmount(transaction) < 0">{{
|
||||
displayAmount(transaction) | currency: 'EUR'
|
||||
}}</strong>
|
||||
<strong>
|
||||
<app-transaction-amount
|
||||
[amount]="transaction.amount"
|
||||
[type]="transaction.type"
|
||||
context="player"
|
||||
/>
|
||||
</strong>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,11 @@ describe('PlayerDetail', () => {
|
||||
};
|
||||
}
|
||||
|
||||
async function create(team: ReturnType<typeof makeTeam>, user: unknown) {
|
||||
async function create(
|
||||
team: ReturnType<typeof makeTeam>,
|
||||
user: unknown,
|
||||
transactions: unknown[] = [],
|
||||
) {
|
||||
refreshTeam = vi.fn();
|
||||
currentUser = signal(user);
|
||||
closeDialog = new Subject<boolean>();
|
||||
@@ -50,7 +54,10 @@ describe('PlayerDetail', () => {
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{ provide: TeamStore, useValue: { team: signal(team), loading: signal(false), refreshTeam } },
|
||||
{
|
||||
provide: TeamStore,
|
||||
useValue: { team: signal(team), loading: signal(false), refreshTeam },
|
||||
},
|
||||
{ provide: AuthStore, useValue: { currentUser } },
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
{
|
||||
@@ -62,7 +69,7 @@ describe('PlayerDetail', () => {
|
||||
const fixture = TestBed.createComponent(PlayerDetail);
|
||||
fixture.detectChanges();
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
httpMock.expectOne(`${teamsApiUrl}/players/7/transactions`).flush([]);
|
||||
httpMock.expectOne(`${teamsApiUrl}/players/7/transactions`).flush(transactions);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
@@ -71,26 +78,59 @@ describe('PlayerDetail', () => {
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('renders the selected player and transaction history', async () => {
|
||||
const fixture = await create(
|
||||
makeTeam({ balance: -12 }),
|
||||
{ id: 99, role: { id: 2 } },
|
||||
);
|
||||
const fixture = await create(makeTeam({ balance: -12 }), { id: 99, role: { id: 2 } });
|
||||
expect(fixture.nativeElement.textContent).toContain('Alex Muster');
|
||||
expect(fixture.nativeElement.textContent).toContain('-12,00');
|
||||
});
|
||||
|
||||
it('renders player inflow, reversal outflow, and neutral amounts with their cash-flow meaning', async () => {
|
||||
const fixture = await create(makeTeam(), { id: 99, role: { id: 2 } }, [
|
||||
{
|
||||
id: 1,
|
||||
date: '2026-07-31',
|
||||
amount: 12,
|
||||
type: { id: 0, name: 'payment' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
date: '2026-07-30',
|
||||
amount: -3,
|
||||
type: { id: 0, name: 'payment' },
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
date: '2026-07-29',
|
||||
amount: 5,
|
||||
type: { id: 11, name: 'fine' },
|
||||
},
|
||||
]);
|
||||
const amounts = [
|
||||
...fixture.nativeElement.querySelectorAll(
|
||||
'app-transaction-amount [data-testid="transaction-amount"]',
|
||||
),
|
||||
] as HTMLElement[];
|
||||
|
||||
expect(amounts).toHaveLength(3);
|
||||
expect(amounts[0].classList).toContain('inflow');
|
||||
expect(amounts[1].classList).toContain('outflow');
|
||||
expect(amounts[2].classList).toContain('neutral');
|
||||
expect(
|
||||
amounts.map((amount) => amount.querySelector('.transaction-amount__sign')?.textContent),
|
||||
).toEqual(['+', '−', '']);
|
||||
});
|
||||
|
||||
it('hides the manage controls for a user without team-manager rights', async () => {
|
||||
const fixture = await create(makeTeam(), { id: 99, role: { id: 2 } });
|
||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
|
||||
b.textContent?.includes('Deaktivieren'),
|
||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find(
|
||||
(b: HTMLButtonElement) => b.textContent?.includes('Deaktivieren'),
|
||||
);
|
||||
expect(button).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows the manage controls for a global admin', async () => {
|
||||
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
|
||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
|
||||
b.textContent?.includes('Deaktivieren'),
|
||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find(
|
||||
(b: HTMLButtonElement) => b.textContent?.includes('Deaktivieren'),
|
||||
);
|
||||
expect(button).toBeDefined();
|
||||
});
|
||||
@@ -100,16 +140,16 @@ describe('PlayerDetail', () => {
|
||||
makeTeam({ user: { id: 42 }, teamRole: { id: 3, name: 'captain' } }),
|
||||
{ id: 42, role: { id: 2 } },
|
||||
);
|
||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
|
||||
b.textContent?.includes('Deaktivieren'),
|
||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find(
|
||||
(b: HTMLButtonElement) => b.textContent?.includes('Deaktivieren'),
|
||||
);
|
||||
expect(button).toBeDefined();
|
||||
});
|
||||
|
||||
it('deactivates the player on confirm and refreshes the team', async () => {
|
||||
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
|
||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
|
||||
b.textContent?.includes('Deaktivieren'),
|
||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find(
|
||||
(b: HTMLButtonElement) => b.textContent?.includes('Deaktivieren'),
|
||||
) as HTMLButtonElement;
|
||||
button.click();
|
||||
|
||||
@@ -128,8 +168,8 @@ describe('PlayerDetail', () => {
|
||||
|
||||
it('does not call the API when the confirmation dialog is dismissed', async () => {
|
||||
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
|
||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
|
||||
b.textContent?.includes('Deaktivieren'),
|
||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find(
|
||||
(b: HTMLButtonElement) => b.textContent?.includes('Deaktivieren'),
|
||||
) as HTMLButtonElement;
|
||||
button.click();
|
||||
closeDialog.next(false);
|
||||
|
||||
@@ -15,8 +15,8 @@ import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { TeamsApi } from '../../../core/team/teams-api';
|
||||
import { PlayerTransaction } from '../../../models/transaction.model';
|
||||
import { signedTransactionAmount } from '../../../models/transaction-amount';
|
||||
import { ConfirmDialog } from '../../../shared/confirm-dialog/confirm-dialog';
|
||||
import { TransactionAmount } from '../../../shared/transaction-amount/transaction-amount';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@@ -32,6 +32,7 @@ registerLocaleData(localeDe);
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
TransactionAmount,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './player-detail.html',
|
||||
@@ -88,10 +89,6 @@ export class PlayerDetail {
|
||||
: (transaction.type?.name ?? 'Buchung');
|
||||
}
|
||||
|
||||
protected displayAmount(transaction: PlayerTransaction): number {
|
||||
return signedTransactionAmount(transaction.amount, transaction.type);
|
||||
}
|
||||
|
||||
protected changeActive(): void {
|
||||
const team = this.team();
|
||||
const player = this.player();
|
||||
@@ -117,7 +114,9 @@ export class PlayerDetail {
|
||||
.subscribe({
|
||||
next: () => this.teamStore.refreshTeam(),
|
||||
error: (error: HttpErrorResponse) =>
|
||||
this.mutationError.set(this.errorMessage(error, 'Status konnte nicht geändert werden.')),
|
||||
this.mutationError.set(
|
||||
this.errorMessage(error, 'Status konnte nicht geändert werden.'),
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
@if (canOpenGuide()) {
|
||||
<a mat-button routerLink="../"><mat-icon>arrow_back</mat-icon>Mehr</a>
|
||||
|
||||
<header class="guide-header">
|
||||
<p class="eyebrow">Team verwalten</p>
|
||||
<h1>Anleitung für Verantwortliche</h1>
|
||||
<p>Ein praktischer Leitfaden für Kassenwarte, Kapitäne, Trainer und ihre Vertretung.</p>
|
||||
</header>
|
||||
|
||||
<mat-card class="handover-card">
|
||||
<mat-card-content>
|
||||
<p class="eyebrow">Schnellstart</p>
|
||||
<h2>Neu in der Verantwortung?</h2>
|
||||
<p>Gehe diese Punkte gemeinsam mit der bisherigen verantwortlichen Person durch.</p>
|
||||
<ol>
|
||||
@for (item of handoverChecklist; track item) {
|
||||
<li>{{ item }}</li>
|
||||
}
|
||||
</ol>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<nav class="guide-nav" aria-label="Inhalt der Anleitung">
|
||||
<strong>Direkt zu einem Thema</strong>
|
||||
<div>
|
||||
@for (section of sections; track section.id) {
|
||||
<a [routerLink]="[]" [fragment]="section.id">{{ section.title }}</a>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="guide-sections">
|
||||
@for (section of sections; track section.id) {
|
||||
<section [id]="section.id" class="guide-section">
|
||||
<p class="eyebrow">{{ $index + 1 | number: '2.0' }}</p>
|
||||
<h2>{{ section.title }}</h2>
|
||||
<p class="guide-section__intro">{{ section.intro }}</p>
|
||||
|
||||
@if (section.id === 'roles') {
|
||||
<div class="role-grid">
|
||||
@for (role of roleGuide; track role.role) {
|
||||
<article>
|
||||
<strong>{{ role.role }}</strong>
|
||||
<span>{{ role.description }}</span>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul>
|
||||
@for (point of section.points; track point) {
|
||||
<li>{{ point }}</li>
|
||||
}
|
||||
</ul>
|
||||
@if (section.note) {
|
||||
<p class="guide-note"><strong>Wichtig:</strong> {{ section.note }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.guide-header {
|
||||
margin: 1rem 0 2rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 18ch;
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
line-height: 1.05;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--mat-sys-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.handover-card {
|
||||
border-radius: 1.25rem;
|
||||
background: var(--mat-sys-primary-container);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
}
|
||||
|
||||
.handover-card mat-card-content {
|
||||
padding: clamp(1.25rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
.handover-card ol {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding-left: 1.4rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.guide-nav {
|
||||
margin: 2rem 0;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.guide-nav div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.guide-nav a {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--mat-sys-on-surface);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.guide-sections {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.guide-section {
|
||||
scroll-margin-top: 1.5rem;
|
||||
padding: clamp(1.25rem, 4vw, 2rem);
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--mat-sys-surface-container-lowest);
|
||||
}
|
||||
|
||||
.guide-section__intro {
|
||||
max-width: 72ch;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.guide-section ul {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.role-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.role-grid article {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.9rem;
|
||||
background: var(--mat-sys-surface-container);
|
||||
}
|
||||
|
||||
.role-grid span {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.guide-note {
|
||||
margin: 1rem 0 0;
|
||||
padding: 1rem;
|
||||
border-left: 4px solid var(--mat-sys-tertiary);
|
||||
background: var(--mat-sys-tertiary-container);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.role-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { HelpAccessService } from '../../../../core/help/help-access';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { Guide } from './guide';
|
||||
|
||||
@Component({ template: '' })
|
||||
class EmptyRoute {}
|
||||
|
||||
describe('Guide', () => {
|
||||
it('renders the handover path and all task-oriented chapters for responsible users', async () => {
|
||||
const fixture = await createFixture(true);
|
||||
const text = fixture.nativeElement.textContent;
|
||||
|
||||
expect(text).toContain('Neu in der Verantwortung?');
|
||||
expect(text).toContain('Amtsübergabe prüfen');
|
||||
expect(text).toContain('Buchungen sicher erfassen');
|
||||
expect(text).toContain('Verantwortung weitergeben');
|
||||
expect(fixture.nativeElement.querySelector('#bookings')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('#faq')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('redirects regular players back to the More page after team loading', async () => {
|
||||
const fixture = await createFixture(false);
|
||||
const router = TestBed.inject(Router);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(router.url).toBe('/team/5/more');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Amtsübergabe prüfen');
|
||||
});
|
||||
});
|
||||
|
||||
async function createFixture(canOpen: boolean) {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Guide],
|
||||
providers: [
|
||||
provideRouter([{ path: 'team/5/more', component: EmptyRoute }]),
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(canOpen) } },
|
||||
{
|
||||
provide: TeamStore,
|
||||
useValue: {
|
||||
team: signal({ id: 5 }),
|
||||
loading: signal(false),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Guide);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
return fixture;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component, effect, inject } from '@angular/core';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import {
|
||||
GUIDE_SECTIONS,
|
||||
HANDOVER_CHECKLIST,
|
||||
ROLE_GUIDE,
|
||||
} from '../../../../core/help/guide-content';
|
||||
import { HelpAccessService } from '../../../../core/help/help-access';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guide',
|
||||
imports: [DecimalPipe, RouterLink, MatButtonModule, MatCardModule, MatIconModule],
|
||||
templateUrl: './guide.html',
|
||||
styleUrl: './guide.scss',
|
||||
})
|
||||
export class Guide {
|
||||
private readonly helpAccess = inject(HelpAccessService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
|
||||
protected readonly sections = GUIDE_SECTIONS;
|
||||
protected readonly handoverChecklist = HANDOVER_CHECKLIST;
|
||||
protected readonly roleGuide = ROLE_GUIDE;
|
||||
protected readonly canOpenGuide = this.helpAccess.canOpenGuide;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const team = this.teamStore.team();
|
||||
if (!this.teamStore.loading() && team && !this.helpAccess.canOpenGuide()) {
|
||||
void this.router.navigateByUrl(`/team/${team.id}/more`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,15 @@
|
||||
<h1>Einladen</h1>
|
||||
<p>Der Link ist personalisiert und darf nur an die ausgewählte Person gehen.</p>
|
||||
</header>
|
||||
|
||||
<app-context-help
|
||||
title="Einladungen richtig zuordnen"
|
||||
[hints]="[
|
||||
'Der persönliche Link verbindet genau das ausgewählte Mitglied mit einem Benutzerkonto.',
|
||||
'Sende den Link nur an die Person, deren Mitgliedseintrag ausgewählt wurde.',
|
||||
]"
|
||||
sectionId="sharing"
|
||||
/>
|
||||
@if (!canInvite()) {
|
||||
<mat-card class="notice"
|
||||
><mat-icon>lock</mat-icon
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('Invite', () => {
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Invite);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Einladungen richtig zuordnen');
|
||||
|
||||
fixture.componentInstance['form'].setValue({ playerId: 7 });
|
||||
fixture.componentInstance['generateLink']();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { AuthApi } from '../../../../core/auth/auth-api';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite',
|
||||
@@ -24,6 +25,7 @@ import { TeamStore } from '../../../../core/team/team-store';
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatSnackBarModule,
|
||||
ContextHelp,
|
||||
],
|
||||
templateUrl: './invite.html',
|
||||
styleUrl: './invite.scss',
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
</header>
|
||||
|
||||
<section class="link-grid">
|
||||
@if (canOpenGuide()) {
|
||||
<a routerLink="guide"
|
||||
><mat-card
|
||||
><mat-icon>menu_book</mat-icon>
|
||||
<div>
|
||||
<strong>Anleitung für Verantwortliche</strong
|
||||
><span>Amtsübergabe, Kasse und Teamverwaltung verstehen</span>
|
||||
</div>
|
||||
<mat-icon>chevron_right</mat-icon></mat-card
|
||||
></a
|
||||
>
|
||||
}
|
||||
<a routerLink="/users"
|
||||
><mat-card
|
||||
><mat-icon>group</mat-icon>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../core/help/help-access';
|
||||
import { More } from './more';
|
||||
|
||||
@Component({ template: '' })
|
||||
@@ -21,6 +22,7 @@ describe('More', () => {
|
||||
clearSession,
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(true) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(More);
|
||||
@@ -30,6 +32,7 @@ describe('More', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('Einladen');
|
||||
expect(fixture.nativeElement.textContent).toContain('Profil');
|
||||
expect(fixture.nativeElement.textContent).toContain('Öffentliche Freigabe');
|
||||
expect(fixture.nativeElement.textContent).toContain('Anleitung für Verantwortliche');
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Benutzer');
|
||||
expect(fixture.nativeElement.querySelector('a[href="/users"]')).not.toBeNull();
|
||||
@@ -39,4 +42,25 @@ describe('More', () => {
|
||||
expect(clearSession).toHaveBeenCalled();
|
||||
expect(TestBed.inject(Router).url).toBe('/auth/login');
|
||||
});
|
||||
|
||||
it('hides the guide link from regular players', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [More],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: AuthStore,
|
||||
useValue: {
|
||||
currentUser: signal({ firstName: 'Alex', lastName: 'Muster', email: 'a@b.de' }),
|
||||
clearSession: vi.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(false) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(More);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Anleitung für Verantwortliche');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../core/help/help-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-more',
|
||||
@@ -13,8 +14,10 @@ import { AuthStore } from '../../../core/auth/auth-store';
|
||||
})
|
||||
export class More {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly helpAccess = inject(HelpAccessService);
|
||||
private readonly router = inject(Router);
|
||||
protected readonly user = this.authStore.currentUser;
|
||||
protected readonly canOpenGuide = this.helpAccess.canOpenGuide;
|
||||
|
||||
protected logout(): void {
|
||||
this.authStore.clearSession();
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
<p>Klare Regeln, transparent für das ganze Team.</p>
|
||||
</header>
|
||||
|
||||
<app-context-help
|
||||
title="Strafenkatalog richtig nutzen"
|
||||
[hints]="[
|
||||
'Ein Katalogeintrag ist eine Vorlage und noch keine Forderung gegen ein Mitglied.',
|
||||
'Bestehende Buchungen ändern sich nicht, wenn ein Katalogeintrag später bearbeitet wird.',
|
||||
]"
|
||||
sectionId="penalties"
|
||||
/>
|
||||
|
||||
@if (canManage()) {
|
||||
<mat-card class="create-card">
|
||||
<form [formGroup]="form" (ngSubmit)="createPenalty()" aria-label="Katalogeintrag anlegen">
|
||||
|
||||
@@ -101,6 +101,7 @@ describe('Penalties', () => {
|
||||
.mockReturnValueOnce(of([first, { id: 2, description: 'Handy', amount: 3 }]));
|
||||
create();
|
||||
fixture.componentInstance['form'].setValue({ description: 'Handy', amount: 3 });
|
||||
expect(text()).toContain('Strafenkatalog richtig nutzen');
|
||||
|
||||
fixture.componentInstance['createPenalty']();
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PenaltyApi } from '../../../../core/team/penalty-api';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { Penalty } from '../../../../models/penalty.model';
|
||||
import { ConfirmDialog } from '../../../../shared/confirm-dialog/confirm-dialog';
|
||||
import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@@ -33,6 +34,7 @@ registerLocaleData(localeDe);
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
ContextHelp,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './penalties.html',
|
||||
|
||||
@@ -25,6 +25,17 @@
|
||||
><input matInput type="password" formControlName="password"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
@if (canOpenGuide()) {
|
||||
<section class="help-settings">
|
||||
<h2>Hilfe</h2>
|
||||
<p class="hint">
|
||||
Blende kurze Erklärungen auf Kasse, Mitglieder und Verwaltungsseiten ein.
|
||||
</p>
|
||||
<mat-slide-toggle formControlName="helpTextsEnabled">
|
||||
Hilfetexte in der App anzeigen
|
||||
</mat-slide-toggle>
|
||||
</section>
|
||||
}
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
|
||||
<mat-icon>save</mat-icon>Speichern
|
||||
</button>
|
||||
|
||||
@@ -44,6 +44,19 @@ h2 {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.help-settings {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--mat-sys-surface-container);
|
||||
}
|
||||
|
||||
.help-settings h2,
|
||||
.help-settings p {
|
||||
margin: 0;
|
||||
}
|
||||
form button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { throwError } from 'rxjs';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { AuthApi } from '../../../../core/auth/auth-api';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../../core/help/help-access';
|
||||
import { Profile } from './profile';
|
||||
|
||||
describe('Profile', () => {
|
||||
@@ -13,6 +16,7 @@ describe('Profile', () => {
|
||||
email: 'alex@example.com',
|
||||
firstName: 'Alexander',
|
||||
lastName: 'Neu',
|
||||
helpTextsEnabled: false,
|
||||
};
|
||||
const updateProfile = vi.fn(() => of(updated));
|
||||
const updateUser = vi.fn();
|
||||
@@ -29,10 +33,12 @@ describe('Profile', () => {
|
||||
email: 'alex@example.com',
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
helpTextsEnabled: true,
|
||||
}),
|
||||
updateUser,
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(true) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Profile);
|
||||
@@ -42,11 +48,56 @@ describe('Profile', () => {
|
||||
lastName: 'Neu',
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
});
|
||||
helpTextsEnabled: false,
|
||||
} as any);
|
||||
|
||||
fixture.componentInstance['save']();
|
||||
|
||||
expect(updateProfile).toHaveBeenCalledWith({ firstName: 'Alexander', lastName: 'Neu' });
|
||||
expect(updateProfile).toHaveBeenCalledWith({
|
||||
firstName: 'Alexander',
|
||||
lastName: 'Neu',
|
||||
helpTextsEnabled: false,
|
||||
});
|
||||
expect(updateUser).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
|
||||
it('keeps the stored preference and reports an error when saving fails', async () => {
|
||||
const updateUser = vi.fn();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Profile],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: AuthApi,
|
||||
useValue: { updateProfile: vi.fn(() => throwError(() => new Error('failed'))) },
|
||||
},
|
||||
{
|
||||
provide: AuthStore,
|
||||
useValue: {
|
||||
currentUser: signal({
|
||||
id: 42,
|
||||
email: 'alex@example.com',
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
helpTextsEnabled: true,
|
||||
}),
|
||||
updateUser,
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(true) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Profile);
|
||||
const snackBar = fixture.debugElement.injector.get(MatSnackBar);
|
||||
const open = vi.spyOn(snackBar, 'open');
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance['form'].patchValue({ helpTextsEnabled: false } as any);
|
||||
|
||||
fixture.componentInstance['save']();
|
||||
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
expect(open).toHaveBeenCalledWith('Profil konnte nicht gespeichert werden.', undefined, {
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { AuthApi, UpdateProfileRequest } from '../../../../core/auth/auth-api';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../../core/help/help-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile',
|
||||
@@ -21,6 +23,7 @@ import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSnackBarModule,
|
||||
MatSlideToggleModule,
|
||||
],
|
||||
templateUrl: './profile.html',
|
||||
styleUrl: './profile.scss',
|
||||
@@ -28,13 +31,16 @@ import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
export class Profile {
|
||||
private readonly authApi = inject(AuthApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly helpAccess = inject(HelpAccessService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly canOpenGuide = this.helpAccess.canOpenGuide;
|
||||
protected readonly form = inject(FormBuilder).nonNullable.group({
|
||||
firstName: [this.authStore.currentUser()?.firstName ?? '', Validators.required],
|
||||
lastName: [this.authStore.currentUser()?.lastName ?? '', Validators.required],
|
||||
oldPassword: [''],
|
||||
password: ['', Validators.minLength(6)],
|
||||
helpTextsEnabled: [this.authStore.currentUser()?.helpTextsEnabled !== false],
|
||||
});
|
||||
|
||||
protected save(): void {
|
||||
@@ -47,6 +53,7 @@ export class Profile {
|
||||
const request: UpdateProfileRequest = {
|
||||
firstName: value.firstName.trim(),
|
||||
lastName: value.lastName.trim(),
|
||||
helpTextsEnabled: value.helpTextsEnabled,
|
||||
};
|
||||
if (value.password) {
|
||||
request.oldPassword = value.oldPassword;
|
||||
@@ -60,7 +67,12 @@ export class Profile {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Profil wurde gespeichert.', undefined, { duration: 4000 });
|
||||
},
|
||||
error: () => this.saving.set(false),
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Profil konnte nicht gespeichert werden.', undefined, {
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
<p>Teile Kassenstand, Mitgliedersalden, Strafenkatalog und Buchungsverläufe.</p>
|
||||
</header>
|
||||
|
||||
<app-context-help
|
||||
title="Öffentliche Freigabe bewusst nutzen"
|
||||
[hints]="[
|
||||
'Jede Person mit dem Link kann Teamstand, Salden, Strafenkatalog und Buchungsverläufe sehen.',
|
||||
'Erneuere den Link, wenn er unerwünscht weitergegeben wurde.',
|
||||
]"
|
||||
sectionId="sharing"
|
||||
/>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="state"><mat-spinner diameter="38" /><span>Freigabe wird geladen …</span></div>
|
||||
} @else if (loadFailed()) {
|
||||
|
||||
@@ -79,6 +79,8 @@ describe('PublicAccess', () => {
|
||||
it('allows a captain to activate sharing', async () => {
|
||||
const { fixture, setEnabled } = await setup(3, false);
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Öffentliche Freigabe bewusst nutzen');
|
||||
|
||||
fixture.componentInstance['toggleAccess']();
|
||||
|
||||
expect(setEnabled).toHaveBeenCalledWith(7, true);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PublicAccessApi } from '../../../../core/team/public-access-api';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { PublicAccessStatus } from '../../../../models/public-access.model';
|
||||
import { ConfirmDialog, ConfirmDialogData } from '../../../../shared/confirm-dialog/confirm-dialog';
|
||||
import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
|
||||
@Component({
|
||||
selector: 'app-public-access',
|
||||
@@ -25,6 +26,7 @@ import { ConfirmDialog, ConfirmDialogData } from '../../../../shared/confirm-dia
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
ContextHelp,
|
||||
],
|
||||
templateUrl: './public-access.html',
|
||||
styleUrl: './public-access.scss',
|
||||
|
||||
@@ -126,9 +126,13 @@
|
||||
><span>{{ activity.note || activity.type }}</span
|
||||
><small>{{ activity.date | date: 'dd.MM.yyyy' }}</small>
|
||||
</div>
|
||||
<strong class="activity__amount" [class.negative]="displayAmount(activity) < 0">{{
|
||||
displayAmount(activity) | currency: 'EUR'
|
||||
}}</strong>
|
||||
<strong>
|
||||
<app-transaction-amount
|
||||
[amount]="activity.amount"
|
||||
[type]="activity.type"
|
||||
[context]="activity.isTeamWalletTransaction ? 'team' : 'player'"
|
||||
/>
|
||||
</strong>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -44,12 +44,7 @@ h2 {
|
||||
.balance-card--primary {
|
||||
// background: var(--mat-sys-primary-container);
|
||||
// color: var(--mat-sys-on-primary-container);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#5ca34c 0%,
|
||||
#4f8f46 55%,
|
||||
#3f7f3c 100%
|
||||
);
|
||||
background: linear-gradient(135deg, #5ca34c 0%, #4f8f46 55%, #3f7f3c 100%);
|
||||
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -76,7 +71,6 @@ h2 {
|
||||
border-radius: 14px;
|
||||
background: var(--mat-sys-secondary-container);
|
||||
color: var(--mat-sys-on-secondary-container);
|
||||
|
||||
}
|
||||
.activity__copy {
|
||||
display: flex;
|
||||
@@ -90,12 +84,6 @@ h2 {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.activity__amount {
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
.activity__amount.negative {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
.state {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
|
||||
@@ -90,11 +90,28 @@ describe('Overview', () => {
|
||||
id: 1,
|
||||
date: '2026-07-31',
|
||||
amount: 12,
|
||||
type: 'fine',
|
||||
type: 'payment',
|
||||
note: 'Beitrag',
|
||||
playerName: 'Alex',
|
||||
isTeamWalletTransaction: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
date: '2026-07-30',
|
||||
amount: 8,
|
||||
type: 'expense',
|
||||
note: 'Material',
|
||||
isTeamWalletTransaction: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
date: '2026-07-29',
|
||||
amount: 5,
|
||||
type: 'fine',
|
||||
note: 'Training',
|
||||
playerName: 'Bea',
|
||||
isTeamWalletTransaction: false,
|
||||
},
|
||||
]);
|
||||
flushStats(sampleStats);
|
||||
await fixture.whenStable();
|
||||
@@ -103,7 +120,18 @@ describe('Overview', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('125,00');
|
||||
expect(fixture.nativeElement.textContent).toContain('Alex');
|
||||
expect(fixture.nativeElement.textContent).toContain('Beitrag');
|
||||
expect(fixture.nativeElement.textContent).toContain('-12,00');
|
||||
const amounts = [
|
||||
...fixture.nativeElement.querySelectorAll(
|
||||
'app-transaction-amount [data-testid="transaction-amount"]',
|
||||
),
|
||||
] as HTMLElement[];
|
||||
expect(amounts).toHaveLength(3);
|
||||
expect(amounts[0].classList).toContain('inflow');
|
||||
expect(amounts[1].classList).toContain('outflow');
|
||||
expect(amounts[2].classList).toContain('neutral');
|
||||
expect(
|
||||
amounts.map((amount) => amount.querySelector('.transaction-amount__sign')?.textContent),
|
||||
).toEqual(['+', '−', '']);
|
||||
|
||||
routeParams.next(convertToParamMap({ id: '6' }));
|
||||
flushTransactions(
|
||||
|
||||
@@ -12,9 +12,9 @@ import { ChartCanvas } from '../../../shared/chart-canvas/chart-canvas';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { TransactionsApi } from '../../../core/team/transactions-api';
|
||||
import { TeamStatsApi } from '../../../core/team/team-stats-api';
|
||||
import { TeamActivity } from '../../../models/transaction.model';
|
||||
import { TeamOverviewStats } from '../../../models/team-stats.model';
|
||||
import { signedTransactionAmount } from '../../../models/transaction-amount';
|
||||
import { TeamActivity } from '../../../models/transaction.model';
|
||||
import { TransactionAmount } from '../../../shared/transaction-amount/transaction-amount';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
|
||||
|
||||
@@ -42,6 +42,7 @@ function formatMonthLabel(month: string): string {
|
||||
MatProgressSpinnerModule,
|
||||
RouterLink,
|
||||
ChartCanvas,
|
||||
TransactionAmount,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './overview.html',
|
||||
@@ -183,8 +184,4 @@ export class Overview {
|
||||
protected activityIcon(activity: TeamActivity): string {
|
||||
return activity.isTeamWalletTransaction ? 'account_balance' : 'person';
|
||||
}
|
||||
|
||||
protected displayAmount(activity: TeamActivity): number {
|
||||
return signedTransactionAmount(activity.amount, activity.type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,83 @@
|
||||
import { signedTransactionAmount } from './transaction-amount';
|
||||
import { presentCashFlow } from './transaction-amount';
|
||||
|
||||
describe('signedTransactionAmount', () => {
|
||||
it('shows debit transaction types as negative amounts', () => {
|
||||
expect(signedTransactionAmount(12, 'fine')).toBe(-12);
|
||||
expect(signedTransactionAmount(12, { id: 14, name: 'expense' })).toBe(-12);
|
||||
});
|
||||
|
||||
it('keeps credits and negative reversal amounts unchanged', () => {
|
||||
expect(signedTransactionAmount(12, 'credit')).toBe(12);
|
||||
expect(signedTransactionAmount(-12, { id: 1, name: 'credit' })).toBe(-12);
|
||||
});
|
||||
describe('presentCashFlow', () => {
|
||||
it.each([
|
||||
{
|
||||
amount: 12,
|
||||
type: 0,
|
||||
context: 'player' as const,
|
||||
expected: { direction: 'inflow', amount: 12, sign: '+' },
|
||||
},
|
||||
{
|
||||
amount: -12,
|
||||
type: 'payment',
|
||||
context: 'player' as const,
|
||||
expected: { direction: 'outflow', amount: 12, sign: '−' },
|
||||
},
|
||||
{
|
||||
amount: 8.5,
|
||||
type: { id: 1, name: 'credit' },
|
||||
context: 'team' as const,
|
||||
expected: { direction: 'inflow', amount: 8.5, sign: '+' },
|
||||
},
|
||||
{
|
||||
amount: 8.5,
|
||||
type: { id: 14, name: 'expense' },
|
||||
context: 'team' as const,
|
||||
expected: { direction: 'outflow', amount: 8.5, sign: '−' },
|
||||
},
|
||||
{
|
||||
amount: 7,
|
||||
type: 'fine',
|
||||
context: 'player' as const,
|
||||
expected: { direction: 'neutral', amount: 7, sign: '' },
|
||||
},
|
||||
{
|
||||
amount: 6,
|
||||
type: 'credit',
|
||||
context: 'player' as const,
|
||||
expected: { direction: 'neutral', amount: 6, sign: '' },
|
||||
},
|
||||
{
|
||||
amount: 5,
|
||||
type: 'levy',
|
||||
context: 'player' as const,
|
||||
expected: { direction: 'neutral', amount: 5, sign: '' },
|
||||
},
|
||||
{
|
||||
amount: 4,
|
||||
type: 'fee',
|
||||
context: 'player' as const,
|
||||
expected: { direction: 'neutral', amount: 4, sign: '' },
|
||||
},
|
||||
{
|
||||
amount: 3,
|
||||
type: 'payment',
|
||||
context: 'team' as const,
|
||||
expected: { direction: 'neutral', amount: 3, sign: '' },
|
||||
},
|
||||
{
|
||||
amount: 2,
|
||||
type: 'expense',
|
||||
context: 'player' as const,
|
||||
expected: { direction: 'neutral', amount: 2, sign: '' },
|
||||
},
|
||||
{
|
||||
amount: -7,
|
||||
type: 'unknown',
|
||||
context: 'player' as const,
|
||||
expected: { direction: 'neutral', amount: 7, sign: '' },
|
||||
},
|
||||
{
|
||||
amount: -9,
|
||||
type: 'unknown',
|
||||
context: 'team' as const,
|
||||
expected: { direction: 'neutral', amount: 9, sign: '' },
|
||||
},
|
||||
])(
|
||||
'presents $context $type transactions with their cash-flow direction',
|
||||
({ amount, type, context, expected }) => {
|
||||
expect(presentCashFlow(amount, type, context)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
export type TransactionTypeLike =
|
||||
string | number | { id?: number; name?: string } | null | undefined;
|
||||
|
||||
const debitTypeNames = new Set(['fine', 'levy', 'fee', 'expense']);
|
||||
export type CashFlowDirection = 'inflow' | 'outflow' | 'neutral';
|
||||
export type CashFlowContext = 'player' | 'team';
|
||||
|
||||
export function signedTransactionAmount(amount: number, type: TransactionTypeLike): number {
|
||||
const typeId = typeof type === 'number' ? type : typeof type === 'object' ? type?.id : undefined;
|
||||
const typeName =
|
||||
typeof type === 'string' ? type : typeof type === 'object' ? type?.name : undefined;
|
||||
const isDebit = (typeId ?? 0) > 10 || debitTypeNames.has(typeName ?? '');
|
||||
return isDebit ? -Math.abs(amount) : amount;
|
||||
export interface CashFlowPresentation {
|
||||
direction: CashFlowDirection;
|
||||
amount: number;
|
||||
sign: '+' | '−' | '';
|
||||
}
|
||||
|
||||
export function presentCashFlow(
|
||||
amount: number,
|
||||
type: TransactionTypeLike,
|
||||
context: CashFlowContext,
|
||||
): CashFlowPresentation {
|
||||
const typeId = typeof type === 'number' ? type : typeof type === 'object' ? type?.id : undefined;
|
||||
const typeName = (
|
||||
typeof type === 'string' ? type : typeof type === 'object' ? type?.name : undefined
|
||||
)
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const direction =
|
||||
context === 'player' && (typeId === 0 || typeName === 'payment')
|
||||
? amount < 0
|
||||
? 'outflow'
|
||||
: 'inflow'
|
||||
: context === 'team' && (typeId === 1 || typeName === 'credit')
|
||||
? 'inflow'
|
||||
: context === 'team' && (typeId === 14 || typeName === 'expense')
|
||||
? 'outflow'
|
||||
: 'neutral';
|
||||
|
||||
return {
|
||||
direction,
|
||||
amount: Math.abs(amount),
|
||||
sign: direction === 'inflow' ? '+' : direction === 'outflow' ? '−' : '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface User {
|
||||
email: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
helpTextsEnabled?: boolean;
|
||||
role?: Role | null;
|
||||
status?: Status;
|
||||
photo?: UserPhoto | null;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
@if (visible()) {
|
||||
<aside class="context-help">
|
||||
<button
|
||||
type="button"
|
||||
class="context-help__toggle"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
(click)="toggle()"
|
||||
>
|
||||
<span aria-hidden="true">?</span>
|
||||
<strong>{{ title() }}</strong>
|
||||
<span aria-hidden="true">{{ expanded() ? '−' : '+' }}</span>
|
||||
</button>
|
||||
@if (expanded()) {
|
||||
<div class="context-help__content">
|
||||
<ul>
|
||||
@for (hint of hints(); track hint) {
|
||||
<li>{{ hint }}</li>
|
||||
}
|
||||
</ul>
|
||||
<a [routerLink]="guideCommands()" [fragment]="sectionId()">Mehr dazu</a>
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
.context-help {
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 1rem;
|
||||
background: var(--mat-sys-surface-container-low);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.context-help__toggle {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1.75rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-help__toggle > span:first-child {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
background: var(--mat-sys-primary-container);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.context-help__content {
|
||||
padding: 0 1rem 1rem 3.4rem;
|
||||
}
|
||||
|
||||
.context-help__content ul {
|
||||
margin: 0 0 0.65rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.context-help__content a {
|
||||
color: var(--mat-sys-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { HelpAccessService } from '../../core/help/help-access';
|
||||
import { TeamStore } from '../../core/team/team-store';
|
||||
import { ContextHelp } from './context-help';
|
||||
|
||||
@Component({
|
||||
imports: [ContextHelp],
|
||||
template: `
|
||||
<app-context-help
|
||||
title="Buchungen verstehen"
|
||||
[hints]="['Mitgliederbuchungen verändern den persönlichen Saldo.']"
|
||||
sectionId="bookings"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
class Host {}
|
||||
|
||||
describe('ContextHelp', () => {
|
||||
it('stays compact, expands on demand and links to the matching guide section', async () => {
|
||||
const fixture = await createFixture(true);
|
||||
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
|
||||
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('persönlichen Saldo');
|
||||
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(button.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(fixture.nativeElement.textContent).toContain('persönlichen Saldo');
|
||||
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a');
|
||||
expect(link.getAttribute('href')).toBe('/team/5/more/guide#bookings');
|
||||
});
|
||||
|
||||
it('renders nothing when contextual help is disabled', async () => {
|
||||
const fixture = await createFixture(false);
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-context-help')?.textContent.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
async function createFixture(show: boolean) {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Host],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: HelpAccessService,
|
||||
useValue: { showContextHelp: signal(show) },
|
||||
},
|
||||
{
|
||||
provide: TeamStore,
|
||||
useValue: { team: signal({ id: 5 }) },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Host);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { HelpAccessService } from '../../core/help/help-access';
|
||||
import { GuideSectionId } from '../../core/help/guide-content';
|
||||
import { TeamStore } from '../../core/team/team-store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-context-help',
|
||||
imports: [RouterLink],
|
||||
templateUrl: './context-help.html',
|
||||
styleUrl: './context-help.scss',
|
||||
})
|
||||
export class ContextHelp {
|
||||
private readonly helpAccess = inject(HelpAccessService);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
|
||||
readonly title = input.required<string>();
|
||||
readonly hints = input.required<readonly string[]>();
|
||||
readonly sectionId = input.required<GuideSectionId>();
|
||||
|
||||
protected readonly visible = this.helpAccess.showContextHelp;
|
||||
protected readonly expanded = signal(false);
|
||||
protected readonly guideCommands = computed(() => [
|
||||
'/team',
|
||||
this.teamStore.team()?.id,
|
||||
'more',
|
||||
'guide',
|
||||
]);
|
||||
|
||||
protected toggle(): void {
|
||||
this.expanded.update((value) => !value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<span
|
||||
data-testid="transaction-amount"
|
||||
class="transaction-amount"
|
||||
[class]="presentation().direction"
|
||||
>
|
||||
<span class="transaction-amount__direction">{{ directionLabel() }}</span>
|
||||
<span class="transaction-amount__sign" aria-hidden="true">{{ presentation().sign }}</span>
|
||||
{{ presentation().amount | currency: 'EUR' }}
|
||||
</span>
|
||||
@@ -0,0 +1,33 @@
|
||||
.transaction-amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
|
||||
&.inflow {
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
&.outflow {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.transaction-amount__direction {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.transaction-amount__sign {
|
||||
display: inline-block;
|
||||
min-width: 0.8ch;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TransactionAmount } from './transaction-amount';
|
||||
|
||||
describe('TransactionAmount', () => {
|
||||
it.each([
|
||||
{
|
||||
amount: 12.5,
|
||||
type: 'payment',
|
||||
context: 'player' as const,
|
||||
sign: '+',
|
||||
semanticClass: 'inflow',
|
||||
direction: 'Einzahlung',
|
||||
text: 'Einzahlung 12,50 \u20ac',
|
||||
},
|
||||
{
|
||||
amount: 12.5,
|
||||
type: 'expense',
|
||||
context: 'team' as const,
|
||||
sign: '−',
|
||||
semanticClass: 'outflow',
|
||||
direction: 'Auszahlung',
|
||||
text: 'Auszahlung 12,50 \u20ac',
|
||||
},
|
||||
{
|
||||
amount: 12.5,
|
||||
type: 'fine',
|
||||
context: 'player' as const,
|
||||
sign: '',
|
||||
semanticClass: 'neutral',
|
||||
direction: 'Keine Kassenbewegung',
|
||||
text: 'Keine Kassenbewegung 12,50 \u20ac',
|
||||
},
|
||||
])(
|
||||
'renders the $semanticClass cash-flow meaning accessibly',
|
||||
async ({ amount, type, context, sign, semanticClass, direction, text }) => {
|
||||
await TestBed.configureTestingModule({ imports: [TransactionAmount] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(TransactionAmount);
|
||||
fixture.componentRef.setInput('amount', amount);
|
||||
fixture.componentRef.setInput('type', type);
|
||||
fixture.componentRef.setInput('context', context);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement.querySelector('[data-testid="transaction-amount"]');
|
||||
const signElement = element.querySelector('.transaction-amount__sign');
|
||||
const directionElement = element.querySelector('.transaction-amount__direction');
|
||||
const accessibleElement = element.cloneNode(true);
|
||||
accessibleElement
|
||||
.querySelectorAll('[aria-hidden=true]')
|
||||
.forEach((node: Element) => node.remove());
|
||||
expect(element.classList).toContain(semanticClass);
|
||||
expect(signElement.textContent).toBe(sign);
|
||||
expect(signElement.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(directionElement.textContent).toBe(direction);
|
||||
expect(accessibleElement.textContent.replace(/\s+/g, ' ').trim()).toBe(text);
|
||||
expect(element.getAttribute('aria-label')).toBeNull();
|
||||
expect(getComputedStyle(element).whiteSpace).toBe('nowrap');
|
||||
expect(getComputedStyle(directionElement).position).toBe('absolute');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { CurrencyPipe, registerLocaleData } from '@angular/common';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import { Component, computed, input, LOCALE_ID } from '@angular/core';
|
||||
import {
|
||||
CashFlowContext,
|
||||
presentCashFlow,
|
||||
TransactionTypeLike,
|
||||
} from '../../models/transaction-amount';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@Component({
|
||||
selector: 'app-transaction-amount',
|
||||
imports: [CurrencyPipe],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './transaction-amount.html',
|
||||
styleUrl: './transaction-amount.scss',
|
||||
})
|
||||
export class TransactionAmount {
|
||||
readonly amount = input.required<number>();
|
||||
readonly type = input.required<TransactionTypeLike>();
|
||||
readonly context = input.required<CashFlowContext>();
|
||||
|
||||
protected readonly presentation = computed(() =>
|
||||
presentCashFlow(this.amount(), this.type(), this.context()),
|
||||
);
|
||||
|
||||
protected readonly directionLabel = computed(
|
||||
() =>
|
||||
({
|
||||
inflow: 'Einzahlung',
|
||||
outflow: 'Auszahlung',
|
||||
neutral: 'Keine Kassenbewegung',
|
||||
})[this.presentation().direction],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user