first commit
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
<section class="overview-page">
|
||||
<header>
|
||||
<p class="eyebrow">Teamübersicht</p>
|
||||
<h1>{{ team()?.name ?? 'TeamWallet' }}</h1>
|
||||
</header>
|
||||
<div class="balance-grid">
|
||||
<mat-card class="balance-card balance-card--primary"
|
||||
><mat-card-content
|
||||
><span>Teamkasse</span><strong>{{ team()?.balance ?? 0 | currency: 'EUR' }}</strong
|
||||
><small>Verfügbarer Kassenstand</small></mat-card-content
|
||||
></mat-card
|
||||
>
|
||||
<mat-card class="balance-card"
|
||||
><mat-card-content
|
||||
><span>Offene Beiträge</span
|
||||
><strong>{{ team()?.outstanding ?? 0 | currency: 'EUR' }}</strong
|
||||
><small>Summe aktiver Mitglieder</small></mat-card-content
|
||||
></mat-card
|
||||
>
|
||||
</div>
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Zuletzt passiert</p>
|
||||
<h2>Aktivitäten</h2>
|
||||
</div>
|
||||
</div>
|
||||
@if (loadingActivities()) {
|
||||
<div class="state"><mat-spinner diameter="32" /></div>
|
||||
} @else if (activities().length === 0) {
|
||||
<div class="state">
|
||||
<mat-icon>receipt_long</mat-icon><strong>Noch keine Buchungen</strong
|
||||
><span>Neue Aktivitäten erscheinen hier.</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="activity-list">
|
||||
@for (activity of activities(); track activity.id) {
|
||||
<article class="activity">
|
||||
<div class="activity__icon">
|
||||
<mat-icon>{{ activityIcon(activity) }}</mat-icon>
|
||||
</div>
|
||||
<div class="activity__copy">
|
||||
<strong>{{ activity.playerName ?? 'Teamkasse' }}</strong
|
||||
><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>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,104 @@
|
||||
.overview-page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 2rem;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
font: var(--mat-sys-headline-medium);
|
||||
font-weight: 700;
|
||||
}
|
||||
h2 {
|
||||
font: var(--mat-sys-title-large);
|
||||
font-weight: 700;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--mat-sys-primary);
|
||||
font: var(--mat-sys-label-large);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.balance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0 2rem;
|
||||
}
|
||||
.balance-card mat-card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.balance-card strong {
|
||||
font: var(--mat-sys-headline-small);
|
||||
font-weight: 700;
|
||||
}
|
||||
.balance-card small {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
.balance-card--primary {
|
||||
background: var(--mat-sys-primary-container);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
}
|
||||
.section-heading {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.activity {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 0;
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
.activity__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
background: var(--mat-sys-secondary-container);
|
||||
color: var(--mat-sys-on-secondary-container);
|
||||
}
|
||||
.activity__copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.activity__copy span,
|
||||
.activity__copy small {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
overflow: hidden;
|
||||
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;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
text-align: center;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.balance-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { Overview } from './overview';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
describe('Overview', () => {
|
||||
it('renders balances and the recent team activity', async () => {
|
||||
const routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Overview],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: TeamStore,
|
||||
useValue: {
|
||||
team: signal({ id: 5, name: 'Team A', alias: 'a', balance: 125, outstanding: 40 }),
|
||||
loading: signal(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
parent: {
|
||||
snapshot: { paramMap: convertToParamMap({ id: '5' }) },
|
||||
paramMap: routeParams,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Overview);
|
||||
fixture.detectChanges();
|
||||
TestBed.inject(HttpTestingController)
|
||||
.expectOne(`${environment.apiUrl}teams/5/transactions`)
|
||||
.flush([
|
||||
{
|
||||
id: 1,
|
||||
date: '2026-07-31',
|
||||
amount: 12,
|
||||
type: 'fine',
|
||||
note: 'Beitrag',
|
||||
playerName: 'Alex',
|
||||
isTeamWalletTransaction: false,
|
||||
},
|
||||
]);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
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');
|
||||
|
||||
routeParams.next(convertToParamMap({ id: '6' }));
|
||||
TestBed.inject(HttpTestingController)
|
||||
.expectOne(`${environment.apiUrl}teams/6/transactions`)
|
||||
.flush([
|
||||
{
|
||||
id: 2,
|
||||
date: '2026-08-01',
|
||||
amount: 7,
|
||||
type: 'credit',
|
||||
note: 'Neues Team',
|
||||
playerName: 'Bea',
|
||||
isTeamWalletTransaction: false,
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Neues Team');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Beitrag');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import { Component, LOCALE_ID, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { TransactionsApi } from '../../../core/team/transactions-api';
|
||||
import { TeamActivity } from '../../../models/transaction.model';
|
||||
import { signedTransactionAmount } from '../../../models/transaction-amount';
|
||||
import { of } from 'rxjs';
|
||||
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@Component({
|
||||
selector: 'app-overview',
|
||||
imports: [CurrencyPipe, DatePipe, MatCardModule, MatIconModule, MatProgressSpinnerModule],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './overview.html',
|
||||
styleUrl: './overview.scss',
|
||||
})
|
||||
export class Overview {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly transactionsApi = inject(TransactionsApi);
|
||||
protected readonly team = inject(TeamStore).team;
|
||||
protected readonly activities = signal<TeamActivity[]>([]);
|
||||
protected readonly loadingActivities = signal(true);
|
||||
|
||||
constructor() {
|
||||
const parentRoute = this.route.parent;
|
||||
if (!parentRoute) {
|
||||
this.loadingActivities.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
parentRoute.paramMap
|
||||
.pipe(
|
||||
map((params) => Number(params.get('id'))),
|
||||
distinctUntilChanged(),
|
||||
tap((id) => {
|
||||
this.activities.set([]);
|
||||
this.loadingActivities.set(Number.isInteger(id) && id > 0);
|
||||
}),
|
||||
switchMap((id) =>
|
||||
Number.isInteger(id) && id > 0
|
||||
? this.transactionsApi.loadTeamTransactions(id).pipe(catchError(() => of([])))
|
||||
: of([]),
|
||||
),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe((activities) => {
|
||||
this.activities.set(activities.slice(0, 10));
|
||||
this.loadingActivities.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
protected activityIcon(activity: TeamActivity): string {
|
||||
return activity.isTeamWalletTransaction ? 'account_balance' : 'person';
|
||||
}
|
||||
|
||||
protected displayAmount(activity: TeamActivity): number {
|
||||
return signedTransactionAmount(activity.amount, activity.type);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user