feat: manage penalty catalog in modern frontend
This commit is contained in:
@@ -33,4 +33,20 @@ describe('PenaltyApi', () => {
|
|||||||
expect(request.request.body).toEqual(penalty);
|
expect(request.request.body).toEqual(penalty);
|
||||||
request.flush({ id: 1, ...penalty });
|
request.flush({ id: 1, ...penalty });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates a penalty catalog entry', () => {
|
||||||
|
const update = { description: 'Zu spät', amount: 7.5 };
|
||||||
|
api.updatePenalty(8, update).subscribe();
|
||||||
|
const request = httpMock.expectOne(`${environment.apiUrl}penalty/8`);
|
||||||
|
expect(request.request.method).toBe('PATCH');
|
||||||
|
expect(request.request.body).toEqual(update);
|
||||||
|
request.flush({ id: 8, ...update });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a penalty catalog entry', () => {
|
||||||
|
api.deletePenalty(8).subscribe();
|
||||||
|
const request = httpMock.expectOne(`${environment.apiUrl}penalty/8`);
|
||||||
|
expect(request.request.method).toBe('DELETE');
|
||||||
|
request.flush(null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { CreatePenalty, Penalty } from '../../models/penalty.model';
|
import { CreatePenalty, Penalty, UpdatePenalty } from '../../models/penalty.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PenaltyApi {
|
export class PenaltyApi {
|
||||||
@@ -16,4 +16,12 @@ export class PenaltyApi {
|
|||||||
createPenalty(penalty: CreatePenalty): Observable<Penalty> {
|
createPenalty(penalty: CreatePenalty): Observable<Penalty> {
|
||||||
return this.http.post<Penalty>(this.baseUrl, penalty);
|
return this.http.post<Penalty>(this.baseUrl, penalty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePenalty(penaltyId: number, penalty: UpdatePenalty): Observable<Penalty> {
|
||||||
|
return this.http.patch<Penalty>(`${this.baseUrl}/${penaltyId}`, penalty);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePenalty(penaltyId: number): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.baseUrl}/${penaltyId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,40 +4,148 @@
|
|||||||
<h1>Strafenkatalog</h1>
|
<h1>Strafenkatalog</h1>
|
||||||
<p>Klare Regeln, transparent für das ganze Team.</p>
|
<p>Klare Regeln, transparent für das ganze Team.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (canManage()) {
|
@if (canManage()) {
|
||||||
<mat-card class="create-card"
|
<mat-card class="create-card">
|
||||||
><form [formGroup]="form" (ngSubmit)="createPenalty()">
|
<form [formGroup]="form" (ngSubmit)="createPenalty()" aria-label="Katalogeintrag anlegen">
|
||||||
<mat-form-field appearance="outline"
|
<mat-form-field appearance="outline">
|
||||||
><mat-label>Beschreibung</mat-label><input matInput formControlName="description"
|
<mat-label>Beschreibung</mat-label>
|
||||||
/></mat-form-field>
|
<input matInput maxlength="120" formControlName="description" />
|
||||||
<mat-form-field appearance="outline"
|
</mat-form-field>
|
||||||
><mat-label>Betrag</mat-label
|
<mat-form-field appearance="outline">
|
||||||
><input matInput type="number" min="0.01" step="0.01" formControlName="amount" /><span
|
<mat-label>Betrag</mat-label>
|
||||||
matTextSuffix
|
<input matInput type="number" min="0.01" max="10000" step="0.01" formControlName="amount" />
|
||||||
>€</span
|
<span matTextSuffix>€</span>
|
||||||
></mat-form-field
|
</mat-form-field>
|
||||||
>
|
|
||||||
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
|
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
|
||||||
<mat-icon>add</mat-icon>Eintrag anlegen
|
@if (saving()) {
|
||||||
</button>
|
<mat-spinner diameter="18" />
|
||||||
</form></mat-card
|
} @else {
|
||||||
>
|
<mat-icon>add</mat-icon>
|
||||||
}
|
}
|
||||||
<mat-form-field appearance="outline" class="search"
|
Eintrag anlegen
|
||||||
><mat-label>Strafen durchsuchen</mat-label><mat-icon matPrefix>search</mat-icon
|
</button>
|
||||||
><input matInput [value]="search()" (input)="search.set($any($event.target).value)"
|
</form>
|
||||||
/></mat-form-field>
|
</mat-card>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (mutationError()) {
|
||||||
|
<p class="error-message" role="alert">{{ mutationError() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="search">
|
||||||
|
<mat-label>Strafen durchsuchen</mat-label>
|
||||||
|
<mat-icon matPrefix>search</mat-icon>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[value]="search()"
|
||||||
|
(input)="search.set($any($event.target).value)"
|
||||||
|
aria-label="Strafenkatalog durchsuchen"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="state"><mat-spinner diameter="36" /></div>
|
<div class="state" aria-live="polite">
|
||||||
|
<mat-spinner diameter="36" />
|
||||||
|
<span>Katalog wird geladen …</span>
|
||||||
|
</div>
|
||||||
|
} @else if (loadError()) {
|
||||||
|
<div class="state" role="alert">
|
||||||
|
<mat-icon>error_outline</mat-icon>
|
||||||
|
<strong>{{ loadError() }}</strong>
|
||||||
|
<button mat-stroked-button type="button" (click)="retryLoad()">Erneut versuchen</button>
|
||||||
|
</div>
|
||||||
|
} @else if (penalties().length === 0) {
|
||||||
|
<div class="state">
|
||||||
|
<mat-icon>gavel</mat-icon>
|
||||||
|
<strong>Noch keine Einträge</strong>
|
||||||
|
<span>Der Strafenkatalog dieses Teams ist leer.</span>
|
||||||
|
</div>
|
||||||
} @else if (filteredPenalties().length === 0) {
|
} @else if (filteredPenalties().length === 0) {
|
||||||
<div class="state"><mat-icon>gavel</mat-icon><span>Keine Einträge gefunden.</span></div>
|
<div class="state">
|
||||||
|
<mat-icon>search_off</mat-icon>
|
||||||
|
<strong>Keine passenden Einträge</strong>
|
||||||
|
<span>Versuche einen anderen Suchbegriff.</span>
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="catalog">
|
<div class="catalog">
|
||||||
@for (penalty of filteredPenalties(); track penalty.id) {
|
@for (penalty of filteredPenalties(); track penalty.id) {
|
||||||
<mat-card
|
<mat-card class="penalty-card">
|
||||||
><span>{{ penalty.description }}</span
|
@if (editingPenaltyId() === penalty.id) {
|
||||||
><strong>{{ penalty.amount | currency: 'EUR' }}</strong></mat-card
|
<form
|
||||||
|
class="edit-form"
|
||||||
|
[formGroup]="editForm"
|
||||||
|
(ngSubmit)="savePenalty(penalty)"
|
||||||
|
[attr.aria-label]="'Katalogeintrag ' + penalty.description + ' bearbeiten'"
|
||||||
>
|
>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Beschreibung</mat-label>
|
||||||
|
<input matInput maxlength="120" formControlName="description" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Betrag</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
max="10000"
|
||||||
|
step="0.01"
|
||||||
|
formControlName="amount"
|
||||||
|
/>
|
||||||
|
<span matTextSuffix>€</span>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
type="button"
|
||||||
|
(click)="cancelEdit()"
|
||||||
|
[disabled]="pendingPenaltyId() === penalty.id"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="editForm.invalid || pendingPenaltyId() === penalty.id"
|
||||||
|
>
|
||||||
|
@if (pendingPenaltyId() === penalty.id) {
|
||||||
|
<mat-spinner diameter="18" />
|
||||||
|
} @else {
|
||||||
|
<mat-icon>save</mat-icon>
|
||||||
|
}
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
} @else {
|
||||||
|
<div class="penalty-content">
|
||||||
|
<span>{{ penalty.description }}</span>
|
||||||
|
<strong>{{ penalty.amount | currency: 'EUR' }}</strong>
|
||||||
|
</div>
|
||||||
|
@if (canManage()) {
|
||||||
|
<div class="penalty-actions">
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
type="button"
|
||||||
|
(click)="startEdit(penalty)"
|
||||||
|
[disabled]="pendingPenaltyId() !== null"
|
||||||
|
[attr.aria-label]="penalty.description + ' bearbeiten'"
|
||||||
|
>
|
||||||
|
<mat-icon>edit</mat-icon>Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-button
|
||||||
|
type="button"
|
||||||
|
(click)="confirmDelete(penalty)"
|
||||||
|
[disabled]="pendingPenaltyId() !== null"
|
||||||
|
[attr.aria-label]="penalty.description + ' löschen'"
|
||||||
|
>
|
||||||
|
<mat-icon>delete</mat-icon>Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</mat-card>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,22 @@
|
|||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
margin: 20px 0 26px;
|
margin: 20px 0 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
p {
|
p {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: clamp(2rem, 4vw, 3rem);
|
font-size: clamp(2rem, 4vw, 3rem);
|
||||||
line-height: clamp(2rem, 4vw, 3rem);
|
line-height: clamp(2rem, 4vw, 3rem);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
color: var(--mat-sys-primary);
|
color: var(--mat-sys-primary);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -24,48 +28,118 @@ h1 {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-card {
|
.create-card {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
margin-bottom: 22px;
|
margin-bottom: 22px;
|
||||||
}
|
}
|
||||||
form {
|
|
||||||
|
.create-card form,
|
||||||
|
.edit-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 160px auto;
|
grid-template-columns: minmax(0, 1fr) 160px auto;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.create-card button,
|
||||||
|
.edit-actions button {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-card button mat-spinner,
|
||||||
|
.edit-actions button mat-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--mat-sys-error);
|
||||||
|
background: var(--mat-sys-error-container);
|
||||||
|
}
|
||||||
|
|
||||||
.catalog {
|
.catalog {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.catalog mat-card {
|
|
||||||
display: flex;
|
.penalty-card {
|
||||||
flex-direction: row;
|
padding: 16px 18px;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 18px;
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.penalty-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
|
text-align: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
color: var(--mat-sys-on-surface-variant);
|
color: var(--mat-sys-on-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
:host {
|
:host {
|
||||||
padding: 20px 16px;
|
padding: 20px 16px;
|
||||||
}
|
}
|
||||||
form {
|
|
||||||
|
.create-card form,
|
||||||
|
.edit-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
form button {
|
|
||||||
justify-self: stretch;
|
.create-card form button,
|
||||||
|
.edit-actions,
|
||||||
|
.edit-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-actions {
|
||||||
|
grid-column: auto;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-content {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-actions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.penalty-actions button {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { of } from 'rxjs';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { Subject, of, throwError } from 'rxjs';
|
||||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||||
import { PenaltyApi } from '../../../../core/team/penalty-api';
|
import { PenaltyApi } from '../../../../core/team/penalty-api';
|
||||||
import { TeamStore } from '../../../../core/team/team-store';
|
import { TeamStore } from '../../../../core/team/team-store';
|
||||||
|
import { Penalty } from '../../../../models/penalty.model';
|
||||||
import { Penalties } from './penalties';
|
import { Penalties } from './penalties';
|
||||||
|
|
||||||
describe('Penalties', () => {
|
const first: Penalty = {
|
||||||
it('renders the catalog and lets a captain add an entry', async () => {
|
id: 1,
|
||||||
const createPenalty = vi.fn(() => of({ id: 2, description: 'Handy in der Kabine', amount: 3 }));
|
description: 'Zu spät',
|
||||||
const loadPenalties = vi.fn(() => of([{ id: 1, description: 'Zu spät', amount: 5 }]));
|
amount: 5,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
const team = {
|
const team = {
|
||||||
id: 5,
|
id: 5,
|
||||||
name: 'Team A',
|
name: 'Team A',
|
||||||
@@ -28,27 +33,177 @@ describe('Penalties', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe('Penalties', () => {
|
||||||
|
let fixture: ComponentFixture<Penalties>;
|
||||||
|
let currentUser: ReturnType<typeof signal<{ id: number; role: { id: number } }>>;
|
||||||
|
let loadPenalties: ReturnType<typeof vi.fn>;
|
||||||
|
let createPenalty: ReturnType<typeof vi.fn>;
|
||||||
|
let updatePenalty: ReturnType<typeof vi.fn>;
|
||||||
|
let deletePenalty: ReturnType<typeof vi.fn>;
|
||||||
|
let dialogClosed: Subject<boolean>;
|
||||||
|
let dialog: { open: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
currentUser = signal({ id: 42, role: { id: 2 } });
|
||||||
|
loadPenalties = vi.fn(() => of([first]));
|
||||||
|
createPenalty = vi.fn(() => of({ id: 2, description: 'Handy', amount: 3 }));
|
||||||
|
updatePenalty = vi.fn(() => of({ ...first, description: 'Neu', amount: 6 }));
|
||||||
|
deletePenalty = vi.fn(() => of(undefined));
|
||||||
|
dialogClosed = new Subject<boolean>();
|
||||||
|
dialog = { open: vi.fn(() => ({ afterClosed: () => dialogClosed.asObservable() })) };
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [Penalties],
|
imports: [Penalties],
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
{ provide: TeamStore, useValue: { team: signal(team) } },
|
{ provide: TeamStore, useValue: { team: signal(team) } },
|
||||||
{ provide: AuthStore, useValue: { currentUser: signal({ id: 42, role: { id: 2 } }) } },
|
{ provide: AuthStore, useValue: { currentUser } },
|
||||||
{ provide: PenaltyApi, useValue: { loadPenalties, createPenalty } },
|
{
|
||||||
|
provide: PenaltyApi,
|
||||||
|
useValue: { loadPenalties, createPenalty, updatePenalty, deletePenalty },
|
||||||
|
},
|
||||||
|
{ provide: MatDialog, useValue: dialog },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const fixture = TestBed.createComponent(Penalties);
|
});
|
||||||
|
|
||||||
|
function create(): void {
|
||||||
|
fixture = TestBed.createComponent(Penalties);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(fixture.nativeElement.textContent).toContain('Zu spät');
|
}
|
||||||
|
|
||||||
|
function text(): string {
|
||||||
|
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(label: string): HTMLButtonElement {
|
||||||
|
const match = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find(
|
||||||
|
(element) => element.textContent?.includes(label),
|
||||||
|
);
|
||||||
|
if (!match) throw new Error(`Missing button: ${label}`);
|
||||||
|
return match as HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows the catalog without mutation controls to a reader', () => {
|
||||||
|
currentUser.set({ id: 7, role: { id: 2 } });
|
||||||
|
create();
|
||||||
|
|
||||||
|
expect(text()).toContain('Zu spät');
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.create-card')).toBeNull();
|
||||||
|
expect(text()).not.toContain('Bearbeiten');
|
||||||
|
expect(text()).not.toContain('Löschen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a captain create an entry and reloads authoritative data', () => {
|
||||||
|
loadPenalties
|
||||||
|
.mockReturnValueOnce(of([first]))
|
||||||
|
.mockReturnValueOnce(of([first, { id: 2, description: 'Handy', amount: 3 }]));
|
||||||
|
create();
|
||||||
|
fixture.componentInstance['form'].setValue({ description: 'Handy', amount: 3 });
|
||||||
|
|
||||||
fixture.componentInstance['form'].setValue({ description: 'Handy in der Kabine', amount: 3 });
|
|
||||||
fixture.componentInstance['createPenalty']();
|
fixture.componentInstance['createPenalty']();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(createPenalty).toHaveBeenCalledWith({
|
expect(createPenalty).toHaveBeenCalledWith({ teamId: 5, description: 'Handy', amount: 3 });
|
||||||
teamId: 5,
|
expect(loadPenalties).toHaveBeenCalledTimes(2);
|
||||||
description: 'Handy in der Kabine',
|
expect(text()).toContain('Handy');
|
||||||
amount: 3,
|
|
||||||
});
|
});
|
||||||
expect(fixture.componentInstance['penalties']().length).toBe(2);
|
|
||||||
|
it('rejects blank descriptions and amounts with more than two decimals', () => {
|
||||||
|
create();
|
||||||
|
|
||||||
|
fixture.componentInstance['form'].setValue({ description: ' ', amount: 1.234 });
|
||||||
|
|
||||||
|
expect(fixture.componentInstance['form'].invalid).toBe(true);
|
||||||
|
fixture.componentInstance['createPenalty']();
|
||||||
|
expect(createPenalty).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens one inline editor, supports cancel, and saves pessimistically', () => {
|
||||||
|
const updateResult = new Subject<Penalty>();
|
||||||
|
updatePenalty.mockReturnValue(updateResult);
|
||||||
|
loadPenalties
|
||||||
|
.mockReturnValueOnce(of([first]))
|
||||||
|
.mockReturnValueOnce(of([{ ...first, description: 'Neu', amount: 6 }]));
|
||||||
|
create();
|
||||||
|
|
||||||
|
button('Bearbeiten').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.edit-form')).not.toBeNull();
|
||||||
|
fixture.componentInstance['cancelEdit']();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.edit-form')).toBeNull();
|
||||||
|
|
||||||
|
button('Bearbeiten').click();
|
||||||
|
fixture.componentInstance['editForm'].setValue({ description: 'Neu', amount: 6 });
|
||||||
|
fixture.componentInstance['savePenalty'](first);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.componentInstance['editingPenaltyId']()).toBe(first.id);
|
||||||
|
expect(fixture.componentInstance['penalties']()).toEqual([first]);
|
||||||
|
expect(loadPenalties).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
updateResult.next({ ...first, description: 'Neu', amount: 6 });
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(updatePenalty).toHaveBeenCalledWith(1, { description: 'Neu', amount: 6 });
|
||||||
|
expect(loadPenalties).toHaveBeenCalledTimes(2);
|
||||||
|
expect(text()).toContain('Neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirms deletion and reloads only after server success', () => {
|
||||||
|
const deletion = new Subject<void>();
|
||||||
|
deletePenalty.mockReturnValue(deletion);
|
||||||
|
loadPenalties.mockReturnValueOnce(of([first])).mockReturnValueOnce(of([]));
|
||||||
|
create();
|
||||||
|
|
||||||
|
button('Löschen').click();
|
||||||
|
expect(dialog.open).toHaveBeenCalled();
|
||||||
|
dialogClosed.next(true);
|
||||||
|
expect(deletePenalty).toHaveBeenCalledWith(1);
|
||||||
|
expect(loadPenalties).toHaveBeenCalledTimes(1);
|
||||||
|
expect(text()).toContain('Zu spät');
|
||||||
|
|
||||||
|
deletion.next();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(loadPenalties).toHaveBeenCalledTimes(2);
|
||||||
|
expect(text()).toContain('Noch keine Einträge');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the inline editor and explains a duplicate conflict', () => {
|
||||||
|
updatePenalty.mockReturnValue(
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new HttpErrorResponse({
|
||||||
|
status: 409,
|
||||||
|
error: { message: 'Ein Eintrag mit dieser Beschreibung existiert bereits.' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
create();
|
||||||
|
button('Bearbeiten').click();
|
||||||
|
fixture.componentInstance['editForm'].setValue({ description: 'Zu spät', amount: 6 });
|
||||||
|
|
||||||
|
fixture.componentInstance['savePenalty'](first);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect((fixture.nativeElement as HTMLElement).querySelector('.edit-form')).not.toBeNull();
|
||||||
|
expect(text()).toContain('existiert bereits');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a load error, retries, and distinguishes an empty search result', () => {
|
||||||
|
loadPenalties
|
||||||
|
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })))
|
||||||
|
.mockReturnValueOnce(of([first]));
|
||||||
|
create();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(text()).toContain('Katalog konnte nicht geladen werden');
|
||||||
|
|
||||||
|
button('Erneut versuchen').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(text()).toContain('Zu spät');
|
||||||
|
|
||||||
|
fixture.componentInstance['search'].set('Nicht vorhanden');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(text()).toContain('Keine passenden Einträge');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { CurrencyPipe, registerLocaleData } from '@angular/common';
|
import { CurrencyPipe, registerLocaleData } from '@angular/common';
|
||||||
import localeDe from '@angular/common/locales/de';
|
import localeDe from '@angular/common/locales/de';
|
||||||
import { Component, LOCALE_ID, computed, effect, inject, signal } from '@angular/core';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Component, DestroyRef, LOCALE_ID, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { RouterLink } from '@angular/router';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { finalize, switchMap, take } from 'rxjs';
|
||||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||||
import { PenaltyApi } from '../../../../core/team/penalty-api';
|
import { PenaltyApi } from '../../../../core/team/penalty-api';
|
||||||
import { TeamStore } from '../../../../core/team/team-store';
|
import { TeamStore } from '../../../../core/team/team-store';
|
||||||
import { Penalty } from '../../../../models/penalty.model';
|
import { Penalty } from '../../../../models/penalty.model';
|
||||||
|
import { ConfirmDialog } from '../../../../shared/confirm-dialog/confirm-dialog';
|
||||||
|
|
||||||
registerLocaleData(localeDe);
|
registerLocaleData(localeDe);
|
||||||
|
|
||||||
@@ -35,25 +40,53 @@ registerLocaleData(localeDe);
|
|||||||
})
|
})
|
||||||
export class Penalties {
|
export class Penalties {
|
||||||
private readonly authStore = inject(AuthStore);
|
private readonly authStore = inject(AuthStore);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly dialog = inject(MatDialog);
|
||||||
private readonly formBuilder = inject(FormBuilder);
|
private readonly formBuilder = inject(FormBuilder);
|
||||||
private readonly penaltyApi = inject(PenaltyApi);
|
private readonly penaltyApi = inject(PenaltyApi);
|
||||||
private readonly teamStore = inject(TeamStore);
|
private readonly teamStore = inject(TeamStore);
|
||||||
private loadedTeamId: number | null = null;
|
private loadedTeamId: number | null = null;
|
||||||
|
|
||||||
protected readonly team = this.teamStore.team;
|
protected readonly team = this.teamStore.team;
|
||||||
protected readonly penalties = signal<Penalty[]>([]);
|
protected readonly penalties = signal<Penalty[]>([]);
|
||||||
protected readonly loading = signal(false);
|
protected readonly loading = signal(false);
|
||||||
protected readonly saving = signal(false);
|
protected readonly saving = signal(false);
|
||||||
|
protected readonly pendingPenaltyId = signal<number | null>(null);
|
||||||
|
protected readonly editingPenaltyId = signal<number | null>(null);
|
||||||
|
protected readonly loadError = signal<string | null>(null);
|
||||||
|
protected readonly mutationError = signal<string | null>(null);
|
||||||
protected readonly search = signal('');
|
protected readonly search = signal('');
|
||||||
protected readonly form = this.formBuilder.nonNullable.group({
|
protected readonly form = this.formBuilder.nonNullable.group({
|
||||||
description: ['', Validators.required],
|
description: ['', [Validators.required, Validators.maxLength(120), Validators.pattern(/\S/)]],
|
||||||
amount: [0, [Validators.required, Validators.min(0.01), Validators.max(10000)]],
|
amount: [
|
||||||
|
0,
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.min(0.01),
|
||||||
|
Validators.max(10000),
|
||||||
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
protected readonly editForm = this.formBuilder.nonNullable.group({
|
||||||
|
description: ['', [Validators.required, Validators.maxLength(120), Validators.pattern(/\S/)]],
|
||||||
|
amount: [
|
||||||
|
0,
|
||||||
|
[
|
||||||
|
Validators.required,
|
||||||
|
Validators.min(0.01),
|
||||||
|
Validators.max(10000),
|
||||||
|
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||||
|
],
|
||||||
|
],
|
||||||
});
|
});
|
||||||
protected readonly canManage = computed(() => {
|
protected readonly canManage = computed(() => {
|
||||||
const user = this.authStore.currentUser();
|
const user = this.authStore.currentUser();
|
||||||
if (user?.role?.id === 1) return true;
|
if (user?.role?.id === 1) return true;
|
||||||
return (
|
return (
|
||||||
this.team()?.players?.some(
|
this.team()?.players?.some(
|
||||||
(player) => player.user?.id === user?.id && (player.teamRole?.id ?? 0) > 2,
|
(player) =>
|
||||||
|
player.active && player.user?.id === user?.id && (player.teamRole?.id ?? 0) >= 3,
|
||||||
) ?? false
|
) ?? false
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -78,24 +111,139 @@ export class Penalties {
|
|||||||
const team = this.team();
|
const team = this.team();
|
||||||
if (!this.canManage() || !team || this.form.invalid || this.saving()) return;
|
if (!this.canManage() || !team || this.form.invalid || this.saving()) return;
|
||||||
this.saving.set(true);
|
this.saving.set(true);
|
||||||
this.penaltyApi.createPenalty({ teamId: team.id, ...this.form.getRawValue() }).subscribe({
|
this.mutationError.set(null);
|
||||||
next: (penalty) => {
|
this.penaltyApi
|
||||||
this.penalties.update((items) => [...items, penalty]);
|
.createPenalty({ teamId: team.id, ...this.form.getRawValue() })
|
||||||
|
.pipe(
|
||||||
|
switchMap(() => this.penaltyApi.loadPenalties(team.id)),
|
||||||
|
finalize(() => this.saving.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (penalties) => {
|
||||||
|
this.penalties.set(penalties);
|
||||||
this.form.reset({ description: '', amount: 0 });
|
this.form.reset({ description: '', amount: 0 });
|
||||||
this.saving.set(false);
|
|
||||||
},
|
},
|
||||||
error: () => this.saving.set(false),
|
error: (error: HttpErrorResponse) =>
|
||||||
|
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht angelegt werden.')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected startEdit(penalty: Penalty): void {
|
||||||
|
if (!this.canManage() || this.pendingPenaltyId() !== null) return;
|
||||||
|
this.editingPenaltyId.set(penalty.id);
|
||||||
|
this.editForm.setValue({ description: penalty.description, amount: penalty.amount });
|
||||||
|
this.mutationError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cancelEdit(): void {
|
||||||
|
if (this.pendingPenaltyId() !== null) return;
|
||||||
|
this.editingPenaltyId.set(null);
|
||||||
|
this.mutationError.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected savePenalty(penalty: Penalty): void {
|
||||||
|
const teamId = this.team()?.id;
|
||||||
|
if (
|
||||||
|
!this.canManage() ||
|
||||||
|
!teamId ||
|
||||||
|
this.editingPenaltyId() !== penalty.id ||
|
||||||
|
this.editForm.invalid ||
|
||||||
|
this.pendingPenaltyId() !== null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingPenaltyId.set(penalty.id);
|
||||||
|
this.mutationError.set(null);
|
||||||
|
this.penaltyApi
|
||||||
|
.updatePenalty(penalty.id, this.editForm.getRawValue())
|
||||||
|
.pipe(
|
||||||
|
switchMap(() => this.penaltyApi.loadPenalties(teamId)),
|
||||||
|
finalize(() => this.pendingPenaltyId.set(null)),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (penalties) => {
|
||||||
|
this.penalties.set(penalties);
|
||||||
|
this.editingPenaltyId.set(null);
|
||||||
|
},
|
||||||
|
error: (error: HttpErrorResponse) =>
|
||||||
|
this.mutationError.set(
|
||||||
|
this.errorMessage(error, 'Eintrag konnte nicht gespeichert werden.'),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected confirmDelete(penalty: Penalty): void {
|
||||||
|
if (!this.canManage() || this.pendingPenaltyId() !== null) return;
|
||||||
|
this.dialog
|
||||||
|
.open(ConfirmDialog, {
|
||||||
|
data: {
|
||||||
|
title: 'Eintrag löschen?',
|
||||||
|
message: `„${penalty.description}“ wird endgültig aus dem Strafenkatalog gelöscht.`,
|
||||||
|
confirmLabel: 'Löschen',
|
||||||
|
},
|
||||||
|
restoreFocus: true,
|
||||||
|
})
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe((confirmed) => {
|
||||||
|
if (confirmed) this.deletePenalty(penalty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected retryLoad(): void {
|
||||||
|
const teamId = this.team()?.id;
|
||||||
|
if (teamId) this.load(teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private deletePenalty(penalty: Penalty): void {
|
||||||
|
const teamId = this.team()?.id;
|
||||||
|
if (!teamId) return;
|
||||||
|
this.pendingPenaltyId.set(penalty.id);
|
||||||
|
this.mutationError.set(null);
|
||||||
|
this.penaltyApi
|
||||||
|
.deletePenalty(penalty.id)
|
||||||
|
.pipe(
|
||||||
|
switchMap(() => this.penaltyApi.loadPenalties(teamId)),
|
||||||
|
finalize(() => this.pendingPenaltyId.set(null)),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (penalties) => {
|
||||||
|
this.penalties.set(penalties);
|
||||||
|
if (this.editingPenaltyId() === penalty.id) this.editingPenaltyId.set(null);
|
||||||
|
},
|
||||||
|
error: (error: HttpErrorResponse) =>
|
||||||
|
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht gelöscht werden.')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private load(teamId: number): void {
|
private load(teamId: number): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.penaltyApi.loadPenalties(teamId).subscribe({
|
this.loadError.set(null);
|
||||||
next: (penalties) => {
|
this.penaltyApi
|
||||||
this.penalties.set(penalties);
|
.loadPenalties(teamId)
|
||||||
this.loading.set(false);
|
.pipe(
|
||||||
|
finalize(() => this.loading.set(false)),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (penalties) => this.penalties.set(penalties),
|
||||||
|
error: () => {
|
||||||
|
this.penalties.set([]);
|
||||||
|
this.loadError.set('Katalog konnte nicht geladen werden.');
|
||||||
},
|
},
|
||||||
error: () => this.loading.set(false),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private errorMessage(error: HttpErrorResponse, fallback: string): string {
|
||||||
|
const detail = typeof error.error?.message === 'string' ? error.error.message : '';
|
||||||
|
if (error.status === 403) return 'Keine Berechtigung für diese Änderung.';
|
||||||
|
if (error.status === 404)
|
||||||
|
return 'Der Eintrag wurde nicht gefunden. Bitte lade den Katalog neu.';
|
||||||
|
if (error.status === 409) return detail || 'Diese Beschreibung existiert bereits.';
|
||||||
|
if (error.status === 422) return 'Bitte prüfe Beschreibung und Betrag.';
|
||||||
|
return detail || fallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ export interface CreatePenalty {
|
|||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdatePenalty {
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user