diff --git a/deploy/kassenbuch.md b/deploy/kassenbuch.md new file mode 100644 index 0000000..db95450 --- /dev/null +++ b/deploy/kassenbuch.md @@ -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 \ No newline at end of file diff --git a/src/Koogle.Application/Interfaces/IClubService.cs b/src/Koogle.Application/Interfaces/IClubService.cs index 47508cf..0bbec1e 100644 --- a/src/Koogle.Application/Interfaces/IClubService.cs +++ b/src/Koogle.Application/Interfaces/IClubService.cs @@ -87,6 +87,14 @@ public interface IClubService /// List of invitations. Task> GetInvitationsByClubAsync(Guid clubId, CancellationToken ct = default); + /// + /// Deletes an invitation. + /// + /// The invitation ID. + /// Cancellation token. + /// True if deleted successfully; otherwise, false. + Task DeleteInvitationAsync(Guid invitationId, CancellationToken ct = default); + /// /// Finds a club by name (case-insensitive). /// @@ -94,4 +102,14 @@ public interface IClubService /// Cancellation token. /// The club if found; otherwise, null. Task GetByNameAsync(string clubName, CancellationToken ct = default); + + + + /// + /// Finds a club by login name (case-insensitive). + /// + /// The club name to search for. + /// Cancellation token. + /// The club if found; otherwise, null. + Task GetByLoginNameAsync(string clubLoginName, CancellationToken ct = default); } diff --git a/src/Koogle.Application/Services/ClubService.cs b/src/Koogle.Application/Services/ClubService.cs index 84be979..fec2bf6 100644 --- a/src/Koogle.Application/Services/ClubService.cs +++ b/src/Koogle.Application/Services/ClubService.cs @@ -241,6 +241,29 @@ public class ClubService : IClubService return club is null ? null : _mapper.Map(club); } + public async Task 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(club); + } + + /// + public async Task 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; + } + /// /// Generates a cryptographically secure random token for invitations. /// diff --git a/src/Koogle.Domain/Interfaces/IEmailService.cs b/src/Koogle.Domain/Interfaces/IEmailService.cs index c72922f..0430bb8 100644 --- a/src/Koogle.Domain/Interfaces/IEmailService.cs +++ b/src/Koogle.Domain/Interfaces/IEmailService.cs @@ -59,4 +59,16 @@ public interface IEmailService /// true if sent successfully, false otherwise. Task SendPasswordResetEmailAsync(string toEmail, string resetUrl, CancellationToken ct = default); + + /// + /// Sends a club invitation email with the invite link. + /// + /// Recipient's email address + /// The full invitation URL with token + /// Name of the club + /// ID of the club for sender email + /// Cancellation token + /// true if sent successfully, false otherwise. + Task SendClubInvitationEmailAsync(string toEmail, string inviteUrl, + string clubName, Guid clubId, CancellationToken ct = default); } diff --git a/src/Koogle.Infrastructure/Services/SmtpEmailService.cs b/src/Koogle.Infrastructure/Services/SmtpEmailService.cs index 1318f96..ddf038e 100644 --- a/src/Koogle.Infrastructure/Services/SmtpEmailService.cs +++ b/src/Koogle.Infrastructure/Services/SmtpEmailService.cs @@ -303,6 +303,49 @@ public class SmtpEmailService : IEmailService } } + /// + public async Task 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 = $@" + + +

Einladung zu {clubName}

+

Du wurdest eingeladen, dem Club {clubName} beizutreten.

+

Klicke auf den folgenden Link, um die Einladung anzunehmen:

+

Einladung annehmen

+

Falls du bereits ein Konto hast, kannst du dich direkt anmelden. Andernfalls wirst du zur Registrierung weitergeleitet.

+

Viele Grüße,
{clubName}

+ +"; + + 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('@'); diff --git a/src/Koogle.Web/Components/Pages/Account/JoinClub.razor b/src/Koogle.Web/Components/Pages/Account/JoinClub.razor index 6e78955..1e3a091 100644 --- a/src/Koogle.Web/Components/Pages/Account/JoinClub.razor +++ b/src/Koogle.Web/Components/Pages/Account/JoinClub.razor @@ -45,10 +45,10 @@ @@ -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 diff --git a/src/Koogle.Web/Components/Pages/Account/Login.razor b/src/Koogle.Web/Components/Pages/Account/Login.razor index f6aa4e5..495a2b3 100644 --- a/src/Koogle.Web/Components/Pages/Account/Login.razor +++ b/src/Koogle.Web/Components/Pages/Account/Login.razor @@ -96,7 +96,7 @@ @code { - private string _returnUrl = "/"; + private string _returnUrl = "/dashboard"; private string? _error; private string _antiToken = ""; private bool _registered; diff --git a/src/Koogle.Web/Components/Pages/Admin/InviteByEmailDialog.razor b/src/Koogle.Web/Components/Pages/Admin/InviteByEmailDialog.razor new file mode 100644 index 0000000..cb60b22 --- /dev/null +++ b/src/Koogle.Web/Components/Pages/Admin/InviteByEmailDialog.razor @@ -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 + + + + + + + + @if (_isSending) + { + + } + + + Abbrechen + + @if (_isSending) + { + + } + Einladung senden + + + + +@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(); + } + } +} diff --git a/src/Koogle.Web/Components/Pages/Settings.razor b/src/Koogle.Web/Components/Pages/Settings.razor index 8512cd6..76714c3 100644 --- a/src/Koogle.Web/Components/Pages/Settings.razor +++ b/src/Koogle.Web/Components/Pages/Settings.razor @@ -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 @inject IDispatcher Dispatcher @@ -20,6 +22,8 @@ @inject IDialogService DialogService @inject IEmailService EmailService @inject IState AuthState +@inject NavigationManager NavigationManager +@inject IJSRuntime JSRuntime Vereins-Einstellungen @@ -42,50 +46,175 @@ else if (_club is null) } else { - - - - - Name - @_club.Name - - - Login-Name - @_club.LoginName - - - Kostenberechnung - - @GetCalculationLabel(_club.ExpenseCalculation) - - - - - - - Bearbeiten - - - @* - Test - *@ - - + + + + + + + Name + @_club.Name + + + Login-Name + @_club.LoginName + + + Kostenberechnung + + @GetCalculationLabel(_club.ExpenseCalculation) + + + + + + + Bearbeiten + + + + + + + + + + + Einladungslink erstellen + + + Per E-Mail einladen + + + + @if (_isLoadingInvitations) + { + + } + else if (!_invitations.Any()) + { + Keine Einladungen vorhanden. + } + else + { + + + Einladungslink + Ablaufdatum + Nutzung + Status + Aktionen + + + + + + @GetInviteUrl(context.Token) + + + + + + + @context.ExpiresAt.ToString("dd.MM.yyyy HH:mm") + + @context.UsedCount / @(context.MaxUses?.ToString() ?? "∞") + + + @if (context.IsValid) + { + Aktiv + } + else + { + Abgelaufen + } + + + + + + + + + } + + + + + + + + @if (_isLoadingPending) + { + + } + else if (!_pendingMemberships.Any()) + { + Keine ausstehenden Mitgliedschaftsanträge. + } + else + { + + + Name + E-Mail + Angefragt am + Aktionen + + + @context.DisplayName + @context.Email + @context.RequestedAt.ToString("dd.MM.yyyy HH:mm") + + + + + + + + + + + } + + + + } @code { private ClubDto? _club; private bool _isLoading = true; + private bool _isLoadingInvitations = true; + private bool _isLoadingPending = true; + private int _activeTabIndex = 0; + private List _invitations = new(); + private List _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("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("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); + } + } + } }