feat: add team cash flow amount presentation

This commit is contained in:
Bastian Wagner
2026-08-01 20:16:09 +02:00
parent 4e6b945eec
commit 239b157015
6 changed files with 200 additions and 1 deletions

View File

@@ -1,4 +1,50 @@
import { signedTransactionAmount } from './transaction-amount';
import { presentCashFlow, signedTransactionAmount } from './transaction-amount';
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: -7,
type: 'unknown',
context: 'player' as const,
expected: { direction: 'neutral', amount: 7, sign: '' },
},
])(
'presents $context $type transactions with their cash-flow direction',
({ amount, type, context, expected }) => {
expect(presentCashFlow(amount, type, context)).toEqual(expected);
},
);
});
describe('signedTransactionAmount', () => {
it('shows debit transaction types as negative amounts', () => {

View File

@@ -1,6 +1,15 @@
export type TransactionTypeLike =
string | number | { id?: number; name?: string } | null | undefined;
export type CashFlowDirection = 'inflow' | 'outflow' | 'neutral';
export type CashFlowContext = 'player' | 'team';
export interface CashFlowPresentation {
direction: CashFlowDirection;
amount: number;
sign: '+' | '' | '';
}
const debitTypeNames = new Set(['fine', 'levy', 'fee', 'expense']);
export function signedTransactionAmount(amount: number, type: TransactionTypeLike): number {
@@ -10,3 +19,33 @@ export function signedTransactionAmount(amount: number, type: TransactionTypeLik
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' ? '' : '',
};
}

View File

@@ -0,0 +1,9 @@
<span
data-testid="transaction-amount"
class="transaction-amount"
[class]="presentation().direction"
[attr.aria-label]="directionLabel()"
>
<span class="transaction-amount__sign">{{ presentation().sign }}</span>
{{ presentation().amount | currency: 'EUR' }}
</span>

View File

@@ -0,0 +1,20 @@
.transaction-amount {
font-variant-numeric: tabular-nums;
&.inflow {
color: var(--mat-sys-primary);
}
&.outflow {
color: var(--mat-sys-error);
}
&.neutral {
color: var(--mat-sys-on-surface-variant);
}
}
.transaction-amount__sign {
display: inline-block;
min-width: 0.8ch;
}

View File

@@ -0,0 +1,49 @@
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',
label: 'Einnahme',
},
{
amount: 12.5,
type: 'expense',
context: 'team' as const,
sign: '',
semanticClass: 'outflow',
label: 'Ausgabe',
},
{
amount: 12.5,
type: 'fine',
context: 'player' as const,
sign: '',
semanticClass: 'neutral',
label: 'Neutraler Betrag',
},
])(
'renders the $semanticClass cash-flow meaning accessibly',
async ({ amount, type, context, sign, semanticClass, label }) => {
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');
expect(element.classList).toContain(semanticClass);
expect(signElement.textContent).toBe(sign);
expect(element.textContent).toContain('12,50');
expect(element.textContent).toContain('€');
expect(element.getAttribute('aria-label')).toBe(label);
},
);
});

View File

@@ -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: 'Einnahme',
outflow: 'Ausgabe',
neutral: 'Neutraler Betrag',
})[this.presentation().direction],
);
}