Merge branch 'feature/cash-flow-presentation'
# Conflicts: # myteamwallet_frontend_modern/src/app/features/team/cashbox/cashbox.ts # myteamwallet_frontend_modern/src/app/features/team/overview/overview.spec.ts # myteamwallet_frontend_modern/src/app/features/team/overview/overview.ts
This commit is contained in:
@@ -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.
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="transactions">
|
<div class="transactions">
|
||||||
@for (transaction of transactions(); track transaction.id) {
|
@for (transaction of transactions(); track transaction.id) {
|
||||||
<mat-card
|
<mat-card>
|
||||||
><div class="icon"><mat-icon>receipt_long</mat-icon></div>
|
<div class="icon"><mat-icon>receipt_long</mat-icon></div>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ typeLabel(transaction) }}</strong
|
<strong>{{ typeLabel(transaction) }}</strong
|
||||||
><span>{{ transaction.date | date: 'dd.MM.yyyy' }}</span>
|
><span>{{ transaction.date | date: 'dd.MM.yyyy' }}</span>
|
||||||
@@ -30,10 +30,14 @@
|
|||||||
<small>{{ transaction.note }}</small>
|
<small>{{ transaction.note }}</small>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<strong class="amount">{{
|
<strong>
|
||||||
displayAmount(transaction) | currency: 'EUR'
|
<app-transaction-amount
|
||||||
}}</strong></mat-card
|
[amount]="transaction.amount"
|
||||||
>
|
[type]="transaction.type"
|
||||||
|
context="player"
|
||||||
|
/>
|
||||||
|
</strong>
|
||||||
|
</mat-card>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,9 +74,6 @@ main {
|
|||||||
.transactions small {
|
.transactions small {
|
||||||
color: var(--mat-sys-on-surface-variant);
|
color: var(--mat-sys-on-surface-variant);
|
||||||
}
|
}
|
||||||
.amount {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.state {
|
.state {
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ describe('PublicPlayer', () => {
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
date: '2026-07-31',
|
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,
|
amount: 5,
|
||||||
note: 'Training',
|
note: 'Training',
|
||||||
type: { id: 11, name: 'fine' },
|
type: { id: 11, name: 'fine' },
|
||||||
@@ -48,6 +62,17 @@ describe('PublicPlayer', () => {
|
|||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Ada Lovelace');
|
expect(fixture.nativeElement.textContent).toContain('Ada Lovelace');
|
||||||
expect(fixture.nativeElement.textContent).toContain('Training');
|
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 localeDe from '@angular/common/locales/de';
|
||||||
import { Component, LOCALE_ID, inject, signal } from '@angular/core';
|
import { Component, LOCALE_ID, inject, signal } from '@angular/core';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
@@ -8,21 +8,21 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
import { PublicTeamApi } from '../../core/team/public-team-api';
|
import { PublicTeamApi } from '../../core/team/public-team-api';
|
||||||
import { PlayerTransaction } from '../../models/transaction.model';
|
import { PlayerTransaction } from '../../models/transaction.model';
|
||||||
import { signedTransactionAmount } from '../../models/transaction-amount';
|
|
||||||
import { PublicPlayer as PublicPlayerModel } from '../../models/public-access.model';
|
import { PublicPlayer as PublicPlayerModel } from '../../models/public-access.model';
|
||||||
|
import { TransactionAmount } from '../../shared/transaction-amount/transaction-amount';
|
||||||
|
|
||||||
registerLocaleData(localeDe);
|
registerLocaleData(localeDe);
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-public-player',
|
selector: 'app-public-player',
|
||||||
imports: [
|
imports: [
|
||||||
CurrencyPipe,
|
|
||||||
DatePipe,
|
DatePipe,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
|
TransactionAmount,
|
||||||
],
|
],
|
||||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||||
templateUrl: './public-player.html',
|
templateUrl: './public-player.html',
|
||||||
@@ -72,8 +72,4 @@ export class PublicPlayer {
|
|||||||
)[type] ?? type
|
)[type] ?? type
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected displayAmount(transaction: PlayerTransaction): number {
|
|
||||||
return signedTransactionAmount(transaction.amount, transaction.type);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,13 @@
|
|||||||
<small>{{ activity.note }}</small>
|
<small>{{ activity.note }}</small>
|
||||||
}
|
}
|
||||||
</div>
|
</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)) {
|
@if (canReverse(activity)) {
|
||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
|
|||||||
@@ -37,12 +37,7 @@ h1 {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
|
|
||||||
background: linear-gradient(
|
background: linear-gradient(135deg, #5ca34c 0%, #4f8f46 55%, #3f7f3c 100%);
|
||||||
135deg,
|
|
||||||
#5ca34c 0%,
|
|
||||||
#4f8f46 55%,
|
|
||||||
#3f7f3c 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
@@ -135,9 +130,6 @@ form button {
|
|||||||
.activity-copy small {
|
.activity-copy small {
|
||||||
color: var(--mat-sys-on-surface-variant);
|
color: var(--mat-sys-on-surface-variant);
|
||||||
}
|
}
|
||||||
.amount {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.state {
|
.state {
|
||||||
min-height: 160px;
|
min-height: 160px;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -49,9 +49,26 @@ describe('Cashbox', () => {
|
|||||||
id: 9,
|
id: 9,
|
||||||
date: '2026-07-31T10:00:00.000Z',
|
date: '2026-07-31T10:00:00.000Z',
|
||||||
amount: 12,
|
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',
|
type: 'fine',
|
||||||
note: 'Training',
|
note: 'Training',
|
||||||
playerName: 'Bea Test',
|
playerName: 'Alex Muster',
|
||||||
isTeamWalletTransaction: false,
|
isTeamWalletTransaction: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -105,9 +122,7 @@ describe('Cashbox', () => {
|
|||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: {
|
useValue: {
|
||||||
snapshot: {
|
snapshot: {
|
||||||
queryParamMap: convertToParamMap(
|
queryParamMap: convertToParamMap(penaltyIdParam ? { penaltyId: penaltyIdParam } : {}),
|
||||||
penaltyIdParam ? { penaltyId: penaltyIdParam } : {},
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -137,11 +152,27 @@ describe('Cashbox', () => {
|
|||||||
const { fixture } = await setup();
|
const { fixture } = await setup();
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Bea Test');
|
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.querySelector('[data-testid="player-booking"]')).not.toBeNull();
|
||||||
expect(fixture.nativeElement.textContent).toContain('Buchungen verstehen');
|
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 () => {
|
it('submits cent-preserving split transactions for selected players', async () => {
|
||||||
const { component, createPlayerTransactions, refreshTeam } = await setup();
|
const { component, createPlayerTransactions, refreshTeam } = await setup();
|
||||||
component['playerForm'].setValue({
|
component['playerForm'].setValue({
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import {
|
|||||||
TeamActivity,
|
TeamActivity,
|
||||||
} from '../../../models/transaction.model';
|
} from '../../../models/transaction.model';
|
||||||
import { ConfirmDialog, ConfirmDialogData } from '../../../shared/confirm-dialog/confirm-dialog';
|
import { ConfirmDialog, ConfirmDialogData } from '../../../shared/confirm-dialog/confirm-dialog';
|
||||||
import { splitAmounts } from './transaction-calculation';
|
|
||||||
import { signedTransactionAmount } from '../../../models/transaction-amount';
|
|
||||||
import { ContextHelp } from '../../../shared/context-help/context-help';
|
import { ContextHelp } from '../../../shared/context-help/context-help';
|
||||||
|
import { TransactionAmount } from '../../../shared/transaction-amount/transaction-amount';
|
||||||
|
import { splitAmounts } from './transaction-calculation';
|
||||||
|
|
||||||
registerLocaleData(localeDe);
|
registerLocaleData(localeDe);
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300;
|
|||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
ContextHelp,
|
ContextHelp,
|
||||||
|
TransactionAmount,
|
||||||
],
|
],
|
||||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||||
templateUrl: './cashbox.html',
|
templateUrl: './cashbox.html',
|
||||||
@@ -64,9 +65,8 @@ export class Cashbox {
|
|||||||
private readonly teamStore = inject(TeamStore);
|
private readonly teamStore = inject(TeamStore);
|
||||||
private readonly transactionsApi = inject(TransactionsApi);
|
private readonly transactionsApi = inject(TransactionsApi);
|
||||||
private loadedTeamId: number | null = null;
|
private loadedTeamId: number | null = null;
|
||||||
private pendingPenaltyId: number | null = Number(
|
private pendingPenaltyId: number | null =
|
||||||
this.route.snapshot.queryParamMap.get('penaltyId'),
|
Number(this.route.snapshot.queryParamMap.get('penaltyId')) || null;
|
||||||
) || null;
|
|
||||||
|
|
||||||
protected readonly team = this.teamStore.team;
|
protected readonly team = this.teamStore.team;
|
||||||
protected readonly activities = signal<TeamActivity[]>([]);
|
protected readonly activities = signal<TeamActivity[]>([]);
|
||||||
@@ -241,10 +241,6 @@ export class Cashbox {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected displayAmount(activity: TeamActivity): number {
|
|
||||||
return signedTransactionAmount(activity.amount, activity.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
private createPlayerTransactions(transactions: CreatePlayerTransaction[]): void {
|
private createPlayerTransactions(transactions: CreatePlayerTransaction[]): void {
|
||||||
this.saving.set(true);
|
this.saving.set(true);
|
||||||
this.transactionsApi.createPlayerTransactions(transactions).subscribe({
|
this.transactionsApi.createPlayerTransactions(transactions).subscribe({
|
||||||
|
|||||||
@@ -49,9 +49,13 @@
|
|||||||
>{{ transaction.date | date: 'dd.MM.yyyy' }} · {{ typeName(transaction) }}</span
|
>{{ transaction.date | date: 'dd.MM.yyyy' }} · {{ typeName(transaction) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<strong [class.negative]="displayAmount(transaction) < 0">{{
|
<strong>
|
||||||
displayAmount(transaction) | currency: 'EUR'
|
<app-transaction-amount
|
||||||
}}</strong>
|
[amount]="transaction.amount"
|
||||||
|
[type]="transaction.type"
|
||||||
|
context="player"
|
||||||
|
/>
|
||||||
|
</strong>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
</div>
|
</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();
|
refreshTeam = vi.fn();
|
||||||
currentUser = signal(user);
|
currentUser = signal(user);
|
||||||
closeDialog = new Subject<boolean>();
|
closeDialog = new Subject<boolean>();
|
||||||
@@ -50,7 +54,10 @@ describe('PlayerDetail', () => {
|
|||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideRouter([]),
|
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: AuthStore, useValue: { currentUser } },
|
||||||
{ provide: MatDialog, useValue: dialog },
|
{ provide: MatDialog, useValue: dialog },
|
||||||
{
|
{
|
||||||
@@ -62,7 +69,7 @@ describe('PlayerDetail', () => {
|
|||||||
const fixture = TestBed.createComponent(PlayerDetail);
|
const fixture = TestBed.createComponent(PlayerDetail);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
httpMock = TestBed.inject(HttpTestingController);
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
httpMock.expectOne(`${teamsApiUrl}/players/7/transactions`).flush([]);
|
httpMock.expectOne(`${teamsApiUrl}/players/7/transactions`).flush(transactions);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
return fixture;
|
return fixture;
|
||||||
@@ -71,26 +78,59 @@ describe('PlayerDetail', () => {
|
|||||||
afterEach(() => httpMock.verify());
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
it('renders the selected player and transaction history', async () => {
|
it('renders the selected player and transaction history', async () => {
|
||||||
const fixture = await create(
|
const fixture = await create(makeTeam({ balance: -12 }), { id: 99, role: { id: 2 } });
|
||||||
makeTeam({ balance: -12 }),
|
|
||||||
{ id: 99, role: { id: 2 } },
|
|
||||||
);
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Alex Muster');
|
expect(fixture.nativeElement.textContent).toContain('Alex Muster');
|
||||||
expect(fixture.nativeElement.textContent).toContain('-12,00');
|
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 () => {
|
it('hides the manage controls for a user without team-manager rights', async () => {
|
||||||
const fixture = await create(makeTeam(), { id: 99, role: { id: 2 } });
|
const fixture = await create(makeTeam(), { id: 99, role: { id: 2 } });
|
||||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
|
const button = [...fixture.nativeElement.querySelectorAll('button')].find(
|
||||||
b.textContent?.includes('Deaktivieren'),
|
(b: HTMLButtonElement) => b.textContent?.includes('Deaktivieren'),
|
||||||
);
|
);
|
||||||
expect(button).toBeUndefined();
|
expect(button).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the manage controls for a global admin', async () => {
|
it('shows the manage controls for a global admin', async () => {
|
||||||
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
|
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
|
||||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
|
const button = [...fixture.nativeElement.querySelectorAll('button')].find(
|
||||||
b.textContent?.includes('Deaktivieren'),
|
(b: HTMLButtonElement) => b.textContent?.includes('Deaktivieren'),
|
||||||
);
|
);
|
||||||
expect(button).toBeDefined();
|
expect(button).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -100,16 +140,16 @@ describe('PlayerDetail', () => {
|
|||||||
makeTeam({ user: { id: 42 }, teamRole: { id: 3, name: 'captain' } }),
|
makeTeam({ user: { id: 42 }, teamRole: { id: 3, name: 'captain' } }),
|
||||||
{ id: 42, role: { id: 2 } },
|
{ id: 42, role: { id: 2 } },
|
||||||
);
|
);
|
||||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
|
const button = [...fixture.nativeElement.querySelectorAll('button')].find(
|
||||||
b.textContent?.includes('Deaktivieren'),
|
(b: HTMLButtonElement) => b.textContent?.includes('Deaktivieren'),
|
||||||
);
|
);
|
||||||
expect(button).toBeDefined();
|
expect(button).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deactivates the player on confirm and refreshes the team', async () => {
|
it('deactivates the player on confirm and refreshes the team', async () => {
|
||||||
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
|
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
|
||||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
|
const button = [...fixture.nativeElement.querySelectorAll('button')].find(
|
||||||
b.textContent?.includes('Deaktivieren'),
|
(b: HTMLButtonElement) => b.textContent?.includes('Deaktivieren'),
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
button.click();
|
button.click();
|
||||||
|
|
||||||
@@ -128,8 +168,8 @@ describe('PlayerDetail', () => {
|
|||||||
|
|
||||||
it('does not call the API when the confirmation dialog is dismissed', async () => {
|
it('does not call the API when the confirmation dialog is dismissed', async () => {
|
||||||
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
|
const fixture = await create(makeTeam(), { id: 1, role: { id: 1 } });
|
||||||
const button = [...fixture.nativeElement.querySelectorAll('button')].find((b: HTMLButtonElement) =>
|
const button = [...fixture.nativeElement.querySelectorAll('button')].find(
|
||||||
b.textContent?.includes('Deaktivieren'),
|
(b: HTMLButtonElement) => b.textContent?.includes('Deaktivieren'),
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
button.click();
|
button.click();
|
||||||
closeDialog.next(false);
|
closeDialog.next(false);
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import { AuthStore } from '../../../core/auth/auth-store';
|
|||||||
import { TeamStore } from '../../../core/team/team-store';
|
import { TeamStore } from '../../../core/team/team-store';
|
||||||
import { TeamsApi } from '../../../core/team/teams-api';
|
import { TeamsApi } from '../../../core/team/teams-api';
|
||||||
import { PlayerTransaction } from '../../../models/transaction.model';
|
import { PlayerTransaction } from '../../../models/transaction.model';
|
||||||
import { signedTransactionAmount } from '../../../models/transaction-amount';
|
|
||||||
import { ConfirmDialog } from '../../../shared/confirm-dialog/confirm-dialog';
|
import { ConfirmDialog } from '../../../shared/confirm-dialog/confirm-dialog';
|
||||||
|
import { TransactionAmount } from '../../../shared/transaction-amount/transaction-amount';
|
||||||
|
|
||||||
registerLocaleData(localeDe);
|
registerLocaleData(localeDe);
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ registerLocaleData(localeDe);
|
|||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
|
TransactionAmount,
|
||||||
],
|
],
|
||||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||||
templateUrl: './player-detail.html',
|
templateUrl: './player-detail.html',
|
||||||
@@ -88,10 +89,6 @@ export class PlayerDetail {
|
|||||||
: (transaction.type?.name ?? 'Buchung');
|
: (transaction.type?.name ?? 'Buchung');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected displayAmount(transaction: PlayerTransaction): number {
|
|
||||||
return signedTransactionAmount(transaction.amount, transaction.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected changeActive(): void {
|
protected changeActive(): void {
|
||||||
const team = this.team();
|
const team = this.team();
|
||||||
const player = this.player();
|
const player = this.player();
|
||||||
@@ -117,7 +114,9 @@ export class PlayerDetail {
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => this.teamStore.refreshTeam(),
|
next: () => this.teamStore.refreshTeam(),
|
||||||
error: (error: HttpErrorResponse) =>
|
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.'),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,9 +126,13 @@
|
|||||||
><span>{{ activity.note || activity.type }}</span
|
><span>{{ activity.note || activity.type }}</span
|
||||||
><small>{{ activity.date | date: 'dd.MM.yyyy' }}</small>
|
><small>{{ activity.date | date: 'dd.MM.yyyy' }}</small>
|
||||||
</div>
|
</div>
|
||||||
<strong class="activity__amount" [class.negative]="displayAmount(activity) < 0">{{
|
<strong>
|
||||||
displayAmount(activity) | currency: 'EUR'
|
<app-transaction-amount
|
||||||
}}</strong>
|
[amount]="activity.amount"
|
||||||
|
[type]="activity.type"
|
||||||
|
[context]="activity.isTeamWalletTransaction ? 'team' : 'player'"
|
||||||
|
/>
|
||||||
|
</strong>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,12 +44,7 @@ h2 {
|
|||||||
.balance-card--primary {
|
.balance-card--primary {
|
||||||
// background: var(--mat-sys-primary-container);
|
// background: var(--mat-sys-primary-container);
|
||||||
// color: var(--mat-sys-on-primary-container);
|
// color: var(--mat-sys-on-primary-container);
|
||||||
background: linear-gradient(
|
background: linear-gradient(135deg, #5ca34c 0%, #4f8f46 55%, #3f7f3c 100%);
|
||||||
135deg,
|
|
||||||
#5ca34c 0%,
|
|
||||||
#4f8f46 55%,
|
|
||||||
#3f7f3c 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
@@ -76,7 +71,6 @@ h2 {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: var(--mat-sys-secondary-container);
|
background: var(--mat-sys-secondary-container);
|
||||||
color: var(--mat-sys-on-secondary-container);
|
color: var(--mat-sys-on-secondary-container);
|
||||||
|
|
||||||
}
|
}
|
||||||
.activity__copy {
|
.activity__copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -90,12 +84,6 @@ h2 {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.activity__amount {
|
|
||||||
color: var(--mat-sys-primary);
|
|
||||||
}
|
|
||||||
.activity__amount.negative {
|
|
||||||
color: var(--mat-sys-error);
|
|
||||||
}
|
|
||||||
.state {
|
.state {
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -90,11 +90,28 @@ describe('Overview', () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
date: '2026-07-31',
|
date: '2026-07-31',
|
||||||
amount: 12,
|
amount: 12,
|
||||||
type: 'fine',
|
type: 'payment',
|
||||||
note: 'Beitrag',
|
note: 'Beitrag',
|
||||||
playerName: 'Alex',
|
playerName: 'Alex',
|
||||||
isTeamWalletTransaction: false,
|
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);
|
flushStats(sampleStats);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
@@ -103,7 +120,18 @@ describe('Overview', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('125,00');
|
expect(fixture.nativeElement.textContent).toContain('125,00');
|
||||||
expect(fixture.nativeElement.textContent).toContain('Alex');
|
expect(fixture.nativeElement.textContent).toContain('Alex');
|
||||||
expect(fixture.nativeElement.textContent).toContain('Beitrag');
|
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' }));
|
routeParams.next(convertToParamMap({ id: '6' }));
|
||||||
flushTransactions(
|
flushTransactions(
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { ChartCanvas } from '../../../shared/chart-canvas/chart-canvas';
|
|||||||
import { TeamStore } from '../../../core/team/team-store';
|
import { TeamStore } from '../../../core/team/team-store';
|
||||||
import { TransactionsApi } from '../../../core/team/transactions-api';
|
import { TransactionsApi } from '../../../core/team/transactions-api';
|
||||||
import { TeamStatsApi } from '../../../core/team/team-stats-api';
|
import { TeamStatsApi } from '../../../core/team/team-stats-api';
|
||||||
import { TeamActivity } from '../../../models/transaction.model';
|
|
||||||
import { TeamOverviewStats } from '../../../models/team-stats.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 { of } from 'rxjs';
|
||||||
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
|
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ function formatMonthLabel(month: string): string {
|
|||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
ChartCanvas,
|
ChartCanvas,
|
||||||
|
TransactionAmount,
|
||||||
],
|
],
|
||||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||||
templateUrl: './overview.html',
|
templateUrl: './overview.html',
|
||||||
@@ -183,8 +184,4 @@ export class Overview {
|
|||||||
protected activityIcon(activity: TeamActivity): string {
|
protected activityIcon(activity: TeamActivity): string {
|
||||||
return activity.isTeamWalletTransaction ? 'account_balance' : 'person';
|
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', () => {
|
describe('presentCashFlow', () => {
|
||||||
it('shows debit transaction types as negative amounts', () => {
|
it.each([
|
||||||
expect(signedTransactionAmount(12, 'fine')).toBe(-12);
|
{
|
||||||
expect(signedTransactionAmount(12, { id: 14, name: 'expense' })).toBe(-12);
|
amount: 12,
|
||||||
});
|
type: 0,
|
||||||
|
context: 'player' as const,
|
||||||
it('keeps credits and negative reversal amounts unchanged', () => {
|
expected: { direction: 'inflow', amount: 12, sign: '+' },
|
||||||
expect(signedTransactionAmount(12, 'credit')).toBe(12);
|
},
|
||||||
expect(signedTransactionAmount(-12, { id: 1, name: 'credit' })).toBe(-12);
|
{
|
||||||
});
|
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 =
|
export type TransactionTypeLike =
|
||||||
string | number | { id?: number; name?: string } | null | undefined;
|
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 {
|
export interface CashFlowPresentation {
|
||||||
const typeId = typeof type === 'number' ? type : typeof type === 'object' ? type?.id : undefined;
|
direction: CashFlowDirection;
|
||||||
const typeName =
|
amount: number;
|
||||||
typeof type === 'string' ? type : typeof type === 'object' ? type?.name : undefined;
|
sign: '+' | '−' | '';
|
||||||
const isDebit = (typeId ?? 0) > 10 || debitTypeNames.has(typeName ?? '');
|
}
|
||||||
return isDebit ? -Math.abs(amount) : amount;
|
|
||||||
|
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' ? '−' : '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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