Merge branch 'feature/manager-guide'
This commit is contained in:
@@ -10,6 +10,15 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<app-context-help
|
||||
title="Buchungen verstehen"
|
||||
[hints]="[
|
||||
'Mitgliederbuchungen verändern persönliche Salden; Teambuchungen erfassen direkte Einnahmen oder Ausgaben der Kasse.',
|
||||
'Bei mehreren Mitgliedern bestimmt die Verteilungsoption, ob der Gesamtbetrag geteilt oder je Person gebucht wird.',
|
||||
]"
|
||||
sectionId="bookings"
|
||||
/>
|
||||
|
||||
@if (canBook()) {
|
||||
<section class="booking-grid">
|
||||
<mat-card data-testid="player-booking">
|
||||
|
||||
@@ -139,6 +139,7 @@ describe('Cashbox', () => {
|
||||
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.textContent).toContain('Buchungen verstehen');
|
||||
});
|
||||
|
||||
it('submits cent-preserving split transactions for selected players', async () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
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';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@@ -46,6 +47,7 @@ const HIGH_AMOUNT_CONFIRM_THRESHOLD = 300;
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
MatSnackBarModule,
|
||||
ContextHelp,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './cashbox.html',
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
</button>
|
||||
}
|
||||
</header>
|
||||
<app-context-help
|
||||
title="Mitglieder verwalten"
|
||||
[hints]="[
|
||||
'Ein Mitglied wird zuerst angelegt und erst danach über eine Einladung mit einem Benutzerkonto verbunden.',
|
||||
'Ausgetretene Mitglieder deaktivieren, damit ihre Buchungshistorie erhalten bleibt.',
|
||||
]"
|
||||
sectionId="members"
|
||||
/>
|
||||
@if (showCreateForm()) {
|
||||
<mat-card class="create-card"
|
||||
><mat-card-content
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('Members', () => {
|
||||
const fixture = TestBed.createComponent(Members);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Bea Test');
|
||||
expect(fixture.nativeElement.textContent).toContain('Mitglieder verwalten');
|
||||
|
||||
fixture.componentInstance['showCreateForm'].set(true);
|
||||
fixture.componentInstance['createForm'].setValue({
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MatSelectModule } from '@angular/material/select';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { TeamStore } from '../../../core/team/team-store';
|
||||
import { TeamsApi } from '../../../core/team/teams-api';
|
||||
import { ContextHelp } from '../../../shared/context-help/context-help';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@@ -27,6 +28,7 @@ registerLocaleData(localeDe);
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
ContextHelp,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './members.html',
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
@if (canOpenGuide()) {
|
||||
<a mat-button routerLink="../"><mat-icon>arrow_back</mat-icon>Mehr</a>
|
||||
|
||||
<header class="guide-header">
|
||||
<p class="eyebrow">Team verwalten</p>
|
||||
<h1>Anleitung für Verantwortliche</h1>
|
||||
<p>Ein praktischer Leitfaden für Kassenwarte, Kapitäne, Trainer und ihre Vertretung.</p>
|
||||
</header>
|
||||
|
||||
<mat-card class="handover-card">
|
||||
<mat-card-content>
|
||||
<p class="eyebrow">Schnellstart</p>
|
||||
<h2>Neu in der Verantwortung?</h2>
|
||||
<p>Gehe diese Punkte gemeinsam mit der bisherigen verantwortlichen Person durch.</p>
|
||||
<ol>
|
||||
@for (item of handoverChecklist; track item) {
|
||||
<li>{{ item }}</li>
|
||||
}
|
||||
</ol>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<nav class="guide-nav" aria-label="Inhalt der Anleitung">
|
||||
<strong>Direkt zu einem Thema</strong>
|
||||
<div>
|
||||
@for (section of sections; track section.id) {
|
||||
<a [routerLink]="[]" [fragment]="section.id">{{ section.title }}</a>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="guide-sections">
|
||||
@for (section of sections; track section.id) {
|
||||
<section [id]="section.id" class="guide-section">
|
||||
<p class="eyebrow">{{ $index + 1 | number: '2.0' }}</p>
|
||||
<h2>{{ section.title }}</h2>
|
||||
<p class="guide-section__intro">{{ section.intro }}</p>
|
||||
|
||||
@if (section.id === 'roles') {
|
||||
<div class="role-grid">
|
||||
@for (role of roleGuide; track role.role) {
|
||||
<article>
|
||||
<strong>{{ role.role }}</strong>
|
||||
<span>{{ role.description }}</span>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul>
|
||||
@for (point of section.points; track point) {
|
||||
<li>{{ point }}</li>
|
||||
}
|
||||
</ul>
|
||||
@if (section.note) {
|
||||
<p class="guide-note"><strong>Wichtig:</strong> {{ section.note }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.guide-header {
|
||||
margin: 1rem 0 2rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 18ch;
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
line-height: 1.05;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--mat-sys-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.handover-card {
|
||||
border-radius: 1.25rem;
|
||||
background: var(--mat-sys-primary-container);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
}
|
||||
|
||||
.handover-card mat-card-content {
|
||||
padding: clamp(1.25rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
.handover-card ol {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding-left: 1.4rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.guide-nav {
|
||||
margin: 2rem 0;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.guide-nav div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.guide-nav a {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--mat-sys-on-surface);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.guide-sections {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.guide-section {
|
||||
scroll-margin-top: 1.5rem;
|
||||
padding: clamp(1.25rem, 4vw, 2rem);
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--mat-sys-surface-container-lowest);
|
||||
}
|
||||
|
||||
.guide-section__intro {
|
||||
max-width: 72ch;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.guide-section ul {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.role-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.role-grid article {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.9rem;
|
||||
background: var(--mat-sys-surface-container);
|
||||
}
|
||||
|
||||
.role-grid span {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.guide-note {
|
||||
margin: 1rem 0 0;
|
||||
padding: 1rem;
|
||||
border-left: 4px solid var(--mat-sys-tertiary);
|
||||
background: var(--mat-sys-tertiary-container);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.role-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { HelpAccessService } from '../../../../core/help/help-access';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { Guide } from './guide';
|
||||
|
||||
@Component({ template: '' })
|
||||
class EmptyRoute {}
|
||||
|
||||
describe('Guide', () => {
|
||||
it('renders the handover path and all task-oriented chapters for responsible users', async () => {
|
||||
const fixture = await createFixture(true);
|
||||
const text = fixture.nativeElement.textContent;
|
||||
|
||||
expect(text).toContain('Neu in der Verantwortung?');
|
||||
expect(text).toContain('Amtsübergabe prüfen');
|
||||
expect(text).toContain('Buchungen sicher erfassen');
|
||||
expect(text).toContain('Verantwortung weitergeben');
|
||||
expect(fixture.nativeElement.querySelector('#bookings')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('#faq')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('redirects regular players back to the More page after team loading', async () => {
|
||||
const fixture = await createFixture(false);
|
||||
const router = TestBed.inject(Router);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(router.url).toBe('/team/5/more');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Amtsübergabe prüfen');
|
||||
});
|
||||
});
|
||||
|
||||
async function createFixture(canOpen: boolean) {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Guide],
|
||||
providers: [
|
||||
provideRouter([{ path: 'team/5/more', component: EmptyRoute }]),
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(canOpen) } },
|
||||
{
|
||||
provide: TeamStore,
|
||||
useValue: {
|
||||
team: signal({ id: 5 }),
|
||||
loading: signal(false),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Guide);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
return fixture;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component, effect, inject } from '@angular/core';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import {
|
||||
GUIDE_SECTIONS,
|
||||
HANDOVER_CHECKLIST,
|
||||
ROLE_GUIDE,
|
||||
} from '../../../../core/help/guide-content';
|
||||
import { HelpAccessService } from '../../../../core/help/help-access';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guide',
|
||||
imports: [DecimalPipe, RouterLink, MatButtonModule, MatCardModule, MatIconModule],
|
||||
templateUrl: './guide.html',
|
||||
styleUrl: './guide.scss',
|
||||
})
|
||||
export class Guide {
|
||||
private readonly helpAccess = inject(HelpAccessService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
|
||||
protected readonly sections = GUIDE_SECTIONS;
|
||||
protected readonly handoverChecklist = HANDOVER_CHECKLIST;
|
||||
protected readonly roleGuide = ROLE_GUIDE;
|
||||
protected readonly canOpenGuide = this.helpAccess.canOpenGuide;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const team = this.teamStore.team();
|
||||
if (!this.teamStore.loading() && team && !this.helpAccess.canOpenGuide()) {
|
||||
void this.router.navigateByUrl(`/team/${team.id}/more`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,15 @@
|
||||
<h1>Einladen</h1>
|
||||
<p>Der Link ist personalisiert und darf nur an die ausgewählte Person gehen.</p>
|
||||
</header>
|
||||
|
||||
<app-context-help
|
||||
title="Einladungen richtig zuordnen"
|
||||
[hints]="[
|
||||
'Der persönliche Link verbindet genau das ausgewählte Mitglied mit einem Benutzerkonto.',
|
||||
'Sende den Link nur an die Person, deren Mitgliedseintrag ausgewählt wurde.',
|
||||
]"
|
||||
sectionId="sharing"
|
||||
/>
|
||||
@if (!canInvite()) {
|
||||
<mat-card class="notice"
|
||||
><mat-icon>lock</mat-icon
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('Invite', () => {
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Invite);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('Einladungen richtig zuordnen');
|
||||
|
||||
fixture.componentInstance['form'].setValue({ playerId: 7 });
|
||||
fixture.componentInstance['generateLink']();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { AuthApi } from '../../../../core/auth/auth-api';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite',
|
||||
@@ -24,6 +25,7 @@ import { TeamStore } from '../../../../core/team/team-store';
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatSnackBarModule,
|
||||
ContextHelp,
|
||||
],
|
||||
templateUrl: './invite.html',
|
||||
styleUrl: './invite.scss',
|
||||
|
||||
@@ -5,6 +5,18 @@
|
||||
</header>
|
||||
|
||||
<section class="link-grid">
|
||||
@if (canOpenGuide()) {
|
||||
<a routerLink="guide"
|
||||
><mat-card
|
||||
><mat-icon>menu_book</mat-icon>
|
||||
<div>
|
||||
<strong>Anleitung für Verantwortliche</strong
|
||||
><span>Amtsübergabe, Kasse und Teamverwaltung verstehen</span>
|
||||
</div>
|
||||
<mat-icon>chevron_right</mat-icon></mat-card
|
||||
></a
|
||||
>
|
||||
}
|
||||
<a routerLink="/users"
|
||||
><mat-card
|
||||
><mat-icon>group</mat-icon>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../core/help/help-access';
|
||||
import { More } from './more';
|
||||
|
||||
@Component({ template: '' })
|
||||
@@ -21,6 +22,7 @@ describe('More', () => {
|
||||
clearSession,
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(true) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(More);
|
||||
@@ -30,6 +32,7 @@ describe('More', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('Einladen');
|
||||
expect(fixture.nativeElement.textContent).toContain('Profil');
|
||||
expect(fixture.nativeElement.textContent).toContain('Öffentliche Freigabe');
|
||||
expect(fixture.nativeElement.textContent).toContain('Anleitung für Verantwortliche');
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Benutzer');
|
||||
expect(fixture.nativeElement.querySelector('a[href="/users"]')).not.toBeNull();
|
||||
@@ -39,4 +42,25 @@ describe('More', () => {
|
||||
expect(clearSession).toHaveBeenCalled();
|
||||
expect(TestBed.inject(Router).url).toBe('/auth/login');
|
||||
});
|
||||
|
||||
it('hides the guide link from regular players', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [More],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: AuthStore,
|
||||
useValue: {
|
||||
currentUser: signal({ firstName: 'Alex', lastName: 'Muster', email: 'a@b.de' }),
|
||||
clearSession: vi.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(false) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(More);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Anleitung für Verantwortliche');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { AuthStore } from '../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../core/help/help-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-more',
|
||||
@@ -13,8 +14,10 @@ import { AuthStore } from '../../../core/auth/auth-store';
|
||||
})
|
||||
export class More {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly helpAccess = inject(HelpAccessService);
|
||||
private readonly router = inject(Router);
|
||||
protected readonly user = this.authStore.currentUser;
|
||||
protected readonly canOpenGuide = this.helpAccess.canOpenGuide;
|
||||
|
||||
protected logout(): void {
|
||||
this.authStore.clearSession();
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
<p>Klare Regeln, transparent für das ganze Team.</p>
|
||||
</header>
|
||||
|
||||
<app-context-help
|
||||
title="Strafenkatalog richtig nutzen"
|
||||
[hints]="[
|
||||
'Ein Katalogeintrag ist eine Vorlage und noch keine Forderung gegen ein Mitglied.',
|
||||
'Bestehende Buchungen ändern sich nicht, wenn ein Katalogeintrag später bearbeitet wird.',
|
||||
]"
|
||||
sectionId="penalties"
|
||||
/>
|
||||
|
||||
@if (canManage()) {
|
||||
<mat-card class="create-card">
|
||||
<form [formGroup]="form" (ngSubmit)="createPenalty()" aria-label="Katalogeintrag anlegen">
|
||||
|
||||
@@ -101,6 +101,7 @@ describe('Penalties', () => {
|
||||
.mockReturnValueOnce(of([first, { id: 2, description: 'Handy', amount: 3 }]));
|
||||
create();
|
||||
fixture.componentInstance['form'].setValue({ description: 'Handy', amount: 3 });
|
||||
expect(text()).toContain('Strafenkatalog richtig nutzen');
|
||||
|
||||
fixture.componentInstance['createPenalty']();
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PenaltyApi } from '../../../../core/team/penalty-api';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { Penalty } from '../../../../models/penalty.model';
|
||||
import { ConfirmDialog } from '../../../../shared/confirm-dialog/confirm-dialog';
|
||||
import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
@@ -33,6 +34,7 @@ registerLocaleData(localeDe);
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
ContextHelp,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './penalties.html',
|
||||
|
||||
@@ -25,6 +25,17 @@
|
||||
><input matInput type="password" formControlName="password"
|
||||
/></mat-form-field>
|
||||
</div>
|
||||
@if (canOpenGuide()) {
|
||||
<section class="help-settings">
|
||||
<h2>Hilfe</h2>
|
||||
<p class="hint">
|
||||
Blende kurze Erklärungen auf Kasse, Mitglieder und Verwaltungsseiten ein.
|
||||
</p>
|
||||
<mat-slide-toggle formControlName="helpTextsEnabled">
|
||||
Hilfetexte in der App anzeigen
|
||||
</mat-slide-toggle>
|
||||
</section>
|
||||
}
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
|
||||
<mat-icon>save</mat-icon>Speichern
|
||||
</button>
|
||||
|
||||
@@ -44,6 +44,19 @@ h2 {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.help-settings {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--mat-sys-surface-container);
|
||||
}
|
||||
|
||||
.help-settings h2,
|
||||
.help-settings p {
|
||||
margin: 0;
|
||||
}
|
||||
form button {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { throwError } from 'rxjs';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { AuthApi } from '../../../../core/auth/auth-api';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../../core/help/help-access';
|
||||
import { Profile } from './profile';
|
||||
|
||||
describe('Profile', () => {
|
||||
@@ -13,6 +16,7 @@ describe('Profile', () => {
|
||||
email: 'alex@example.com',
|
||||
firstName: 'Alexander',
|
||||
lastName: 'Neu',
|
||||
helpTextsEnabled: false,
|
||||
};
|
||||
const updateProfile = vi.fn(() => of(updated));
|
||||
const updateUser = vi.fn();
|
||||
@@ -29,10 +33,12 @@ describe('Profile', () => {
|
||||
email: 'alex@example.com',
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
helpTextsEnabled: true,
|
||||
}),
|
||||
updateUser,
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(true) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Profile);
|
||||
@@ -42,11 +48,56 @@ describe('Profile', () => {
|
||||
lastName: 'Neu',
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
});
|
||||
helpTextsEnabled: false,
|
||||
} as any);
|
||||
|
||||
fixture.componentInstance['save']();
|
||||
|
||||
expect(updateProfile).toHaveBeenCalledWith({ firstName: 'Alexander', lastName: 'Neu' });
|
||||
expect(updateProfile).toHaveBeenCalledWith({
|
||||
firstName: 'Alexander',
|
||||
lastName: 'Neu',
|
||||
helpTextsEnabled: false,
|
||||
});
|
||||
expect(updateUser).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
|
||||
it('keeps the stored preference and reports an error when saving fails', async () => {
|
||||
const updateUser = vi.fn();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Profile],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: AuthApi,
|
||||
useValue: { updateProfile: vi.fn(() => throwError(() => new Error('failed'))) },
|
||||
},
|
||||
{
|
||||
provide: AuthStore,
|
||||
useValue: {
|
||||
currentUser: signal({
|
||||
id: 42,
|
||||
email: 'alex@example.com',
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
helpTextsEnabled: true,
|
||||
}),
|
||||
updateUser,
|
||||
},
|
||||
},
|
||||
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(true) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(Profile);
|
||||
const snackBar = fixture.debugElement.injector.get(MatSnackBar);
|
||||
const open = vi.spyOn(snackBar, 'open');
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance['form'].patchValue({ helpTextsEnabled: false } as any);
|
||||
|
||||
fixture.componentInstance['save']();
|
||||
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
expect(open).toHaveBeenCalledWith('Profil konnte nicht gespeichert werden.', undefined, {
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,10 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { AuthApi, UpdateProfileRequest } from '../../../../core/auth/auth-api';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { HelpAccessService } from '../../../../core/help/help-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile',
|
||||
@@ -21,6 +23,7 @@ import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSnackBarModule,
|
||||
MatSlideToggleModule,
|
||||
],
|
||||
templateUrl: './profile.html',
|
||||
styleUrl: './profile.scss',
|
||||
@@ -28,13 +31,16 @@ import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
export class Profile {
|
||||
private readonly authApi = inject(AuthApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly helpAccess = inject(HelpAccessService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly canOpenGuide = this.helpAccess.canOpenGuide;
|
||||
protected readonly form = inject(FormBuilder).nonNullable.group({
|
||||
firstName: [this.authStore.currentUser()?.firstName ?? '', Validators.required],
|
||||
lastName: [this.authStore.currentUser()?.lastName ?? '', Validators.required],
|
||||
oldPassword: [''],
|
||||
password: ['', Validators.minLength(6)],
|
||||
helpTextsEnabled: [this.authStore.currentUser()?.helpTextsEnabled !== false],
|
||||
});
|
||||
|
||||
protected save(): void {
|
||||
@@ -47,6 +53,7 @@ export class Profile {
|
||||
const request: UpdateProfileRequest = {
|
||||
firstName: value.firstName.trim(),
|
||||
lastName: value.lastName.trim(),
|
||||
helpTextsEnabled: value.helpTextsEnabled,
|
||||
};
|
||||
if (value.password) {
|
||||
request.oldPassword = value.oldPassword;
|
||||
@@ -60,7 +67,12 @@ export class Profile {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Profil wurde gespeichert.', undefined, { duration: 4000 });
|
||||
},
|
||||
error: () => this.saving.set(false),
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Profil konnte nicht gespeichert werden.', undefined, {
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
<p>Teile Kassenstand, Mitgliedersalden, Strafenkatalog und Buchungsverläufe.</p>
|
||||
</header>
|
||||
|
||||
<app-context-help
|
||||
title="Öffentliche Freigabe bewusst nutzen"
|
||||
[hints]="[
|
||||
'Jede Person mit dem Link kann Teamstand, Salden, Strafenkatalog und Buchungsverläufe sehen.',
|
||||
'Erneuere den Link, wenn er unerwünscht weitergegeben wurde.',
|
||||
]"
|
||||
sectionId="sharing"
|
||||
/>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="state"><mat-spinner diameter="38" /><span>Freigabe wird geladen …</span></div>
|
||||
} @else if (loadFailed()) {
|
||||
|
||||
@@ -79,6 +79,8 @@ describe('PublicAccess', () => {
|
||||
it('allows a captain to activate sharing', async () => {
|
||||
const { fixture, setEnabled } = await setup(3, false);
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Öffentliche Freigabe bewusst nutzen');
|
||||
|
||||
fixture.componentInstance['toggleAccess']();
|
||||
|
||||
expect(setEnabled).toHaveBeenCalledWith(7, true);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PublicAccessApi } from '../../../../core/team/public-access-api';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { PublicAccessStatus } from '../../../../models/public-access.model';
|
||||
import { ConfirmDialog, ConfirmDialogData } from '../../../../shared/confirm-dialog/confirm-dialog';
|
||||
import { ContextHelp } from '../../../../shared/context-help/context-help';
|
||||
|
||||
@Component({
|
||||
selector: 'app-public-access',
|
||||
@@ -25,6 +26,7 @@ import { ConfirmDialog, ConfirmDialogData } from '../../../../shared/confirm-dia
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
ContextHelp,
|
||||
],
|
||||
templateUrl: './public-access.html',
|
||||
styleUrl: './public-access.scss',
|
||||
|
||||
Reference in New Issue
Block a user