feat: add manager guide and contextual help

This commit is contained in:
Bastian Wagner
2026-08-02 09:22:16 +02:00
parent 6eab6251d5
commit 0e8ac74116
43 changed files with 1002 additions and 6 deletions

View File

@@ -1,5 +1,5 @@
import { ApplicationConfig, isDevMode, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideServiceWorker } from '@angular/service-worker';
@@ -10,7 +10,13 @@ import { errorInterceptor } from './core/http/error-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideRouter(
routes,
withInMemoryScrolling({
anchorScrolling: 'enabled',
scrollPositionRestoration: 'enabled',
}),
),
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),

View File

@@ -66,6 +66,11 @@ describe('app routing', () => {
expect(teamRoute?.children?.some((route) => route.path === 'more/public-access')).toBe(true);
});
it('registers the guide below the protected team shell', () => {
const teamRoute = routes.find((route) => route.path === 'team/:id');
expect(teamRoute?.children?.some((route) => route.path === 'more/guide')).toBe(true);
});
it('accepts the password reset URL generated by the backend', async () => {
expect(routes.some((route) => route.path === 'password-change/:hash')).toBe(true);
await router.navigateByUrl('/password-change/reset-hash');

View File

@@ -101,6 +101,11 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/team/more/public-access/public-access').then((m) => m.PublicAccess),
},
{
path: 'more/guide',
loadComponent: () =>
import('./features/team/more/guide/guide').then((m) => m.Guide),
},
],
},
{

View File

@@ -30,6 +30,7 @@ export interface CreateInviteRequest extends InviteDetails {}
export interface UpdateProfileRequest {
firstName?: string;
lastName?: string;
helpTextsEnabled?: boolean;
oldPassword?: string;
password?: string;
}

View File

@@ -0,0 +1,144 @@
export type GuideSectionId =
| 'roles'
| 'handover'
| 'members'
| 'bookings'
| 'reversals'
| 'penalties'
| 'sharing'
| 'next-handover'
| 'faq';
export interface GuideSection {
id: GuideSectionId;
title: string;
intro: string;
points: readonly string[];
note?: string;
}
export const HANDOVER_CHECKLIST = [
'Prüfe, ob dein Benutzerkonto mit dem richtigen Mitglied und der richtigen Teamrolle verknüpft ist.',
'Vergleiche Teamkasse und offene Mitgliedersalden mit den Unterlagen der bisherigen Verantwortung.',
'Kontrolliere aktive und inaktive Mitglieder sowie die vergebenen Verantwortungsrollen.',
'Lies bestehende Buchungen und Stornierungen, bevor du eine neue Buchung erfasst.',
'Prüfe Strafenkatalog, offene Einladungen und die öffentliche Freigabe des Teams.',
'Vereinbare, ab welchem Datum du neue Vorgänge in TeamWallet pflegst.',
] as const;
export const ROLE_GUIDE = [
{
role: '2. Kassenwart',
description: 'Kann Buchungen erfassen und nachvollziehen; organisatorische Verwaltung bleibt der Teamleitung vorbehalten.',
},
{
role: 'Kapitän',
description: 'Kann Kasse, Mitglieder, Strafenkatalog, Einladungen und Freigaben verwalten.',
},
{
role: 'Kassenwart',
description: 'Trägt die zentrale Verantwortung für Kassenstand, Buchungen, Salden und Übergabe.',
},
{
role: 'Trainer',
description: 'Kann die organisatorischen Verwaltungsfunktionen einschließlich Kasse und Mitglieder nutzen.',
},
] as const;
export const GUIDE_SECTIONS: readonly GuideSection[] = [
{
id: 'roles',
title: 'Deine Rolle und Berechtigungen',
intro: 'TeamWallet zeigt Aktionen passend zu deiner Rolle. Eine ausgeblendete Aktion ist deshalb meist kein Fehler.',
points: [
'Deine Teamrolle gehört zur Mitgliedschaft im aktuell ausgewählten Team.',
'Bei mehreren Teams können Rolle und Berechtigungen je Team unterschiedlich sein.',
'Mindestens ein aktiver Kassenwart muss im Team erhalten bleiben.',
],
},
{
id: 'handover',
title: 'Amtsübergabe prüfen',
intro: 'Beginne nicht mit einer Korrekturbuchung, bevor du den übergebenen Stand gemeinsam geprüft hast.',
points: [
'Vergleiche den angezeigten Kassenstand mit Bargeld, Bankkonto oder der bisherigen Kassenliste.',
'Offene Beiträge sind die Summe der Salden aktiver Mitglieder und nicht automatisch Bargeld in der Kasse.',
'Dokumentiere Abweichungen außerhalb der App und kläre zuerst ihren Ursprung.',
],
note: 'Nutze keine künstliche Testbuchung. Eine echte fehlerhafte Buchung wird über eine Stornierung nachvollziehbar korrigiert.',
},
{
id: 'members',
title: 'Mitglieder und Rollen verwalten',
intro: 'Mitglied, Benutzerkonto und Teamrolle sind getrennte Dinge: Eine Person wird zuerst als Mitglied geführt und kann danach eingeladen werden.',
points: [
'Lege ein Mitglied nur einmal an und wähle die Rolle passend zur tatsächlichen Verantwortung.',
'Deaktiviere ausgetretene Mitglieder, statt ihre Historie zu löschen.',
'Prüfe vor einem Rollenwechsel, dass ein aktiver Kassenwart im Team verbleibt.',
],
},
{
id: 'bookings',
title: 'Buchungen sicher erfassen',
intro: 'Mitgliederbuchungen verändern persönliche Salden; Teambuchungen erfassen direkte Einnahmen oder Ausgaben der Teamkasse.',
points: [
'Wähle zuerst die richtige Buchungsart und prüfe Betrag, Datum, Personen und Notiz vor dem Speichern.',
'Bei mehreren Mitgliedern entscheidet „Betrag gleichmäßig verteilen“, ob der Gesamtbetrag geteilt oder je Person gebucht wird.',
'Nutze eine eindeutige Notiz, damit ein Vorgang auch Monate später verständlich bleibt.',
'Ein Strafenkatalogeintrag kann Betrag, Art und Notiz einer Mitgliederbuchung vorbereiten.',
],
},
{
id: 'reversals',
title: 'Fehler nachvollziehen und stornieren',
intro: 'Eine Stornierung bewahrt die Originalbuchung und erzeugt eine nachvollziehbare Gegenbewegung.',
points: [
'Kontrolliere im Journal zuerst Person, Betrag, Datum und Buchungsart.',
'Storniere die falsche Mitgliederbuchung und erfasse anschließend den korrekten Vorgang neu.',
'Vermeide ausgleichende Fantasiebuchungen, weil dadurch der Prüfpfad unklar wird.',
],
},
{
id: 'penalties',
title: 'Strafenkatalog verwenden',
intro: 'Der Strafenkatalog enthält wiederkehrende Regeln und Beträge, aber noch keine Forderung gegen ein bestimmtes Mitglied.',
points: [
'Formuliere Einträge kurz und eindeutig und vermeide doppelte Regeln.',
'Änderungen am Katalog verändern bereits gebuchte Strafen nicht rückwirkend.',
'Übernimm einen Eintrag in der Kasse und wähle dort erst die betroffenen Mitglieder.',
],
},
{
id: 'sharing',
title: 'Einladen und Team teilen',
intro: 'Einladungen und öffentliche Freigabe erfüllen unterschiedliche Zwecke.',
points: [
'Eine Einladung verknüpft ein persönliches Benutzerkonto mit einem bereits angelegten Mitglied.',
'Die öffentliche Freigabe zeigt Teamstand, Salden, Strafenkatalog und Buchungsverläufe ohne Anmeldung.',
'Aktiviere öffentliche Freigaben nur nach Abstimmung im Team und erneuere den Link, wenn er unerwünscht weitergegeben wurde.',
],
},
{
id: 'next-handover',
title: 'Verantwortung weitergeben',
intro: 'Eine gute Übergabe sorgt dafür, dass Kassenstand und Zuständigkeit zu einem eindeutigen Datum nachvollziehbar sind.',
points: [
'Stimme Kassenstand und offene Salden gemeinsam ab.',
'Übertrage die Teamrolle und prüfe den Zugang der neuen verantwortlichen Person.',
'Erkläre besondere offene Vorgänge, Freigaben und vereinbarte Buchungstexte.',
'Entferne deine eigene Rolle erst, wenn mindestens ein aktiver Kassenwart verbleibt.',
],
},
{
id: 'faq',
title: 'Häufige Fragen',
intro: 'Diese Antworten helfen bei typischen Unsicherheiten im laufenden Betrieb.',
points: [
'Falsche Person oder falscher Betrag: Original stornieren und korrekt neu buchen.',
'Kassenstand weicht ab: Journal und externe Unterlagen ab dem letzten bestätigten Stand vergleichen.',
'Aktion fehlt: ausgewähltes Team, aktive Mitgliedschaft und Teamrolle prüfen.',
'Einladung passt nicht: prüfen, ob das richtige Mitglied im richtigen Team ausgewählt wurde.',
'Hilfetexte stören: im Profil deaktivieren; diese vollständige Anleitung bleibt erreichbar.',
],
},
] as const;

View File

@@ -0,0 +1,87 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { AuthStore } from '../auth/auth-store';
import { TeamStore } from '../team/team-store';
import { HelpAccessService } from './help-access';
describe('HelpAccessService', () => {
it('allows an active second treasurer to open the guide', () => {
const access = createAccess(
{ id: 7, role: { id: 2 }, helpTextsEnabled: true },
teamWithPlayer(7, 2, true),
);
expect(access.canOpenGuide()).toBe(true);
expect(access.showContextHelp()).toBe(true);
});
it('does not treat an inactive responsible player as responsible', () => {
const access = createAccess(
{ id: 7, role: { id: 2 }, helpTextsEnabled: true },
teamWithPlayer(7, 4, false),
);
expect(access.canOpenGuide()).toBe(false);
expect(access.showContextHelp()).toBe(false);
});
it('keeps the guide available when contextual help is disabled', () => {
const access = createAccess(
{ id: 7, role: { id: 2 }, helpTextsEnabled: false },
teamWithPlayer(7, 3, true),
);
expect(access.canOpenGuide()).toBe(true);
expect(access.showContextHelp()).toBe(false);
});
it('defaults contextual help to enabled for stored users without the new field', () => {
const access = createAccess(
{ id: 7, role: { id: 2 } },
teamWithPlayer(7, 5, true),
);
expect(access.showContextHelp()).toBe(true);
});
it('allows global administrators without a team player assignment', () => {
const access = createAccess(
{ id: 1, role: { id: 1 }, helpTextsEnabled: true },
{ id: 5, name: 'Team', alias: 'team', balance: 0, players: [] },
);
expect(access.canOpenGuide()).toBe(true);
});
});
function createAccess(user: any, team: any): any {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
HelpAccessService,
{ provide: AuthStore, useValue: { currentUser: signal(user) } },
{ provide: TeamStore, useValue: { team: signal(team) } },
],
});
return TestBed.inject(HelpAccessService);
}
function teamWithPlayer(userId: number, roleId: number, active: boolean): any {
return {
id: 5,
name: 'Team',
alias: 'team',
balance: 0,
players: [
{
id: 9,
firstName: 'Alex',
lastName: 'Muster',
balance: 0,
active,
teamRole: { id: roleId },
user: { id: userId },
},
],
};
}

View File

@@ -0,0 +1,32 @@
import { Injectable, computed, inject } from '@angular/core';
import { RoleId } from '../../models/role.model';
import { TeamRole } from '../../models/team-role.model';
import { AuthStore } from '../auth/auth-store';
import { TeamStore } from '../team/team-store';
@Injectable({ providedIn: 'root' })
export class HelpAccessService {
private readonly authStore = inject(AuthStore);
private readonly teamStore = inject(TeamStore);
readonly canOpenGuide = computed(() => {
const user = this.authStore.currentUser();
if (!user) return false;
if (user.role?.id === RoleId.Admin) return true;
return (
this.teamStore
.team()
?.players?.some(
(player) =>
player.active &&
player.user?.id === user.id &&
(player.teamRole?.id ?? 0) >= TeamRole.ScndTreasurer,
) ?? false
);
});
readonly showContextHelp = computed(
() => this.canOpenGuide() && this.authStore.currentUser()?.helpTextsEnabled !== false,
);
}

View File

@@ -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">

View File

@@ -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 () => {

View File

@@ -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',

View File

@@ -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

View File

@@ -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({

View File

@@ -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',

View File

@@ -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>
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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`);
}
});
}
}

View File

@@ -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

View File

@@ -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']();

View File

@@ -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',

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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();

View File

@@ -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">

View File

@@ -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();

View File

@@ -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',

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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,
});
});
});

View File

@@ -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,
});
},
});
}
}

View File

@@ -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()) {

View File

@@ -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);

View File

@@ -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',

View File

@@ -11,6 +11,7 @@ export interface User {
email: string | null;
firstName: string | null;
lastName: string | null;
helpTextsEnabled?: boolean;
role?: Role | null;
status?: Status;
photo?: UserPhoto | null;

View File

@@ -0,0 +1,24 @@
@if (visible()) {
<aside class="context-help">
<button
type="button"
class="context-help__toggle"
[attr.aria-expanded]="expanded()"
(click)="toggle()"
>
<span aria-hidden="true">?</span>
<strong>{{ title() }}</strong>
<span aria-hidden="true">{{ expanded() ? '' : '+' }}</span>
</button>
@if (expanded()) {
<div class="context-help__content">
<ul>
@for (hint of hints(); track hint) {
<li>{{ hint }}</li>
}
</ul>
<a [routerLink]="guideCommands()" [fragment]="sectionId()">Mehr dazu</a>
</div>
}
</aside>
}

View File

@@ -0,0 +1,46 @@
.context-help {
margin: 1rem 0;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 1rem;
background: var(--mat-sys-surface-container-low);
overflow: hidden;
}
.context-help__toggle {
width: 100%;
display: grid;
grid-template-columns: 1.75rem 1fr auto;
align-items: center;
gap: 0.65rem;
padding: 0.85rem 1rem;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.context-help__toggle > span:first-child {
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
font-weight: 700;
}
.context-help__content {
padding: 0 1rem 1rem 3.4rem;
}
.context-help__content ul {
margin: 0 0 0.65rem;
padding-left: 1rem;
}
.context-help__content a {
color: var(--mat-sys-primary);
font-weight: 700;
}

View File

@@ -0,0 +1,62 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { HelpAccessService } from '../../core/help/help-access';
import { TeamStore } from '../../core/team/team-store';
import { ContextHelp } from './context-help';
@Component({
imports: [ContextHelp],
template: `
<app-context-help
title="Buchungen verstehen"
[hints]="['Mitgliederbuchungen verändern den persönlichen Saldo.']"
sectionId="bookings"
/>
`,
})
class Host {}
describe('ContextHelp', () => {
it('stays compact, expands on demand and links to the matching guide section', async () => {
const fixture = await createFixture(true);
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(fixture.nativeElement.textContent).not.toContain('persönlichen Saldo');
button.click();
fixture.detectChanges();
expect(button.getAttribute('aria-expanded')).toBe('true');
expect(fixture.nativeElement.textContent).toContain('persönlichen Saldo');
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a');
expect(link.getAttribute('href')).toBe('/team/5/more/guide#bookings');
});
it('renders nothing when contextual help is disabled', async () => {
const fixture = await createFixture(false);
expect(fixture.nativeElement.querySelector('app-context-help')?.textContent.trim()).toBe('');
});
});
async function createFixture(show: boolean) {
await TestBed.configureTestingModule({
imports: [Host],
providers: [
provideRouter([]),
{
provide: HelpAccessService,
useValue: { showContextHelp: signal(show) },
},
{
provide: TeamStore,
useValue: { team: signal({ id: 5 }) },
},
],
}).compileComponents();
const fixture = TestBed.createComponent(Host);
fixture.detectChanges();
return fixture;
}

View File

@@ -0,0 +1,33 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { HelpAccessService } from '../../core/help/help-access';
import { GuideSectionId } from '../../core/help/guide-content';
import { TeamStore } from '../../core/team/team-store';
@Component({
selector: 'app-context-help',
imports: [RouterLink],
templateUrl: './context-help.html',
styleUrl: './context-help.scss',
})
export class ContextHelp {
private readonly helpAccess = inject(HelpAccessService);
private readonly teamStore = inject(TeamStore);
readonly title = input.required<string>();
readonly hints = input.required<readonly string[]>();
readonly sectionId = input.required<GuideSectionId>();
protected readonly visible = this.helpAccess.showContextHelp;
protected readonly expanded = signal(false);
protected readonly guideCommands = computed(() => [
'/team',
this.teamStore.team()?.id,
'more',
'guide',
]);
protected toggle(): void {
this.expanded.update((value) => !value);
}
}