From 239b15701557e1beb0454154d4908f91ba097189 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 20:16:09 +0200 Subject: [PATCH] feat: add team cash flow amount presentation --- .../src/app/models/transaction-amount.spec.ts | 48 +++++++++++++++++- .../src/app/models/transaction-amount.ts | 39 +++++++++++++++ .../transaction-amount.html | 9 ++++ .../transaction-amount.scss | 20 ++++++++ .../transaction-amount.spec.ts | 49 +++++++++++++++++++ .../transaction-amount/transaction-amount.ts | 36 ++++++++++++++ 6 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.html create mode 100644 myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.scss create mode 100644 myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.ts diff --git a/myteamwallet_frontend_modern/src/app/models/transaction-amount.spec.ts b/myteamwallet_frontend_modern/src/app/models/transaction-amount.spec.ts index 0496c9b..737ee76 100644 --- a/myteamwallet_frontend_modern/src/app/models/transaction-amount.spec.ts +++ b/myteamwallet_frontend_modern/src/app/models/transaction-amount.spec.ts @@ -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', () => { diff --git a/myteamwallet_frontend_modern/src/app/models/transaction-amount.ts b/myteamwallet_frontend_modern/src/app/models/transaction-amount.ts index 3d1ec30..4ce424d 100644 --- a/myteamwallet_frontend_modern/src/app/models/transaction-amount.ts +++ b/myteamwallet_frontend_modern/src/app/models/transaction-amount.ts @@ -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' ? '−' : '', + }; +} diff --git a/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.html b/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.html new file mode 100644 index 0000000..b917de6 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.html @@ -0,0 +1,9 @@ + + {{ presentation().sign }} + {{ presentation().amount | currency: 'EUR' }} + diff --git a/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.scss b/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.scss new file mode 100644 index 0000000..0d0774c --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.scss @@ -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; +} diff --git a/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.spec.ts b/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.spec.ts new file mode 100644 index 0000000..e6cfc3a --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.spec.ts @@ -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); + }, + ); +}); diff --git a/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.ts b/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.ts new file mode 100644 index 0000000..7ee87be --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/shared/transaction-amount/transaction-amount.ts @@ -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(); + readonly type = input.required(); + readonly context = input.required(); + + 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], + ); +}