manage invitations and requests by club-admin
This commit is contained in:
parent
7d0c26ad0c
commit
028909c24f
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('@');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@
|
|||
|
||||
|
||||
@code {
|
||||
private string _returnUrl = "/";
|
||||
private string _returnUrl = "/dashboard";
|
||||
private string? _error;
|
||||
private string _antiToken = "";
|
||||
private bool _registered;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue