manage invitations and requests by club-admin

This commit is contained in:
beo3000 2026-01-02 14:47:09 +01:00
parent 7d0c26ad0c
commit 028909c24f
9 changed files with 943 additions and 42 deletions

380
deploy/kassenbuch.md Normal file
View File

@ -0,0 +1,380 @@
1. Allgemeine Rahmenbedingungen
Die Finanzverwaltung wird als Modul / Erweiterung einer bestehenden Software umgesetzt.
Eine Benutzerverwaltung existiert bereits und wird genutzt.
Der Kegelverein ist kein eingetragener Verein, es bestehen keine steuerlichen oder rechtlichen Sonderanforderungen.
Es gibt ein Vereinskonto und eine Währung (EUR).
2. Benutzer- und Rollenmodell
2.1 Rollen
Es wird eine zusätzliche Rolle für Finanzdaten integriert (z. B. „Kassenwart“, „Finanzverwaltung“).
Nur Benutzer mit dieser Rolle dürfen:
Einnahmen und Ausgaben erfassen
Kategorien verwalten
Korrekturbuchungen durchführen
Finanzdaten ändern oder löschen
Berichte exportieren
2.2 Zugriffsrechte
Benutzer ohne Finanzrolle haben keinen Zugriff auf Finanzdaten.
Leserechte und Schreibrechte müssen klar getrennt umsetzbar sein (falls von der bestehenden Software unterstützt).
3. Kategorienverwaltung
3.1 Kategorien für Einnahmen und Ausgaben
Einnahmen und Ausgaben müssen kategorisiert werden.
Kategorien sind:
frei definierbar
beliebig erweiterbar
editierbar (Name ändern)
deaktivierbar, ohne bestehende Buchungen zu verändern
3.2 Kategorietypen
Es muss mindestens eine Unterscheidung geben zwischen:
Einnahmen-Kategorien
Ausgaben-Kategorien
4. Buchungen (Kassenbuch)
4.1 Erfassung von Einnahmen und Ausgaben
Für jede Buchung müssen folgende Felder vorhanden sein:
Buchungsart: Einnahme / Ausgabe
Betrag (positiv, in EUR)
Kategorie
Buchungsdatum (frei wählbar, nicht zwingend aktuelles Datum)
Optionaler Kommentar / Beschreibung
Optionaler externer Belegverweis (z. B. Belegnummer oder Text)
4.2 Belege
Kein Upload von Dokumenten
Stattdessen:
Freitextfeld oder Referenz zur Verknüpfung mit extern abgelegten Belegen
5. Kontostand und Kontenabgleich
5.1 Kontostand
Die Software führt einen internen Kontostand auf Basis der erfassten Buchungen.
Es existiert genau ein Konto.
5.2 Manueller Abgleich
Der Abgleich mit dem realen Bankkonto erfolgt manuell.
Abweichungen können durch Korrekturbuchungen ausgeglichen werden.
Korrekturbuchungen:
sind normale Buchungen
müssen eindeutig als solche erkennbar sein (z. B. spezielle Kategorie oder Kennzeichnung)
6. Auswertungen und Berichte
6.1 Standardberichte
Folgende Berichte müssen verfügbar sein:
Monatsübersicht
Jahresübersicht
6.2 Inhalt der Berichte
Anfangs- und Endsaldo
Summe der Einnahmen
Summe der Ausgaben
Aufschlüsselung nach Kategorien
Optionale Detailansicht aller Buchungen im Zeitraum
6.3 Export
Berichte müssen exportierbar sein als:
Excel (z. B. XLSX)
PDF
7. Änderungsprotokoll (Audit-Log)
7.1 Protokollierung
Alle Änderungen an Finanzdaten müssen protokolliert werden:
Neuanlage von Buchungen
Änderungen an Buchungen
Löschungen von Buchungen
Änderungen an Kategorien
7.2 Protokollinhalt
Das Protokoll enthält mindestens:
Datum und Uhrzeit der Änderung
Benutzer
Art der Änderung (Anlage / Änderung / Löschung)
Alte und neue Werte (soweit sinnvoll darstellbar)
7.3 Einsicht
Das Änderungsprotokoll ist für berechtigte Benutzer einsehbar.
8. Bedienbarkeit und Usability
Zielgruppe sind softwareerfahrene Benutzer.
Es wird keine Benutzeranleitung benötigt.
Fokus liegt auf:
klaren Masken
übersichtlichen Tabellen
schneller Datenerfassung
Konsistenz mit der bestehenden Software-Oberfläche ist wünschenswert.
9. Technische und funktionale Abgrenzungen (Nicht-Ziele)
Folgende Funktionen sind nicht Bestandteil der ersten Version:
Automatische Zahlungserinnerungen
Import von Bankumsätzen
Schnittstellen zu anderen Systemen
Dokumenten-Uploads
Mehrere Konten oder Währungen
Steuerliche oder rechtliche Auswertungen
10. Erweiterbarkeit (optional, aber empfehlenswert)
Die Architektur sollte so gestaltet sein, dass spätere Erweiterungen möglich sind, z. B.:
Automatischer Kontoimport
Mehrere Konten
Erinnerungen
Schnittstellen
Wenn du möchtest, kann ich dir im nächsten Schritt z. B.
ein Datenmodell (Tabellen / Entitäten)
User Stories
oder eine Minimal-Feature-Liste für ein MVP
ableiten sag einfach Bescheid.
Du:
ja, mach das
ChatGPT:
Perfekt 👍
Dann liefere ich dir alle drei Punkte in sinnvoller Reihenfolge und aufeinander aufbauend:
Datenmodell (Entitäten & Felder)
User Stories
MVP-Feature-Liste (klar abgegrenzt)
So kannst du direkt mit Design oder Implementierung starten.
1. Datenmodell (fachlich / logisch)
1.1 Benutzer
Existiert bereits wird hier nur referenziert
User
user_id (PK)
name
… (bestehende Felder)
rolle_finanz (Boolean oder Rollenreferenz)
1.2 Kategorien
Kategorie
kategorie_id (PK)
name
typ (EINNAHME | AUSGABE)
aktiv (Boolean)
erstellt_am
erstellt_von (user_id)
1.3 Buchungen (Kassenbuch)
Buchung
buchung_id (PK)
buchungsart (EINNAHME | AUSGABE)
betrag (Decimal, > 0)
buchungsdatum (Date)
kategorie_id (FK)
kommentar (Text, optional)
belegreferenz (Text, optional)
ist_korrekturbuchung (Boolean)
erstellt_am (Timestamp)
erstellt_von (user_id)
geändert_am (Timestamp, optional)
geändert_von (user_id, optional)
gelöscht (Boolean, Soft Delete empfohlen)
1.4 Änderungsprotokoll (Audit-Log)
Finanz_Audit_Log
log_id (PK)
entität (BUCHUNG | KATEGORIE)
entität_id
aktion (CREATE | UPDATE | DELETE)
feldname (optional, bei UPDATE)
alter_wert (Text)
neuer_wert (Text)
zeitpunkt (Timestamp)
benutzer_id (user_id)
Empfehlung: bei UPDATE pro Feld ein Logeintrag
1.5 Abgeleitete Werte (kein persistentes Objekt)
Kontostand
wird dynamisch berechnet:
Summe(Einnahmen) Summe(Ausgaben)
2. User Stories (fachlich, umsetzungsnah)
Kategorien
Als Finanznutzer möchte ich Kategorien für Einnahmen und Ausgaben anlegen,
damit ich Buchungen sauber strukturieren kann.
Als Finanznutzer möchte ich Kategorien deaktivieren können,
ohne bestehende Buchungen zu verändern.
Buchungen
Als Finanznutzer möchte ich Einnahmen und Ausgaben erfassen,
damit das Kassenbuch aktuell ist.
Als Finanznutzer möchte ich ein freies Buchungsdatum eingeben können,
damit ich auch vergangene Vorgänge korrekt erfasse.
Als Finanznutzer möchte ich Kommentare und Belegreferenzen hinterlegen,
damit Buchungen nachvollziehbar bleiben.
Als Finanznutzer möchte ich Korrekturbuchungen erfassen können,
um Abweichungen zum Bankkonto auszugleichen.
Übersicht & Kontrolle
Als Finanznutzer möchte ich jederzeit den aktuellen Kontostand sehen,
um zu wissen, wie viel Geld verfügbar ist.
Als Finanznutzer möchte ich alle Buchungen eines Monats oder Jahres sehen,
um Einnahmen und Ausgaben nachzuvollziehen.
Berichte
Als Finanznutzer möchte ich Monats- und Jahresberichte erzeugen,
um diese dem Verein vorlegen zu können.
Als Finanznutzer möchte ich diese Berichte als Excel oder PDF exportieren,
um sie weiterzugeben oder zu archivieren.
Sicherheit & Nachvollziehbarkeit
Als Verein möchten wir, dass alle Änderungen an Finanzdaten protokolliert werden,
damit Transparenz und Nachvollziehbarkeit gewährleistet sind.
3. MVP Minimal Viable Product
3.1 Muss-Funktionen (Version 1.0)
✅ Pflicht
Finanzrolle integrieren
Kategorien anlegen / bearbeiten / deaktivieren
Einnahmen & Ausgaben erfassen
Buchungen bearbeiten & (soft) löschen
Korrekturbuchungen
Kontostandberechnung
Monats- & Jahresübersichten
Export als Excel & PDF
Vollständiges Änderungsprotokoll
3.2 Sollte-Funktionen (Version 1.1)
🔶 Optional, aber sinnvoll
Filter (Zeitraum, Kategorie)
Summen pro Kategorie
Kennzeichnung auffälliger Buchungen (z. B. hohe Beträge)
Schnellanlage über Standardkategorien
3.3 Explizit ausgeschlossen (vorerst)
❌ Nicht Teil des MVP
Bankimport
Mehrere Konten
Mehrere Währungen
Dokumentenupload
Automatische Erinnerungen
Externe Schnittstellen

View File

@ -87,6 +87,14 @@ public interface IClubService
/// <returns>List of invitations.</returns>
Task<IReadOnlyList<ClubInvitationDto>> GetInvitationsByClubAsync(Guid clubId, CancellationToken ct = default);
/// <summary>
/// Deletes an invitation.
/// </summary>
/// <param name="invitationId">The invitation ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if deleted successfully; otherwise, false.</returns>
Task<bool> DeleteInvitationAsync(Guid invitationId, CancellationToken ct = default);
/// <summary>
/// Finds a club by name (case-insensitive).
/// </summary>
@ -94,4 +102,14 @@ public interface IClubService
/// <param name="ct">Cancellation token.</param>
/// <returns>The club if found; otherwise, null.</returns>
Task<ClubDto?> GetByNameAsync(string clubName, CancellationToken ct = default);
/// <summary>
/// Finds a club by login name (case-insensitive).
/// </summary>
/// <param name="clubLoginName">The club name to search for.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The club if found; otherwise, null.</returns>
Task<ClubDto?> GetByLoginNameAsync(string clubLoginName, CancellationToken ct = default);
}

View File

@ -241,6 +241,29 @@ public class ClubService : IClubService
return club is null ? null : _mapper.Map<ClubDto>(club);
}
public async Task<ClubDto?> GetByLoginNameAsync(string clubLoginName, CancellationToken ct = default)
{
var club = await _appDb.Clubs
.FirstOrDefaultAsync(c => c.LoginName.ToLower() == clubLoginName.ToLower() && !c.IsDeleted, ct);
return club is null ? null : _mapper.Map<ClubDto>(club);
}
/// <inheritdoc />
public async Task<bool> DeleteInvitationAsync(Guid invitationId, CancellationToken ct = default)
{
var invitation = await _appDb.ClubInvitations
.FirstOrDefaultAsync(i => i.Id == invitationId && !i.IsDeleted, ct);
if (invitation == null)
return false;
invitation.IsDeleted = true;
invitation.ModifiedAt = DateTime.UtcNow;
await _appDb.SaveChangesAsync(ct);
return true;
}
/// <summary>
/// Generates a cryptographically secure random token for invitations.
/// </summary>

View File

@ -59,4 +59,16 @@ public interface IEmailService
/// <returns>true if sent successfully, false otherwise.</returns>
Task<bool> SendPasswordResetEmailAsync(string toEmail, string resetUrl,
CancellationToken ct = default);
/// <summary>
/// Sends a club invitation email with the invite link.
/// </summary>
/// <param name="toEmail">Recipient's email address</param>
/// <param name="inviteUrl">The full invitation URL with token</param>
/// <param name="clubName">Name of the club</param>
/// <param name="clubId">ID of the club for sender email</param>
/// <param name="ct">Cancellation token</param>
/// <returns>true if sent successfully, false otherwise.</returns>
Task<bool> SendClubInvitationEmailAsync(string toEmail, string inviteUrl,
string clubName, Guid clubId, CancellationToken ct = default);
}

View File

@ -303,6 +303,49 @@ public class SmtpEmailService : IEmailService
}
}
/// <inheritdoc />
public async Task<bool> SendClubInvitationEmailAsync(string toEmail, string inviteUrl,
string clubName, Guid clubId, CancellationToken ct = default)
{
if (!_settings.Enabled)
{
_logger.LogInformation("[EMAIL DISABLED] Club invitation - To: {Email}, Club: {Club}", toEmail, clubName);
return false;
}
try
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
var club = await context.Clubs.FirstOrDefaultAsync(c => c.Id == clubId, ct);
if (club is null)
return false;
var senderEmail = GetSenderEmail(club);
var senderName = clubName;
var subject = $"Einladung zu {clubName}";
var body = $@"
<html>
<body>
<h2>Einladung zu {clubName}</h2>
<p>Du wurdest eingeladen, dem Club <strong>{clubName}</strong> beizutreten.</p>
<p>Klicke auf den folgenden Link, um die Einladung anzunehmen:</p>
<p><a href=""{inviteUrl}"">Einladung annehmen</a></p>
<p>Falls du bereits ein Konto hast, kannst du dich direkt anmelden. Andernfalls wirst du zur Registrierung weitergeleitet.</p>
<p>Viele Grüße,<br/>{clubName}</p>
</body>
</html>";
await SendEmailAsync(senderEmail, senderName, toEmail, subject, body, ct);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send club invitation email to {Email}", toEmail);
return false;
}
}
private string GetSenderEmail(Club club)
{
var parts = _settings.DefaultSenderEmail.Split('@');

View File

@ -45,10 +45,10 @@
<MudForm @ref="_form" @bind-IsValid="_isValid">
<MudTextField @bind-Value="_clubName"
Label="Club-Name"
Label="Club-Name (Login-Name)"
Variant="Variant.Outlined"
Required="true"
RequiredError="Bitte gib einen Club-Namen ein"
RequiredError="Bitte den Login-Name deines Clubs ein"
Immediate="true"
OnKeyUp="OnClubNameKeyUp"
Class="mb-3" />
@ -160,7 +160,7 @@
try
{
_searchResult = await ClubService.GetByNameAsync(_clubName.Trim());
_searchResult = await ClubService.GetByLoginNameAsync(_clubName.Trim());
_searched = true;
// Check if user is already member

View File

@ -96,7 +96,7 @@
@code {
private string _returnUrl = "/";
private string _returnUrl = "/dashboard";
private string? _error;
private string _antiToken = "";
private bool _registered;

View File

@ -0,0 +1,112 @@
@using Koogle.Application.DTOs
@using Koogle.Application.Interfaces
@using Koogle.Domain.Interfaces
@using System.ComponentModel.DataAnnotations
@inject IClubService ClubService
@inject IUserService UserService
@inject IEmailService EmailService
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudForm @ref="_form" @bind-IsValid="_isValid">
<MudTextField @bind-Value="_email"
Label="E-Mail-Adresse"
Variant="Variant.Outlined"
Required="true"
RequiredError="E-Mail-Adresse ist erforderlich"
Validation="@(new EmailAddressAttribute() { ErrorMessage = "Ungültige E-Mail-Adresse" })"
Immediate="true"
Class="mb-3" />
</MudForm>
@if (_isSending)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Disabled="_isSending">Abbrechen</MudButton>
<MudButton Color="Color.Primary"
Variant="Variant.Filled"
OnClick="Send"
Disabled="@(!_isValid || _isSending)">
@if (_isSending)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Einladung senden
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public Guid ClubId { get; set; }
[Parameter] public string ClubName { get; set; } = "";
private MudForm _form = null!;
private string _email = "";
private bool _isValid;
private bool _isSending;
private void Cancel() => MudDialog.Cancel();
private async Task Send()
{
await _form.Validate();
if (!_isValid) return;
_isSending = true;
StateHasChanged();
try
{
var currentUser = await UserService.GetCurrentUserAsync();
if (currentUser is null)
{
Snackbar.Add("Nicht angemeldet", Severity.Error);
return;
}
// Create invitation with 7 days expiry
var dto = new CreateClubInvitationDto
{
ClubId = ClubId,
ExpiresAt = DateTime.UtcNow.AddDays(7),
MaxUses = 1
};
var invitation = await ClubService.CreateInvitationAsync(dto, currentUser.ProfileId);
// Build invite URL
var baseUrl = NavigationManager.BaseUri.TrimEnd('/');
var inviteUrl = $"{baseUrl}/club/join/{invitation.Token}";
// Send email
var sent = await EmailService.SendClubInvitationEmailAsync(_email, inviteUrl, ClubName, ClubId);
if (sent)
{
Snackbar.Add($"Einladung an {_email} gesendet", Severity.Success);
MudDialog.Close(DialogResult.Ok(true));
}
else
{
Snackbar.Add("E-Mail konnte nicht gesendet werden", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
}
finally
{
_isSending = false;
StateHasChanged();
}
}
}

View File

@ -11,8 +11,10 @@
@using Koogle.Web.Store.ClubState
@using Koogle.Web.Components.Pages.Admin
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Web
@inject IClubService ClubService
@inject IUserService UserService
@inject ICurrentClubContext CurrentClubContext
@inject IState<ClubState> ClubState
@inject IDispatcher Dispatcher
@ -20,6 +22,8 @@
@inject IDialogService DialogService
@inject IEmailService EmailService
@inject IState<AuthState> AuthState
@inject NavigationManager NavigationManager
@inject IJSRuntime JSRuntime
<PageTitle>Vereins-Einstellungen</PageTitle>
@ -42,50 +46,175 @@ else if (_club is null)
}
else
{
<MudCard>
<MudCardContent>
<MudGrid>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.caption" Color="Color.Secondary">Name</MudText>
<MudText Typo="Typo.body1">@_club.Name</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.caption" Color="Color.Secondary">Login-Name</MudText>
<MudText Typo="Typo.body1">@_club.LoginName</MudText>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.caption" Color="Color.Secondary">Kostenberechnung</MudText>
<MudChip T="string" Size="Size.Small" Color="GetCalculationColor(_club.ExpenseCalculation)">
@GetCalculationLabel(_club.ExpenseCalculation)
</MudChip>
</MudItem>
</MudGrid>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Edit"
OnClick="OpenEditDialog">
Bearbeiten
</MudButton>
@* <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Edit"
OnClick="RunTest">
Test
</MudButton> *@
</MudCardActions>
</MudCard>
<MudTabs @bind-ActivePanelIndex="_activeTabIndex" Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Class="mb-4">
<MudTabPanel Text="Einstellungen">
<MudCard Class="mt-4">
<MudCardContent>
<MudGrid>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.caption" Color="Color.Secondary">Name</MudText>
<MudText Typo="Typo.body1">@_club.Name</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.caption" Color="Color.Secondary">Login-Name</MudText>
<MudText Typo="Typo.body1">@_club.LoginName</MudText>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.caption" Color="Color.Secondary">Kostenberechnung</MudText>
<MudChip T="string" Size="Size.Small" Color="GetCalculationColor(_club.ExpenseCalculation)">
@GetCalculationLabel(_club.ExpenseCalculation)
</MudChip>
</MudItem>
</MudGrid>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Edit"
OnClick="OpenEditDialog">
Bearbeiten
</MudButton>
</MudCardActions>
</MudCard>
</MudTabPanel>
<MudTabPanel Text="Einladungen" BadgeData="@(_invitations.Count(i => i.IsValid))" BadgeColor="Color.Primary">
<MudCard Class="mt-4">
<MudCardContent>
<MudStack Row="true" Class="mb-4">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Link"
OnClick="CreateInvitationLink">
Einladungslink erstellen
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Email"
OnClick="OpenInviteByEmailDialog">
Per E-Mail einladen
</MudButton>
</MudStack>
@if (_isLoadingInvitations)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
}
else if (!_invitations.Any())
{
<MudAlert Severity="Severity.Info">Keine Einladungen vorhanden.</MudAlert>
}
else
{
<MudTable Items="_invitations" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Einladungslink</MudTh>
<MudTh>Ablaufdatum</MudTh>
<MudTh>Nutzung</MudTh>
<MudTh>Status</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Link">
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Style="max-width: 200px; overflow: hidden; text-overflow: ellipsis;">
@GetInviteUrl(context.Token)
</MudText>
<MudTooltip Text="Link kopieren">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
Size="Size.Small"
OnClick="@(() => CopyToClipboard(GetInviteUrl(context.Token)))" />
</MudTooltip>
</MudStack>
</MudTd>
<MudTd DataLabel="Ablauf">@context.ExpiresAt.ToString("dd.MM.yyyy HH:mm")</MudTd>
<MudTd DataLabel="Nutzung">
@context.UsedCount / @(context.MaxUses?.ToString() ?? "∞")
</MudTd>
<MudTd DataLabel="Status">
@if (context.IsValid)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">Aktiv</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">Abgelaufen</MudChip>
}
</MudTd>
<MudTd DataLabel="Aktionen">
<MudTooltip Text="Löschen">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => DeleteInvitation(context))" />
</MudTooltip>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudCardContent>
</MudCard>
</MudTabPanel>
<MudTabPanel Text="Anträge" BadgeData="@(_pendingMemberships.Count)" BadgeColor="@(_pendingMemberships.Any() ? Color.Warning : Color.Default)">
<MudCard Class="mt-4">
<MudCardContent>
@if (_isLoadingPending)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
}
else if (!_pendingMemberships.Any())
{
<MudAlert Severity="Severity.Info">Keine ausstehenden Mitgliedschaftsanträge.</MudAlert>
}
else
{
<MudTable Items="_pendingMemberships" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>E-Mail</MudTh>
<MudTh>Angefragt am</MudTh>
<MudTh>Aktionen</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.DisplayName</MudTd>
<MudTd DataLabel="E-Mail">@context.Email</MudTd>
<MudTd DataLabel="Angefragt am">@context.RequestedAt.ToString("dd.MM.yyyy HH:mm")</MudTd>
<MudTd DataLabel="Aktionen">
<MudTooltip Text="Genehmigen">
<MudIconButton Icon="@Icons.Material.Filled.Check"
Color="Color.Success"
Size="Size.Small"
OnClick="@(() => ApproveMembership(context))"/>
</MudTooltip>
<MudTooltip Text="Ablehnen">
<MudIconButton Icon="@Icons.Material.Filled.Close"
Color="Color.Error"
Size="Size.Small"
OnClick="@(() => OpenRejectDialog(context))"/>
</MudTooltip>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudCardContent>
</MudCard>
</MudTabPanel>
</MudTabs>
}
@code {
private ClubDto? _club;
private bool _isLoading = true;
private bool _isLoadingInvitations = true;
private bool _isLoadingPending = true;
private int _activeTabIndex = 0;
private List<ClubInvitationDto> _invitations = new();
private List<PendingMembershipDto> _pendingMemberships = new();
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
await LoadClubAsync();
await LoadAllDataAsync();
}
private async Task LoadClubAsync()
private async Task LoadAllDataAsync()
{
_isLoading = true;
try
@ -94,6 +223,8 @@ else
if (clubId != Guid.Empty)
{
_club = await ClubService.GetByIdAsync(clubId);
await LoadInvitationsAsync();
await LoadPendingMembershipsAsync();
}
}
finally
@ -102,6 +233,40 @@ else
}
}
private async Task LoadInvitationsAsync()
{
_isLoadingInvitations = true;
try
{
var clubId = CurrentClubContext.ClubId;
if (clubId != Guid.Empty)
{
_invitations = (await ClubService.GetInvitationsByClubAsync(clubId)).ToList();
}
}
finally
{
_isLoadingInvitations = false;
}
}
private async Task LoadPendingMembershipsAsync()
{
_isLoadingPending = true;
try
{
var clubId = CurrentClubContext.ClubId;
if (clubId != Guid.Empty)
{
_pendingMemberships = (await UserService.GetPendingMembershipsAsync(clubId)).ToList();
}
}
finally
{
_isLoadingPending = false;
}
}
private void ClearError()
{
Dispatcher.Dispatch(new ClearClubErrorAction());
@ -139,13 +304,161 @@ else
{
Dispatcher.Dispatch(new UpdateClubAction(dto));
Snackbar.Add("Einstellungen werden gespeichert...", Severity.Info);
await LoadClubAsync();
await LoadAllDataAsync();
}
}
// private async Task RunTest(MouseEventArgs obj)
// {
// await EmailService.SendTestMailAsync("christian@kauer-buchhagen.de", AuthState.Value.CurrentClub.ClubId);
// }
private string GetInviteUrl(string token)
{
var baseUrl = NavigationManager.BaseUri.TrimEnd('/');
return $"{baseUrl}/club/join/{token}";
}
private async Task CreateInvitationLink()
{
if (_club is null) return;
var currentUser = await UserService.GetCurrentUserAsync();
if (currentUser is null)
{
Snackbar.Add("Nicht angemeldet", Severity.Error);
return;
}
try
{
var dto = new CreateClubInvitationDto
{
ClubId = _club.Id,
ExpiresAt = DateTime.UtcNow.AddDays(7),
MaxUses = null
};
var invitation = await ClubService.CreateInvitationAsync(dto, currentUser.ProfileId);
Snackbar.Add("Einladungslink erstellt", Severity.Success);
// Copy to clipboard immediately
await CopyToClipboard(GetInviteUrl(invitation.Token));
await LoadInvitationsAsync();
StateHasChanged();
}
catch (Exception ex)
{
Snackbar.Add($"Fehler: {ex.Message}", Severity.Error);
}
}
private async Task OpenInviteByEmailDialog()
{
if (_club is null) return;
var parameters = new DialogParameters
{
{ "ClubId", _club.Id },
{ "ClubName", _club.Name }
};
var dialog = await DialogService.ShowAsync<InviteByEmailDialog>("Per E-Mail einladen", parameters);
var result = await dialog.Result;
if (result != null && !result.Canceled)
{
await LoadInvitationsAsync();
StateHasChanged();
}
}
private async Task DeleteInvitation(ClubInvitationDto invitation)
{
var confirmed = await DialogService.ShowMessageBox(
"Einladung löschen",
$"Möchten Sie diese Einladung wirklich löschen?",
yesText: "Löschen",
cancelText: "Abbrechen");
if (confirmed == true)
{
var success = await ClubService.DeleteInvitationAsync(invitation.Id);
if (success)
{
Snackbar.Add("Einladung gelöscht", Severity.Success);
await LoadInvitationsAsync();
StateHasChanged();
}
else
{
Snackbar.Add("Fehler beim Löschen", Severity.Error);
}
}
}
private async Task CopyToClipboard(string text)
{
try
{
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text);
Snackbar.Add("Link in Zwischenablage kopiert", Severity.Success);
}
catch
{
Snackbar.Add("Kopieren fehlgeschlagen", Severity.Warning);
}
}
private async Task ApproveMembership(PendingMembershipDto pending)
{
var currentUser = await UserService.GetCurrentUserAsync();
if (currentUser == null)
{
Snackbar.Add("Nicht angemeldet", Severity.Error);
return;
}
var success = await UserService.ApproveMembershipAsync(pending.UserProfileId, pending.ClubId, currentUser.ProfileId);
if (success)
{
Snackbar.Add($"Mitgliedschaft von {pending.DisplayName} genehmigt", Severity.Success);
await LoadPendingMembershipsAsync();
StateHasChanged();
}
else
{
Snackbar.Add("Fehler beim Genehmigen der Mitgliedschaft", Severity.Error);
}
}
private async Task OpenRejectDialog(PendingMembershipDto pending)
{
var parameters = new DialogParameters
{
{ "Pending", pending }
};
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small };
var dialog = await DialogService.ShowAsync<RejectMembershipDialog>("Mitgliedschaft ablehnen", parameters, options);
var result = await dialog.Result;
if (result != null && !result.Canceled && result.Data is string reason)
{
var currentUser = await UserService.GetCurrentUserAsync();
if (currentUser == null)
{
Snackbar.Add("Nicht angemeldet", Severity.Error);
return;
}
var success = await UserService.RejectMembershipAsync(pending.UserProfileId, pending.ClubId, currentUser.ProfileId, reason);
if (success)
{
Snackbar.Add($"Mitgliedschaft von {pending.DisplayName} abgelehnt", Severity.Success);
await LoadPendingMembershipsAsync();
StateHasChanged();
}
else
{
Snackbar.Add("Fehler beim Ablehnen der Mitgliedschaft", Severity.Error);
}
}
}
}