feat: add manager guide and contextual help
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface CreateInviteRequest extends InviteDetails {}
|
||||
export interface UpdateProfileRequest {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
helpTextsEnabled?: boolean;
|
||||
oldPassword?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
144
myteamwallet_frontend_modern/src/app/core/help/guide-content.ts
Normal file
144
myteamwallet_frontend_modern/src/app/core/help/guide-content.ts
Normal 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;
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user